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

+12
View File
@@ -26,6 +26,18 @@ describe("channel", () => {
expect(JSON.parse(seen[0]!.data)).toEqual({ a: 1 });
});
it("lastEventId tracks the most recently assigned seq (unicast included; seq 0 when none)", () => {
const ch = new Channel();
expect(ch.lastEventId).toBe(`${ch.epoch}-0`);
ch.publish("a");
expect(ch.lastEventId).toBe(`${ch.epoch}-1`);
// Unicast (sendTo) consumes a seq too: lastEventId is a position marker, not a buffer key.
ch.sendTo(() => {}, "hello", "server_event");
expect(ch.lastEventId).toBe(`${ch.epoch}-2`);
ch.publish("b");
expect(ch.lastEventId).toBe(`${ch.epoch}-3`);
});
it("no longer receives after unsubscribe", () => {
const ch = new Channel();
const seen: ChannelEvent[] = [];
+174
View File
@@ -0,0 +1,174 @@
/**
* LiveTailTracker unit tests: open-fragment accumulation into synthetic `partial_* start`
* messages, closure on stop / matching complete message / clear, origin preservation for
* subagent fragments, and the tail-keeping cap.
*/
import { describe, expect, it } from "vitest";
import {
assistantText,
partialText,
partialThinking,
partialToolCall,
partialToolCallOutput,
requestBegin,
thinkingMessage,
toolCall,
toolCallOutput,
userText,
withOrigin,
} from "@prismshadow/penguin-core";
import type {
PartialTextPayload,
PartialToolCallOutputPayload,
PartialToolCallPayload,
} from "@prismshadow/penguin-core";
import { LiveTailTracker } from "../src/runtime/live-tail.js";
const SID = "s1";
describe("live-tail", () => {
it("accumulates text/thinking/tool-call/tool-output fragments into synthetic starts with full prefixes", () => {
const t = new LiveTailTracker();
t.observe(SID, partialThinking("start"));
t.observe(SID, partialThinking("delta", "let me "));
t.observe(SID, partialThinking("delta", "think"));
t.observe(SID, partialText("start", ""));
t.observe(SID, partialText("delta", "Hel"));
t.observe(SID, partialText("delta", "lo"));
t.observe(SID, partialToolCall({ eventType: "start", name: "exec_command", toolCallId: "t1" }));
t.observe(
SID,
partialToolCall({
eventType: "delta",
name: "",
arguments: '{"cmd":"ls"}',
toolCallId: "t1",
}),
);
const frags = t.fragments(SID);
expect(frags.map((f) => (f.payload as { type: string }).type)).toEqual([
"partial_thinking",
"partial_text",
"partial_tool_call",
]);
// Every synthetic message is a start carrying the accumulated content.
for (const f of frags) {
expect((f.payload as { event_type: string }).event_type).toBe("start");
}
expect((frags[0]!.payload as { thinking: string }).thinking).toBe("let me think");
expect((frags[1]!.payload as PartialTextPayload).text).toBe("Hello");
const call = frags[2]!.payload as PartialToolCallPayload;
expect(call.name).toBe("exec_command");
expect(call.arguments).toBe('{"cmd":"ls"}');
expect(call.tool_call_id).toBe("t1");
});
it("stop closes the fragment; a matching complete message also closes it (stop-less closure)", () => {
const t = new LiveTailTracker();
t.observe(SID, partialText("start", "a"));
t.observe(SID, partialThinking("start", "b"));
expect(t.fragments(SID)).toHaveLength(2);
t.observe(SID, partialText("stop"));
expect(t.fragments(SID)).toHaveLength(1);
// Complete thinking closes the open thinking fragment even without a stop.
t.observe(SID, thinkingMessage("b (complete)"));
expect(t.fragments(SID)).toEqual([]);
});
it("a complete tool_call/tool_call_output closes only the fragment with the same tool_call_id", () => {
const t = new LiveTailTracker();
t.observe(SID, partialToolCallOutput({ eventType: "start", toolCallId: "t1" }));
t.observe(
SID,
partialToolCallOutput({ eventType: "delta", output: "line 1\n", toolCallId: "t1" }),
);
t.observe(SID, partialToolCall({ eventType: "start", name: "x", toolCallId: "t2" }));
t.observe(SID, toolCallOutput({ output: "other", toolCallId: "t9" }));
expect(t.fragments(SID)).toHaveLength(2);
t.observe(SID, toolCallOutput({ output: "line 1\nline 2\n", toolCallId: "t1" }));
t.observe(SID, toolCall({ name: "x", arguments: "{}", toolCallId: "t2" }));
expect(t.fragments(SID)).toEqual([]);
});
it("a user text (steering) does not close the model's open text fragment; events are ignored", () => {
const t = new LiveTailTracker();
t.observe(SID, partialText("start", "prefix"));
t.observe(SID, userText("steer this way"));
t.observe(SID, requestBegin());
const frags = t.fragments(SID);
expect(frags).toHaveLength(1);
expect((frags[0]!.payload as PartialTextPayload).text).toBe("prefix");
// The assistant's complete text does close it.
t.observe(SID, assistantText("prefix done"));
expect(t.fragments(SID)).toEqual([]);
});
it("preserves origin: subagent fragments are keyed and emitted with their origin chain", () => {
const t = new LiveTailTracker();
t.observe(SID, partialText("start", "parent "));
t.observe(SID, withOrigin(partialText("start", "child "), "c1"));
t.observe(SID, withOrigin(partialText("delta", "progress"), "c1"));
// The parent's complete text closes only the parent fragment (same type, different origin).
t.observe(SID, assistantText("parent done"));
const frags = t.fragments(SID);
expect(frags).toHaveLength(1);
expect(frags[0]!.origin).toEqual(["c1"]);
expect((frags[0]!.payload as PartialTextPayload).text).toBe("child progress");
});
it("tool-output images ride the synthetic start (whole-set semantics)", () => {
const dataUrl = "data:image/png;base64,AAAA";
const t = new LiveTailTracker();
t.observe(SID, partialToolCallOutput({ eventType: "start", toolCallId: "t1" }));
t.observe(
SID,
partialToolCallOutput({
eventType: "delta",
output: "image/png",
toolCallId: "t1",
images: [dataUrl],
}),
);
const frag = t.fragments(SID)[0]!.payload as PartialToolCallOutputPayload;
expect(frag.output).toBe("image/png");
expect(frag.images).toEqual([dataUrl]);
});
it("caps a runaway fragment by keeping the TAIL (the seed then joins the live deltas seamlessly)", () => {
const t = new LiveTailTracker();
t.observe(SID, partialText("start", ""));
const chunk = "x".repeat(64 * 1024);
for (let i = 0; i < 12; i += 1) t.observe(SID, partialText("delta", chunk)); // 768KB total
t.observe(SID, partialText("delta", "THE-TAIL"));
const text = (t.fragments(SID)[0]!.payload as PartialTextPayload).text;
expect(text.length).toBeLessThanOrEqual(512 * 1024 + 64 * 1024);
expect(text.endsWith("THE-TAIL")).toBe(true);
});
it("clear drops every fragment of the session; other sessions are unaffected", () => {
const t = new LiveTailTracker();
t.observe(SID, partialText("start", "a"));
t.observe("s2", partialText("start", "b"));
t.clear(SID);
expect(t.fragments(SID)).toEqual([]);
expect(t.fragments("s2")).toHaveLength(1);
});
it("a lenient delta without a start still opens a fragment (mirrors core PartialAggregator); a bare stop is a no-op", () => {
const t = new LiveTailTracker();
t.observe(SID, partialText("stop"));
expect(t.fragments(SID)).toEqual([]);
t.observe(SID, partialText("delta", "late"));
expect((t.fragments(SID)[0]!.payload as PartialTextPayload).text).toBe("late");
});
it("a reopening start replaces the previous same-key fragment", () => {
const t = new LiveTailTracker();
t.observe(SID, partialText("start", "first"));
t.observe(SID, partialText("start", "second"));
const frags = t.fragments(SID);
expect(frags).toHaveLength(1);
expect((frags[0]!.payload as PartialTextPayload).text).toBe("second");
});
});
+136
View File
@@ -0,0 +1,136 @@
/**
* GET /api/sessions/:id/messages live-tail integration: while a Task runs, the response
* carries `live` — a channel cursor plus one synthetic `partial_* start` per open
* streaming fragment (origin preserved for subagent fragments); once the run ends the
* field disappears and the tail is cleared.
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
approvalDecision,
assistantText,
partialText,
partialThinking,
partialToolCallOutput,
toolCall,
toolCallOutput,
userText,
withOrigin,
} from "@prismshadow/penguin-core";
import type { ApproveFn, OmniMessage } from "@prismshadow/penguin-core";
import type { MessagesResponse } from "../src/api/types.js";
import type { SessionRow } from "../src/db/repos/sessions.js";
import type { RuntimeSession } from "../src/runtime/session-manager.js";
import { apiClient, createTestApp, provisionUser, waitFor } from "./helpers.js";
import type { TestApp } from "./helpers.js";
const SID = "session-2026-07-25-10-00-00-cafe0001";
/**
* Fake session frozen mid-stream: it streams a thinking fragment, a closed text segment,
* a tool call with a partially streamed output, and a subagent text fragment, then blocks
* on approval (always-ask) so the Task stays running while the test inspects /messages.
*/
function midStreamFakeSession(sessionId: string): RuntimeSession {
return {
sessionId,
toolPermission: () => "rw",
generateTitle: async () => ({ title: null, usage: null }),
compactability: () => "ok" as const,
steer: () => false,
async *run(_input: OmniMessage[], opts: { approve: ApproveFn; signal: AbortSignal }) {
yield partialThinking("start");
yield partialThinking("delta", "let me think");
// A closed segment: start+delta+stop+complete leaves no open fragment behind.
yield partialText("start", "");
yield partialText("delta", "Hello");
yield partialText("stop");
yield assistantText("Hello");
const tc = toolCall({ name: "exec_command", arguments: '{"cmd":"x"}', toolCallId: "tc-lv" });
yield tc;
yield withOrigin(partialText("start", "child says "), "child-1");
yield partialToolCallOutput({ eventType: "start", toolCallId: "tc-lv" });
yield partialToolCallOutput({ eventType: "delta", output: "line 1\n", toolCallId: "tc-lv" });
const decision = await opts.approve(tc); // blocks until the test decides
yield approvalDecision(decision, "tc-lv");
yield toolCallOutput({ output: "line 1\nline 2\n", toolCallId: "tc-lv" });
yield assistantText("done");
},
async *compact() {},
};
}
describe("messages live tail", () => {
let t: TestApp;
let cookie: string;
let row: SessionRow;
beforeEach(async () => {
t = await createTestApp();
({ cookie } = await provisionUser(t.app, "livetailer"));
row = {
sessionId: SID,
projectId: "livetailer-default_project",
agentId: "default_agent",
modelId: "m1",
provider: "custom",
workspace: "/tmp/w",
approvalMode: "always-ask",
title: null,
createdAt: new Date().toISOString(),
};
t.deps.sessionsRepo.insert(row);
});
afterEach(async () => {
await t.cleanup();
});
const getMessages = async (): Promise<MessagesResponse> => {
const res = await apiClient(t.app, cookie).get(`/api/sessions/${SID}/messages`);
expect(res.status).toBe(200);
return (await res.json()) as MessagesResponse;
};
it("no `live` field while idle", async () => {
const body = await getMessages();
expect(body.messages).toEqual([]);
expect(body.live).toBeUndefined();
});
it("while running: `live` carries the cursor and one synthetic start per open fragment (origin preserved); it disappears once the run ends", async () => {
t.deps.manager.adopt(row, midStreamFakeSession(SID));
await t.deps.manager.startTask(SID, [userText("go")]);
await waitFor(() => t.deps.manager.pendingApprovalCount(SID) === 1);
const body = await getMessages();
expect(body.live).toBeDefined();
expect(body.live!.cursor).toMatch(/^[0-9a-f]{8}-\d+$/);
const frags = body.live!.fragments.map((f) => ({
type: (f.payload as { type: string }).type,
origin: f.origin,
}));
// Closed segments (the text) leave nothing; the thinking, the subagent text and the
// tool output are still open. All are `start` events with accumulated content.
expect(frags).toEqual([
{ type: "partial_thinking", origin: undefined },
{ type: "partial_text", origin: ["child-1"] },
{ type: "partial_tool_call_output", origin: undefined },
]);
for (const f of body.live!.fragments) {
expect((f.payload as { event_type: string }).event_type).toBe("start");
}
expect((body.live!.fragments[0]!.payload as { thinking: string }).thinking).toBe(
"let me think",
);
expect((body.live!.fragments[1]!.payload as { text: string }).text).toBe("child says ");
const out = body.live!.fragments[2]!.payload as { output: string; tool_call_id: string };
expect(out.output).toBe("line 1\n");
expect(out.tool_call_id).toBe("tc-lv");
// Let the run finish: the tail is cleared and `live` disappears.
expect(t.deps.manager.decideApproval(SID, "tc-lv", "deny")).toBe(true);
await waitFor(() => t.deps.manager.statusOf(SID) === "idle");
expect(t.deps.manager.liveFragments(SID)).toEqual([]);
const after = await getMessages();
expect(after.live).toBeUndefined();
});
});