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:
1 parent
3d3495528c
commit
094ebd6118
18 files changed
+1214
-40
No files matched your search
@@ -5,6 +5,10 @@
|
||||
* - files-card probe ("files card test") -> text with two backtick paths (one real, one missing)
|
||||
* - subagent's own turn (its prompt is the only user text) -> final text
|
||||
* - parent asked to delegate ("run a subagent") -> tool_use(run_subagent)
|
||||
* - "slow stream test" -> tool_use(exec_command) with a command that prints one line
|
||||
* every 200ms for ~8s (reload-midstream.spec reloads while its output streams)
|
||||
* - "slow text test" -> a long text streamed one delta every 200ms for ~8s
|
||||
* (reload-midstream.spec reloads while the TEXT streams)
|
||||
* - last message has tool_result -> final text (turn 2)
|
||||
* - otherwise (first user turn) -> thinking + text + tool_use(exec_command)
|
||||
*/
|
||||
@@ -250,6 +254,46 @@ const server = http.createServer((req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Slow tool-output test case (reload-midstream.spec): a real exec_command whose output
|
||||
// streams one line every 200ms for ~8s — long enough to reload the page mid-stream and
|
||||
// watch the output keep growing afterwards.
|
||||
if (flat.includes("slow stream test") && !hasToolResult) {
|
||||
block(res, 0, { type: "tool_use", id: "toolu_slow_1", name: "exec_command", input: {} }, [
|
||||
{ type: "input_json_delta", partial_json: '{"cmd": "for i in $(seq 1 40); do' },
|
||||
{ type: "input_json_delta", partial_json: ' echo line $i; sleep 0.2; done"}' },
|
||||
]);
|
||||
messageStop(res, "tool_use", 14);
|
||||
return;
|
||||
}
|
||||
|
||||
// Slow TEXT test case (reload-midstream.spec): a single text block streamed one delta
|
||||
// every 200ms (~8s total) so the page can be reloaded while the assistant text is
|
||||
// still streaming. Single turn: ends with end_turn, no tool call.
|
||||
if (flat.includes("slow text test")) {
|
||||
sse(res, "content_block_start", {
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "text", text: "" },
|
||||
});
|
||||
let n = 0;
|
||||
const tick = () => {
|
||||
n += 1;
|
||||
sse(res, "content_block_delta", {
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "text_delta", text: `chunk-${n} ` },
|
||||
});
|
||||
if (n < 40) {
|
||||
setTimeout(tick, 200);
|
||||
} else {
|
||||
sse(res, "content_block_stop", { type: "content_block_stop", index: 0 });
|
||||
messageStop(res, "end_turn", 80);
|
||||
}
|
||||
};
|
||||
tick();
|
||||
return;
|
||||
}
|
||||
|
||||
if (wantsSubagent && !hasToolResult) {
|
||||
block(res, 0, { type: "tool_use", id: "toolu_mock_sub", name: "run_subagent", input: {} }, [
|
||||
{ type: "input_json_delta", partial_json: '{"prompt": ' },
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Reload mid-stream: while a Task streams a long tool output (or a long assistant text),
|
||||
* refreshing the page must bring the in-progress message straight back — the already
|
||||
* streamed prefix visible promptly (before the stream finishes), the content still
|
||||
* growing live, and the final state identical to a run that was never reloaded.
|
||||
*
|
||||
* Mechanics under test: GET /messages returns `live` ({cursor, fragments}) while the
|
||||
* Task runs; the frontend seeds the synthetic `partial_* start` fragments on top of
|
||||
* history and drops the buffered partials the snapshot already covers.
|
||||
*
|
||||
* The LLM is mock-llm.mjs: "slow stream test" makes it call exec_command with a command
|
||||
* that prints one line every 200ms for ~8s (real tool execution, really streamed);
|
||||
* "slow text test" streams a 40-chunk text one delta every 200ms.
|
||||
*/
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { provisionAndLogin } from "./auth.mjs";
|
||||
|
||||
const BASE = process.env.BASE_URL;
|
||||
const MOCK = process.env.MOCK_URL;
|
||||
const U = "reloaduser";
|
||||
const P = "password123";
|
||||
|
||||
/** Create a session for the user's auto-provisioned project (models PUT is idempotent). */
|
||||
async function createSession(page, approvalMode) {
|
||||
const projects = await (await page.request.get(`${BASE}/api/projects`)).json();
|
||||
const projectId = projects.projects[0].projectId;
|
||||
const put = await page.request.put(`${BASE}/api/projects/${projectId}/models`, {
|
||||
data: {
|
||||
defaultModel: { provider: "custom", modelId: "claude-4-8" },
|
||||
models: [
|
||||
{
|
||||
provider: "custom",
|
||||
modelId: "claude-4-8",
|
||||
apiKey: "sk-mock",
|
||||
baseUrl: MOCK,
|
||||
contextWindow: 200000,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(put.ok(), "put models").toBeTruthy();
|
||||
const res = await page.request.post(
|
||||
`${BASE}/api/projects/${projectId}/agents/default_agent/sessions`,
|
||||
{ data: { provider: "custom", modelId: "claude-4-8", approvalMode } },
|
||||
);
|
||||
expect(res.ok(), `create session: ${await res.text()}`).toBeTruthy();
|
||||
return (await res.json()).session.sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the running work group open past the end of the turn (it auto-collapses at turn
|
||||
* end unless the user toggled it — same trick as chat.spec), then expand the
|
||||
* exec_command tool card and return the output <pre> locator.
|
||||
*/
|
||||
async function openToolOutput(page) {
|
||||
const group = page
|
||||
.locator("button[aria-expanded]")
|
||||
.filter({ hasText: /运行中|运行完毕/ })
|
||||
.first();
|
||||
await expect(group).toBeVisible();
|
||||
if ((await group.textContent())?.includes("运行中")) {
|
||||
await group.click(); // toggle → marks the group user-toggled
|
||||
await group.click(); // toggle back → deliberately kept open, survives turn end
|
||||
} else if ((await group.getAttribute("aria-expanded")) !== "true") {
|
||||
await group.click(); // finished and collapsed: open it to reach the card
|
||||
}
|
||||
const toolCard = page
|
||||
.locator("button[aria-expanded]")
|
||||
.filter({ hasText: "exec_command" })
|
||||
.first();
|
||||
await expect(toolCard).toBeVisible();
|
||||
if ((await toolCard.getAttribute("aria-expanded")) !== "true") await toolCard.click();
|
||||
await expect(toolCard).toHaveAttribute("aria-expanded", "true");
|
||||
// Two <pre>s live in the expanded card (arguments, then output); the output one is the
|
||||
// only one containing a literal "line 1".
|
||||
return page.locator("pre", { hasText: /line 1\b/ }).first();
|
||||
}
|
||||
|
||||
/** Send the "slow stream test" prompt, approve exec_command, and return the output <pre>. */
|
||||
async function startSlowToolRun(page, sessionId) {
|
||||
await page.goto(`${BASE}/chat/${sessionId}`);
|
||||
const ta = page.getByPlaceholder(/输入消息/);
|
||||
await ta.waitFor();
|
||||
await ta.fill("slow stream test");
|
||||
await page.getByRole("button", { name: "发送" }).click();
|
||||
await expect(page.getByText("exec_command").first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "允许" }).click();
|
||||
const outputPre = await openToolOutput(page);
|
||||
await expect(outputPre).toContainText("line 3");
|
||||
return outputPre;
|
||||
}
|
||||
|
||||
test("in-progress tool output survives a reload: prefix back promptly, still growing, final state matches a never-reloaded run", async ({
|
||||
page,
|
||||
}) => {
|
||||
await provisionAndLogin(page.request, U, P);
|
||||
|
||||
// --- Control run (never reloaded): capture the final tool output to compare against ---
|
||||
const controlId = await createSession(page, "always-ask");
|
||||
await startSlowToolRun(page, controlId);
|
||||
await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const controlPre = await openToolOutput(page);
|
||||
const controlOutput = await controlPre.textContent();
|
||||
expect(controlOutput).toContain("line 40");
|
||||
|
||||
// --- The run under test: reload while the output is streaming ---
|
||||
const sessionId = await createSession(page, "always-ask");
|
||||
await startSlowToolRun(page, sessionId);
|
||||
await page.reload();
|
||||
|
||||
// (a) The already-streamed prefix is back promptly — well before the ~8s command ends.
|
||||
const outputPre = await openToolOutput(page);
|
||||
await expect(outputPre).toContainText("line 2", { timeout: 4000 });
|
||||
// Not finished yet: what we see is genuinely the in-progress stream, not the final state.
|
||||
await expect(outputPre).not.toContainText("line 40");
|
||||
|
||||
// (b) The output keeps growing live on the same connection.
|
||||
await expect(outputPre).toContainText("line 35", { timeout: 15_000 });
|
||||
|
||||
// (c) After completion the final state matches the never-reloaded run exactly
|
||||
// (every line exactly once — no lost prefix, no duplicated overlap).
|
||||
await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const reloadedOutput = await (await openToolOutput(page)).textContent();
|
||||
expect(reloadedOutput).toBe(controlOutput);
|
||||
});
|
||||
|
||||
test("in-progress assistant TEXT survives a reload and keeps streaming", async ({ page }) => {
|
||||
await provisionAndLogin(page.request, U, P);
|
||||
const sessionId = await createSession(page, "allow-all");
|
||||
|
||||
await page.goto(`${BASE}/chat/${sessionId}`);
|
||||
const ta = page.getByPlaceholder(/输入消息/);
|
||||
await ta.waitFor();
|
||||
await ta.fill("slow text test");
|
||||
await page.getByRole("button", { name: "发送" }).click();
|
||||
|
||||
const reply = page.locator(".md-body", { hasText: /chunk-1\s/ }).first();
|
||||
await expect(reply).toContainText("chunk-3 ");
|
||||
await page.reload();
|
||||
|
||||
// (a) The streamed prefix is back promptly, while the ~8s stream is still going.
|
||||
const reloaded = page.locator(".md-body", { hasText: /chunk-1\s/ }).first();
|
||||
await expect(reloaded).toContainText("chunk-2 ", { timeout: 4000 });
|
||||
await expect(reloaded).not.toContainText("chunk-40");
|
||||
|
||||
// (b) It keeps streaming live, up to the full reply.
|
||||
await expect(reloaded).toContainText("chunk-40", { timeout: 15_000 });
|
||||
|
||||
// (c) No duplicated prefix: the buffered partials the snapshot covered were dropped,
|
||||
// so every chunk appears exactly once.
|
||||
const text = await reloaded.textContent();
|
||||
expect(text.match(/chunk-5 /g)).toHaveLength(1);
|
||||
expect(text.match(/chunk-1 /g)).toHaveLength(1);
|
||||
});
|
||||
@@ -15,10 +15,15 @@ import type { OmniMessage } from "@prismshadow/penguin-core/omnimessage";
|
||||
import type { ServerEvent } from "@prismshadow/penguin-server/api";
|
||||
|
||||
export interface StreamHandlers {
|
||||
/** A single OmniMessage (full/streaming/event, envelope as-is). */
|
||||
onOmniMessage: (msg: OmniMessage) => void;
|
||||
/** A single server event. */
|
||||
onServerEvent: (event: ServerEvent) => void;
|
||||
/**
|
||||
* A single OmniMessage (full/streaming/event, envelope as-is). `eventId` is the SSE
|
||||
* event id assigned by the server channel (`<epoch>-<seq>`; null if the event carried
|
||||
* none) — stream-controller uses it to align buffered events with the live-tail cursor
|
||||
* that GET /messages returns.
|
||||
*/
|
||||
onOmniMessage: (msg: OmniMessage, eventId: string | null) => void;
|
||||
/** A single server event (`eventId`: same as onOmniMessage). */
|
||||
onServerEvent: (event: ServerEvent, eventId: string | null) => void;
|
||||
/** Connection established (including a successful auto-reconnect). */
|
||||
onOpen?: () => void;
|
||||
/**
|
||||
@@ -37,14 +42,15 @@ function subscribe(url: string, handlers: StreamHandlers): StreamConnection {
|
||||
const source = new EventSource(url);
|
||||
source.onmessage = (e: MessageEvent<string>) => {
|
||||
try {
|
||||
handlers.onOmniMessage(JSON.parse(e.data) as OmniMessage);
|
||||
// Every server event carries an `id:` line; lastEventId is "" only if none did.
|
||||
handlers.onOmniMessage(JSON.parse(e.data) as OmniMessage, e.lastEventId || null);
|
||||
} catch {
|
||||
// Ignore lines that fail to parse (the protocol guarantees single-line JSON data, so this shouldn't normally happen).
|
||||
}
|
||||
};
|
||||
source.addEventListener("server_event", (e: MessageEvent<string>) => {
|
||||
try {
|
||||
handlers.onServerEvent(JSON.parse(e.data) as ServerEvent);
|
||||
handlers.onServerEvent(JSON.parse(e.data) as ServerEvent, e.lastEventId || null);
|
||||
} catch {
|
||||
// Same as above.
|
||||
}
|
||||
|
||||
@@ -146,7 +146,9 @@ export function useSessionStream(
|
||||
setPendingTick((t) => t + 1);
|
||||
|
||||
const controller = createStreamController({
|
||||
loadMessages: async () => (await getMessages(sessionId)).messages,
|
||||
// The whole response rides through: `live` (in-progress stream tail) lets the
|
||||
// controller seed the currently streaming message after a reload (see stream-controller).
|
||||
loadMessages: () => getMessages(sessionId),
|
||||
onTaskState: setTaskState,
|
||||
onQueuedFollowUps: setQueuedFollowUps,
|
||||
onLoading: setLoading,
|
||||
|
||||
@@ -20,11 +20,18 @@
|
||||
* when a resent approval_request can't find its tool card (sub-session
|
||||
* messages aren't written to the parent Trace, so the card can be missing
|
||||
* after a reload), the toolCall carried by the event (with origin) is fed
|
||||
* to the reducer to rebuild the nested card, making the sub-session's approval visible and decidable.
|
||||
* to the reducer to rebuild the nested card, making the sub-session's approval visible and decidable;
|
||||
* - Live-tail seeding: while a Task runs, `/messages` also returns `live`
|
||||
* ({cursor, fragments} — see MessagesLiveTail): buffered partial events at
|
||||
* or before the cursor are dropped (their content is already accumulated
|
||||
* inside the fragments), the synthetic `partial_* start` fragments are fed
|
||||
* through the normal reducer path at the cursor's position, and the rest of
|
||||
* the buffer replays on top — so the in-progress message survives a refresh
|
||||
* with its streamed prefix intact and keeps streaming.
|
||||
*/
|
||||
import { isEventMessage, isPartialPayload } from "@prismshadow/penguin-core/omnimessage";
|
||||
import type { OmniMessage, ToolCallPayload } 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 {
|
||||
approvalKey,
|
||||
buildDedupIndex,
|
||||
@@ -46,11 +53,23 @@ export interface PendingApproval {
|
||||
origin?: string[];
|
||||
}
|
||||
|
||||
type BufferedEvent = { kind: "omni"; msg: OmniMessage } | { kind: "server"; ev: ServerEvent };
|
||||
/** Buffered stream event; `id` is the SSE event id (`<epoch>-<seq>`, null when unknown); `seeded` marks a live-tail fragment injected at replay time (never carried over into a later round's buffer — the new round refetches fresh fragments). */
|
||||
type BufferedEvent =
|
||||
| { kind: "omni"; msg: OmniMessage; id: string | null; seeded?: boolean }
|
||||
| { kind: "server"; ev: ServerEvent; id: string | null };
|
||||
|
||||
/** Parse a channel event id (`<epoch>-<seq>`); null when malformed (same split rule as the server's Channel.replayAfter). */
|
||||
function parseEventId(id: string): { epoch: string; seq: number } | null {
|
||||
const sep = id.lastIndexOf("-");
|
||||
if (sep <= 0) return null;
|
||||
const seq = Number.parseInt(id.slice(sep + 1), 10);
|
||||
if (!Number.isInteger(seq) || seq < 0) return null;
|
||||
return { epoch: id.slice(0, sep), seq };
|
||||
}
|
||||
|
||||
export interface StreamControllerDeps {
|
||||
/** Fetch history messages (GET /api/sessions/:id/messages). */
|
||||
loadMessages: () => Promise<OmniMessage[]>;
|
||||
/** Fetch history messages (GET /api/sessions/:id/messages), including the live tail while running. */
|
||||
loadMessages: () => Promise<{ messages: OmniMessage[]; live?: MessagesLiveTail }>;
|
||||
/** Authoritative running state from the stream (covers both the subscription snapshot and transition events). */
|
||||
onTaskState: (state: SessionStatus) => void;
|
||||
/** Queued follow-up count carried on task_state events (absent on old servers -> 0). */
|
||||
@@ -78,10 +97,10 @@ export interface StreamController {
|
||||
load: () => Promise<void>;
|
||||
/** Retry entry point after a history load failure (keeps the buffer, refetches history). */
|
||||
retry: () => Promise<void>;
|
||||
/** SSE OmniMessage entry point. */
|
||||
handleOmni: (msg: OmniMessage) => void;
|
||||
/** SSE server-event entry point. */
|
||||
handleServer: (ev: ServerEvent) => void;
|
||||
/** SSE OmniMessage entry point (`eventId`: the SSE event id, used for live-tail cursor alignment). */
|
||||
handleOmni: (msg: OmniMessage, eventId?: string | null) => void;
|
||||
/** SSE server-event entry point (`eventId`: same as handleOmni). */
|
||||
handleServer: (ev: ServerEvent, eventId?: string | null) => void;
|
||||
/** Register that this end clicked an approval ("manual" label, persists across resync rebuilds). */
|
||||
markLocalDecision: (toolCallId: string) => void;
|
||||
/** Remove a pending approval by its composite key (optimistic update; also removed as a fallback when the event arrives). */
|
||||
@@ -101,6 +120,8 @@ export function createStreamController(deps: StreamControllerDeps): StreamContro
|
||||
let buffer: BufferedEvent[] = [];
|
||||
/** The most recent in-stream task_state (null = the snapshot hasn't arrived yet; history finalization trusts only this value). */
|
||||
let streamStatus: SessionStatus | null = null;
|
||||
/** The most recent SSE event id seen on this connection (null = none yet): identifies the channel epoch the live-tail cursor must match. */
|
||||
let lastEventId: string | null = null;
|
||||
/** Load epoch: incremented on rebuild/retry; any replay or finalization from an older epoch is discarded. */
|
||||
let epoch = 0;
|
||||
/** Whether the most recent load failed (retry only takes effect after a failure, to avoid mistakenly replaying history). */
|
||||
@@ -200,9 +221,42 @@ export function createStreamController(deps: StreamControllerDeps): StreamContro
|
||||
await load(epoch, createStreamModel(localDecisions));
|
||||
};
|
||||
|
||||
/**
|
||||
* Weave the live tail into the buffered replay (see MessagesLiveTail in the server API):
|
||||
* entries at/or before the cursor come first with their partials dropped (the fragment
|
||||
* snapshot already accumulates them; completes still replay — the dedup gate decides),
|
||||
* then the synthetic fragment starts at the cursor's position, then everything after the
|
||||
* cursor. Skipped entirely when the cursor's epoch doesn't match the epoch of the events
|
||||
* seen on this connection (channel recycled/server restarted between the two requests —
|
||||
* seq comparison would be meaningless; the resync flow covers that path).
|
||||
*/
|
||||
const weaveLiveTail = (replay: BufferedEvent[], live: MessagesLiveTail): BufferedEvent[] => {
|
||||
const cursor = parseEventId(live.cursor);
|
||||
if (!cursor) return replay;
|
||||
const seen = lastEventId === null ? null : parseEventId(lastEventId);
|
||||
if (seen !== null && seen.epoch !== cursor.epoch) return replay;
|
||||
const pre: BufferedEvent[] = [];
|
||||
const post: BufferedEvent[] = [];
|
||||
for (const e of replay) {
|
||||
const eid = e.id === null ? null : parseEventId(e.id);
|
||||
if (eid !== null && eid.epoch === cursor.epoch && eid.seq <= cursor.seq) {
|
||||
if (e.kind !== "omni" || !isPartialPayload(e.msg.payload)) pre.push(e);
|
||||
} else {
|
||||
post.push(e);
|
||||
}
|
||||
}
|
||||
const seeds: BufferedEvent[] = live.fragments.map((msg) => ({
|
||||
kind: "omni",
|
||||
msg,
|
||||
id: null,
|
||||
seeded: true,
|
||||
}));
|
||||
return [...pre, ...seeds, ...post];
|
||||
};
|
||||
|
||||
const load = async (currentEpoch: number, freshModel?: StreamModel): Promise<void> => {
|
||||
try {
|
||||
const messages = await deps.loadMessages();
|
||||
const { messages, live } = await deps.loadMessages();
|
||||
if (disposed || currentEpoch !== epoch) return;
|
||||
// Rebuild path: make the freshly-built model visible only now, atomically — the old model
|
||||
// stayed on screen throughout the refetch above (see rebuild). Initial load / retry pass no
|
||||
@@ -211,8 +265,9 @@ export function createStreamController(deps: StreamControllerDeps): StreamContro
|
||||
const target = model;
|
||||
pushMessages(target, messages, now());
|
||||
const dedup = buildDedupIndex(messages, 100);
|
||||
// Replay the buffer (events that arrived while fetching history), with dedup.
|
||||
const replay = buffer;
|
||||
// Replay the buffer (events that arrived while fetching history), with dedup; while a
|
||||
// Task runs, the live tail is woven in so the in-progress message is seeded too.
|
||||
const replay = live !== undefined ? weaveLiveTail(buffer, live) : buffer;
|
||||
buffer = [];
|
||||
for (let i = 0; i < replay.length; i += 1) {
|
||||
const e = replay[i]!;
|
||||
@@ -222,8 +277,10 @@ export function createStreamController(deps: StreamControllerDeps): StreamContro
|
||||
if (currentEpoch !== epoch) {
|
||||
// A rebuild was triggered mid-replay (e.g. resync_required): this
|
||||
// round is discarded, and the remaining events are handed off to
|
||||
// the new round's buffer — phase is left unchanged and the old buffer is never fed to the new model.
|
||||
buffer.push(...replay.slice(i + 1));
|
||||
// the new round's buffer — phase is left unchanged and the old buffer is never
|
||||
// fed to the new model. Seeded fragments are snapshot-bound to THIS round and
|
||||
// are dropped instead of carried over (the new round refetches fresh ones).
|
||||
buffer.push(...replay.slice(i + 1).filter((r) => r.kind !== "omni" || !r.seeded));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -269,17 +326,19 @@ export function createStreamController(deps: StreamControllerDeps): StreamContro
|
||||
epoch += 1;
|
||||
await load(epoch, createStreamModel(localDecisions));
|
||||
},
|
||||
handleOmni: (msg) => {
|
||||
handleOmni: (msg, eventId = null) => {
|
||||
if (disposed) return;
|
||||
if (eventId !== null) lastEventId = eventId;
|
||||
if (phase === "buffering") {
|
||||
buffer.push({ kind: "omni", msg });
|
||||
buffer.push({ kind: "omni", msg, id: eventId });
|
||||
return;
|
||||
}
|
||||
feedOmni(msg, null);
|
||||
deps.onModelChange();
|
||||
},
|
||||
handleServer: (ev) => {
|
||||
handleServer: (ev, eventId = null) => {
|
||||
if (disposed) return;
|
||||
if (eventId !== null) lastEventId = eventId;
|
||||
// session_title / session_created only affect list display (unrelated
|
||||
// to the view model/history): forwarded immediately at any phase, never buffered.
|
||||
if (ev.type === "session_title") {
|
||||
@@ -299,7 +358,7 @@ export function createStreamController(deps: StreamControllerDeps): StreamContro
|
||||
deps.onTaskState(ev.state);
|
||||
deps.onQueuedFollowUps?.(ev.queued ?? 0);
|
||||
}
|
||||
buffer.push({ kind: "server", ev });
|
||||
buffer.push({ kind: "server", ev, id: eventId });
|
||||
return;
|
||||
}
|
||||
handleServer(ev);
|
||||
|
||||
@@ -739,6 +739,9 @@ function handlePartial(model: StreamModel, p: PartialModelPayload, tsMs?: number
|
||||
if (p.event_type === "start") {
|
||||
card.outputStreaming = true;
|
||||
if (p.output) card.output += p.output;
|
||||
// A live-tail synthetic start (mid-stream join seed) may already carry the image
|
||||
// set; same whole-set semantics as the delta branch below.
|
||||
if (p.images && p.images.length > 0) card.images = p.images;
|
||||
return;
|
||||
}
|
||||
if (!card.outputStreaming) return; // orphan delta/stop
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user