diff --git a/packages/protocol/src/schemas.ts b/packages/protocol/src/schemas.ts index 289eed3..4ced78c 100644 --- a/packages/protocol/src/schemas.ts +++ b/packages/protocol/src/schemas.ts @@ -63,6 +63,12 @@ export const sessionAttachSchema = z.object({ ...base, type: z.literal("session.attach"), sessionId: sessionIdField, + resume: z + .object({ + key: z.string().min(1).max(LIMITS.ID_MAX), + sinceSeq: z.number().int().nonnegative(), + }) + .optional(), }); export const sessionDetachSchema = z.object({ @@ -79,6 +85,7 @@ export const sessionSendSchema = z.object({ text: z.string().max(LIMITS.SEND_TEXT_MAX), attachments: z.array(attachmentSchema).max(LIMITS.ATTACHMENTS_MAX).optional(), priority: z.enum(["now", "next", "later"]).optional(), + clientMsgId: z.string().min(1).max(LIMITS.ID_MAX).optional(), }); export const sessionInterruptSchema = z.object({ diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index c7b720a..b0c0cef 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -577,6 +577,15 @@ export interface SessionMessage { /** Extensible metadata — frontends ignore unknown keys */ metadata?: Record; timestamp: string; + /** + * Session sequence cursor (`replay.resume` capability): the session's + * monotonic mutation counter at the time this frame was produced. Clients + * track `max(seq)` per session and pass it back on `session.attach.resume` + * to receive an incremental tail instead of a full scrollback replay. + * Absent on daemons that predate resume, and on messages the daemon no + * longer holds in its replay buffer. + */ + seq?: number; } /** @@ -603,6 +612,8 @@ export interface SessionMessageDelta { /** Update tool state (state machine transition) */ toolStateUpdate?: ToolState; timestamp: string; + /** Session sequence cursor — see `SessionMessage.seq`. */ + seq?: number; } // ============================================================================= @@ -701,6 +712,16 @@ export interface SessionListMsg extends BaseClientMsg { export interface SessionAttachMsg extends BaseClientMsg { type: "session.attach"; sessionId: string; + /** + * Incremental resume (`replay.resume` capability). `key` is the + * `resumeKey` from a previous replay on this session; `sinceSeq` is the + * highest `seq` the client has applied. When the key matches the daemon's + * current replay buffer, the daemon replays only entries mutated after + * `sinceSeq` (`mode: "incremental"`); on any mismatch (daemon restarted, + * buffer rebuilt, key unknown) it falls back to a full snapshot. Omit for + * the legacy full-replay behaviour. + */ + resume?: { key: string; sinceSeq: number }; } export interface SessionDetachMsg extends BaseClientMsg { @@ -727,6 +748,15 @@ export interface SessionSendMsg extends BaseClientMsg { * Frontends that don't care pass nothing; FIFO stays the default. */ priority?: "now" | "next" | "later"; + /** + * Idempotency key (`send.idempotency` capability). Generate ONCE per user + * action (not per network attempt) and reuse it on retries: a send whose + * `clientMsgId` the daemon has already processed for this session is + * acknowledged without running a second turn. This is the guard against + * ambiguous delivery (socket drop between send and ack) turning one prompt + * into two billed turns. Omit to opt out (every send processes). + */ + clientMsgId?: string; } export interface Attachment { @@ -1302,10 +1332,32 @@ export interface ScrollbackReplayMsg { type: "scrollback.replay"; sessionId: string; messages: SessionMessage[]; - /** 0-based chunk index of a chunked replay. Absent = single-frame replay. */ + /** + * 0-based CHUNK index of a chunked replay (#84). Absent = single-frame + * replay. NOTE: unrelated to the per-message session cursor + * `SessionMessage.seq` — this one only orders the frames of one replay. + */ seq?: number; /** True on the last chunk of a chunked replay. Absent = single-frame replay. */ final?: boolean; + /** + * Replay semantics (`replay.resume` capability): + * - "snapshot" (or absent — the legacy shape): the authoritative full + * scrollback; clients RESET their local buffer to it. + * - "incremental": only entries mutated since the client's `sinceSeq`; + * clients APPEND/UPSERT by messageId — never reset. Sent when a + * `session.attach.resume` key matched. + */ + mode?: "snapshot" | "incremental"; + /** + * Identity of the daemon's replay buffer. Store it with `maxSeq` and pass + * both back on `session.attach.resume`. Changes whenever the buffer is + * rebuilt (e.g. daemon restart) — a mismatch means cursors are invalid and + * the daemon answers with a snapshot. + */ + resumeKey?: string; + /** Highest session sequence included/known — the client's next cursor. */ + maxSeq?: number; } /** Result of a session.search query. */ diff --git a/src/daemon/scrollback.ts b/src/daemon/scrollback.ts index 8f341f6..5b06ecc 100644 --- a/src/daemon/scrollback.ts +++ b/src/daemon/scrollback.ts @@ -30,10 +30,15 @@ const DEFAULT_CONFIG: ScrollbackConfig = { * grow in place between push and finalize; eviction must subtract exactly * what was added, never the current (grown) serialized size — otherwise the * counter drifts negative and the byte cap stops evicting. + * + * `seq` is the buffer's monotonic mutation counter value at this entry's + * LAST mutation (push / update / touch). Incremental resume filters on it: + * any entry mutated after a client's cursor gets resent in merged form. */ interface Entry { msg: DaemonMessage; size: number; + seq: number; } function messageIdOf(msg: DaemonMessage): string | undefined { @@ -55,11 +60,24 @@ export class ScrollbackBuffer { #byId = new Map(); #bytes = 0; #config: ScrollbackConfig; + /** + * Monotonic mutation counter — the session sequence cursor domain for + * incremental resume (`replay.resume`). Bumped by every push / update / + * touch; NEVER reset while the buffer lives. Scoped to this buffer + * instance: cursors are only meaningful together with the session's + * `resumeKey`, which changes when the buffer is rebuilt. + */ + #seq = 0; constructor(config: Partial = {}) { this.#config = { ...DEFAULT_CONFIG, ...config }; } + /** Highest sequence value assigned so far (0 = nothing ever buffered). */ + get maxSeq(): number { + return this.#seq; + } + /** * Push a message into the buffer. Evicts oldest entries if limits are exceeded. * @@ -75,6 +93,15 @@ export class ScrollbackBuffer { * re-serializes every one of them purely for byte accounting. */ push(msg: DaemonMessage, sizeHint?: number): void { + const seq = ++this.#seq; + // Stamp the session cursor onto the message itself so the object the + // session broadcasts (same reference) carries it on the wire. Skipped + // when the caller supplied a sizeHint (restore-from-transcript) — the + // hint reflects the unstamped line, and restored messages don't need a + // wire seq (the replay frame's maxSeq covers the client's cursor). + if (sizeHint === undefined && msg.type === "session.message") { + msg.seq = seq; + } const messageId = messageIdOf(msg); if (messageId !== undefined) { const existing = this.#byId.get(messageId); @@ -83,17 +110,41 @@ export class ScrollbackBuffer { this.#bytes += size - existing.size; existing.msg = msg; existing.size = size; + existing.seq = seq; this.#evict(); return; } } - const entry: Entry = { msg, size: sizeHint ?? serializedSizeOf(msg) }; + const entry: Entry = { msg, size: sizeHint ?? serializedSizeOf(msg), seq }; this.#entries.push(entry); if (messageId !== undefined) this.#byId.set(messageId, entry); this.#bytes += entry.size; this.#evict(); } + /** + * Record a mutation of a buffered message WITHOUT re-accounting its bytes — + * the streaming path calls this once per delta, so it must stay O(1) with + * no re-serialization. Bumps the buffer counter, marks the entry as + * mutated-at-that-seq (so incremental resume resends the merged message), + * stamps the buffered message so later replays emit a self-consistent + * per-message seq (entry.seq === msg.seq), and returns the new seq for + * stamping the outgoing delta frame. + * + * The seq stamp is deliberately NOT byte-re-accounted: the drift is bounded + * (~15 bytes per entry, once) and re-serializing per delta would put a full + * JSON.stringify on the per-token hot path. + * + * Returns undefined when the message is unknown/evicted. + */ + touch(messageId: string): number | undefined { + const entry = this.#byId.get(messageId); + if (!entry) return undefined; + entry.seq = ++this.#seq; + if (entry.msg.type === "session.message") entry.msg.seq = entry.seq; + return entry.seq; + } + /** Evict oldest entries until within both limits. */ #evict(): void { while ( @@ -131,12 +182,33 @@ export class ScrollbackBuffer { * the message. Returns [] for an empty buffer. */ readChunked(maxBytes: number): DaemonMessage[][] { + return ScrollbackBuffer.#partition(this.#entries, maxBytes); + } + + /** + * Incremental-resume read (`replay.resume`): every entry mutated after + * `sinceSeq` — new messages AND older messages that grew via deltas or + * tool-state transitions since the client's cursor — in buffer order, + * partitioned by the same byte budget as `readChunked`. Returns [] when + * the client is fully caught up. + */ + readChunkedSince(sinceSeq: number, maxBytes: number): DaemonMessage[][] { + const stale = this.#entries.filter((e) => e.seq > sinceSeq); + return ScrollbackBuffer.#partition(stale, maxBytes); + } + + /** + * Partition entries into ordered chunks (oldest→newest), each holding at + * most ~`maxBytes` of serialized payload, using the byte sizes already + * accounted per entry — no re-serialization. A single message larger than + * `maxBytes` occupies its own chunk (never split). Never emits an empty + * chunk; returns [] for no entries. + */ + static #partition(entries: readonly Entry[], maxBytes: number): DaemonMessage[][] { const chunks: DaemonMessage[][] = []; let current: DaemonMessage[] = []; let currentBytes = 0; - for (const entry of this.#entries) { - // Start a new chunk when adding this entry would overflow the budget, - // but never emit an empty chunk (a lone oversized message stays put). + for (const entry of entries) { if (current.length > 0 && currentBytes + entry.size > maxBytes) { chunks.push(current); current = []; @@ -149,17 +221,6 @@ export class ScrollbackBuffer { return chunks; } - /** - * Read messages after a given timestamp (for incremental catch-up). - */ - readSince(timestamp: string): DaemonMessage[] { - return this.#entries - .map((e) => e.msg) - .filter( - (msg) => "timestamp" in msg && (msg as { timestamp: string }).timestamp > timestamp, - ); - } - /** * Update a message in the buffer by messageId. Used to apply tool state * transitions so scrollback replay shows final states, not intermediate. @@ -168,6 +229,10 @@ export class ScrollbackBuffer { const entry = this.#byId.get(messageId); if (!entry) return; updater(entry.msg); + entry.seq = ++this.#seq; + // Keep the buffered message's wire seq consistent with the entry — + // stamped BEFORE re-accounting, so the bytes stay exact here. + if (entry.msg.type === "session.message") entry.msg.seq = entry.seq; const after = serializedSizeOf(entry.msg); this.#bytes += after - entry.size; entry.size = after; diff --git a/src/daemon/server.ts b/src/daemon/server.ts index d93393f..504f66a 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -36,7 +36,11 @@ import type { IncomingMessage, ServerResponse } from "node:http"; * capability-gated behaviour lands; clients feature-detect on it instead of * version-sniffing. */ -const SERVER_CAPABILITIES: string[] = [CAPABILITIES.CHUNKED_REPLAY]; +const SERVER_CAPABILITIES: string[] = [ + CAPABILITIES.CHUNKED_REPLAY, + CAPABILITIES.SEQ_RESUME, + CAPABILITIES.SEND_IDEMPOTENCY, +]; /** * Per-connection state carried on `ws.data`. Defined once and cast against in diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index e1f8bc2..da0d741 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -920,7 +920,7 @@ export class SessionManager { return { type: "response.error", requestId: msg.id, error: "Session not found", code: "not_found" }; } - session.attach(client); + session.attach(client, msg.resume); return { type: "response.ok", requestId: msg.id, data: session.toInfo() }; } @@ -950,6 +950,15 @@ export class SessionManager { return { type: "response.error", requestId: msg.id, error: "Session not found", code: "not_found" }; } + // Duplicate-send suppression (`send.idempotency`): a client that + // couldn't observe whether its send survived a dropped socket resends + // with the SAME clientMsgId — acknowledging instead of dispatching + // prevents one prompt from becoming two billed turns. Checked after + // scope + ownership so a rejected send never poisons the id. + if (msg.clientMsgId !== undefined && session.markClientMsgSeen(msg.clientMsgId)) { + return { type: "response.ok", requestId: msg.id, data: { duplicate: true } }; + } + // Fire and forget — output streams to attached clients. The user message // is persisted synchronously at the top of session.send() before any // fallible work, so a later throw can't lose it. Surface that throw as a diff --git a/src/daemon/session.ts b/src/daemon/session.ts index 6d72fea..61541f5 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -171,6 +171,21 @@ export class Session { #identityManager?: AgentIdentityManager; #agentIdentity: MessageIdentity; #scrollback = new ScrollbackBuffer(); + /** + * Identity of this Session instance's replay buffer (`replay.resume`). + * A client cursor (`sinceSeq`) is only valid against the buffer that + * issued it; regenerating the key on every construction (incl. restart + * resume, where the buffer is rebuilt from the transcript with fresh + * seqs) forces stale cursors down the full-snapshot path. + */ + #resumeKey = randomUUID(); + /** + * Recently-processed `session.send.clientMsgId`s (`send.idempotency`) — + * insertion-ordered for FIFO eviction. Bounds the window in which an + * ambiguous-delivery retry is recognized as a duplicate; 256 comfortably + * outlives any client resend queue while staying O(1) per send. + */ + #seenClientMsgIds = new Set(); #provider!: SessionProvider; #activeRun: TurnRun | null = null; #eventConsumerTask: Promise | null = null; @@ -566,25 +581,52 @@ export class Session { // ── Client management ───────────────────────────────────────────────── - attach(client: AttachedClient): void { + attach(client: AttachedClient, resume?: { key: string; sinceSeq: number }): void { this.#clients.set(client.id, client); this.#store.audit(client.auth.sub, "session.attach", this.id); + // Incremental resume (`replay.resume`): when the client's cursor belongs + // to THIS replay buffer (key match), replay only the entries mutated + // after it — new messages plus older ones grown by deltas / tool-state + // transitions — instead of the whole scrollback. Any mismatch (daemon + // restarted and rebuilt the buffer, unknown key) falls back to the + // authoritative full snapshot; the client resets on `mode: "snapshot"`. + const incremental = resume !== undefined && resume.key === this.#resumeKey; + // Replay scrollback — full SessionMessage objects, not deltas. Partition // by byte budget so a large session can't emit one oversized frame that // trips the WS backpressure limit and force-closes the client (#84). - const chunks = this.#scrollback.readChunked(REPLAY_CHUNK_BYTES) as SessionMessage[][]; - if (chunks.length === 0) return; + const chunks = ( + incremental + ? this.#scrollback.readChunkedSince(resume.sinceSeq, REPLAY_CHUNK_BYTES) + : this.#scrollback.readChunked(REPLAY_CHUNK_BYTES) + ) as SessionMessage[][]; + const meta = { + mode: incremental ? ("incremental" as const) : ("snapshot" as const), + resumeKey: this.#resumeKey, + maxSeq: this.#scrollback.maxSeq, + }; + + if (chunks.length === 0) { + // Nothing to replay. A client that ASKED to resume still gets an empty + // frame: it acks the cursor (incremental, fully caught up) or re-syncs + // a stale key (snapshot after a daemon restart with an empty buffer). + // Legacy clients keep the silent no-frame behaviour. + if (resume !== undefined) { + client.send({ type: "scrollback.replay", sessionId: this.id, messages: [], ...meta }); + } + return; + } if (chunks.length === 1) { - // Common case: the whole scrollback fits one frame. Send it synchronously - // in the legacy single-frame shape (no seq/final) — behaviour and wire - // format are unchanged for every session small enough to fit. - client.send({ type: "scrollback.replay", sessionId: this.id, messages: chunks[0]! }); + // Common case: the whole replay fits one frame. Send it synchronously + // in the single-frame shape (no chunk seq/final) — wire format is + // unchanged for legacy clients apart from the additive resume fields. + client.send({ type: "scrollback.replay", sessionId: this.id, messages: chunks[0]!, ...meta }); return; } - // Large scrollback: stream chunks oldest→newest, pacing on socket drain so + // Large replay: stream chunks oldest→newest, pacing on socket drain so // frames don't accumulate past the backpressure limit. Because that pacing // is async, live broadcasts to this client are buffered until the replay // finishes — otherwise a newer live message could land ahead of older @@ -605,7 +647,7 @@ export class Session { }; this.#clients.set(raw.id, buffered); - void this.#streamReplay(raw, buffered, chunks).finally(() => { + void this.#streamReplay(raw, buffered, chunks, meta).finally(() => { // Flush live messages that arrived during replay, in order — but only if // this client is still the current attachment (not detached/replaced). if (this.#clients.get(raw.id) === buffered) { @@ -625,6 +667,7 @@ export class Session { raw: AttachedClient, token: AttachedClient, chunks: SessionMessage[][], + meta: { mode: "snapshot" | "incremental"; resumeKey: string; maxSeq: number }, ): Promise { const last = chunks.length - 1; for (let i = 0; i <= last; i++) { @@ -635,6 +678,7 @@ export class Session { messages: chunks[i]!, seq: i, final: i === last, + ...meta, }); if (i < last) await raw.flush?.(); } @@ -2417,6 +2461,16 @@ export class Session { /** Broadcast any DaemonMessage to all attached clients */ #broadcastRaw(msg: DaemonMessage): void { + // Stamp the session cursor (`replay.resume`) onto outbound streaming + // frames. Deltas mutate their target message in place, so the buffer + // entry's seq must advance with each one — touch() is the single point + // that both records the mutation and yields the frame's seq. Full + // messages already carry the seq assigned by scrollback.push(). O(1), + // no re-serialization — safe on the per-token hot path. + if (msg.type === "session.message.delta") { + const seq = this.#scrollback.touch(msg.messageId); + if (seq !== undefined) msg.seq = seq; + } for (const client of this.#clients.values()) { try { client.send(msg); @@ -2426,6 +2480,22 @@ export class Session { } } + /** + * Duplicate-send guard (`send.idempotency`). Returns true when this + * clientMsgId was already accepted for this session — the caller should + * ack without dispatching a second turn (a duplicated user prompt is a + * duplicated LLM turn: real token spend). Records the id on first sight. + */ + markClientMsgSeen(clientMsgId: string): boolean { + if (this.#seenClientMsgIds.has(clientMsgId)) return true; + this.#seenClientMsgIds.add(clientMsgId); + if (this.#seenClientMsgIds.size > 256) { + const oldest = this.#seenClientMsgIds.values().next().value; + if (oldest !== undefined) this.#seenClientMsgIds.delete(oldest); + } + return false; + } + #setStatus(status: SessionStatus): void { // Many call sites re-assert the current value (thinking → thinking // between the tool calls of a long turn). Those carry no information diff --git a/src/tests/scrollback.test.ts b/src/tests/scrollback.test.ts index 42e5674..23866b1 100644 --- a/src/tests/scrollback.test.ts +++ b/src/tests/scrollback.test.ts @@ -88,19 +88,17 @@ describe("ScrollbackBuffer", () => { expect(buf.read()).toHaveLength(0); }); - test("readSince filters by timestamp", () => { + test("readChunkedSince filters by mutation seq (supersedes timestamp catch-up)", () => { const buf = new ScrollbackBuffer(); - const t1 = "2026-01-01T00:00:00Z"; - const t2 = "2026-01-01T00:00:01Z"; - const t3 = "2026-01-01T00:00:02Z"; + buf.push(makeMsg("old")); + const cursor = buf.maxSeq; + buf.push(makeMsg("mid")); + buf.push(makeMsg("new")); - buf.push({ ...makeMsg("old"), timestamp: t1 }); - buf.push({ ...makeMsg("mid"), timestamp: t2 }); - buf.push({ ...makeMsg("new"), timestamp: t3 }); - - const since = buf.readSince(t1); + const since = buf.readChunkedSince(cursor, 10 * 1024 * 1024).flat(); expect(since).toHaveLength(2); expect((since[0] as SessionMessage).content).toBe("mid"); + expect((since[1] as SessionMessage).content).toBe("new"); }); test("handles large number of messages", () => { @@ -213,3 +211,78 @@ describe("ScrollbackBuffer — readChunked (#84)", () => { expect(chunks.flat()).toEqual(buf.read()); }); }); + +describe("ScrollbackBuffer — seq & incremental resume (replay.resume)", () => { + test("push assigns strictly increasing seqs; maxSeq tracks; message is stamped", () => { + const buf = new ScrollbackBuffer(); + expect(buf.maxSeq).toBe(0); + const m1 = makeMsg("one"); + const m2 = makeMsg("two"); + buf.push(m1); + buf.push(m2); + expect(buf.maxSeq).toBe(2); + // Live pushes stamp the message object so the broadcast frame (same + // reference) carries the cursor on the wire. + expect(m1.seq).toBe(1); + expect(m2.seq).toBe(2); + }); + + test("push with a sizeHint (restore path) does NOT stamp the message", () => { + const buf = new ScrollbackBuffer(); + const m = makeMsg("restored"); + buf.push(m, 100); + expect(m.seq).toBeUndefined(); + expect(buf.maxSeq).toBe(1); // entry still gets a seq internally + }); + + test("touch bumps the counter, returns the new seq, undefined for unknown ids", () => { + const buf = new ScrollbackBuffer(); + const m = makeMsg("streamed"); + buf.push(m); + const seq = buf.touch(m.messageId); + expect(seq).toBe(2); + expect(buf.maxSeq).toBe(2); + // The buffered message is re-stamped so replays emit a self-consistent + // per-message seq (entry.seq === msg.seq), never a stale push-time value. + expect(m.seq).toBe(2); + expect((buf.read()[0] as SessionMessage).seq).toBe(2); + expect(buf.touch("nope")).toBeUndefined(); + expect(buf.maxSeq).toBe(2); // failed touch doesn't burn a seq + }); + + test("upsert push and updateMessage advance the entry past an old cursor", () => { + const buf = new ScrollbackBuffer(); + const m = makeMsg("v1"); + buf.push(m); + buf.push(makeMsg("other")); + const cursor = buf.maxSeq; // client saw both + + // Mutation via updateMessage → entry must be resent to a resuming client, + // carrying the post-mutation seq (not the stale push-time one). + buf.updateMessage(m.messageId, (msg) => { + (msg as SessionMessage).content = "v2"; + }); + const tail = buf.readChunkedSince(cursor, 10 * 1024 * 1024).flat(); + expect(tail).toHaveLength(1); + expect((tail[0] as SessionMessage).content).toBe("v2"); + expect((tail[0] as SessionMessage).seq).toBe(buf.maxSeq); + + // Fully caught up → empty. + expect(buf.readChunkedSince(buf.maxSeq, 10 * 1024 * 1024)).toEqual([]); + }); + + test("readChunkedSince preserves buffer order and respects the byte budget", () => { + const buf = new ScrollbackBuffer(); + buf.push(makeMsg("before-cursor")); + const cursor = buf.maxSeq; + for (let i = 0; i < 6; i++) buf.push(makeMsg(`tail-${i}-${"x".repeat(150)}`)); + + const budget = 2 * Buffer.byteLength(JSON.stringify(makeMsg(`tail-0-${"x".repeat(150)}`))) + 20; + const chunks = buf.readChunkedSince(cursor, budget); + expect(chunks.length).toBeGreaterThan(1); + const flat = chunks.flat() as SessionMessage[]; + expect(flat).toHaveLength(6); + expect(flat.map((m) => m.content.split("-")[1])).toEqual(["0", "1", "2", "3", "4", "5"]); + for (const chunk of chunks) expect(chunk.length).toBeGreaterThan(0); + }); +}); diff --git a/src/tests/session-integration.test.ts b/src/tests/session-integration.test.ts index 0bd8cae..27e1554 100644 --- a/src/tests/session-integration.test.ts +++ b/src/tests/session-integration.test.ts @@ -672,6 +672,179 @@ describe("T5b – chunked scrollback replay (#84)", () => { }); }); +// ── T5c: incremental resume + send idempotency (replay.resume / send.idempotency) + +describe("T5c – incremental resume & send idempotency", () => { + type ReplayFrame = Extract; + const replays = (received: DaemonMessage[]) => + received.filter((m) => m.type === "scrollback.replay") as ReplayFrame[]; + + function makeRestoredMsg(session: Session, id: string, content: string): DaemonMessage { + return { + type: "session.message", + sessionId: session.id, + messageId: id, + role: "assistant", + content, + identity: { sub: "agent:test", name: "Claude", type: "agent" }, + timestamp: new Date().toISOString(), + }; + } + + it("snapshot replay carries resume meta (mode, resumeKey, maxSeq)", () => { + const session = makeSession(new MockSessionProvider("claude")); + session.restoreScrollback([makeRestoredMsg(session, "m0", "hello")]); + + const { client, received } = makeClient(); + session.attach(client); + + const frames = replays(received); + expect(frames).toHaveLength(1); + expect(frames[0]!.mode).toBe("snapshot"); + expect(typeof frames[0]!.resumeKey).toBe("string"); + expect(frames[0]!.maxSeq).toBeGreaterThanOrEqual(1); + }); + + it("matching-key resume replays only the tail; wrong key falls back to snapshot", () => { + const session = makeSession(new MockSessionProvider("claude")); + session.restoreScrollback([ + makeRestoredMsg(session, "m0", "old-0"), + makeRestoredMsg(session, "m1", "old-1"), + ]); + + // First attach: full snapshot; capture the cursor. + const a = makeClient(); + session.attach(a.client); + const snap = replays(a.received)[0]!; + session.detach(a.client.id); + + // New activity after the cursor. + session.restoreScrollback([makeRestoredMsg(session, "m2", "new-2")]); + + // Resume with the captured cursor → incremental, only m2. + const b = makeClient(); + session.attach(b.client, { key: snap.resumeKey!, sinceSeq: snap.maxSeq! }); + const inc = replays(b.received)[0]!; + expect(inc.mode).toBe("incremental"); + expect(inc.messages.map((m) => m.messageId)).toEqual(["m2"]); + expect(inc.maxSeq!).toBeGreaterThan(snap.maxSeq!); + + // Wrong key → authoritative snapshot with everything. + const c = makeClient(); + session.attach(c.client, { key: "not-this-buffer", sinceSeq: snap.maxSeq! }); + const full = replays(c.received)[0]!; + expect(full.mode).toBe("snapshot"); + expect(full.messages.map((m) => m.messageId)).toEqual(["m0", "m1", "m2"]); + }); + + it("fully-caught-up resume gets an empty incremental ack (legacy attach stays silent)", () => { + const session = makeSession(new MockSessionProvider("claude")); + session.restoreScrollback([makeRestoredMsg(session, "m0", "only")]); + + const a = makeClient(); + session.attach(a.client); + const snap = replays(a.received)[0]!; + session.detach(a.client.id); + + // Caught-up resume → one empty incremental frame (cursor ack). + const b = makeClient(); + session.attach(b.client, { key: snap.resumeKey!, sinceSeq: snap.maxSeq! }); + const ack = replays(b.received)[0]!; + expect(ack.mode).toBe("incremental"); + expect(ack.messages).toHaveLength(0); + expect(ack.maxSeq).toBe(snap.maxSeq!); + + // Legacy attach (no resume) on an EMPTY session sends no frame at all. + const empty = makeSession(new MockSessionProvider("claude")); + const c = makeClient(); + empty.attach(c.client); + expect(replays(c.received)).toHaveLength(0); + }); + + it("a live streamed turn advances the cursor; resuming from the pre-turn cursor returns only the turn's messages", async () => { + const provider = new MockSessionProvider("claude", [ + [ + { type: "text_delta", content: "He" }, + { type: "text_delta", content: "llo" }, + { type: "turn_done", result: mockResult({ providerId: "claude" }) }, + ], + ]); + const session = makeSession(provider); + session.restoreScrollback([makeRestoredMsg(session, "m0", "pre-turn")]); + + const a = makeClient(); + session.attach(a.client); + const preTurn = replays(a.received)[0]!; + + await session.send("hi there", TEST_AUTH); + await waitForIdle(session); + + // Live frames carry the session cursor: streamed deltas are stamped via + // scrollback.touch, new messages via scrollback.push. + const deltas = a.received.filter((m) => m.type === "session.message.delta"); + expect(deltas.length).toBeGreaterThan(0); + expect(deltas.every((d) => typeof (d as { seq?: number }).seq === "number")).toBe(true); + const liveMsgs = a.received.filter((m) => m.type === "session.message"); + expect(liveMsgs.some((m) => typeof (m as { seq?: number }).seq === "number")).toBe(true); + session.detach(a.client.id); + + // Resume from the PRE-turn cursor: only the turn's messages, not m0. + const b = makeClient(); + session.attach(b.client, { key: preTurn.resumeKey!, sinceSeq: preTurn.maxSeq! }); + const inc = replays(b.received)[0]!; + expect(inc.mode).toBe("incremental"); + const ids = inc.messages.map((m) => m.messageId); + expect(ids).not.toContain("m0"); + expect(inc.messages.some((m) => m.role === "user")).toBe(true); + expect(inc.messages.some((m) => m.role === "assistant" && m.content === "Hello")).toBe(true); + }); + + it("markClientMsgSeen: first sight false, duplicate true, FIFO eviction past 256", () => { + const session = makeSession(new MockSessionProvider("claude")); + expect(session.markClientMsgSeen("k1")).toBe(false); + expect(session.markClientMsgSeen("k1")).toBe(true); + expect(session.markClientMsgSeen("k2")).toBe(false); + + // Evict k1 by inserting 256 more distinct ids (cap is 256). + for (let i = 0; i < 256; i++) session.markClientMsgSeen(`fill-${i}`); + expect(session.markClientMsgSeen("k1")).toBe(false); // forgotten → processes again + }); + + it("duplicate clientMsgId does not dispatch a second turn (manager guard semantics)", async () => { + const provider = new MockSessionProvider("claude", [ + [ + { type: "text_done", content: "reply one" }, + { type: "turn_done", result: mockResult({ providerId: "claude" }) }, + ], + [ + { type: "text_done", content: "reply two" }, + { type: "turn_done", result: mockResult({ providerId: "claude" }) }, + ], + ]); + const session = makeSession(provider); + + // Mirrors SessionManager#send exactly: check-and-record, skip dispatch on + // duplicate. Two "deliveries" of the same user action, one turn. + // (Awaited so waitForIdle observes a session that actually started — + // see the waitForIdle doc note.) + const dispatch = async (text: string, clientMsgId: string): Promise => { + if (session.markClientMsgSeen(clientMsgId)) return false; // duplicate → ack only + await session.send(text, TEST_AUTH); + return true; + }; + + expect(await dispatch("do the thing", "action-1")).toBe(true); + expect(await dispatch("do the thing", "action-1")).toBe(false); + await waitForIdle(session); + + const { client, received } = makeClient(); + session.attach(client); + expect(replays(received)).toHaveLength(1); + const userMsgs = replays(received)[0]!.messages.filter((m) => m.role === "user"); + expect(userMsgs).toHaveLength(1); + }); +}); + // ── T6: ZeroID fence timeout ────────────────────────────────────────────────── describe("T6 – ZeroID fence 5 s timeout", () => { diff --git a/web/src/App.tsx b/web/src/App.tsx index d620aba..4b1539e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -22,6 +22,7 @@ import { send, } from "./state/connection"; import { focusedSession, focusedSessionId, mergeSession } from "./state/sessions"; +import { resumeFor } from "./state/resume"; import type { SessionInfo } from "./protocol/types"; import { resetClaudeConfig } from "./state/claude-config"; import { installApprovalNotifications } from "./state/desktop-notifications"; @@ -88,7 +89,10 @@ const App: Component = () => { // duplicate attaches. On failure, remove so the next focus change retries. attached.add(id); const reqId = newRequestId(); - request({ type: "session.attach", id: reqId, sessionId: id }) + // Resume incrementally when we hold a cursor for this session — the + // daemon then replays only what changed since, not the full scrollback. + const resume = resumeFor(id); + request({ type: "session.attach", id: reqId, sessionId: id, ...(resume ? { resume } : {}) }) .then((data) => { // The attach response includes the session's current SessionInfo. // Update local state immediately so the status dot reflects reality diff --git a/web/src/components/prompt/PromptBox.tsx b/web/src/components/prompt/PromptBox.tsx index 0951dc7..6a452d0 100644 --- a/web/src/components/prompt/PromptBox.tsx +++ b/web/src/components/prompt/PromptBox.tsx @@ -246,6 +246,10 @@ const PromptBox: Component = () => { id: newRequestId(), sessionId: session.id, text: raw, + // Idempotency key (`send.idempotency`): minted once per submit, so if + // this send is retried after an ambiguous socket drop the daemon acks + // the duplicate instead of running (and billing) a second turn. + clientMsgId: crypto.randomUUID(), ...(payload.length > 0 ? { attachments: payload } : {}), }).catch((e) => { setError(`Message not delivered: ${e instanceof Error ? e.message : String(e)}`); diff --git a/web/src/protocol/types.ts b/web/src/protocol/types.ts index 19c9536..7fdee4c 100644 --- a/web/src/protocol/types.ts +++ b/web/src/protocol/types.ts @@ -246,6 +246,8 @@ export interface SessionMessage { tool?: ToolInfo; metadata?: Record; timestamp: string; + /** Session sequence cursor (`replay.resume`) — track max(seq) per session. */ + seq?: number; } export interface SessionMessageDelta { @@ -257,6 +259,8 @@ export interface SessionMessageDelta { partsUpdate?: { index: number; part: ContentPart }[]; toolStateUpdate?: ToolState; timestamp: string; + /** Session sequence cursor (`replay.resume`) — track max(seq) per session. */ + seq?: number; } // ----------------------------------------------------------------------------- @@ -286,6 +290,13 @@ export interface SessionListMsg extends BaseClientMsg { export interface SessionAttachMsg extends BaseClientMsg { type: "session.attach"; sessionId: string; + /** + * Incremental resume (`replay.resume`): pass the `resumeKey` + highest + * `seq` from previous frames to receive only the tail mutated since, + * instead of a full scrollback replay. Daemon falls back to a snapshot + * on any key mismatch. + */ + resume?: { key: string; sinceSeq: number }; } export interface SessionDetachMsg extends BaseClientMsg { @@ -299,6 +310,12 @@ export interface SessionSendMsg extends BaseClientMsg { text: string; attachments?: { path: string; content?: string; mimeType?: string; data?: string }[]; priority?: "now" | "next" | "later"; + /** + * Idempotency key (`send.idempotency`): generated once per user action; + * the daemon acks duplicates instead of running a second turn, so an + * ambiguous socket drop + retry can't double-bill a prompt. + */ + clientMsgId?: string; } export interface SessionInterruptMsg extends BaseClientMsg { @@ -512,9 +529,20 @@ export interface ScrollbackReplayMsg { * large scrollback into ordered chunks (oldest→newest). Reset scrollback * when `seq` is absent or 0; append when `seq > 0`; the replay is complete * on `final` (or when `seq` is absent — a single-frame legacy replay). + * NOTE: this is the CHUNK index — unrelated to `SessionMessage.seq`. */ seq?: number; final?: boolean; + /** + * Replay semantics (`replay.resume`): "snapshot" (or absent) = reset the + * local buffer to this replay; "incremental" = append/upsert only — the + * daemon sent just the tail mutated since our resume cursor. + */ + mode?: "snapshot" | "incremental"; + /** Replay-buffer identity — store with maxSeq, pass back on attach.resume. */ + resumeKey?: string; + /** Highest session seq included/known — the next resume cursor. */ + maxSeq?: number; } export interface SessionSearchSnippet { diff --git a/web/src/state/connection.ts b/web/src/state/connection.ts index 54449ec..a0a3a16 100644 --- a/web/src/state/connection.ts +++ b/web/src/state/connection.ts @@ -27,6 +27,7 @@ import { appendScrollback, replaceScrollback, } from "./messages"; +import { noteLiveSeq, noteReplayFrame } from "./resume"; // Resolve the daemon WebSocket URL: // 1. explicit VITE_CODEOID_URL build override, else @@ -227,14 +228,24 @@ export function disconnect(): void { function routeBroadcast(msg: DaemonMessage): void { switch (msg.type) { case "session.message": + noteLiveSeq(msg.sessionId, msg.seq); applyMessage(msg); return; case "session.message.delta": + noteLiveSeq(msg.sessionId, msg.seq); applyDelta(msg); return; case "scrollback.replay": - // Chunked replay (#84): chunk 0 (or a single-frame legacy replay, where - // seq is absent) resets the session; later chunks append in order. + noteReplayFrame(msg); + // Incremental resume (`replay.resume`): the daemon sent only the tail + // mutated since our cursor — upsert into the existing buffer, never + // reset (chunk 0 of an incremental replay is NOT a snapshot). + if (msg.mode === "incremental") { + appendScrollback(msg.sessionId, msg.messages); + return; + } + // Snapshot (chunked #84): chunk 0 (or a single-frame legacy replay, + // where seq is absent) resets the session; later chunks append in order. if (msg.seq === undefined || msg.seq === 0) { replaceScrollback(msg.sessionId, msg.messages); } else { diff --git a/web/src/state/resume.test.ts b/web/src/state/resume.test.ts new file mode 100644 index 0000000..9e2de66 --- /dev/null +++ b/web/src/state/resume.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + _resetResumeForTest, + clearResumeCursor, + noteLiveSeq, + noteReplayFrame, + resumeFor, +} from "./resume"; +import type { ScrollbackReplayMsg } from "../protocol/types"; + +function replayFrame(overrides: Partial = {}): ScrollbackReplayMsg { + return { + type: "scrollback.replay", + sessionId: "s1", + messages: [], + mode: "snapshot", + resumeKey: "key-a", + maxSeq: 10, + ...overrides, + }; +} + +describe("resume cursors", () => { + beforeEach(() => _resetResumeForTest()); + + it("no cursor until a replay frame with resume meta arrives", () => { + expect(resumeFor("s1")).toBeUndefined(); + // Legacy daemon frame (no resumeKey/maxSeq) establishes nothing. + noteReplayFrame(replayFrame({ resumeKey: undefined, maxSeq: undefined })); + expect(resumeFor("s1")).toBeUndefined(); + }); + + it("replay frame establishes the cursor; same-key frames only ever raise it", () => { + noteReplayFrame(replayFrame({ maxSeq: 10 })); + expect(resumeFor("s1")).toEqual({ key: "key-a", sinceSeq: 10 }); + + noteReplayFrame(replayFrame({ maxSeq: 25 })); + expect(resumeFor("s1")!.sinceSeq).toBe(25); + + // A stale/duplicate frame must never LOWER the cursor (leading > lagging + // is the dangerous direction; lowering is safe but wasteful — we keep max). + noteReplayFrame(replayFrame({ maxSeq: 5 })); + expect(resumeFor("s1")!.sinceSeq).toBe(25); + }); + + it("a NEW resumeKey (daemon restart) resets the cursor to the new domain", () => { + noteReplayFrame(replayFrame({ resumeKey: "key-a", maxSeq: 100 })); + noteReplayFrame(replayFrame({ resumeKey: "key-b", maxSeq: 3 })); + // Old-domain seq 100 is meaningless under key-b — cursor must be 3, not 100. + expect(resumeFor("s1")).toEqual({ key: "key-b", sinceSeq: 3 }); + }); + + it("live seqs raise the cursor, never lower it, and are dropped without a key", () => { + noteLiveSeq("s1", 42); // no cursor yet — nothing to anchor the domain + expect(resumeFor("s1")).toBeUndefined(); + + noteReplayFrame(replayFrame({ maxSeq: 10 })); + noteLiveSeq("s1", 12); + expect(resumeFor("s1")!.sinceSeq).toBe(12); + noteLiveSeq("s1", 11); + expect(resumeFor("s1")!.sinceSeq).toBe(12); + noteLiveSeq("s1", undefined); + expect(resumeFor("s1")!.sinceSeq).toBe(12); + }); + + it("cursors are per-session and cleared on destroy", () => { + noteReplayFrame(replayFrame({ sessionId: "s1", maxSeq: 7 })); + noteReplayFrame(replayFrame({ sessionId: "s2", resumeKey: "key-z", maxSeq: 3 })); + expect(resumeFor("s1")!.sinceSeq).toBe(7); + expect(resumeFor("s2")).toEqual({ key: "key-z", sinceSeq: 3 }); + + clearResumeCursor("s1"); + expect(resumeFor("s1")).toBeUndefined(); + expect(resumeFor("s2")).toBeDefined(); + }); +}); diff --git a/web/src/state/resume.ts b/web/src/state/resume.ts new file mode 100644 index 0000000..5093335 --- /dev/null +++ b/web/src/state/resume.ts @@ -0,0 +1,71 @@ +/** + * Per-session resume cursors (`replay.resume`). + * + * Tracks, per session, the daemon's replay-buffer identity (`resumeKey`) and + * the highest session sequence value observed (`seq`) across replay frames + * and live message/delta traffic. On re-attach the cursor is passed back so + * the daemon replays only the tail mutated since — instead of the full + * scrollback — which is what makes reconnects cheap on flaky links. + * + * Safety property: the cursor may lag reality (a frame without `seq` doesn't + * advance it) but must never lead it — a lagging cursor just means a few + * messages are resent and deduped by the store's upsert-by-messageId; a + * leading cursor would silently skip content. Everything here only ever + * raises the cursor to values actually observed. + * + * Plain module state (not a Solid store): cursors are read at attach time, + * never rendered. + */ + +import type { ScrollbackReplayMsg } from "../protocol/types"; + +interface Cursor { + key: string; + seq: number; +} + +const cursors = new Map(); + +/** + * Ingest a replay frame. A frame carrying a NEW `resumeKey` (first contact, + * or the daemon restarted and rebuilt its buffer) resets the cursor to that + * key's `maxSeq` — old-key seq values are meaningless in the new domain. + * Same-key frames only ever raise the cursor. + */ +export function noteReplayFrame(msg: ScrollbackReplayMsg): void { + if (msg.resumeKey === undefined || msg.maxSeq === undefined) return; + const existing = cursors.get(msg.sessionId); + if (existing && existing.key === msg.resumeKey) { + if (msg.maxSeq > existing.seq) existing.seq = msg.maxSeq; + } else { + cursors.set(msg.sessionId, { key: msg.resumeKey, seq: msg.maxSeq }); + } +} + +/** + * Ingest a live frame's session cursor (`SessionMessage.seq` / + * `SessionMessageDelta.seq`). Only meaningful once a replay frame has + * established which key the seq domain belongs to — live seqs arriving + * before any cursor exists are dropped (we can't resume without a key). + */ +export function noteLiveSeq(sessionId: string, seq: number | undefined): void { + if (seq === undefined) return; + const cursor = cursors.get(sessionId); + if (cursor && seq > cursor.seq) cursor.seq = seq; +} + +/** The resume argument for `session.attach`, or undefined for a full replay. */ +export function resumeFor(sessionId: string): { key: string; sinceSeq: number } | undefined { + const cursor = cursors.get(sessionId); + return cursor ? { key: cursor.key, sinceSeq: cursor.seq } : undefined; +} + +/** Drop a session's cursor (session destroyed). */ +export function clearResumeCursor(sessionId: string): void { + cursors.delete(sessionId); +} + +/** Test-only: reset module state. */ +export function _resetResumeForTest(): void { + cursors.clear(); +} diff --git a/web/src/state/sessions.ts b/web/src/state/sessions.ts index b88221e..aa89870 100644 --- a/web/src/state/sessions.ts +++ b/web/src/state/sessions.ts @@ -11,6 +11,7 @@ import { createStore, produce } from "solid-js/store"; import type { SessionInfo, SessionStatus } from "../protocol/types"; import { clearSessionMessages, setFocusedSessionAccessor } from "./messages"; +import { clearResumeCursor } from "./resume"; interface SessionsState { byId: Record; @@ -154,6 +155,7 @@ export function setSessionStatus(id: string, status: SessionStatus): void { export function removeSession(id: string): void { batch(() => { clearSessionMessages(id); + clearResumeCursor(id); setState( "byId", produce>((m) => {