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 Zheng
2026-07-27 22:31:57 +08:00
committed by GitHub
parent 3d3495528c
commit 094ebd6118
18 changed files with 1214 additions and 40 deletions
+19 -1
View File
@@ -15,6 +15,7 @@ import type { OmniMessage, ThinkingLevelName } from "@prismshadow/penguin-core";
import type {
ApprovalMode,
FilesStatResponse,
MessagesLiveTail,
MessagesResponse,
ServerEvent,
SessionCategory,
@@ -337,12 +338,29 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
app.get("/:sessionId/messages", async (c) => {
const row = resolveSession(c);
// Live tail (running/compacting sessions only): capture the channel cursor and the
// open-fragment snapshot together, synchronously — no await between the two, and both
// BEFORE the trace read starts. That ordering is what makes the client contract safe
// (see MessagesLiveTail in api/types.ts): every published event with id <= cursor is
// already reflected in `fragments`, and partial_* messages never reach the Trace, so
// the client may drop its buffered partials at/or before the cursor and seed from
// `fragments` without loss or duplication. Complete messages are never dropped by the
// cursor — the client's overlap dedup against `messages` decides for them — so a
// complete message whose trace append is still in flight when the read starts is not
// lost either.
let live: MessagesLiveTail | undefined;
if (deps.manager.statusOf(row.sessionId) !== "idle") {
live = {
cursor: deps.channels.get(row.sessionId).lastEventId,
fragments: deps.manager.liveFragments(row.sessionId),
};
}
const messages = await deps.traceService.readMessages(
row.projectId,
row.agentId,
row.sessionId,
);
return c.json({ messages } satisfies MessagesResponse);
return c.json({ messages, ...(live !== undefined ? { live } : {}) } satisfies MessagesResponse);
});
app.get("/:sessionId/stream", (c) => {