Merge remote-tracking branch 'origin/develop' into work/T-669-subjektiv-memory-tools
This commit is contained in:
@@ -0,0 +1,113 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import type { Event } from "$lib/generated/protocol";
|
||||||
|
import { cleanup, render, waitFor } from "@testing-library/svelte";
|
||||||
|
import { afterEach, expect, test } from "vitest";
|
||||||
|
import ConsoleLineItem from "./ConsoleLineItem.svelte";
|
||||||
|
import { createConsoleProjector } from "./model.ts";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function reconnectSnapshot(text: string): Event {
|
||||||
|
return {
|
||||||
|
event: "snapshot",
|
||||||
|
data: {
|
||||||
|
session: {
|
||||||
|
entries: [],
|
||||||
|
pending_submissions: {
|
||||||
|
revision: 0,
|
||||||
|
head_id: null,
|
||||||
|
notification_count: 0,
|
||||||
|
submissions: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
greeting: {
|
||||||
|
worker_name: "Worker",
|
||||||
|
cwd: "/repo",
|
||||||
|
provider: "provider",
|
||||||
|
model: "model",
|
||||||
|
scope_summary: "bounded",
|
||||||
|
tools: [],
|
||||||
|
context_window: 100,
|
||||||
|
context_tokens: 20,
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
last_command_id: 0,
|
||||||
|
state: { kind: "busy", state: { kind: "run", state: "running" } },
|
||||||
|
},
|
||||||
|
in_flight: {
|
||||||
|
blocks: [{ kind: "text", text, finished: false }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("reconnected assistant output keeps the normal streaming Markdown path", async () => {
|
||||||
|
const projector = createConsoleProjector();
|
||||||
|
let projection = projector.append([{
|
||||||
|
eventId: "snapshot",
|
||||||
|
event: reconnectSnapshot("**hel"),
|
||||||
|
}]);
|
||||||
|
const { container, rerender } = render(ConsoleLineItem, {
|
||||||
|
item: projection.lines[0],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector("li")?.classList.contains("assistant")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(container.querySelector(".message-heading")).toBeNull();
|
||||||
|
expect(container.querySelector(".console-plain-text")).toBeNull();
|
||||||
|
expect(container.textContent).not.toContain("in-flight");
|
||||||
|
|
||||||
|
projection = projector.append([{
|
||||||
|
eventId: "inline-suffix",
|
||||||
|
event: {
|
||||||
|
event: "text_delta",
|
||||||
|
data: { text: "lo** and [safe](https://exam" },
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
await rerender({ item: projection.lines[0] });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(container.querySelector("strong")?.textContent).toBe("hello");
|
||||||
|
expect(container.querySelector('a[href="https://example.com"]')).toBeNull();
|
||||||
|
expect(container.textContent).toContain("[safe](https://exam");
|
||||||
|
});
|
||||||
|
|
||||||
|
projection = projector.append([{
|
||||||
|
eventId: "fence-prefix",
|
||||||
|
event: {
|
||||||
|
event: "text_delta",
|
||||||
|
data: { text: "ple.com)\n\n```ts\nconst answer = 42;" },
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
await rerender({ item: projection.lines[0] });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(container.querySelector('a[href="https://example.com"]')).not
|
||||||
|
.toBeNull();
|
||||||
|
expect(container.querySelector("pre code")?.textContent).toContain(
|
||||||
|
"const answer = 42;",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
projection = projector.append([{
|
||||||
|
eventId: "fence-suffix",
|
||||||
|
event: { event: "text_delta", data: { text: "\n```" } },
|
||||||
|
}]);
|
||||||
|
await rerender({ item: projection.lines[0] });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(container.querySelector("strong")?.textContent).toBe("hello");
|
||||||
|
expect(container.querySelector('a[href="https://example.com"]')).not
|
||||||
|
.toBeNull();
|
||||||
|
expect(container.querySelector("pre code")?.textContent).toContain(
|
||||||
|
"const answer = 42;",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(projection.lines).toHaveLength(1);
|
||||||
|
expect(projection.lines[0]).toMatchObject({
|
||||||
|
kind: "assistant",
|
||||||
|
streaming: true,
|
||||||
|
body:
|
||||||
|
"**hello** and [safe](https://example.com)\n\n```ts\nconst answer = 42;\n```",
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -346,6 +346,36 @@ Deno.test("line previews include a current turn outside the retained five-turn p
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("line previews track a snapshot-restored assistant as its body streams", () => {
|
||||||
|
const lines = [{
|
||||||
|
id: "user",
|
||||||
|
entryId: "user-entry",
|
||||||
|
kind: "user" as const,
|
||||||
|
title: "User",
|
||||||
|
body: "question",
|
||||||
|
source: "event" as const,
|
||||||
|
}, {
|
||||||
|
id: "restored-assistant",
|
||||||
|
kind: "assistant" as const,
|
||||||
|
title: "assistant streaming",
|
||||||
|
body: "**hel",
|
||||||
|
source: "event" as const,
|
||||||
|
streaming: true,
|
||||||
|
}];
|
||||||
|
|
||||||
|
assert(
|
||||||
|
conversationTurnPreviewsFromLines(lines)[0].assistant.join("|") ===
|
||||||
|
"**hel",
|
||||||
|
"restored assistant prefix must use the ordinary assistant preview path",
|
||||||
|
);
|
||||||
|
const continued = [lines[0], { ...lines[1], body: "**hello**" }];
|
||||||
|
assert(
|
||||||
|
conversationTurnPreviewsFromLines(continued)[0].assistant.join("|") ===
|
||||||
|
"**hello**",
|
||||||
|
"live suffixes must update the same turn preview",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("turn preview uses one user line and final non-empty assistant up to three lines", () => {
|
Deno.test("turn preview uses one user line and final non-empty assistant up to three lines", () => {
|
||||||
const preview = conversationTurnPreviews([turn(4)])[0];
|
const preview = conversationTurnPreviews([turn(4)])[0];
|
||||||
assert(preview.user === "question 4", "user preview must use its first line");
|
assert(preview.user === "question 4", "user preview must use its first line");
|
||||||
|
|||||||
@@ -203,8 +203,7 @@ export function conversationTurnPreviewsFromLines(
|
|||||||
assistants: [],
|
assistants: [],
|
||||||
};
|
};
|
||||||
} else if (
|
} else if (
|
||||||
(line.kind === "assistant" ||
|
line.kind === "assistant" &&
|
||||||
(line.kind === "in_flight" && line.title === "in-flight assistant text")) &&
|
|
||||||
current && line.body.trim().length > 0
|
current && line.body.trim().length > 0
|
||||||
) {
|
) {
|
||||||
current.assistants.push(line.body);
|
current.assistants.push(line.body);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
Event,
|
Event,
|
||||||
|
InFlightBlock,
|
||||||
SessionSnapshotEntry,
|
SessionSnapshotEntry,
|
||||||
WorkerStateSnapshot,
|
WorkerStateSnapshot,
|
||||||
WorkerStatus,
|
WorkerStatus,
|
||||||
@@ -176,6 +177,39 @@ function snapshotEvent(cwd: string, entries: unknown[] = []): Event {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SnapshotEvent = Extract<Event, { event: "snapshot" }>;
|
||||||
|
|
||||||
|
function snapshotWithInFlight(
|
||||||
|
blocks: InFlightBlock[],
|
||||||
|
entries: unknown[] = [],
|
||||||
|
): SnapshotEvent {
|
||||||
|
const snapshot = snapshotEvent("/repo", entries) as SnapshotEvent;
|
||||||
|
snapshot.data.in_flight = { blocks };
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function semanticOutput(lines: readonly ConsoleLine[]) {
|
||||||
|
return lines.map((line) => ({
|
||||||
|
kind: line.kind,
|
||||||
|
title: line.title,
|
||||||
|
body: line.body,
|
||||||
|
streaming: line.streaming,
|
||||||
|
entryId: line.entryId,
|
||||||
|
toolCall: line.toolCall
|
||||||
|
? {
|
||||||
|
id: line.toolCall.id,
|
||||||
|
name: line.toolCall.name,
|
||||||
|
argsStream: line.toolCall.argsStream,
|
||||||
|
arguments: line.toolCall.arguments,
|
||||||
|
state: line.toolCall.state,
|
||||||
|
summary: line.toolCall.summary,
|
||||||
|
output: line.toolCall.output,
|
||||||
|
isError: line.toolCall.isError,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
Deno.test("large paste segments project compact artifact metadata", () => {
|
Deno.test("large paste segments project compact artifact metadata", () => {
|
||||||
const body = "secret pasted body";
|
const body = "secret pasted body";
|
||||||
const text = segmentsToText([{
|
const text = segmentsToText([{
|
||||||
@@ -2334,11 +2368,273 @@ Deno.test("projectConsole renders snapshot entries and in-flight output", () =>
|
|||||||
"user:new user:false",
|
"user:new user:false",
|
||||||
"assistant:assistant reply:false",
|
"assistant:assistant reply:false",
|
||||||
"tool:Read(1 file)\n /tmp/a.md:false",
|
"tool:Read(1 file)\n /tmp/a.md:false",
|
||||||
"in_flight:partial:true",
|
"assistant:partial:true",
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("snapshot text blocks match live prefixes, suffixes, and ordered commits", () => {
|
||||||
|
const committedPrefix = [{
|
||||||
|
kind: "user_input",
|
||||||
|
segments: [{ kind: "text", content: "question" }],
|
||||||
|
}];
|
||||||
|
const livePrefix: ConsoleEventInput[] = [
|
||||||
|
{ eventId: "live-base", event: snapshotEvent("/repo", committedPrefix) },
|
||||||
|
{
|
||||||
|
eventId: "live-first-delta",
|
||||||
|
event: { event: "text_delta", data: { text: "first draft" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "live-first-done",
|
||||||
|
event: { event: "text_done", data: { text: "first draft" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "live-second-delta",
|
||||||
|
event: { event: "text_delta", data: { text: "**hel" } },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const restoredPrefix: ConsoleEventInput[] = [{
|
||||||
|
eventId: "restored-base",
|
||||||
|
event: snapshotWithInFlight([
|
||||||
|
{ kind: "text", text: "first draft", finished: true },
|
||||||
|
{ kind: "text", text: "**hel", finished: false },
|
||||||
|
], committedPrefix),
|
||||||
|
}];
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
semanticOutput(projectConsole(restoredPrefix).lines),
|
||||||
|
semanticOutput(projectConsole(livePrefix).lines),
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstCommit: SessionSnapshotEntry = {
|
||||||
|
entry_id: "assistant-first",
|
||||||
|
timestamp: 2,
|
||||||
|
provenance: "model_output",
|
||||||
|
kind: "message",
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ kind: "text", text: "first canonical" }],
|
||||||
|
};
|
||||||
|
const secondCommit: SessionSnapshotEntry = {
|
||||||
|
entry_id: "assistant-second",
|
||||||
|
timestamp: 3,
|
||||||
|
provenance: "model_output",
|
||||||
|
kind: "message",
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ kind: "text", text: "**hello**" }],
|
||||||
|
};
|
||||||
|
const suffix: ConsoleEventInput[] = [
|
||||||
|
{
|
||||||
|
eventId: "commit-first",
|
||||||
|
event: {
|
||||||
|
event: "session_entry_committed",
|
||||||
|
data: { entry: firstCommit },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "second-suffix",
|
||||||
|
event: { event: "text_delta", data: { text: "lo**" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "second-done",
|
||||||
|
event: { event: "text_done", data: { text: "**hello**" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "commit-second",
|
||||||
|
event: {
|
||||||
|
event: "session_entry_committed",
|
||||||
|
data: { entry: secondCommit },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const liveFinal = projectConsole([...livePrefix, ...suffix]);
|
||||||
|
const restoredFinal = projectConsole([...restoredPrefix, ...suffix]);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
semanticOutput(restoredFinal.lines),
|
||||||
|
semanticOutput(liveFinal.lines),
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
restoredFinal.lines.filter((line) => line.kind === "assistant").map(
|
||||||
|
(line) => [line.body, line.entryId, line.streaming],
|
||||||
|
),
|
||||||
|
[
|
||||||
|
["first canonical", "assistant-first", false],
|
||||||
|
["**hello**", "assistant-second", false],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("snapshot thinking blocks preserve boundaries and live continuation", () => {
|
||||||
|
const livePrefix: ConsoleEventInput[] = [
|
||||||
|
{ eventId: "base", event: snapshotEvent("/repo") },
|
||||||
|
{ eventId: "thinking-1-start", event: { event: "thinking_start" } },
|
||||||
|
{
|
||||||
|
eventId: "thinking-1-delta",
|
||||||
|
event: { event: "thinking_delta", data: { text: "finished thought" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "thinking-1-done",
|
||||||
|
event: { event: "thinking_done", data: { text: "finished thought" } },
|
||||||
|
},
|
||||||
|
{ eventId: "thinking-2-start", event: { event: "thinking_start" } },
|
||||||
|
{
|
||||||
|
eventId: "thinking-2-delta",
|
||||||
|
event: { event: "thinking_delta", data: { text: "next tho" } },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const restoredPrefix: ConsoleEventInput[] = [{
|
||||||
|
eventId: "restored",
|
||||||
|
event: snapshotWithInFlight([
|
||||||
|
{ kind: "thinking", text: "finished thought", finished: true },
|
||||||
|
{ kind: "thinking", text: "next tho", finished: false },
|
||||||
|
]),
|
||||||
|
}];
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
semanticOutput(projectConsole(restoredPrefix).lines),
|
||||||
|
semanticOutput(projectConsole(livePrefix).lines),
|
||||||
|
);
|
||||||
|
|
||||||
|
const suffix: ConsoleEventInput[] = [
|
||||||
|
{
|
||||||
|
eventId: "thinking-2-suffix",
|
||||||
|
event: { event: "thinking_delta", data: { text: "ught" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "thinking-2-done",
|
||||||
|
event: { event: "thinking_done", data: { text: "next thought" } },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const restored = projectConsole([...restoredPrefix, ...suffix]);
|
||||||
|
assertEquals(
|
||||||
|
semanticOutput(restored.lines),
|
||||||
|
semanticOutput(projectConsole([...livePrefix, ...suffix]).lines),
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
restored.lines.map((line) => [line.kind, line.body, line.streaming]),
|
||||||
|
[
|
||||||
|
["thinking", "finished thought", false],
|
||||||
|
["thinking", "next thought", false],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assertEquals(projectOverviewLines(restored.lines), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("snapshot tool calls retain identity and state through live suffixes", () => {
|
||||||
|
const livePrefix: ConsoleEventInput[] = [
|
||||||
|
{ eventId: "base", event: snapshotEvent("/repo") },
|
||||||
|
{
|
||||||
|
eventId: "pending-start",
|
||||||
|
event: {
|
||||||
|
event: "tool_call_start",
|
||||||
|
data: { id: "pending", name: "Read" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "streaming-start",
|
||||||
|
event: {
|
||||||
|
event: "tool_call_start",
|
||||||
|
data: { id: "streaming", name: "Bash" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "streaming-args",
|
||||||
|
event: {
|
||||||
|
event: "tool_call_args_delta",
|
||||||
|
data: { id: "streaming", json: '{"command":"ec' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "done-start",
|
||||||
|
event: { event: "tool_call_start", data: { id: "done", name: "Glob" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "done-args",
|
||||||
|
event: {
|
||||||
|
event: "tool_call_args_delta",
|
||||||
|
data: { id: "done", json: '{"pattern":"*.ts"}' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "done-call",
|
||||||
|
event: {
|
||||||
|
event: "tool_call_done",
|
||||||
|
data: { id: "done", name: "Glob", arguments: '{"pattern":"*.ts"}' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const restoredPrefix: ConsoleEventInput[] = [{
|
||||||
|
eventId: "restored",
|
||||||
|
event: snapshotWithInFlight([
|
||||||
|
{
|
||||||
|
kind: "tool_call",
|
||||||
|
id: "pending",
|
||||||
|
name: "Read",
|
||||||
|
args: "",
|
||||||
|
state: "pending",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tool_call",
|
||||||
|
id: "streaming",
|
||||||
|
name: "Bash",
|
||||||
|
args: '{"command":"ec',
|
||||||
|
state: "streaming_args",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tool_call",
|
||||||
|
id: "done",
|
||||||
|
name: "Glob",
|
||||||
|
args: '{"pattern":"*.ts"}',
|
||||||
|
state: "done",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}];
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
semanticOutput(projectConsole(restoredPrefix).lines),
|
||||||
|
semanticOutput(projectConsole(livePrefix).lines),
|
||||||
|
);
|
||||||
|
|
||||||
|
const suffix: ConsoleEventInput[] = [
|
||||||
|
{
|
||||||
|
eventId: "pending-done",
|
||||||
|
event: {
|
||||||
|
event: "tool_call_done",
|
||||||
|
data: { id: "pending", name: "Read", arguments: '{"file_path":"a"}' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "streaming-suffix",
|
||||||
|
event: {
|
||||||
|
event: "tool_call_args_delta",
|
||||||
|
data: { id: "streaming", json: 'ho hi"}' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "streaming-done",
|
||||||
|
event: {
|
||||||
|
event: "tool_call_done",
|
||||||
|
data: {
|
||||||
|
id: "streaming",
|
||||||
|
name: "Bash",
|
||||||
|
arguments: '{"command":"echo hi"}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...["pending", "streaming", "done"].map((id): ConsoleEventInput => ({
|
||||||
|
eventId: `${id}-result`,
|
||||||
|
event: {
|
||||||
|
event: "tool_result",
|
||||||
|
data: { id, summary: `${id} result`, output: id, is_error: false },
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
assertEquals(
|
||||||
|
semanticOutput(projectConsole([...restoredPrefix, ...suffix]).lines),
|
||||||
|
semanticOutput(projectConsole([...livePrefix, ...suffix]).lines),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("projectConsole restores system items from snapshot entries", () => {
|
Deno.test("projectConsole restores system items from snapshot entries", () => {
|
||||||
const projection = projectConsole([{
|
const projection = projectConsole([{
|
||||||
eventId: "system-snapshot",
|
eventId: "system-snapshot",
|
||||||
@@ -2666,6 +2962,65 @@ Deno.test("Internal Worker output stays separate and revision-fenced", () => {
|
|||||||
assertEquals(resolveConsoleWorkerView(projection, "missing").sessionId, null);
|
assertEquals(resolveConsoleWorkerView(projection, "missing").sessionId, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("Internal Worker snapshot output continues without entering the parent view", () => {
|
||||||
|
const worker = {
|
||||||
|
session_id: "child-reconnect",
|
||||||
|
name: "research",
|
||||||
|
parent_session_id: "parent-session",
|
||||||
|
kind: "sub_worker" as const,
|
||||||
|
};
|
||||||
|
const snapshot = snapshotEvent("/repo") as SnapshotEvent;
|
||||||
|
snapshot.data.in_flight = {
|
||||||
|
blocks: [{ kind: "text", text: "parent", finished: false }],
|
||||||
|
};
|
||||||
|
snapshot.data.internal_workers = [{
|
||||||
|
worker,
|
||||||
|
revision: 4,
|
||||||
|
session: canonicalSession([]),
|
||||||
|
status: "running",
|
||||||
|
in_flight: {
|
||||||
|
blocks: [{ kind: "text", text: "**chi", finished: false }],
|
||||||
|
},
|
||||||
|
internal_workers: [],
|
||||||
|
}];
|
||||||
|
|
||||||
|
const projector = createConsoleProjector();
|
||||||
|
let projection = projector.append([{ eventId: "snapshot", event: snapshot }]);
|
||||||
|
assertEquals(projection.lines.map((line) => line.body), ["parent"]);
|
||||||
|
assertEquals(
|
||||||
|
projection.internalWorkers[0].console.lines.map((line) => line.body),
|
||||||
|
["**chi"],
|
||||||
|
);
|
||||||
|
|
||||||
|
projection = projector.append([
|
||||||
|
{
|
||||||
|
eventId: "parent-suffix",
|
||||||
|
event: { event: "text_delta", data: { text: " only" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "child-suffix",
|
||||||
|
event: {
|
||||||
|
event: "internal_worker",
|
||||||
|
data: {
|
||||||
|
worker,
|
||||||
|
revision: 5,
|
||||||
|
event: { event: "text_delta", data: { text: "ld**" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assertEquals(projection.lines.map((line) => line.body), ["parent only"]);
|
||||||
|
assertEquals(
|
||||||
|
projection.internalWorkers[0].console.lines.map((line) => [
|
||||||
|
line.kind,
|
||||||
|
line.body,
|
||||||
|
line.streaming,
|
||||||
|
]),
|
||||||
|
[["assistant", "**child**", true]],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("console Worker views expose only direct Internal Workers", () => {
|
Deno.test("console Worker views expose only direct Internal Workers", () => {
|
||||||
const projector = createConsoleProjector();
|
const projector = createConsoleProjector();
|
||||||
projector.append([{
|
projector.append([{
|
||||||
@@ -3032,10 +3387,7 @@ Deno.test("overview hides thinking and aggregates uninterrupted tool activity",
|
|||||||
|
|
||||||
Deno.test("overview keeps tool failure counts without marking the activity as an error", () => {
|
Deno.test("overview keeps tool failure counts without marking the activity as an error", () => {
|
||||||
const overview = projectOverviewLines([
|
const overview = projectOverviewLines([
|
||||||
{
|
consoleLine("thinking-active", "thinking"),
|
||||||
...consoleLine("thinking-in-flight", "in_flight"),
|
|
||||||
title: "in-flight thinking",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
...consoleLine("failed-read", "tool"),
|
...consoleLine("failed-read", "tool"),
|
||||||
error: true,
|
error: true,
|
||||||
@@ -3398,7 +3750,8 @@ Deno.test("committed history inserts current live rows after their last stable a
|
|||||||
historyUser.entryId = "user-1";
|
historyUser.entryId = "user-1";
|
||||||
const currentUser = consoleLine("current-user", "user");
|
const currentUser = consoleLine("current-user", "user");
|
||||||
currentUser.entryId = "user-1";
|
currentUser.entryId = "user-1";
|
||||||
const live = consoleLine("live-assistant", "in_flight");
|
const live = consoleLine("live-assistant", "assistant");
|
||||||
|
live.streaming = true;
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
mergeCommittedHistoryLines([historyUser], [currentUser, live]).map((line) =>
|
mergeCommittedHistoryLines([historyUser], [currentUser, live]).map((line) =>
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ export type ConsoleLineKind =
|
|||||||
| "status"
|
| "status"
|
||||||
| "error"
|
| "error"
|
||||||
| "usage"
|
| "usage"
|
||||||
| "in_flight"
|
|
||||||
| "system";
|
| "system";
|
||||||
|
|
||||||
type ToolCallState =
|
type ToolCallState =
|
||||||
@@ -361,8 +360,7 @@ function projectVisibleConsole(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isOverviewThinkingLine(line: ConsoleLine): boolean {
|
function isOverviewThinkingLine(line: ConsoleLine): boolean {
|
||||||
return line.kind === "thinking" ||
|
return line.kind === "thinking";
|
||||||
(line.kind === "in_flight" && line.title === "in-flight thinking");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function representedToolCallCount(line: ConsoleLine): number {
|
function representedToolCallCount(line: ConsoleLine): number {
|
||||||
@@ -559,12 +557,58 @@ function appendSnapshotInFlightLines(
|
|||||||
eventId: string,
|
eventId: string,
|
||||||
cwd: string | null,
|
cwd: string | null,
|
||||||
): void {
|
): void {
|
||||||
const lineIds = new Set(projection.lines.map((line) => line.id));
|
|
||||||
blocks.forEach((block, index) => {
|
blocks.forEach((block, index) => {
|
||||||
const pending = inFlightLine(`${eventId}:${index}`, block, cwd);
|
const blockEventId = `${eventId}:${index}`;
|
||||||
if (lineIds.has(pending.id)) return;
|
switch (block.kind) {
|
||||||
projection.lines.push(pending);
|
case "text":
|
||||||
lineIds.add(pending.id);
|
appendStreaming(
|
||||||
|
projection,
|
||||||
|
blockEventId,
|
||||||
|
"assistant",
|
||||||
|
"assistant streaming",
|
||||||
|
block.text,
|
||||||
|
);
|
||||||
|
if (block.finished) {
|
||||||
|
finalizeStreaming(
|
||||||
|
projection,
|
||||||
|
"assistant",
|
||||||
|
blockEventId,
|
||||||
|
"assistant",
|
||||||
|
block.text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "thinking":
|
||||||
|
startStreaming(projection, blockEventId, "thinking", "Thinking...");
|
||||||
|
if (block.text) {
|
||||||
|
appendStreaming(
|
||||||
|
projection,
|
||||||
|
blockEventId,
|
||||||
|
"thinking",
|
||||||
|
"Thinking...",
|
||||||
|
block.text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (block.finished) {
|
||||||
|
finalizeStreaming(
|
||||||
|
projection,
|
||||||
|
"thinking",
|
||||||
|
blockEventId,
|
||||||
|
"Thought",
|
||||||
|
block.text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "tool_call":
|
||||||
|
upsertToolCall(projection, blockEventId, block.id, {
|
||||||
|
name: block.name,
|
||||||
|
argsStream: block.args,
|
||||||
|
arguments: block.state === "done" ? block.args : undefined,
|
||||||
|
state: inFlightToolState(block.state),
|
||||||
|
cwd,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -924,10 +968,23 @@ export function applyProtocolEvent(
|
|||||||
entryId: event.data.entry_id ?? undefined,
|
entryId: event.data.entry_id ?? undefined,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case "session_entry_committed":
|
case "session_entry_committed": {
|
||||||
reconcileCommittedSessionEntry(next, event.data.entry);
|
const replacementIndex = reconcileCommittedSessionEntry(
|
||||||
|
next,
|
||||||
|
event.data.entry,
|
||||||
|
);
|
||||||
applySessionEntry(next, envelope.eventId, event.data.entry);
|
applySessionEntry(next, envelope.eventId, event.data.entry);
|
||||||
|
if (replacementIndex !== undefined) {
|
||||||
|
const committedIndex = next.lines.findIndex((line) =>
|
||||||
|
line.entryId === event.data.entry.entry_id
|
||||||
|
);
|
||||||
|
if (committedIndex >= 0) {
|
||||||
|
const [committed] = next.lines.splice(committedIndex, 1);
|
||||||
|
next.lines.splice(replacementIndex, 0, committed);
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case "system_item":
|
case "system_item":
|
||||||
next.lines.push(systemItemLine(envelope.eventId, event.data.item));
|
next.lines.push(systemItemLine(envelope.eventId, event.data.item));
|
||||||
applyTaskSystemItem(next, event.data.item);
|
applyTaskSystemItem(next, event.data.item);
|
||||||
@@ -951,9 +1008,7 @@ export function applyProtocolEvent(
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case "thinking_start":
|
case "thinking_start":
|
||||||
next.lines.push(
|
startStreaming(next, envelope.eventId, "thinking", "Thinking...");
|
||||||
line(envelope.eventId, "thinking", "Thinking...", "", undefined, true),
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
case "thinking_delta":
|
case "thinking_delta":
|
||||||
appendStreaming(
|
appendStreaming(
|
||||||
@@ -1296,7 +1351,8 @@ function appendDurableRunFailure(
|
|||||||
) return;
|
) return;
|
||||||
projection.lines = projection.lines.filter((line) => {
|
projection.lines = projection.lines.filter((line) => {
|
||||||
if (isDurableRunFailure(line)) return true;
|
if (isDurableRunFailure(line)) return true;
|
||||||
return !(line.kind === "error" && runFailureMessagesMatch(line.body, message));
|
return !(line.kind === "error" &&
|
||||||
|
runFailureMessagesMatch(line.body, message));
|
||||||
});
|
});
|
||||||
projection.lines.push({
|
projection.lines.push({
|
||||||
...line(
|
...line(
|
||||||
@@ -1344,6 +1400,15 @@ function findLastLineIndex(
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startStreaming(
|
||||||
|
projection: ConsoleProjection,
|
||||||
|
eventId: string,
|
||||||
|
kind: "assistant" | "thinking",
|
||||||
|
title: string,
|
||||||
|
): void {
|
||||||
|
projection.lines.push(line(eventId, kind, title, "", undefined, true));
|
||||||
|
}
|
||||||
|
|
||||||
function appendStreaming(
|
function appendStreaming(
|
||||||
projection: ConsoleProjection,
|
projection: ConsoleProjection,
|
||||||
eventId: string,
|
eventId: string,
|
||||||
@@ -2131,16 +2196,22 @@ function snapshotProjectionFromSession(
|
|||||||
function reconcileCommittedSessionEntry(
|
function reconcileCommittedSessionEntry(
|
||||||
projection: ConsoleProjection,
|
projection: ConsoleProjection,
|
||||||
entry: SessionSnapshotEntry,
|
entry: SessionSnapshotEntry,
|
||||||
): void {
|
): number | undefined {
|
||||||
if (entry.kind !== "message" || entry.role !== "assistant") return;
|
if (entry.kind !== "message" || entry.role !== "assistant") return;
|
||||||
for (let index = projection.lines.length - 1; index >= 0; index -= 1) {
|
const turnStart = findLastLineIndex(
|
||||||
|
projection.lines,
|
||||||
|
(line) => line.kind === "user",
|
||||||
|
);
|
||||||
|
for (let index = turnStart + 1; index < projection.lines.length; index += 1) {
|
||||||
const line = projection.lines[index];
|
const line = projection.lines[index];
|
||||||
if (line.entryId) continue;
|
if (
|
||||||
if (line.kind === "assistant" || line.kind === "in_flight") {
|
line.kind === "assistant" &&
|
||||||
|
!line.entryId &&
|
||||||
|
line.streaming !== true
|
||||||
|
) {
|
||||||
projection.lines.splice(index, 1);
|
projection.lines.splice(index, 1);
|
||||||
return;
|
return index;
|
||||||
}
|
}
|
||||||
if (line.kind === "user") return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2358,42 +2429,6 @@ function loggedContentText(parts: unknown[]): string {
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function inFlightLine(
|
|
||||||
eventId: string,
|
|
||||||
block: InFlightBlock,
|
|
||||||
cwd: string | null,
|
|
||||||
): ConsoleLine {
|
|
||||||
switch (block.kind) {
|
|
||||||
case "text":
|
|
||||||
return line(
|
|
||||||
eventId,
|
|
||||||
"in_flight",
|
|
||||||
"in-flight assistant text",
|
|
||||||
block.text,
|
|
||||||
undefined,
|
|
||||||
!block.finished,
|
|
||||||
);
|
|
||||||
case "thinking":
|
|
||||||
return line(
|
|
||||||
eventId,
|
|
||||||
"in_flight",
|
|
||||||
"in-flight thinking",
|
|
||||||
block.text,
|
|
||||||
undefined,
|
|
||||||
!block.finished,
|
|
||||||
);
|
|
||||||
case "tool_call":
|
|
||||||
return toolLine(eventId, {
|
|
||||||
id: block.id,
|
|
||||||
name: block.name,
|
|
||||||
argsStream: block.args,
|
|
||||||
arguments: block.state === "done" ? block.args : undefined,
|
|
||||||
state: inFlightToolState(block.state),
|
|
||||||
cwd,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function inFlightToolState(
|
function inFlightToolState(
|
||||||
state: InFlightToolCallState | undefined,
|
state: InFlightToolCallState | undefined,
|
||||||
): ToolCallState {
|
): ToolCallState {
|
||||||
|
|||||||
@@ -54,11 +54,11 @@ Deno.test("streaming updates change the preview without creating another turn",
|
|||||||
equal(answer.body, "Hi");
|
equal(answer.body, "Hi");
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("snapshot in-flight assistant text is previewed but in-flight thinking is not", () => {
|
Deno.test("snapshot-restored assistant text is previewed but thinking is not", () => {
|
||||||
equal(consoleTurns([
|
equal(consoleTurns([
|
||||||
line("u", "user", "Question"),
|
line("u", "user", "Question"),
|
||||||
{ ...line("reasoning", "in_flight", "Private"), title: "in-flight thinking" },
|
line("reasoning", "thinking", "Private"),
|
||||||
{ ...line("text", "in_flight", "Answer in progress"), title: "in-flight assistant text" },
|
line("text", "assistant", "Answer in progress"),
|
||||||
]), [{ id: "u", user: "Question", assistant: "Answer in progress" }]);
|
]), [{ id: "u", user: "Question", assistant: "Answer in progress" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,11 +23,7 @@ export function consoleTurns(lines: readonly ConsoleLine[]): ConsoleTurn[] {
|
|||||||
if (line.kind === "user") {
|
if (line.kind === "user") {
|
||||||
current = { id: line.id, user: previewText(line.body), assistant: "" };
|
current = { id: line.id, user: previewText(line.body), assistant: "" };
|
||||||
turns.push(current);
|
turns.push(current);
|
||||||
} else if (
|
} else if (current && line.kind === "assistant") {
|
||||||
current &&
|
|
||||||
(line.kind === "assistant" ||
|
|
||||||
(line.kind === "in_flight" && line.title === "in-flight assistant text"))
|
|
||||||
) {
|
|
||||||
const assistant = previewText(line.body);
|
const assistant = previewText(line.body);
|
||||||
if (assistant) current.assistant = assistant;
|
if (assistant) current.assistant = assistant;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user