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
@@ -561,9 +561,42 @@ export interface SessionPatchRequest {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live in-progress tail of a running Session, carried by `MessagesResponse.live`.
|
||||
*
|
||||
* Contract (see runtime/live-tail.ts and the GET /messages route): the server captures
|
||||
* `cursor` and `fragments` atomically — in one synchronous tick, before starting the
|
||||
* trace read — while the Session is running/compacting.
|
||||
* - `cursor`: the Session channel's most recently assigned SSE event id
|
||||
* (`<epoch>-<seq>`); every event published up to and including this id is already
|
||||
* reflected in `fragments`.
|
||||
* - `fragments`: one synthetic `partial_* start` OmniMessage per open streaming
|
||||
* fragment, whose payload carries the full accumulated content so far (text/thinking
|
||||
* prefix, tool-call name + accumulated arguments, tool-output prefix + images), with
|
||||
* the original `origin` chain preserved.
|
||||
*
|
||||
* Client usage (the bundled Web App's connect-first flow): after applying `messages`,
|
||||
* when the cursor's epoch matches the epoch of the SSE events seen on the current
|
||||
* connection, drop every buffered **partial** event with seq <= cursor (its content is
|
||||
* already inside `fragments`), feed `fragments` through the normal reducer path, then
|
||||
* replay the rest of the buffer. Buffered **complete** messages are never dropped by the
|
||||
* cursor — the regular overlap dedup decides for them — so nothing is lost even when a
|
||||
* complete message's trace append is still in flight at read time.
|
||||
*/
|
||||
export interface MessagesLiveTail {
|
||||
cursor: string;
|
||||
fragments: OmniMessage[];
|
||||
}
|
||||
|
||||
/** Message history: the full messages and events from concatenating all of this Session's Trace files in order (excludes partial_*). */
|
||||
export interface MessagesResponse {
|
||||
messages: OmniMessage[];
|
||||
/**
|
||||
* Present only while the Session is running/compacting: the in-progress stream tail
|
||||
* (open streaming fragments + the channel cursor they cover), so a client joining
|
||||
* mid-stream can render the currently streaming message. Omitted when idle.
|
||||
*/
|
||||
live?: MessagesLiveTail;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -73,6 +73,17 @@ export class Channel {
|
||||
return this.listeners.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Id of the most recently assigned event (`<epoch>-<seq>`; seq 0 when none was assigned
|
||||
* yet). Unicast (sendTo) seqs count too — the value is a position marker, not a buffer
|
||||
* lookup key. Used as the live-tail cursor on GET /messages: captured in the same
|
||||
* synchronous tick as the fragment snapshot, it tells the client which buffered events
|
||||
* the snapshot already covers.
|
||||
*/
|
||||
get lastEventId(): string {
|
||||
return `${this.epoch}-${this.nextSeq - 1}`;
|
||||
}
|
||||
|
||||
/** Broadcast an event: number it, buffer it (evicting the oldest), notify all subscribers. */
|
||||
publish(data: unknown, event?: string): ChannelEvent {
|
||||
const entry = this.makeEvent(data, event);
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Live tail of a running Session's stream: the accumulated state of every OPEN
|
||||
* streaming fragment (partial_text / partial_thinking / partial_tool_call /
|
||||
* partial_tool_call_output), kept per origin chain.
|
||||
*
|
||||
* Why: `partial_*` messages never reach the Trace (core's Writer filters them), so a
|
||||
* client that joins mid-stream — a refresh during a long tool call — cannot rebuild the
|
||||
* in-progress message from `GET /messages` alone, and its fresh EventSource carries no
|
||||
* `Last-Event-ID` for the channel buffer to replay. This tracker lets the messages
|
||||
* endpoint attach, alongside history, one synthetic `partial_* start` OmniMessage per
|
||||
* open fragment whose payload carries the full accumulated content so far (text/thinking
|
||||
* prefix, tool-call name + accumulated arguments, tool-output prefix + images); the
|
||||
* client seeds these into its stream model and live deltas keep appending on top. See
|
||||
* `MessagesResponse.live` in api/types.ts for the client-facing contract.
|
||||
*
|
||||
* Fed by SessionManager.drive in the same synchronous tick as each channel publish, so a
|
||||
* "channel cursor + fragments" capture between two publishes is always a consistent
|
||||
* snapshot. Mirrors core PartialAggregator's merge semantics (fragment key = payload type
|
||||
* + tool_call_id; start reopens, delta accumulates, stop closes) with the origin chain
|
||||
* added to the key — the aggregator collapses fragments into complete messages, while
|
||||
* this keeps the running prefix instead. A complete model message with the same identity
|
||||
* also closes the fragment (covers stop-less closures, e.g. interruption cleanup); the
|
||||
* whole session entry is dropped when the run ends (SessionManager.drive's finally).
|
||||
*/
|
||||
import { isPartialPayload } from "@prismshadow/penguin-core";
|
||||
import type {
|
||||
CompleteModelPayload,
|
||||
OmniMessage,
|
||||
PartialModelPayload,
|
||||
} from "@prismshadow/penguin-core";
|
||||
|
||||
type PartialKind = PartialModelPayload["type"];
|
||||
|
||||
/**
|
||||
* Per-fragment accumulation cap. Environment already front-truncates tool output online
|
||||
* (default 16k chars), so tool fragments stay small by construction; text/thinking have
|
||||
* no upstream cap and a fast large-code reply can reach a few hundred KB (see the channel
|
||||
* buffer sizing note) — 512KB covers that comfortably while bounding a runaway fragment.
|
||||
* When the cap trips, the TAIL is kept (the prefix is dropped): the seeded content then
|
||||
* joins seamlessly with the live deltas that follow the capture, and the complete message
|
||||
* reconciles the full content at the end anyway. Trimming happens with slack so it costs
|
||||
* one copy per `FRAGMENT_CAP_SLACK` chars of growth, not one per delta. Tail-keeping is a
|
||||
* deliberate one-rule-for-all: it is seamless for text/thinking/tool output, and for
|
||||
* `partial_tool_call` arguments it yields a syntactically broken JSON prefix — accepted,
|
||||
* since arguments approaching 512KB are not reachable in practice and the complete
|
||||
* message reconciles either way.
|
||||
*/
|
||||
const FRAGMENT_CAP = 512 * 1024;
|
||||
const FRAGMENT_CAP_SLACK = 64 * 1024;
|
||||
|
||||
interface OpenFragment {
|
||||
kind: PartialKind;
|
||||
/** Origin chain copied from the opening message (absent = main session). */
|
||||
origin?: string[];
|
||||
/** Timestamp of the fragment's original start (reused on the synthetic start). */
|
||||
timestamp: string;
|
||||
/** Accumulated text / thinking / tool-call arguments / tool output (tail-capped). */
|
||||
buffer: string;
|
||||
name?: string;
|
||||
toolCallId?: string;
|
||||
/** Tool-output images: not incremental — a single delta carries the whole set; a later one overwrites. */
|
||||
images?: string[];
|
||||
}
|
||||
|
||||
/** The partial kind a complete payload closes out; null when it has no streamed counterpart. */
|
||||
function partialKindFor(p: CompleteModelPayload): PartialKind | null {
|
||||
switch (p.type) {
|
||||
case "text":
|
||||
// Only assistant text streams; a user text mid-run (steering) must not close the model's open fragment.
|
||||
return p.role === "assistant" ? "partial_text" : null;
|
||||
case "thinking":
|
||||
return "partial_thinking";
|
||||
case "tool_call":
|
||||
return "partial_tool_call";
|
||||
case "tool_call_output":
|
||||
return "partial_tool_call_output";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fragment key: origin chain + payload type + tool_call_id (same merge rule as core's PartialAggregator, origin added). */
|
||||
function fragmentKey(origin: string[] | undefined, kind: PartialKind, toolCallId: string): string {
|
||||
// "\0" (the escape, not a raw byte -- a raw NUL makes git classify the file as binary)
|
||||
// separates the origin chain from the kind: session ids are [A-Za-z0-9_-], so the
|
||||
// separator can never occur inside a chain segment and keys cannot collide.
|
||||
return `${(origin ?? []).join("/")}\0${kind}::${toolCallId}`;
|
||||
}
|
||||
|
||||
function toolCallIdOf(p: object): string {
|
||||
const id = (p as { tool_call_id?: unknown }).tool_call_id;
|
||||
return typeof id === "string" ? id : "";
|
||||
}
|
||||
|
||||
function appendDelta(frag: OpenFragment, p: PartialModelPayload): void {
|
||||
switch (p.type) {
|
||||
case "partial_text":
|
||||
frag.buffer += p.text;
|
||||
break;
|
||||
case "partial_thinking":
|
||||
frag.buffer += p.thinking;
|
||||
break;
|
||||
case "partial_tool_call":
|
||||
frag.buffer += p.arguments;
|
||||
if (p.name) frag.name = p.name;
|
||||
frag.toolCallId = p.tool_call_id;
|
||||
break;
|
||||
case "partial_tool_call_output":
|
||||
frag.buffer += p.output;
|
||||
if (p.images && p.images.length > 0) frag.images = p.images;
|
||||
frag.toolCallId = p.tool_call_id;
|
||||
break;
|
||||
}
|
||||
if (frag.buffer.length > FRAGMENT_CAP + FRAGMENT_CAP_SLACK) {
|
||||
frag.buffer = frag.buffer.slice(frag.buffer.length - FRAGMENT_CAP);
|
||||
}
|
||||
}
|
||||
|
||||
/** The synthetic `partial_* start` payload carrying the fragment's full accumulated content. */
|
||||
function startPayload(frag: OpenFragment): PartialModelPayload {
|
||||
switch (frag.kind) {
|
||||
case "partial_text":
|
||||
return { type: "partial_text", role: "assistant", event_type: "start", text: frag.buffer };
|
||||
case "partial_thinking":
|
||||
return {
|
||||
type: "partial_thinking",
|
||||
role: "assistant",
|
||||
event_type: "start",
|
||||
thinking: frag.buffer,
|
||||
};
|
||||
case "partial_tool_call":
|
||||
return {
|
||||
type: "partial_tool_call",
|
||||
role: "assistant",
|
||||
event_type: "start",
|
||||
name: frag.name ?? "",
|
||||
arguments: frag.buffer,
|
||||
tool_call_id: frag.toolCallId ?? "",
|
||||
};
|
||||
case "partial_tool_call_output":
|
||||
return {
|
||||
type: "partial_tool_call_output",
|
||||
role: "user",
|
||||
event_type: "start",
|
||||
output: frag.buffer,
|
||||
...(frag.images !== undefined && frag.images.length > 0 ? { images: frag.images } : {}),
|
||||
tool_call_id: frag.toolCallId ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class LiveTailTracker {
|
||||
/** sessionId → open fragments keyed by fragmentKey, in the order they were opened. */
|
||||
private readonly sessions = new Map<string, Map<string, OpenFragment>>();
|
||||
|
||||
/** Feed one published message (call in the same synchronous tick as the channel publish). */
|
||||
observe(sessionId: string, msg: OmniMessage): void {
|
||||
if (msg.type !== "model_msg") return;
|
||||
const p = msg.payload;
|
||||
if (!isPartialPayload(p)) {
|
||||
// A complete message closes the matching open fragment (normally the stop already
|
||||
// did; this also covers stop-less closures such as interruption cleanup).
|
||||
const kind = partialKindFor(p as CompleteModelPayload);
|
||||
if (kind === null) return;
|
||||
const open = this.sessions.get(sessionId);
|
||||
if (!open) return;
|
||||
open.delete(fragmentKey(msg.origin, kind, toolCallIdOf(p)));
|
||||
if (open.size === 0) this.sessions.delete(sessionId);
|
||||
return;
|
||||
}
|
||||
const key = fragmentKey(msg.origin, p.type, toolCallIdOf(p));
|
||||
let open = this.sessions.get(sessionId);
|
||||
if (p.event_type === "start") {
|
||||
if (!open) {
|
||||
open = new Map();
|
||||
this.sessions.set(sessionId, open);
|
||||
}
|
||||
// start reopens the fragment: a previous same-key fragment (out-of-order) is replaced.
|
||||
const frag: OpenFragment = {
|
||||
kind: p.type,
|
||||
timestamp: msg.timestamp,
|
||||
buffer: "",
|
||||
...(msg.origin && msg.origin.length > 0 ? { origin: [...msg.origin] } : {}),
|
||||
};
|
||||
open.delete(key); // re-insert so the emit order tracks the reopen
|
||||
open.set(key, frag);
|
||||
appendDelta(frag, p);
|
||||
return;
|
||||
}
|
||||
let frag = open?.get(key);
|
||||
if (!frag) {
|
||||
// delta/stop without a start: lenient, same as core's PartialAggregator.
|
||||
if (p.event_type === "stop") return; // nothing was open; nothing to keep or clear
|
||||
if (!open) {
|
||||
open = new Map();
|
||||
this.sessions.set(sessionId, open);
|
||||
}
|
||||
frag = {
|
||||
kind: p.type,
|
||||
timestamp: msg.timestamp,
|
||||
buffer: "",
|
||||
...(msg.origin && msg.origin.length > 0 ? { origin: [...msg.origin] } : {}),
|
||||
};
|
||||
open.set(key, frag);
|
||||
}
|
||||
appendDelta(frag, p);
|
||||
if (p.event_type === "stop") {
|
||||
open!.delete(key);
|
||||
if (open!.size === 0) this.sessions.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthetic `partial_* start` messages for every open fragment (in open order), each
|
||||
* carrying the full accumulated content, the original origin chain, and the original
|
||||
* start timestamp. Empty when the session has no open fragments.
|
||||
*/
|
||||
fragments(sessionId: string): OmniMessage[] {
|
||||
const open = this.sessions.get(sessionId);
|
||||
if (!open) return [];
|
||||
const out: OmniMessage[] = [];
|
||||
for (const frag of open.values()) {
|
||||
out.push({
|
||||
timestamp: frag.timestamp,
|
||||
type: "model_msg",
|
||||
payload: startPayload(frag),
|
||||
...(frag.origin !== undefined ? { origin: [...frag.origin] } : {}),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Drop all fragment state for a session (the run ended; nothing will continue these fragments). */
|
||||
clear(sessionId: string): void {
|
||||
this.sessions.delete(sessionId);
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ import { ApprovalRegistry, makeApprove } from "./approvals.js";
|
||||
import type { PendingApproval } from "./approvals.js";
|
||||
import type { ChannelHub } from "./channel.js";
|
||||
import type { ErrorSink } from "./error-recorder.js";
|
||||
import { LiveTailTracker } from "./live-tail.js";
|
||||
import { asSessionSource } from "./session-sources.js";
|
||||
import type { SessionSources } from "./session-sources.js";
|
||||
import { StreamErrorWatcher } from "./stream-error-watcher.js";
|
||||
@@ -329,6 +330,8 @@ export class SessionManager {
|
||||
private readonly deletingSessions = new Set<string>();
|
||||
/** Per-Agent config generation (key = agentKey), bumped by invalidateAgentRuntimes on vault updates. */
|
||||
private readonly agentGenerations = new Map<string, number>();
|
||||
/** Open streaming fragments of running sessions (fed by drive, served to GET /messages; see live-tail.ts). */
|
||||
private readonly liveTail = new LiveTailTracker();
|
||||
private readonly sweepTimer: NodeJS.Timeout;
|
||||
|
||||
constructor(private readonly deps: SessionManagerDeps) {
|
||||
@@ -356,6 +359,16 @@ export class SessionManager {
|
||||
return this.entries.get(sessionId)?.followUps.length ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live tail of a running session: one synthetic `partial_* start` OmniMessage per open
|
||||
* streaming fragment, carrying the full accumulated content so far (see live-tail.ts).
|
||||
* Empty when idle or when nothing is streaming. GET /messages attaches this (with a
|
||||
* channel cursor) so a client joining mid-stream can render the in-progress message.
|
||||
*/
|
||||
liveFragments(sessionId: string): OmniMessage[] {
|
||||
return this.liveTail.fragments(sessionId);
|
||||
}
|
||||
|
||||
/** Number of Sessions for this Agent that are currently running / compacting. */
|
||||
activeCountForAgent(projectId: string, agentId: string): number {
|
||||
let n = 0;
|
||||
@@ -944,6 +957,10 @@ export class SessionManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Live-tail bookkeeping in the same synchronous tick as the publish below: the
|
||||
// messages endpoint captures "channel cursor + open fragments" between two
|
||||
// publishes, so the pair is always a consistent snapshot (see live-tail.ts).
|
||||
this.liveTail.observe(entry.sessionId, msg);
|
||||
// Re-fetch the channel before every publish (matches publishEvent): the channel
|
||||
// may have been recycled and recreated during a long wait on approval, and
|
||||
// holding a stale reference would send output to an orphaned, detached channel.
|
||||
@@ -966,6 +983,9 @@ export class SessionManager {
|
||||
} finally {
|
||||
// Wrap-up: persist any still-pending LLM failure and clear the tool-name cache (the watcher's state doesn't carry across runs).
|
||||
watcher?.close();
|
||||
// The run is over: no fragment will ever continue, so drop the live tail before the
|
||||
// idle flip (GET /messages stops attaching `live` the moment status reads idle).
|
||||
this.liveTail.clear(entry.sessionId);
|
||||
entry.approvals.denyAll();
|
||||
entry.status = "idle";
|
||||
entry.abort = null;
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user