fix(server,web): live in-progress output survives refresh and reconnect (#77)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei ZhengandClaude Fable 5 authored and GitHub committed 2026-07-27 22:31:57 +08:00
1 parent 3d3495528c
commit 094ebd6118
18 files changed
+1214 -40

No files matched your search

+118 -6
View File
@@ -9,17 +9,19 @@ import { describe, expect, it } from "vitest";
import {
approvalDecision,
assistantText,
partialText,
partialToolCallOutput,
tokenUsage,
toolCall,
userText,
withOrigin,
} from "@prismshadow/penguin-core/omnimessage";
import type { OmniMessage, TokenCounts } from "@prismshadow/penguin-core/omnimessage";
import type { ServerEvent, SessionStatus } from "@prismshadow/penguin-server/api";
import type { MessagesLiveTail, ServerEvent, SessionStatus } from "@prismshadow/penguin-server/api";
import { createStreamController } from "../src/lib/omni/stream-controller";
import type { StreamController } from "../src/lib/omni/stream-controller";
import { approvalKey, findToolCard } from "../src/lib/omni/stream-model";
import type { ToolCallItem } from "../src/lib/omni/stream-model";
import type { AssistantTextItem, ToolCallItem } from "../src/lib/omni/stream-model";
/** Override a message timestamp (constructor defaults to the current time). */
function at<M extends OmniMessage>(msg: M, ts: string): M {
@@ -39,13 +41,13 @@ interface Harness {
errors: Array<string | null>;
loadings: boolean[];
loadCalls: () => number;
resolveLoad: (messages: OmniMessage[]) => void;
resolveLoad: (messages: OmniMessage[], live?: MessagesLiveTail) => void;
rejectLoad: (err: Error) => void;
}
function createHarness(): Harness {
const pendingLoads: Array<{
resolve: (m: OmniMessage[]) => void;
resolve: (m: { messages: OmniMessage[]; live?: MessagesLiveTail }) => void;
reject: (e: unknown) => void;
}> = [];
const states: SessionStatus[] = [];
@@ -54,7 +56,7 @@ function createHarness(): Harness {
let calls = 0;
const controller = createStreamController({
loadMessages: () =>
new Promise<OmniMessage[]>((resolve, reject) => {
new Promise<{ messages: OmniMessage[]; live?: MessagesLiveTail }>((resolve, reject) => {
calls += 1;
pendingLoads.push({ resolve, reject });
}),
@@ -71,7 +73,8 @@ function createHarness(): Harness {
errors,
loadings,
loadCalls: () => calls,
resolveLoad: (messages) => pendingLoads.shift()!.resolve(messages),
resolveLoad: (messages, live) =>
pendingLoads.shift()!.resolve({ messages, ...(live !== undefined ? { live } : {}) }),
rejectLoad: (err) => pendingLoads.shift()!.reject(err),
};
}
@@ -255,6 +258,115 @@ describe("resync rebuild", () => {
});
});
describe("live-tail seeding (reload mid-stream)", () => {
it("drops buffered partials at/before the cursor and seeds fragments; later partials continue on top", async () => {
const h = createHarness();
const p = h.controller.load();
h.controller.handleServer({ type: "task_state", state: "running" }, "e1-3");
// Already covered by the fragment snapshot (seq <= cursor): must be dropped, or the
// seeded prefix would double.
h.controller.handleOmni(partialText("start", "Hel"), "e1-4");
h.controller.handleOmni(partialText("delta", "lo"), "e1-5");
// After the cursor: continues the seeded fragment.
h.controller.handleOmni(partialText("delta", " world"), "e1-6");
h.resolveLoad([at(userText("question"), "2026-07-05T00:00:00.000Z")], {
cursor: "e1-5",
fragments: [at(partialText("start", "Hello"), "2026-07-05T00:00:01.000Z")],
});
await p;
const texts = h.controller.model.items.filter((i) => i.kind === "assistant_text");
expect(texts).toHaveLength(1);
expect((texts[0] as AssistantTextItem).text).toBe("Hello world");
expect((texts[0] as AssistantTextItem).streaming).toBe(true);
});
it("tool-output fragment seeds onto the history-built card and keeps streaming", async () => {
const h = createHarness();
const p = h.controller.load();
h.controller.handleServer({ type: "task_state", state: "running" }, "e1-7");
h.controller.handleOmni(
partialToolCallOutput({ eventType: "delta", output: "line 2\n", toolCallId: "t1" }),
"e1-9",
);
h.resolveLoad(
[
at(userText("run it"), "2026-07-05T00:00:00.000Z"),
at(
toolCall({ name: "exec_command", arguments: '{"cmd":"x"}', toolCallId: "t1" }),
"2026-07-05T00:00:01.000Z",
),
],
{
cursor: "e1-8",
fragments: [
at(
partialToolCallOutput({ eventType: "start", output: "line 1\n", toolCallId: "t1" }),
"2026-07-05T00:00:02.000Z",
),
],
},
);
await p;
const cards = h.controller.model.items.filter((i) => i.kind === "tool_call");
expect(cards).toHaveLength(1);
const card = cards[0] as ToolCallItem;
expect(card.output).toBe("line 1\nline 2\n");
expect(card.outputStreaming).toBe(true);
expect(card.outputComplete).toBe(false);
});
it("epoch mismatch: neither drops nor seeds (buffered partials replay as-is)", async () => {
const h = createHarness();
const p = h.controller.load();
h.controller.handleOmni(partialText("start", "He"), "old-4");
h.controller.handleOmni(partialText("delta", "llo"), "old-5");
h.resolveLoad([], {
cursor: "new-9",
fragments: [partialText("start", "Hello")],
});
await p;
// The buffered start+delta applied normally; the fragment was NOT seeded (a seed on
// top of the applied start would produce a second item).
const texts = h.controller.model.items.filter((i) => i.kind === "assistant_text");
expect(texts).toHaveLength(1);
expect((texts[0] as AssistantTextItem).text).toBe("Hello");
});
it("complete messages at/before the cursor are never cursor-dropped: overlap dedup decides", async () => {
const h = createHarness();
const p = h.controller.load();
const inHistory = at(assistantText("done"), "2026-07-05T00:00:01.000Z");
// Trace append still in flight at read time: the buffered copy is the only copy.
const diskLagged = at(assistantText("lagged"), "2026-07-05T00:00:02.000Z");
h.controller.handleOmni(inHistory, "e1-4");
h.controller.handleOmni(diskLagged, "e1-5");
h.resolveLoad([at(userText("q"), "2026-07-05T00:00:00.000Z"), inHistory], {
cursor: "e1-6",
fragments: [],
});
await p;
const texts = h.controller.model.items.filter((i) => i.kind === "assistant_text");
expect(texts.map((i) => (i as AssistantTextItem).text)).toEqual(["done", "lagged"]);
});
it("subagent fragments preserve origin and land on the nested model", async () => {
const h = createHarness();
const p = h.controller.load();
h.controller.handleServer({ type: "task_state", state: "running" }, "e1-2");
h.resolveLoad([], {
cursor: "e1-2",
fragments: [withOrigin(partialText("start", "sub progress"), "c1")],
});
await p;
const sub = h.controller.model.subagents.get("c1");
expect(sub).toBeDefined();
const texts = sub!.items.filter((i) => i.kind === "assistant_text");
expect(texts).toHaveLength(1);
expect((texts[0] as AssistantTextItem).text).toBe("sub progress");
expect((texts[0] as AssistantTextItem).streaming).toBe(true);
});
});
describe("history load failure and retry (#6)", () => {
it("failure surfaces the error and stops loading; retry keeps the buffer (snapshot and initial events are not lost)", async () => {
const h = createHarness();
+104
View File
@@ -224,6 +224,110 @@ describe("partial aggregation and full-message convergence", () => {
});
});
describe("live-tail synthetic starts (mid-stream join seeding)", () => {
it("a text start carrying the accumulated prefix opens a streaming item on top of history; deltas continue and the full message replaces", () => {
const m = createStreamModel();
pushMessage(m, userText("question"));
pushMessage(m, partialText("start", "Already streamed prefix"));
const item = items(m)[1] as AssistantTextItem;
expect(item.kind).toBe("assistant_text");
expect(item.text).toBe("Already streamed prefix");
expect(item.streaming).toBe(true);
pushMessage(m, partialText("delta", " + live tail"));
expect(item.text).toBe("Already streamed prefix + live tail");
pushMessage(m, partialText("stop"));
pushMessage(m, assistantText("Already streamed prefix + live tail."));
expect(items(m).filter((i) => i.kind === "assistant_text")).toHaveLength(1);
expect(item.text).toBe("Already streamed prefix + live tail.");
});
it("a thinking start carrying the accumulated prefix seeds a streaming thinking item with its start time", () => {
const m = createStreamModel();
pushMessage(m, at(partialThinking("start", "half a thought"), "2026-07-05T00:00:01.000Z"));
const item = items(m)[0] as ThinkingItem;
expect(item.thinking).toBe("half a thought");
expect(item.streaming).toBe(true);
expect(item.startedAtMs).toBe(Date.parse("2026-07-05T00:00:01.000Z"));
});
it("an output start seeds the prefix (and images) onto a call-complete card; a start on an outputComplete card is ignored", () => {
const dataUrl = "data:image/png;base64,AAAA";
const m = createStreamModel();
pushMessage(m, toolCall({ name: "exec_command", arguments: '{"cmd":"x"}', toolCallId: "t1" }));
const card = items(m)[0] as ToolCallItem;
// Synthetic start carries the accumulated prefix + the whole image set.
pushMessage(
m,
partialToolCallOutput({
eventType: "start",
output: "line 1\nline 2\n",
toolCallId: "t1",
images: [dataUrl],
}),
);
expect(card.output).toBe("line 1\nline 2\n");
expect(card.outputStreaming).toBe(true);
expect(card.images).toEqual([dataUrl]);
pushMessage(
m,
partialToolCallOutput({ eventType: "delta", output: "line 3\n", toolCallId: "t1" }),
);
expect(card.output).toBe("line 1\nline 2\nline 3\n");
// Once the output is complete, a stray synthetic start must not reopen or append.
pushMessage(m, toolCallOutput({ output: "final", toolCallId: "t1" }));
pushMessage(
m,
partialToolCallOutput({ eventType: "start", output: "stale", toolCallId: "t1" }),
);
expect(card.output).toBe("final");
expect(card.outputStreaming).toBe(false);
});
it("an arguments start for an id whose call is already complete is ignored (no duplicate card, no reset)", () => {
const m = createStreamModel();
pushMessage(m, toolCall({ name: "exec_command", arguments: '{"cmd":"x"}', toolCallId: "t1" }));
pushMessage(
m,
partialToolCall({
eventType: "start",
name: "exec_command",
arguments: '{"cmd":"x"}',
toolCallId: "t1",
}),
);
expect(items(m)).toHaveLength(1);
expect((items(m)[0] as ToolCallItem).argumentsText).toBe('{"cmd":"x"}');
});
it("an arguments start carrying accumulated arguments seeds a card that the full message then completes", () => {
const m = createStreamModel();
pushMessage(
m,
partialToolCall({
eventType: "start",
name: "exec_command",
arguments: '{"cmd":"seq 1',
toolCallId: "t1",
}),
);
const card = items(m)[0] as ToolCallItem;
expect(card.argumentsText).toBe('{"cmd":"seq 1');
expect(card.callStreaming).toBe(true);
pushMessage(
m,
partialToolCall({ eventType: "delta", name: "", arguments: ' 40"}', toolCallId: "t1" }),
);
pushMessage(m, partialToolCall({ eventType: "stop", name: "", toolCallId: "t1" }));
pushMessage(
m,
toolCall({ name: "exec_command", arguments: '{"cmd":"seq 1 40"}', toolCallId: "t1" }),
);
expect(items(m)).toHaveLength(1);
expect(card.argumentsText).toBe('{"cmd":"seq 1 40"}');
expect(card.callComplete).toBe(true);
});
});
describe("approvals and events", () => {
it("approval_decision annotates the matching tool card; locally registered ones are manual, the rest remote", () => {
const m = createStreamModel();