runtime: remove worker transcript projection

This commit is contained in:
2026-07-11 03:55:44 +09:00
parent 210f41e020
commit 156f8ad044
20 changed files with 428 additions and 1179 deletions
+9 -5
View File
@@ -967,13 +967,18 @@
}
.worker-console-shell {
min-height: 100dvh;
padding-bottom: 0;
display: flex;
flex-direction: column;
min-height: 0;
height: calc(100dvh - (var(--space-6) * 2));
overflow: hidden;
}
.worker-console-shell > .console-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
.console-header-actions {
@@ -1018,12 +1023,10 @@
.console-log {
display: grid;
align-content: start;
gap: var(--space-2);
max-height: none;
gap: var(--space-3);
min-height: 0;
margin: 0;
padding: 0;
overflow-y: auto;
list-style: none;
}
@@ -1134,6 +1137,7 @@
position: sticky;
bottom: 0;
z-index: 2;
flex: 0 0 auto;
display: grid;
gap: var(--space-3);
margin-inline: calc(-1 * var(--space-6));
@@ -53,71 +53,64 @@ Deno.test("segmentsToText preserves protocol segment semantics", () => {
);
});
Deno.test("projectConsole projects initial console output and live visible protocol rows", () => {
const projection = projectConsole(
[
{
sequence: 1,
role: "user",
content: "transcript input",
event_id: 10,
},
],
[
{
cursor: "11",
event: {
event: "text_delta",
data: { text: "stream" },
} satisfies Event,
},
{
cursor: "12",
event: {
event: "thinking_done",
data: { text: "reasoning" },
} satisfies Event,
},
{
cursor: "13",
event: {
event: "tool_result",
data: {
id: "tool-1",
summary: "read file",
output: "content",
is_error: false,
},
} satisfies Event,
},
{
cursor: "14",
event: {
event: "usage",
data: { input_tokens: 12, output_tokens: 5 },
} satisfies Event,
},
{
cursor: "15",
event: {
event: "error",
data: { code: "invalid_request", message: "bad frame" },
} satisfies Event,
},
],
);
Deno.test("projectConsole projects visible protocol rows", () => {
const projection = projectConsole([
{
cursor: "10",
event: {
event: "user_message",
data: { segments: [{ kind: "text", content: "input" }] },
} satisfies Event,
},
{
cursor: "11",
event: {
event: "text_delta",
data: { text: "stream" },
} satisfies Event,
},
{
cursor: "12",
event: {
event: "thinking_done",
data: { text: "reasoning" },
} satisfies Event,
},
{
cursor: "13",
event: {
event: "tool_result",
data: {
id: "tool-1",
summary: "read file",
output: "content",
is_error: false,
},
} satisfies Event,
},
{
cursor: "14",
event: {
event: "usage",
data: { input_tokens: 12, output_tokens: 5 },
} satisfies Event,
},
{
cursor: "15",
event: {
event: "error",
data: { code: "invalid_request", message: "bad frame" },
} satisfies Event,
},
]);
assert(
projection.lines.some((line) =>
line.source === "initial" && line.kind === "user"
),
"initial user row expected",
projection.lines.some((line) => line.source === "event" && line.kind === "user"),
"user protocol row expected",
);
assert(
projection.lines.some((line) =>
line.source === "live" && line.kind === "assistant"
),
"assistant live row expected",
projection.lines.some((line) => line.source === "event" && line.kind === "assistant"),
"assistant protocol row expected",
);
assert(
projection.lines.some((line) => line.kind === "thinking"),
@@ -141,85 +134,25 @@ Deno.test("projectConsole projects initial console output and live visible proto
);
});
Deno.test("projectConsole suppresses replayed live conversation rows already present in initial transcript", () => {
const projection = projectConsole(
[
{
sequence: 1,
role: "user",
content: "hello",
event_id: 10,
},
{
sequence: 2,
role: "assistant",
content: "world",
event_id: 11,
},
],
[
{
cursor: "12",
event: {
event: "user_message",
data: { segments: [{ kind: "text", content: "hello" }] },
} satisfies Event,
},
{
cursor: "13",
event: { event: "text_delta", data: { text: "wo" } } satisfies Event,
},
{
cursor: "14",
event: { event: "text_delta", data: { text: "rld" } } satisfies Event,
},
{
cursor: "15",
event: { event: "text_done", data: { text: "world" } } satisfies Event,
},
{
cursor: "16",
event: { event: "status", data: { status: "idle" } } satisfies Event,
},
],
);
assertEquals(
projection.lines.map((line) => `${line.source}:${line.kind}:${line.body}`),
["initial:user:hello", "initial:assistant:world"],
);
assertEquals(projection.status, "idle");
});
Deno.test("projectConsole preserves live assistant stream not yet present in initial transcript", () => {
const projection = projectConsole(
[
{
sequence: 1,
role: "user",
content: "hello",
event_id: 10,
},
],
[
{
cursor: "13",
event: { event: "text_delta", data: { text: "new" } } satisfies Event,
},
],
);
Deno.test("projectConsole preserves in-progress assistant protocol stream", () => {
const projection = projectConsole([
{
cursor: "13",
event: { event: "text_delta", data: { text: "new" } } satisfies Event,
},
]);
assert(
projection.lines.some((line) =>
line.source === "live" && line.kind === "assistant" &&
line.source === "event" && line.kind === "assistant" &&
line.body === "new" && line.streaming
),
"live in-progress assistant stream should remain visible",
"in-progress assistant stream should remain visible",
);
});
Deno.test("projectConsole keeps protocol lifecycle events out of the console surface", () => {
const projection = projectConsole([], [
const projection = projectConsole([
{
cursor: "30",
event: { event: "status", data: { status: "running" } } satisfies Event,
@@ -250,7 +183,7 @@ Deno.test("projectConsole keeps protocol lifecycle events out of the console sur
});
Deno.test("projectConsole uses snapshot for state without rendering it as console output", () => {
const projection = projectConsole([], [
const projection = projectConsole([
{
cursor: "20",
event: {
@@ -270,14 +203,7 @@ Deno.test("projectConsole uses snapshot for state without rendering it as consol
status: "running",
in_flight: {
blocks: [
{ kind: "text", text: "unfinished answer", finished: false },
{
kind: "tool_call",
id: "call-1",
name: "Read",
args: "{}",
state: "streaming_args",
},
{ kind: "text", text: "partial" },
],
},
},
@@ -285,13 +211,9 @@ Deno.test("projectConsole uses snapshot for state without rendering it as consol
},
]);
assert(projection.status === "running", "snapshot should update status");
assert(
!projection.lines.some((line) => line.title.includes("snapshot")),
"snapshot should not render as a console row",
);
assert(
projection.lines.filter((line) => line.kind === "in_flight").length === 2,
"in-flight rows expected",
assertEquals(projection.status, "running");
assertEquals(
projection.lines.map((line) => `${line.kind}:${line.body}:${line.streaming}`),
["in_flight:partial:true"],
);
});
@@ -3,7 +3,6 @@ import type {
InFlightBlock,
Segment,
} from "$lib/generated/protocol";
import type { WorkerTranscriptItem } from "$lib/workspace-sidebar/types";
import { workspaceRoute } from "$lib/workspace-api/http";
export type ConsoleLineKind =
@@ -24,7 +23,7 @@ export type ConsoleLine = {
body: string;
detail?: string;
cursor?: string | null;
source: "initial" | "live";
source: "event";
streaming?: boolean;
error?: boolean;
};
@@ -60,103 +59,17 @@ export function workerConsolePath(
return workerConsoleHref({ runtime_id: runtimeId, worker_id: workerId }, workspaceId);
}
export function initialConsoleLines(items: WorkerTranscriptItem[]): ConsoleLine[] {
return items.map((item) => ({
id: `initial-${item.event_id}-${item.sequence}`,
kind: initialRoleKind(item.role),
title: item.role,
body: item.content,
source: "initial",
}));
}
export type ConsoleEventInput = { cursor: string; event: ProtocolEvent };
export function projectConsole(
initialItems: WorkerTranscriptItem[],
events: Array<{ cursor: string; event: ProtocolEvent }> = [],
): ConsoleProjection {
const visibleEvents = dedupeInitialTranscriptReplay(initialItems, events);
return visibleEvents.reduce(applyProtocolEvent, {
lines: initialConsoleLines(initialItems),
export function projectConsole(events: ConsoleEventInput[] = []): ConsoleProjection {
return events.reduce(applyProtocolEvent, {
lines: [],
status: null,
usage: null,
lastCursor: null,
});
}
type EventEnvelope = { cursor: string; event: ProtocolEvent };
function dedupeInitialTranscriptReplay(
initialItems: WorkerTranscriptItem[],
events: EventEnvelope[],
): EventEnvelope[] {
const remainingInitial = new Map<string, number>();
for (const item of initialItems) {
if (item.role !== "user" && item.role !== "assistant") continue;
const key = transcriptKey(item.role, item.content);
remainingInitial.set(key, (remainingInitial.get(key) ?? 0) + 1);
}
const output: EventEnvelope[] = [];
let pendingAssistant: EventEnvelope[] = [];
let pendingAssistantText = "";
const flushPendingAssistant = () => {
if (pendingAssistant.length === 0) return;
output.push(...pendingAssistant);
pendingAssistant = [];
pendingAssistantText = "";
};
for (const envelope of events) {
const event = envelope.event;
if (event.event === "user_message") {
flushPendingAssistant();
const body = segmentsToText(event.data.segments);
if (consumeTranscriptKey(remainingInitial, "user", body)) continue;
output.push(envelope);
continue;
}
if (event.event === "text_delta") {
pendingAssistant.push(envelope);
pendingAssistantText += event.data.text;
continue;
}
if (event.event === "text_done") {
const body = event.data.text || pendingAssistantText;
if (!consumeTranscriptKey(remainingInitial, "assistant", body)) {
output.push(...pendingAssistant, envelope);
}
pendingAssistant = [];
pendingAssistantText = "";
continue;
}
flushPendingAssistant();
output.push(envelope);
}
flushPendingAssistant();
return output;
}
function consumeTranscriptKey(
remainingInitial: Map<string, number>,
role: "user" | "assistant",
body: string,
): boolean {
const key = transcriptKey(role, body);
const remaining = remainingInitial.get(key) ?? 0;
if (remaining <= 0) return false;
if (remaining === 1) {
remainingInitial.delete(key);
} else {
remainingInitial.set(key, remaining - 1);
}
return true;
}
function transcriptKey(role: "user" | "assistant", body: string): string {
return `${role}\0${body}`;
}
export function applyProtocolEvent(
projection: ConsoleProjection,
envelope: { cursor: string; event: ProtocolEvent },
@@ -353,13 +266,6 @@ export function segmentsToText(segments: Segment[]): string {
.join("\n");
}
function initialRoleKind(role: string): ConsoleLineKind {
if (role === "user" || role === "assistant" || role === "system") {
return role;
}
return "system";
}
function line(
cursor: string,
kind: ConsoleLineKind,
@@ -376,7 +282,7 @@ function line(
body,
detail,
cursor,
source: "live",
source: "event",
streaming,
error,
};
@@ -52,6 +52,35 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
);
});
Deno.test("Worker Console uses protocol observation events without transcript fetch", async () => {
const consolePage = await Deno.readTextFile(
new URL("./../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte", import.meta.url),
);
assert(
consolePage.includes("seenObservationEventIds") &&
consolePage.includes("rememberObservationEvent(frame.envelope.event_id)") &&
consolePage.includes("projectConsole(observedEvents.map") &&
!consolePage.includes("/transcript") &&
!consolePage.includes("WorkerTranscriptProjection"),
"Console should render protocol observation replay/live events directly and dedupe repeated frames by event id",
);
});
Deno.test("Decodal source editor keeps imperative EditorView out of reactive state", async () => {
const editor = await Deno.readTextFile(
new URL("../workspace-settings/DecodalSourceEditor.svelte", import.meta.url),
);
assert(
editor.includes("let view: EditorView | null = null") &&
!editor.includes("$state<EditorView") &&
editor.includes("untrack(() => value)") &&
editor.includes("untrack(() => onChange)"),
"CodeMirror EditorView must not be reactive state; otherwise mount cleanup can loop forever",
);
});
Deno.test("workspace Runtime management pages expose Runtimes and Runtime-owned workdirs", async () => {
const sidebar = await Deno.readTextFile(
new URL("../workspace-sidebar/WorkspaceSidebar.svelte", import.meta.url),
@@ -138,9 +167,9 @@ Deno.test("Worker Console page is routed by runtime_id and worker_id through bac
"Worker detail should use the scoped backend Worker detail API",
);
assert(
consolePage.includes("/transcript?limit=200") &&
!consolePage.includes("/transcript") &&
consolePage.includes("/events/ws") && consolePage.includes("/input"),
"Console should use bounded transcript, observation WS, and input APIs",
"Console should use protocol observation WS and input APIs without a transcript API",
);
assert(
!consolePage.includes("/api/companion"),
@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte';
import { EditorState } from '@codemirror/state';
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
import { decodal } from 'decodal-codemirror';
@@ -16,7 +17,7 @@
} = $props();
let host = $state<HTMLDivElement | null>(null);
let view = $state<EditorView | null>(null);
let view: EditorView | null = null;
const theme = EditorView.theme({
'&': {
@@ -35,20 +36,23 @@
$effect(() => {
if (!host || view) return;
const initialValue = untrack(() => value);
const initialReadonly = untrack(() => readonly);
const handleChange = untrack(() => onChange);
const editor = new EditorView({
parent: host,
state: EditorState.create({
doc: value,
doc: initialValue,
extensions: [
lineNumbers(),
drawSelection(),
highlightActiveLine(),
decodal(),
keymap.of([]),
EditorState.readOnly.of(readonly),
EditorView.editable.of(!readonly),
EditorState.readOnly.of(initialReadonly),
EditorView.editable.of(!initialReadonly),
EditorView.updateListener.of((update) => {
if (update.docChanged) onChange(update.state.doc.toString());
if (update.docChanged) handleChange(update.state.doc.toString());
}),
theme,
],
@@ -181,30 +181,10 @@ export type WorkerInputResult = {
state: WorkerOperationState;
runtime_id: string;
worker_id: string;
transcript_sequence?: number | null;
event_id?: number | null;
diagnostics: Diagnostic[];
};
export type WorkerTranscriptItem = {
sequence: number;
role: "user" | "assistant" | "system" | string;
content: string;
event_id: number;
};
export type WorkerTranscriptProjection = {
state: WorkerOperationState;
runtime_id: string;
worker_id: string;
start: number;
limit: number;
total_items: number;
next_start?: number | null;
items: WorkerTranscriptItem[];
diagnostics: Diagnostic[];
};
export type ClientWorkerEventWsEnvelope = {
cursor: string;
event_id: string;
@@ -1,4 +1,5 @@
<script lang="ts">
import { tick } from 'svelte';
import {
projectConsole,
type ConsoleLine
@@ -8,8 +9,7 @@
ClientWorkerEventWsFrame,
Diagnostic,
Worker,
WorkerInputResult,
WorkerTranscriptProjection
WorkerInputResult
} from '$lib/workspace-sidebar/types';
type Props = {
@@ -32,15 +32,17 @@
let worker = $state<Worker | null>(null);
let workerError = $state<string | null>(null);
let transcript = $state<WorkerTranscriptProjection | null>(null);
let transcriptError = $state<string | null>(null);
let draft = $state('');
let sending = $state(false);
let sendError = $state<string | null>(null);
let streamState = $state<'connecting' | 'open' | 'closed' | 'error'>('connecting');
let streamDiagnostics = $state<Diagnostic[]>([]);
let workerDetailsOpen = $state(false);
let consoleBodyElement: HTMLElement | null = null;
let autoFollowConsole = $state(true);
const CONSOLE_BOTTOM_THRESHOLD_PX = 48;
let observedEvents = $state<Array<{ cursor: string; event: ClientWorkerEventWsFrame & { kind: 'event' } }>>([]);
let seenObservationEventIds = new Set<string>();
let nextReloadToken = 0;
let reloadToken = $state(0);
@@ -52,15 +54,10 @@
const consoleTarget = $derived({ runtimeId, workerId });
const projection = $derived(
projectConsole(
transcript?.items ?? [],
observedEvents.map((item) => ({ cursor: item.cursor, event: item.event.envelope.payload }))
)
projectConsole(observedEvents.map((item) => ({ cursor: item.cursor, event: item.event.envelope.payload })))
);
const lines = $derived(projection.lines);
const diagnostics = $derived(
mergeDiagnostics(worker?.diagnostics ?? [], transcript?.diagnostics ?? [], streamDiagnostics)
);
const diagnostics = $derived(mergeDiagnostics(worker?.diagnostics ?? [], streamDiagnostics));
const canSend = $derived(Boolean(worker?.capabilities.can_accept_input) && draft.trim().length > 0 && !sending);
async function getJson<T>(path: string): Promise<T> {
@@ -108,20 +105,8 @@
}
}
async function loadTranscript(target: ConsoleTarget) {
transcriptError = null;
try {
transcript = await getJson<WorkerTranscriptProjection>(
workerApiPath(`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}/transcript?limit=200`)
);
} catch (error) {
transcriptError = error instanceof Error ? error.message : String(error);
transcript = null;
}
}
async function loadConsoleData(target: ConsoleTarget) {
await Promise.all([loadWorker(target), loadTranscript(target)]);
await loadWorker(target);
}
function advanceReloadToken(): number {
@@ -130,6 +115,19 @@
return nextReloadToken;
}
function resetObservedEvents() {
observedEvents = [];
seenObservationEventIds = new Set();
}
function rememberObservationEvent(eventId: string): boolean {
if (seenObservationEventIds.has(eventId)) {
return false;
}
seenObservationEventIds.add(eventId);
return true;
}
async function sendMessage(event: SubmitEvent) {
event.preventDefault();
const content = draft.trim();
@@ -149,7 +147,6 @@
} else {
sendError = diagnosticsToText(result.diagnostics) || `Input was ${result.state}.`;
}
await loadTranscript(consoleTarget);
} catch (error) {
sendError = error instanceof Error ? error.message : String(error);
} finally {
@@ -181,6 +178,9 @@
try {
const frame = JSON.parse(String(message.data)) as ClientWorkerEventWsFrame;
if (frame.kind === 'event') {
if (!rememberObservationEvent(frame.envelope.event_id)) {
return;
}
observedEvents = [
...observedEvents,
{
@@ -243,9 +243,42 @@
return line.error ? 'error' : line.kind;
}
function isNearConsoleBottom(element: HTMLElement): boolean {
return element.scrollHeight - element.scrollTop - element.clientHeight <= CONSOLE_BOTTOM_THRESHOLD_PX;
}
function handleConsoleScroll() {
if (!consoleBodyElement) {
return;
}
autoFollowConsole = isNearConsoleBottom(consoleBodyElement);
}
async function scrollConsoleToBottom() {
await tick();
if (!consoleBodyElement) {
return;
}
consoleBodyElement.scrollTop = consoleBodyElement.scrollHeight;
autoFollowConsole = true;
}
const scrollFollowKey = $derived(
lines
.map((line) => `${line.source}:${line.kind}:${line.body.length}:${line.streaming ? 'streaming' : 'done'}`)
.join('|')
);
$effect(() => {
scrollFollowKey;
if (autoFollowConsole) {
void scrollConsoleToBottom();
}
});
$effect(() => {
const target = consoleTarget;
observedEvents = [];
resetObservedEvents();
streamDiagnostics = [];
advanceReloadToken();
void loadConsoleData(target);
@@ -274,7 +307,7 @@
</div>
</section>
<section class="console-body">
<section class="console-body" bind:this={consoleBodyElement} onscroll={handleConsoleScroll}>
<article class="card console-card worker-console-card">
{#if projection.status || projection.usage}
<p class="section-note">
@@ -287,9 +320,6 @@
{#if workerError}
<p class="error">{workerError}</p>
{/if}
{#if transcriptError}
<p class="error">{transcriptError}</p>
{/if}
{#if lines.length === 0}
<p>No console output is available for this Worker yet.</p>