From b71893d6c6fc38cb0ff554670b8131b9bf5f616b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 9 Feb 2026 23:18:12 -0800 Subject: [PATCH 1/2] standardized messages --- AGENTS.md | 2 +- README.md | 11 +- apps/server/src/codexAppServerManager.ts | 42 +- .../src/providerEventNormalizer.test.ts | 118 ++++ apps/server/src/providerEventNormalizer.ts | 427 +++++++++++++ apps/server/src/providerManager.test.ts | 6 +- apps/server/src/providerManager.ts | 26 +- apps/server/src/providerStreamStore.test.ts | 181 ++++++ apps/server/src/providerStreamStore.ts | 428 +++++++++++++ .../providerStreamSubscriptionManager.test.ts | 162 +++++ .../src/providerStreamSubscriptionManager.ts | 298 +++++++++ apps/server/src/wsServer.test.ts | 48 ++ apps/server/src/wsServer.ts | 62 +- apps/web/src/App.tsx | 10 +- apps/web/src/components/ChatView.tsx | 104 ++- apps/web/src/session-logic.test.ts | 592 ++++++------------ apps/web/src/session-logic.ts | 470 ++++---------- apps/web/src/store.test.ts | 124 +++- apps/web/src/store.ts | 371 ++++++++--- apps/web/src/types.ts | 10 +- apps/web/src/wsNativeApi.ts | 146 ++++- apps/web/src/wsTransport.ts | 27 + docs/provider-ws-api.md | 151 +++++ packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 14 +- packages/contracts/src/provider.test.ts | 20 +- packages/contracts/src/provider.ts | 18 +- packages/contracts/src/providerStream.test.ts | 79 +++ packages/contracts/src/providerStream.ts | 294 +++++++++ packages/contracts/src/ws.ts | 6 +- 30 files changed, 3243 insertions(+), 1005 deletions(-) create mode 100644 apps/server/src/providerEventNormalizer.test.ts create mode 100644 apps/server/src/providerEventNormalizer.ts create mode 100644 apps/server/src/providerStreamStore.test.ts create mode 100644 apps/server/src/providerStreamStore.ts create mode 100644 apps/server/src/providerStreamSubscriptionManager.test.ts create mode 100644 apps/server/src/providerStreamSubscriptionManager.ts create mode 100644 docs/provider-ws-api.md create mode 100644 packages/contracts/src/providerStream.test.ts create mode 100644 packages/contracts/src/providerStream.ts diff --git a/AGENTS.md b/AGENTS.md index 7f5148fce0f2..43b5f2583950 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ How we use it in this codebase: - Session startup/resume and turn lifecycle are brokered in `apps/server/src/codexAppServerManager.ts`. - Provider dispatch and thread event logging are coordinated in `apps/server/src/providerManager.ts`. - WebSocket server routes NativeApi methods in `apps/server/src/wsServer.ts`. -- Web app consumes provider event streams via WebSocket push on channel `providers.event`. +- Web app consumes canonical provider stream frames via WebSocket push on channel `providers.stream`. Docs: - Codex App Server docs: https://developers.openai.com/codex/sdk/#app-server diff --git a/README.md b/README.md index 1f18c9d30017..d9353a049baf 100644 --- a/README.md +++ b/README.md @@ -76,18 +76,21 @@ T3 Code has a global runtime mode switch in the chat toolbar: ## Provider architecture -The web app communicates with the server via WebSocket using a simple JSON-RPC-style protocol: +The web app communicates with the server via WebSocket using a JSON-RPC-style request/response layer plus a single canonical provider stream: - **Request/Response**: `{ id, method, params }` → `{ id, result }` or `{ id, error }` -- **Push events**: `{ type: "push", channel, data }` for streaming provider events +- **Push stream**: `{ type: "push", channel: "providers.stream", data: ProviderStreamFrame }` Methods mirror the `NativeApi` interface defined in `@t3tools/contracts`: - `providers.startSession`, `providers.sendTurn`, `providers.interruptTurn` -- `providers.respondToRequest`, `providers.stopSession`, `providers.listSessions` +- `providers.respondToApproval`, `providers.stopSession`, `providers.listSessions` +- `providers.openStream`, `providers.closeStream` - `shell.openInEditor`, `server.getConfig` -Codex is the only implemented provider. `claudeCode` is reserved in contracts/UI. +The stream contract is `snapshot + delta` with cursor replay (`afterSeq`) for reconnect safety. The server normalizes provider-native events (currently Codex raw app-server events) into canonical events so UI rendering stays provider-agnostic. + +See `docs/provider-ws-api.md` for the full canonical schema, event mapping, and replay/gap semantics. ## CI quality gates diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index af1e67e36b61..c91c230297c3 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -5,7 +5,7 @@ import readline from "node:readline"; import type { ProviderApprovalDecision, - ProviderEvent, + ProviderRawEvent, ProviderRequestKind, ProviderSendTurnInput, ProviderSession, @@ -135,7 +135,7 @@ export function isRecoverableThreadResumeError(error: unknown): boolean { } export interface CodexAppServerManagerEvents { - event: [event: ProviderEvent]; + event: [event: ProviderRawEvent]; } export class CodexAppServerManager extends EventEmitter { @@ -333,18 +333,18 @@ export class CodexAppServerManager extends EventEmitter { const context = this.requireSession(sessionId); - const pendingRequest = context.pendingApprovals.get(requestId); + const pendingRequest = context.pendingApprovals.get(approvalId); if (!pendingRequest) { - throw new Error(`Unknown pending approval request: ${requestId}`); + throw new Error(`Unknown pending approval request: ${approvalId}`); } - context.pendingApprovals.delete(requestId); + context.pendingApprovals.delete(approvalId); this.writeMessage(context, { id: pendingRequest.jsonRpcId, result: { @@ -584,9 +584,12 @@ export class CodexAppServerManager extends EventEmitter = {}): ProviderRawEvent { + return { + id: "raw-1", + kind: "notification", + provider: "codex", + sessionId: "sess-1", + createdAt: "2026-02-10T00:00:00.000Z", + method: "turn/started", + payload: { + thread: { id: "thread-1" }, + turn: { id: "turn-1" }, + }, + ...overrides, + }; +} + +function makeSession(overrides: Partial = {}): CanonicalSessionState { + return { + sessionId: "sess-1", + provider: "codex", + status: "ready", + createdAt: "2026-02-10T00:00:00.000Z", + updatedAt: "2026-02-10T00:00:00.000Z", + ...overrides, + }; +} + +describe("ProviderEventNormalizer", () => { + it("maps turn start notifications to canonical session and turn events", () => { + const normalizer = new ProviderEventNormalizer(); + + const normalized = normalizer.normalize( + makeRawEvent({ + method: "turn/started", + }), + makeSession(), + ); + + expect(normalized).toHaveLength(2); + expect(normalized[0]?.type).toBe("session.updated"); + expect(normalized[1]?.type).toBe("turn.started"); + }); + + it("maps command approval requests and decision notifications", () => { + const normalizer = new ProviderEventNormalizer(); + + const requested = normalizer.normalize( + makeRawEvent({ + kind: "request", + method: "item/commandExecution/requestApproval", + requestId: "approval-1", + payload: { + thread: { id: "thread-1" }, + turn: { id: "turn-1" }, + command: "git status --short", + }, + }), + makeSession(), + ); + + expect(requested.some((event) => event.type === "approval.requested")).toBe(true); + + const resolved = normalizer.normalize( + makeRawEvent({ + kind: "notification", + method: "item/requestApproval/decision", + requestId: "approval-1", + payload: { + requestId: "approval-1", + decision: "acceptForSession", + }, + }), + makeSession(), + ); + + const approvalResolved = resolved.find((event) => event.type === "approval.resolved"); + expect(approvalResolved).toBeDefined(); + if (!approvalResolved || approvalResolved.type !== "approval.resolved") { + throw new Error("expected approval.resolved event"); + } + expect(approvalResolved.decision).toBe("accept_for_session"); + }); + + it("drops unmapped methods from the core stream", () => { + const normalizer = new ProviderEventNormalizer(); + + const normalized = normalizer.normalize( + makeRawEvent({ + method: "item/reasoning/summaryPartAdded", + payload: { + text: "partial summary", + }, + }), + makeSession(), + ); + + expect(normalized).toHaveLength(0); + }); + + it("creates debug.raw wrappers", () => { + const normalizer = new ProviderEventNormalizer(); + const raw = makeRawEvent({ method: "item/reasoning/summaryPartAdded" }); + + const debug = normalizer.toDebugRaw(raw); + + expect(debug.type).toBe("debug.raw"); + if (debug.type !== "debug.raw") { + throw new Error("expected debug.raw event"); + } + expect(debug.provider).toBe("codex"); + expect(debug.method).toBe("item/reasoning/summaryPartAdded"); + }); +}); diff --git a/apps/server/src/providerEventNormalizer.ts b/apps/server/src/providerEventNormalizer.ts new file mode 100644 index 000000000000..547b1010e48e --- /dev/null +++ b/apps/server/src/providerEventNormalizer.ts @@ -0,0 +1,427 @@ +import type { + CanonicalSessionState, + ProviderCoreEvent, + ProviderRawEvent, +} from "@t3tools/contracts"; + +function asObject(value: unknown): Record | undefined { + if (!value || typeof value !== "object") return undefined; + return value as Record; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function asBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +function readThreadId(event: ProviderRawEvent): string | undefined { + if (event.threadId) return event.threadId; + const payload = asObject(event.payload); + const thread = asObject(payload?.thread); + return ( + asString(payload?.threadId) ?? + asString(payload?.thread_id) ?? + asString(thread?.id) + ); +} + +function readTurnId(event: ProviderRawEvent): string | undefined { + if (event.turnId) return event.turnId; + const payload = asObject(event.payload); + const turn = asObject(payload?.turn); + return asString(payload?.turnId) ?? asString(payload?.turn_id) ?? asString(turn?.id); +} + +function readItemId(event: ProviderRawEvent): string | undefined { + if (event.itemId) return event.itemId; + const payload = asObject(event.payload); + const item = asObject(payload?.item); + return asString(payload?.itemId) ?? asString(payload?.item_id) ?? asString(item?.id); +} + +function mapApprovalDecision(decision: string): "accept" | "accept_for_session" | "decline" | "cancel" | "timed_out" { + switch (decision) { + case "accept": + return "accept"; + case "acceptForSession": + return "accept_for_session"; + case "decline": + return "decline"; + case "cancel": + return "cancel"; + default: + return "cancel"; + } +} + +function nextSessionState( + raw: ProviderRawEvent, + current: CanonicalSessionState | undefined, +): CanonicalSessionState | undefined { + const now = raw.createdAt; + const base: CanonicalSessionState = + current ?? { + sessionId: raw.sessionId, + provider: raw.provider, + status: "ready", + createdAt: now, + updatedAt: now, + }; + const threadId = readThreadId(raw); + const turnId = readTurnId(raw); + const payload = asObject(raw.payload); + const turn = asObject(payload?.turn); + const turnStatus = asString(turn?.status); + const turnError = asString(asObject(turn?.error)?.message); + + if (raw.kind === "session") { + if (raw.method === "session/connecting") { + return { + ...base, + status: "connecting", + updatedAt: now, + }; + } + if (raw.method === "session/ready") { + return { + ...base, + status: "ready", + threadId: threadId ?? base.threadId, + updatedAt: now, + }; + } + if (raw.method === "session/closed" || raw.method === "session/exited") { + return { + ...base, + status: "closed", + activeTurnId: undefined, + lastError: raw.message ?? base.lastError, + updatedAt: now, + }; + } + if (raw.method === "session/startFailed") { + return { + ...base, + status: "error", + lastError: raw.message ?? base.lastError, + updatedAt: now, + }; + } + } + + if (raw.method === "thread/started") { + return { + ...base, + threadId: threadId ?? base.threadId, + updatedAt: now, + }; + } + + if (raw.method === "turn/started") { + return { + ...base, + status: "running", + threadId: threadId ?? base.threadId, + activeTurnId: turnId ?? base.activeTurnId, + updatedAt: now, + }; + } + + if (raw.method === "turn/completed") { + return { + ...base, + status: turnStatus === "failed" ? "error" : "ready", + threadId: threadId ?? base.threadId, + activeTurnId: undefined, + lastError: turnError ?? base.lastError, + updatedAt: now, + }; + } + + if (raw.kind === "error") { + return { + ...base, + status: "error", + lastError: raw.message ?? base.lastError, + updatedAt: now, + }; + } + + if (raw.method === "error") { + const errorMessage = asString(asObject(payload?.error)?.message); + return { + ...base, + status: "error", + lastError: errorMessage ?? raw.message ?? base.lastError, + updatedAt: now, + }; + } + + return undefined; +} + +function isActionableItemType(type: string | undefined): boolean { + if (!type) return false; + const normalized = type.toLowerCase(); + if ( + normalized.includes("agentmessage") || + normalized.includes("reasoning") || + normalized.includes("preamble") + ) { + return false; + } + return true; +} + +function mapActivityLabel(type: string | undefined): string { + const normalized = type?.toLowerCase() ?? ""; + if (normalized.includes("command")) return "Command run"; + if (normalized.includes("filechange") || normalized.includes("file_change")) { + return "File change"; + } + if (normalized.includes("tool")) return "Tool call"; + return "Activity"; +} + +function mapActivityKind(type: string | undefined): "tool" | "plan" | "system" { + const normalized = type?.toLowerCase() ?? ""; + if (normalized.includes("plan")) return "plan"; + if (normalized.includes("system")) return "system"; + return "tool"; +} + +export interface ProviderEventAdapter { + normalize( + raw: ProviderRawEvent, + currentSession: CanonicalSessionState | undefined, + ): ProviderCoreEvent[]; + toDebugRaw(raw: ProviderRawEvent): ProviderCoreEvent; +} + +export class ProviderEventNormalizer implements ProviderEventAdapter { + normalize( + raw: ProviderRawEvent, + currentSession: CanonicalSessionState | undefined, + ): ProviderCoreEvent[] { + const events: ProviderCoreEvent[] = []; + const payload = asObject(raw.payload); + const threadId = readThreadId(raw); + const turnId = readTurnId(raw); + const itemId = readItemId(raw); + + const sessionUpdate = nextSessionState(raw, currentSession); + if (sessionUpdate) { + events.push({ + type: "session.updated", + session: sessionUpdate, + }); + } + + if (raw.method === "turn/started" && threadId && turnId) { + events.push({ + type: "turn.started", + sessionId: raw.sessionId, + threadId, + turnId, + startedAt: raw.createdAt, + }); + } + + if (raw.method === "turn/completed" && threadId && turnId) { + const turn = asObject(payload?.turn); + const status = asString(turn?.status); + const turnError = asString(asObject(turn?.error)?.message); + let outcome: "completed" | "failed" | "interrupted" = "completed"; + if (status === "failed") { + outcome = "failed"; + } else if (status === "interrupted") { + outcome = "interrupted"; + } + events.push({ + type: "turn.completed", + sessionId: raw.sessionId, + threadId, + turnId, + completedAt: raw.createdAt, + outcome, + ...(turnError ? { error: turnError } : {}), + }); + } + + if (raw.method === "item/agentMessage/delta" && threadId && itemId) { + const delta = raw.textDelta ?? asString(payload?.delta) ?? ""; + if (delta.length > 0) { + events.push({ + type: "message.delta", + sessionId: raw.sessionId, + threadId, + ...(turnId ? { turnId } : {}), + messageId: itemId, + role: "assistant", + delta, + }); + } + } + + if (raw.method === "item/completed" && threadId) { + const item = asObject(payload?.item); + if (asString(item?.type) === "agentMessage") { + const messageId = asString(item?.id) ?? itemId; + const text = asString(item?.text) ?? ""; + if (messageId) { + events.push({ + type: "message.completed", + sessionId: raw.sessionId, + threadId, + ...(turnId ? { turnId } : {}), + messageId, + role: "assistant", + text, + }); + } + } + } + + if ( + raw.method === "item/commandExecution/requestApproval" && + raw.requestId + ) { + const command = asString(payload?.command); + const reason = asString(payload?.reason); + events.push({ + type: "approval.requested", + sessionId: raw.sessionId, + ...(threadId ? { threadId } : {}), + ...(turnId ? { turnId } : {}), + approvalId: raw.requestId, + approvalKind: "command", + title: "Command approval requested", + ...(command ? { detail: command } : reason ? { detail: reason } : {}), + payload: raw.payload, + requestedAt: raw.createdAt, + }); + } + + if (raw.method === "item/fileChange/requestApproval" && raw.requestId) { + const reason = asString(payload?.reason); + events.push({ + type: "approval.requested", + sessionId: raw.sessionId, + ...(threadId ? { threadId } : {}), + ...(turnId ? { turnId } : {}), + approvalId: raw.requestId, + approvalKind: "file_change", + title: "File change approval requested", + ...(reason ? { detail: reason } : {}), + payload: raw.payload, + requestedAt: raw.createdAt, + }); + } + + if (raw.method === "item/tool/requestUserInput") { + const approvalId = raw.requestId ?? raw.id; + events.push({ + type: "approval.requested", + sessionId: raw.sessionId, + ...(threadId ? { threadId } : {}), + ...(turnId ? { turnId } : {}), + approvalId, + approvalKind: "user_input", + title: "Tool requested user input", + payload: raw.payload, + requestedAt: raw.createdAt, + }); + } + + if (raw.method === "item/requestApproval/decision" && raw.requestId) { + const decision = mapApprovalDecision( + asString(payload?.decision) ?? "cancel", + ); + events.push({ + type: "approval.resolved", + sessionId: raw.sessionId, + approvalId: raw.requestId, + decision, + resolvedAt: raw.createdAt, + }); + } + + if (raw.method === "item/started" || raw.method === "item/completed") { + const item = asObject(payload?.item); + const itemType = asString(item?.type); + if (threadId && itemId && isActionableItemType(itemType)) { + events.push({ + type: "activity", + sessionId: raw.sessionId, + threadId, + ...(turnId ? { turnId } : {}), + activityId: itemId, + activityKind: mapActivityKind(itemType), + label: mapActivityLabel(itemType), + ...(asString(item?.command) ? { detail: asString(item?.command) } : {}), + status: raw.method === "item/started" ? "created" : "success", + ...(raw.method === "item/started" + ? { startedAt: raw.createdAt } + : { completedAt: raw.createdAt }), + }); + } + } + + if (raw.method === "turn/plan/updated" && threadId) { + const explanation = asString(payload?.explanation); + const plan = payload?.plan; + events.push({ + type: "activity", + sessionId: raw.sessionId, + threadId, + ...(turnId ? { turnId } : {}), + activityId: raw.id, + activityKind: "plan", + label: "Plan updated", + ...(explanation ? { detail: explanation } : {}), + status: "success", + completedAt: raw.createdAt, + ...(explanation || plan + ? { + extensions: { + "codex.turn.plan": { + explanation: explanation ?? null, + plan: plan ?? null, + }, + }, + } + : {}), + }); + } + + if (raw.kind === "error" || raw.method === "error") { + const notificationMessage = asString(asObject(payload?.error)?.message); + const retryable = asBoolean(payload?.willRetry) ?? asBoolean(payload?.will_retry); + const message = notificationMessage ?? raw.message ?? "Provider error"; + events.push({ + type: "error", + sessionId: raw.sessionId, + ...(threadId ? { threadId } : {}), + ...(turnId ? { turnId } : {}), + code: raw.method, + message, + ...(retryable !== undefined ? { retryable } : {}), + }); + } + + return events; + } + + toDebugRaw(raw: ProviderRawEvent): ProviderCoreEvent { + return { + type: "debug.raw", + provider: raw.provider, + sessionId: raw.sessionId, + method: raw.method, + payload: raw.payload ?? null, + }; + } +} diff --git a/apps/server/src/providerManager.test.ts b/apps/server/src/providerManager.test.ts index c159bb296cf6..8d3a11e43332 100644 --- a/apps/server/src/providerManager.test.ts +++ b/apps/server/src/providerManager.test.ts @@ -49,13 +49,13 @@ describe("ProviderManager", () => { expect(() => manager.dispose()).not.toThrow(); }); - it("rejects request responses for unknown sessions", async () => { + it("rejects approval responses for unknown sessions", async () => { const manager = new ProviderManager(); await expect( - manager.respondToRequest({ + manager.respondToApproval({ sessionId: "missing-session", - requestId: "req-1", + approvalId: "approval-1", decision: "accept", }), ).rejects.toThrow("Unknown provider session: missing-session"); diff --git a/apps/server/src/providerManager.ts b/apps/server/src/providerManager.ts index be17babc35b9..49c53e0cbb46 100644 --- a/apps/server/src/providerManager.ts +++ b/apps/server/src/providerManager.ts @@ -3,16 +3,16 @@ import fs from "node:fs"; import path from "node:path"; import { - type ProviderEvent, type ProviderInterruptTurnInput, - type ProviderRespondToRequestInput, + type ProviderRawEvent, + type ProviderRespondToApprovalInput, type ProviderSendTurnInput, type ProviderSession, type ProviderSessionStartInput, type ProviderStopSessionInput, type ProviderTurnStartResult, providerInterruptTurnInputSchema, - providerRespondToRequestInputSchema, + providerRespondToApprovalInputSchema, providerSendTurnInputSchema, providerSessionStartInputSchema, providerStopSessionInputSchema, @@ -20,7 +20,7 @@ import { import { CodexAppServerManager } from "./codexAppServerManager"; export interface ProviderManagerEvents { - event: [event: ProviderEvent]; + event: [event: ProviderRawEvent]; } export class ProviderManager extends EventEmitter { @@ -28,9 +28,9 @@ export class ProviderManager extends EventEmitter { private readonly threadLogsDir: string; private readonly threadLogStreams = new Map(); private readonly sessionThreadIds = new Map(); - private readonly pendingEventsBySession = new Map(); + private readonly pendingEventsBySession = new Map(); private disposed = false; - private readonly onCodexEvent = (event: ProviderEvent) => { + private readonly onCodexEvent = (event: ProviderRawEvent) => { if (this.disposed) { return; } @@ -81,15 +81,15 @@ export class ProviderManager extends EventEmitter { await this.codex.interruptTurn(input.sessionId, input.turnId); } - async respondToRequest(raw: ProviderRespondToRequestInput): Promise { - const input = providerRespondToRequestInputSchema.parse(raw); + async respondToApproval(raw: ProviderRespondToApprovalInput): Promise { + const input = providerRespondToApprovalInputSchema.parse(raw); if (!this.codex.hasSession(input.sessionId)) { throw new Error(`Unknown provider session: ${input.sessionId}`); } - await this.codex.respondToRequest( + await this.codex.respondToApproval( input.sessionId, - input.requestId, + input.approvalId, input.decision, ); } @@ -124,7 +124,7 @@ export class ProviderManager extends EventEmitter { this.pendingEventsBySession.clear(); } - private routeEventToThreadLog(event: ProviderEvent): void { + private routeEventToThreadLog(event: ProviderRawEvent): void { const threadId = this.resolveThreadId(event); if (!threadId) { const pending = this.pendingEventsBySession.get(event.sessionId) ?? []; @@ -149,7 +149,7 @@ export class ProviderManager extends EventEmitter { this.pendingEventsBySession.delete(sessionId); } - private resolveThreadId(event: ProviderEvent): string | undefined { + private resolveThreadId(event: ProviderRawEvent): string | undefined { const fromPayload = this.readThreadIdFromPayload(event.payload); const threadId = event.threadId ?? @@ -187,7 +187,7 @@ export class ProviderManager extends EventEmitter { return undefined; } - private writeThreadEvent(threadId: string, event: ProviderEvent): void { + private writeThreadEvent(threadId: string, event: ProviderRawEvent): void { const stream = this.getOrCreateThreadLogStream(threadId); stream.write(`${JSON.stringify(event)}\n`); } diff --git a/apps/server/src/providerStreamStore.test.ts b/apps/server/src/providerStreamStore.test.ts new file mode 100644 index 000000000000..fad89b5092bc --- /dev/null +++ b/apps/server/src/providerStreamStore.test.ts @@ -0,0 +1,181 @@ +import type { CanonicalSessionState } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { + PROVIDER_STREAM_MAX_REPLAY_EVENTS, + ProviderStreamStore, + providerStreamEventKindOf, +} from "./providerStreamStore"; + +function makeSessionState(overrides: Partial = {}): CanonicalSessionState { + return { + sessionId: "sess-1", + provider: "codex", + status: "ready", + createdAt: "2026-02-10T00:00:00.000Z", + updatedAt: "2026-02-10T00:00:00.000Z", + ...overrides, + }; +} + +describe("ProviderStreamStore", () => { + it("stores events in seq order and builds snapshot state", () => { + const store = new ProviderStreamStore(); + const baseMs = Date.now(); + const at = (offsetSeconds: number) => new Date(baseMs + offsetSeconds * 1_000).toISOString(); + + store.appendEvent( + { + type: "session.updated", + session: makeSessionState(), + }, + at(0), + ); + + store.appendEvent( + { + type: "turn.started", + sessionId: "sess-1", + threadId: "thread-1", + turnId: "turn-1", + startedAt: at(1), + }, + at(1), + ); + + store.appendEvent( + { + type: "message.delta", + sessionId: "sess-1", + threadId: "thread-1", + turnId: "turn-1", + messageId: "msg-1", + role: "assistant", + delta: "Hello", + }, + at(2), + ); + + store.appendEvent( + { + type: "approval.requested", + sessionId: "sess-1", + threadId: "thread-1", + turnId: "turn-1", + approvalId: "approval-1", + approvalKind: "command", + title: "Command approval requested", + requestedAt: at(3), + }, + at(3), + ); + + const snapshot = store.getSnapshot(); + expect(snapshot.sessions).toHaveLength(1); + expect(snapshot.activeTurns).toHaveLength(1); + expect(snapshot.activeMessages).toHaveLength(1); + expect(snapshot.pendingApprovals).toHaveLength(1); + + const replay = store.selectReplay(1, 10); + expect(replay.ok).toBe(true); + if (!replay.ok) { + throw new Error("expected replay selection"); + } + expect(replay.events.map((entry) => entry.seq)).toEqual([2, 3, 4]); + }); + + it("returns cursor_ahead and replay_limit_exceeded when needed", () => { + const store = new ProviderStreamStore(); + const baseMs = Date.now(); + for (let i = 0; i < 20; i += 1) { + store.appendEvent( + { + type: "error", + sessionId: "sess-1", + code: "runtime/error", + message: `err-${i}`, + }, + new Date(baseMs + i * 1_000).toISOString(), + ); + } + + const ahead = store.selectReplay(30, 10); + expect(ahead.ok).toBe(false); + if (ahead.ok) { + throw new Error("expected gap selection"); + } + expect(ahead.reason).toBe("cursor_ahead"); + + const overLimit = store.selectReplay(0, 10); + expect(overLimit.ok).toBe(false); + if (overLimit.ok) { + throw new Error("expected replay_limit_exceeded"); + } + expect(overLimit.reason).toBe("replay_limit_exceeded"); + }); + + it("evicts old events and reports cursor_too_old", () => { + const store = new ProviderStreamStore(); + + for (let i = 0; i < PROVIDER_STREAM_MAX_REPLAY_EVENTS + 10; i += 1) { + store.appendEvent( + { + type: "error", + sessionId: "sess-1", + code: "runtime/error", + message: `err-${i}`, + }, + "2026-02-10T00:00:00.000Z", + ); + } + + const oldestSeq = store.oldestSeq; + expect(oldestSeq).toBeGreaterThan(1); + + const stale = store.selectReplay(Math.max(0, oldestSeq - 2), 100); + expect(stale.ok).toBe(false); + if (stale.ok) { + throw new Error("expected cursor_too_old"); + } + expect(stale.reason).toBe("cursor_too_old"); + }); + + it("filters snapshot by session id", () => { + const store = new ProviderStreamStore(); + + store.appendEvent({ + type: "session.updated", + session: makeSessionState({ sessionId: "sess-a" }), + }); + store.appendEvent({ + type: "session.updated", + session: makeSessionState({ sessionId: "sess-b" }), + }); + + const filtered = store.getSnapshot(new Set(["sess-a"])); + expect(filtered.sessions).toHaveLength(1); + expect(filtered.sessions[0]?.sessionId).toBe("sess-a"); + }); + + it("classifies provider stream event kinds", () => { + expect( + providerStreamEventKindOf({ + type: "message.delta", + sessionId: "sess-1", + threadId: "thread-1", + messageId: "msg-1", + role: "assistant", + delta: "x", + }), + ).toBe("message"); + + expect( + providerStreamEventKindOf({ + type: "debug.raw", + provider: "codex", + method: "thread/started", + payload: null, + }), + ).toBe("debug.raw"); + }); +}); diff --git a/apps/server/src/providerStreamStore.ts b/apps/server/src/providerStreamStore.ts new file mode 100644 index 000000000000..a6bc39e119f0 --- /dev/null +++ b/apps/server/src/providerStreamStore.ts @@ -0,0 +1,428 @@ +import type { + CanonicalApprovalState, + CanonicalMessageState, + CanonicalSessionState, + CanonicalTurnState, + ProviderCoreEvent, + ProviderSnapshot, + ProviderStreamEventKind, + ProviderStreamFrame, + ProviderStreamGapReason, +} from "@t3tools/contracts"; + +export const PROVIDER_STREAM_MAX_REPLAY_EVENTS = 20_000; +export const PROVIDER_STREAM_MAX_REPLAY_BYTES = 64 * 1024 * 1024; +export const PROVIDER_STREAM_MAX_REPLAY_AGE_MS = 60 * 60 * 1_000; +export const PROVIDER_STREAM_REPLAY_OPEN_LIMIT = 10_000; + +interface StoredStreamEvent { + seq: number; + at: string; + data: ProviderCoreEvent; + encodedSize: number; +} + +export type ReplaySelection = + | { + ok: true; + currentSeq: number; + oldestSeq: number; + events: Array>; + } + | { + ok: false; + currentSeq: number; + oldestSeq: number; + reason: ProviderStreamGapReason; + }; + +function parseIsoToMs(value: string): number { + const parsed = Date.parse(value); + if (Number.isNaN(parsed)) { + return Date.now(); + } + return parsed; +} + +function sessionEventKey(sessionId: string, id: string): string { + return `${sessionId}:${id}`; +} + +function eventSizeBytes(seq: number, at: string, data: ProviderCoreEvent): number { + return Buffer.byteLength(JSON.stringify({ kind: "event", seq, at, data })); +} + +function eventKindOf(event: ProviderCoreEvent): ProviderStreamEventKind { + if (event.type === "session.updated") { + return "session"; + } + if (event.type === "turn.started" || event.type === "turn.completed") { + return "turn"; + } + if (event.type === "message.delta" || event.type === "message.completed") { + return "message"; + } + if (event.type === "approval.requested" || event.type === "approval.resolved") { + return "approval"; + } + if (event.type === "activity") { + return "activity"; + } + if (event.type === "error") { + return "error"; + } + return "debug.raw"; +} + +export function providerStreamEventKindOf( + event: ProviderCoreEvent, +): ProviderStreamEventKind { + return eventKindOf(event); +} + +export function providerStreamEventSessionId( + event: ProviderCoreEvent, +): string | undefined { + if (event.type === "session.updated") { + return event.session.sessionId; + } + + if (event.type === "debug.raw") { + return event.sessionId; + } + + return event.sessionId; +} + +export function filterEventExtensions( + event: ProviderCoreEvent, + includeExtensions: ReadonlySet, +): ProviderCoreEvent { + if (!("extensions" in event)) { + return event; + } + + if (!event.extensions || includeExtensions.size === 0) { + const withoutExtensions = { ...event }; + delete withoutExtensions.extensions; + return withoutExtensions; + } + + const filtered: Record = {}; + for (const [key, value] of Object.entries(event.extensions)) { + if (includeExtensions.has(key)) { + filtered[key] = value; + } + } + + if (Object.keys(filtered).length === 0) { + const withoutExtensions = { ...event }; + delete withoutExtensions.extensions; + return withoutExtensions; + } + + return { + ...event, + extensions: filtered, + }; +} + +function isFromSessionFilter( + sessionIds: ReadonlySet | undefined, + sessionId: string, +): boolean { + if (!sessionIds || sessionIds.size === 0) { + return true; + } + + return sessionIds.has(sessionId); +} + +export class ProviderStreamStore { + private nextSeq = 1; + private replayLog: StoredStreamEvent[] = []; + private replayBytes = 0; + + private readonly sessionsById = new Map(); + private readonly activeTurnsByKey = new Map(); + private readonly activeMessagesByKey = new Map(); + private readonly pendingApprovalsByKey = new Map(); + + appendEvent(event: ProviderCoreEvent, at = new Date().toISOString()): ProviderStreamFrame { + const seq = this.nextSeq; + this.nextSeq += 1; + + this.applyEvent(event, at); + + const encodedSize = eventSizeBytes(seq, at, event); + this.replayLog.push({ + seq, + at, + data: event, + encodedSize, + }); + this.replayBytes += encodedSize; + + this.pruneReplayLog(parseIsoToMs(at)); + + return { + kind: "event", + seq, + at, + data: event, + }; + } + + buildSnapshotFrame( + at = new Date().toISOString(), + sessionIds?: ReadonlySet, + ): ProviderStreamFrame { + return { + kind: "snapshot", + seq: this.currentSeq, + at, + data: this.getSnapshot(sessionIds), + }; + } + + buildGapFrame( + reason: ProviderStreamGapReason, + at = new Date().toISOString(), + ): ProviderStreamFrame { + return { + kind: "gap", + seq: this.currentSeq, + at, + data: { + reason, + oldestSeq: this.oldestSeq, + currentSeq: this.currentSeq, + }, + }; + } + + selectReplay(afterSeq: number, replayLimit: number): ReplaySelection { + const currentSeq = this.currentSeq; + const oldestSeq = this.oldestSeq; + const minimumValidCursor = Math.max(0, oldestSeq - 1); + + if (afterSeq > currentSeq) { + return { + ok: false, + currentSeq, + oldestSeq, + reason: "cursor_ahead", + }; + } + + if (afterSeq < minimumValidCursor) { + return { + ok: false, + currentSeq, + oldestSeq, + reason: "cursor_too_old", + }; + } + + const firstMissingIndex = this.replayLog.findIndex((entry) => entry.seq > afterSeq); + const replayStartIndex = firstMissingIndex === -1 ? this.replayLog.length : firstMissingIndex; + const missingCount = this.replayLog.length - replayStartIndex; + + if (missingCount > replayLimit) { + return { + ok: false, + currentSeq, + oldestSeq, + reason: "replay_limit_exceeded", + }; + } + + const events = this.replayLog + .slice(replayStartIndex) + .map((entry) => ({ + seq: entry.seq, + at: entry.at, + data: entry.data, + })); + + return { + ok: true, + currentSeq, + oldestSeq, + events, + }; + } + + getSession(sessionId: string): CanonicalSessionState | undefined { + return this.sessionsById.get(sessionId); + } + + getSnapshot(sessionIds?: ReadonlySet): ProviderSnapshot { + const sessions = Array.from(this.sessionsById.values()).filter((session) => + isFromSessionFilter(sessionIds, session.sessionId), + ); + const allowedSessionIds = new Set(sessions.map((session) => session.sessionId)); + + const activeTurns = Array.from(this.activeTurnsByKey.values()).filter((turn) => + allowedSessionIds.has(turn.sessionId), + ); + const activeMessages = Array.from(this.activeMessagesByKey.values()).filter((message) => + allowedSessionIds.has(message.sessionId), + ); + const pendingApprovals = Array.from(this.pendingApprovalsByKey.values()).filter((approval) => + allowedSessionIds.has(approval.sessionId), + ); + + return { + sessions, + activeTurns, + activeMessages, + pendingApprovals, + }; + } + + get currentSeq(): number { + if (this.nextSeq <= 1) { + return 0; + } + return this.nextSeq - 1; + } + + get oldestSeq(): number { + const first = this.replayLog[0]; + if (first) { + return first.seq; + } + return this.currentSeq; + } + + private pruneReplayLog(nowMs: number): void { + while (this.replayLog.length > 0) { + const first = this.replayLog[0]; + if (!first) { + return; + } + + const overCount = this.replayLog.length > PROVIDER_STREAM_MAX_REPLAY_EVENTS; + const overBytes = this.replayBytes > PROVIDER_STREAM_MAX_REPLAY_BYTES; + const tooOld = nowMs - parseIsoToMs(first.at) > PROVIDER_STREAM_MAX_REPLAY_AGE_MS; + + if (!overCount && !overBytes && !tooOld) { + return; + } + + this.replayLog.shift(); + this.replayBytes -= first.encodedSize; + } + } + + private applyEvent(event: ProviderCoreEvent, at: string): void { + if (event.type === "session.updated") { + this.sessionsById.set(event.session.sessionId, event.session); + + if (event.session.status === "closed") { + this.removeSessionState(event.session.sessionId); + } + return; + } + + if (event.type === "turn.started") { + this.activeTurnsByKey.set( + sessionEventKey(event.sessionId, event.turnId), + { + sessionId: event.sessionId, + threadId: event.threadId, + turnId: event.turnId, + startedAt: event.startedAt, + ...(event.model ? { model: event.model } : {}), + }, + ); + return; + } + + if (event.type === "turn.completed") { + this.activeTurnsByKey.delete(sessionEventKey(event.sessionId, event.turnId)); + this.removeTurnTransientState(event.sessionId, event.turnId); + return; + } + + if (event.type === "message.delta") { + const key = sessionEventKey(event.sessionId, event.messageId); + const previous = this.activeMessagesByKey.get(key); + this.activeMessagesByKey.set(key, { + sessionId: event.sessionId, + threadId: event.threadId, + ...(event.turnId ? { turnId: event.turnId } : {}), + messageId: event.messageId, + role: "assistant", + text: `${previous?.text ?? ""}${event.delta}`, + startedAt: previous?.startedAt ?? at, + updatedAt: at, + }); + return; + } + + if (event.type === "message.completed") { + this.activeMessagesByKey.delete(sessionEventKey(event.sessionId, event.messageId)); + return; + } + + if (event.type === "approval.requested") { + this.pendingApprovalsByKey.set( + sessionEventKey(event.sessionId, event.approvalId), + { + sessionId: event.sessionId, + ...(event.threadId ? { threadId: event.threadId } : {}), + ...(event.turnId ? { turnId: event.turnId } : {}), + approvalId: event.approvalId, + approvalKind: event.approvalKind, + title: event.title, + ...(event.detail ? { detail: event.detail } : {}), + ...(event.payload !== undefined ? { payload: event.payload } : {}), + ...(event.timeoutAt ? { timeoutAt: event.timeoutAt } : {}), + requestedAt: event.requestedAt, + }, + ); + return; + } + + if (event.type === "approval.resolved") { + this.pendingApprovalsByKey.delete(sessionEventKey(event.sessionId, event.approvalId)); + return; + } + } + + private removeSessionState(sessionId: string): void { + for (const key of this.activeTurnsByKey.keys()) { + if (key.startsWith(`${sessionId}:`)) { + this.activeTurnsByKey.delete(key); + } + } + + for (const key of this.activeMessagesByKey.keys()) { + if (key.startsWith(`${sessionId}:`)) { + this.activeMessagesByKey.delete(key); + } + } + + for (const key of this.pendingApprovalsByKey.keys()) { + if (key.startsWith(`${sessionId}:`)) { + this.pendingApprovalsByKey.delete(key); + } + } + } + + private removeTurnTransientState(sessionId: string, turnId: string): void { + for (const [key, message] of this.activeMessagesByKey.entries()) { + if (message.sessionId === sessionId && message.turnId === turnId) { + this.activeMessagesByKey.delete(key); + } + } + + for (const [key, approval] of this.pendingApprovalsByKey.entries()) { + if (approval.sessionId === sessionId && approval.turnId === turnId) { + this.pendingApprovalsByKey.delete(key); + } + } + } +} diff --git a/apps/server/src/providerStreamSubscriptionManager.test.ts b/apps/server/src/providerStreamSubscriptionManager.test.ts new file mode 100644 index 000000000000..efdade079ced --- /dev/null +++ b/apps/server/src/providerStreamSubscriptionManager.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it, vi } from "vitest"; + +import { ProviderStreamStore } from "./providerStreamStore"; +import { ProviderStreamSubscriptionManager } from "./providerStreamSubscriptionManager"; + +interface FakeSocket { + OPEN: number; + readyState: number; + bufferedAmount: number; + sent: unknown[]; + send: (payload: string) => void; + close: ReturnType; +} + +function makeSocket(): FakeSocket { + const socket: FakeSocket = { + OPEN: 1, + readyState: 1, + bufferedAmount: 0, + sent: [], + send(payload: string) { + this.sent.push(JSON.parse(payload)); + }, + close: vi.fn(function close() { + socket.readyState = 3; + }), + }; + return socket; +} + +function lastPush(socket: FakeSocket): Record { + const last = socket.sent.at(-1); + if (!last || typeof last !== "object") { + throw new Error("no push payload found"); + } + return last as Record; +} + +describe("ProviderStreamSubscriptionManager", () => { + it("opens with snapshot mode and emits snapshot frame", () => { + const store = new ProviderStreamStore(); + store.appendEvent({ + type: "session.updated", + session: { + sessionId: "sess-1", + provider: "codex", + status: "ready", + createdAt: "2026-02-10T00:00:00.000Z", + updatedAt: "2026-02-10T00:00:00.000Z", + }, + }); + + const manager = new ProviderStreamSubscriptionManager(store); + const socket = makeSocket(); + + const result = manager.openStream(socket as never, undefined); + expect(result.mode).toBe("snapshot"); + + const push = lastPush(socket); + expect(push.channel).toBe("providers.stream"); + expect((push.data as { kind: string }).kind).toBe("snapshot"); + }); + + it("replays missing events for valid cursor", () => { + const store = new ProviderStreamStore(); + store.appendEvent({ + type: "session.updated", + session: { + sessionId: "sess-1", + provider: "codex", + status: "ready", + createdAt: "2026-02-10T00:00:00.000Z", + updatedAt: "2026-02-10T00:00:00.000Z", + }, + }); + store.appendEvent({ + type: "error", + sessionId: "sess-1", + code: "runtime/error", + message: "boom", + }); + + const manager = new ProviderStreamSubscriptionManager(store); + const socket = makeSocket(); + + const result = manager.openStream(socket as never, { afterSeq: 1 }); + expect(result.mode).toBe("replay"); + expect(result.replayedCount).toBe(1); + + const push = lastPush(socket); + expect((push.data as { kind: string }).kind).toBe("event"); + }); + + it("returns snapshot_resync for stale cursors", () => { + const store = new ProviderStreamStore(); + for (let i = 0; i < 12_000; i += 1) { + store.appendEvent({ + type: "error", + sessionId: "sess-1", + code: "runtime/error", + message: `err-${i}`, + }); + } + + const manager = new ProviderStreamSubscriptionManager(store); + const socket = makeSocket(); + + const result = manager.openStream(socket as never, { afterSeq: 0 }); + expect(result.mode).toBe("snapshot_resync"); + + const kinds = socket.sent + .map((payload) => ((payload as Record).data as { kind: string }).kind); + expect(kinds).toContain("gap"); + expect(kinds).toContain("snapshot"); + }); + + it("filters by session and debug flags", () => { + const store = new ProviderStreamStore(); + const manager = new ProviderStreamSubscriptionManager(store); + const socket = makeSocket(); + + manager.openStream(socket as never, { + sessionIds: ["sess-1"], + includeDebugRaw: false, + }); + + manager.publish( + store.appendEvent({ + type: "error", + sessionId: "sess-2", + code: "runtime/error", + message: "hidden", + }), + ); + + manager.publish( + store.appendEvent({ + type: "debug.raw", + provider: "codex", + sessionId: "sess-1", + method: "thread/started", + payload: null, + }), + ); + + manager.publish( + store.appendEvent({ + type: "error", + sessionId: "sess-1", + code: "runtime/error", + message: "visible", + }), + ); + + const pushedKinds = socket.sent + .map((payload) => ((payload as Record).data as { kind: string }).kind) + .filter((kind) => kind === "event"); + + // Only the final sess-1 error event should pass filters. + expect(pushedKinds).toHaveLength(1); + }); +}); diff --git a/apps/server/src/providerStreamSubscriptionManager.ts b/apps/server/src/providerStreamSubscriptionManager.ts new file mode 100644 index 000000000000..450cca58067a --- /dev/null +++ b/apps/server/src/providerStreamSubscriptionManager.ts @@ -0,0 +1,298 @@ +import { + WS_CHANNELS, + type ProviderCoreEvent, + type ProviderStreamEventKind, + type ProviderStreamFrame, + type ProvidersOpenStreamInput, + type ProvidersOpenStreamResult, + providersOpenStreamInputSchema, +} from "@t3tools/contracts"; +import type { WsPush } from "@t3tools/contracts"; +import type { WebSocket } from "ws"; + +import { + PROVIDER_STREAM_REPLAY_OPEN_LIMIT, + ProviderStreamStore, + filterEventExtensions, + providerStreamEventKindOf, + providerStreamEventSessionId, +} from "./providerStreamStore"; + +const BACKPRESSURE_HIGH_WATER_MARK_BYTES = 2 * 1_024 * 1_024; +const BACKPRESSURE_HIGH_WATER_DURATION_MS = 5_000; + +interface StreamFilter { + sessionIds?: Set; + eventKinds: Set; + includeExtensions: Set; + includeDebugRaw: boolean; +} + +interface StreamSubscription { + filter: StreamFilter; + overHighWaterSinceMs: number | null; +} + +interface SubscriptionManagerOptions { + onPush?: ((push: WsPush) => void) | undefined; +} + +const DEFAULT_EVENT_KINDS: ReadonlyArray = [ + "session", + "turn", + "message", + "approval", + "activity", + "error", + "debug.raw", +]; + +function normalizeFilter(input: ProvidersOpenStreamInput): StreamFilter { + const sessionIds = input.sessionIds && input.sessionIds.length > 0 + ? new Set(input.sessionIds) + : undefined; + const eventKinds = new Set(input.eventKinds ?? DEFAULT_EVENT_KINDS); + + return { + ...(sessionIds ? { sessionIds } : {}), + eventKinds, + includeExtensions: new Set(input.includeExtensions ?? []), + includeDebugRaw: input.includeDebugRaw ?? false, + }; +} + +export class ProviderStreamSubscriptionManager { + private readonly subscriptions = new Map(); + private readonly onPush: ((push: WsPush) => void) | undefined; + + constructor( + private readonly store: ProviderStreamStore, + options: SubscriptionManagerOptions = {}, + ) { + this.onPush = options.onPush; + } + + openStream( + ws: WebSocket, + rawInput: ProvidersOpenStreamInput | undefined, + ): ProvidersOpenStreamResult { + const input = providersOpenStreamInputSchema.parse(rawInput ?? {}); + const filter = normalizeFilter(input); + + this.subscriptions.set(ws, { + filter, + overHighWaterSinceMs: null, + }); + + const nowIso = new Date().toISOString(); + + if (input.afterSeq === undefined) { + this.sendFrame(ws, { + kind: "snapshot", + seq: this.store.currentSeq, + at: nowIso, + data: this.store.getSnapshot(filter.sessionIds), + }); + + return { + mode: "snapshot", + currentSeq: this.store.currentSeq, + oldestSeq: this.store.oldestSeq, + replayedCount: 0, + }; + } + + const replay = this.store.selectReplay( + input.afterSeq, + PROVIDER_STREAM_REPLAY_OPEN_LIMIT, + ); + + if (!replay.ok) { + this.sendFrame(ws, { + kind: "gap", + seq: replay.currentSeq, + at: nowIso, + data: { + reason: replay.reason, + oldestSeq: replay.oldestSeq, + currentSeq: replay.currentSeq, + }, + }); + this.sendFrame(ws, { + kind: "snapshot", + seq: replay.currentSeq, + at: nowIso, + data: this.store.getSnapshot(filter.sessionIds), + }); + + return { + mode: "snapshot_resync", + currentSeq: replay.currentSeq, + oldestSeq: replay.oldestSeq, + replayedCount: 0, + }; + } + + let replayedCount = 0; + for (const event of replay.events) { + const delivered = this.sendFrame(ws, { + kind: "event", + seq: event.seq, + at: event.at, + data: event.data, + }); + if (delivered) { + replayedCount += 1; + } + } + + return { + mode: "replay", + currentSeq: replay.currentSeq, + oldestSeq: replay.oldestSeq, + replayedCount, + }; + } + + closeStream(ws: WebSocket): void { + this.subscriptions.delete(ws); + } + + closeAll(): void { + this.subscriptions.clear(); + } + + publish(frame: ProviderStreamFrame): void { + for (const [ws] of this.subscriptions) { + if (!this.isSocketOpen(ws)) { + this.subscriptions.delete(ws); + continue; + } + this.sendFrame(ws, frame); + } + } + + private sendFrame(ws: WebSocket, frame: ProviderStreamFrame): boolean { + const subscription = this.subscriptions.get(ws); + if (!subscription) { + return false; + } + + if (!this.isSocketOpen(ws)) { + this.subscriptions.delete(ws); + return false; + } + + if (!this.checkBackpressure(ws, subscription)) { + return false; + } + + const filteredFrame = this.filteredFrameForSubscription(frame, subscription.filter); + if (!filteredFrame) { + return false; + } + + const push: WsPush = { + type: "push", + channel: WS_CHANNELS.providerStream, + data: filteredFrame, + }; + + try { + ws.send(JSON.stringify(push)); + } catch { + this.subscriptions.delete(ws); + try { + ws.close(); + } catch { + // Ignore close errors on already-failed sockets. + } + return false; + } + + this.onPush?.(push); + this.checkBackpressure(ws, subscription); + return true; + } + + private filteredFrameForSubscription( + frame: ProviderStreamFrame, + filter: StreamFilter, + ): ProviderStreamFrame | null { + if (frame.kind === "snapshot") { + return { + ...frame, + data: this.store.getSnapshot(filter.sessionIds), + }; + } + + if (frame.kind === "gap") { + return frame; + } + + if (!this.shouldIncludeEvent(frame.data, filter)) { + return null; + } + + return { + ...frame, + data: filterEventExtensions(frame.data, filter.includeExtensions), + }; + } + + private shouldIncludeEvent(event: ProviderCoreEvent, filter: StreamFilter): boolean { + const kind = providerStreamEventKindOf(event); + + if (!filter.eventKinds.has(kind)) { + return false; + } + + if (kind === "debug.raw" && !filter.includeDebugRaw) { + return false; + } + + if (!filter.sessionIds || filter.sessionIds.size === 0) { + return true; + } + + const sessionId = providerStreamEventSessionId(event); + if (!sessionId) { + return false; + } + + return filter.sessionIds.has(sessionId); + } + + private checkBackpressure( + ws: WebSocket, + subscription: StreamSubscription, + ): boolean { + const buffered = ws.bufferedAmount; + if (buffered <= BACKPRESSURE_HIGH_WATER_MARK_BYTES) { + subscription.overHighWaterSinceMs = null; + return true; + } + + const nowMs = Date.now(); + if (subscription.overHighWaterSinceMs === null) { + subscription.overHighWaterSinceMs = nowMs; + return true; + } + + if (nowMs - subscription.overHighWaterSinceMs < BACKPRESSURE_HIGH_WATER_DURATION_MS) { + return true; + } + + this.subscriptions.delete(ws); + try { + ws.close(1013, "provider stream backpressure"); + } catch { + // Ignore close errors on failed sockets. + } + return false; + } + + private isSocketOpen(ws: WebSocket): boolean { + return ws.readyState === ws.OPEN; + } +} diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index ef5cb1b96311..8d53c532ac6b 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -213,6 +213,54 @@ describe("WebSocket Server", () => { expect(response.result).toEqual([]); }); + it("opens provider stream with snapshot and returns stream metadata", async () => { + server = createTestServer({ cwd: "/test" }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + + // Consume welcome + await waitForMessage(ws); + + const id = crypto.randomUUID(); + ws.send( + JSON.stringify({ + id, + method: WS_METHODS.providersOpenStream, + params: {}, + }), + ); + + const first = (await waitForMessage(ws)) as Record; + const second = (await waitForMessage(ws)) as Record; + const messages = [first, second]; + + let response: WsResponse | null = null; + const sawSnapshotPush = messages.some( + (message) => + message.type === "push" && + message.channel === WS_CHANNELS.providerStream && + typeof message.data === "object" && + message.data !== null && + (message.data as { kind?: string }).kind === "snapshot", + ); + + for (const message of messages) { + if (message.id === id) { + response = message as WsResponse; + } + } + + expect(sawSnapshotPush).toBe(true); + expect(response).not.toBeNull(); + expect(response?.error).toBeUndefined(); + const result = response?.result as { mode?: string }; + expect(result?.mode).toBe("snapshot"); + }); + it("handles invalid JSON gracefully", async () => { server = createTestServer({ cwd: "/test" }); await server.start(); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index ae9dc7ca687e..48d7d489e199 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -9,6 +9,7 @@ import { EDITORS, WS_CHANNELS, WS_METHODS, + type ProviderCoreEvent, type WsPush, type WsRequest, type WsResponse, @@ -17,8 +18,11 @@ import { import { WebSocketServer, type WebSocket } from "ws"; import { createLogger } from "./logger"; +import { ProviderEventNormalizer } from "./providerEventNormalizer"; import { ProjectRegistry } from "./projectRegistry"; import { ProviderManager } from "./providerManager"; +import { ProviderStreamStore } from "./providerStreamStore"; +import { ProviderStreamSubscriptionManager } from "./providerStreamSubscriptionManager"; const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", @@ -70,6 +74,16 @@ export function createServer(options: ServerOptions) { providedRegistry ?? new ProjectRegistry(path.join(os.homedir(), ".t3", "userdata")); const clients = new Set(); const logger = createLogger("ws"); + const providerEventNormalizer = new ProviderEventNormalizer(); + const providerStreamStore = new ProviderStreamStore(); + const providerStreamSubscriptions = new ProviderStreamSubscriptionManager( + providerStreamStore, + { + onPush: (push) => { + logOutgoingPush(push, 1); + }, + }, + ); const logWebSocketEvents = explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl); @@ -82,22 +96,24 @@ export function createServer(options: ServerOptions) { }); } - // Forward provider events to all connected WebSocket clients - providerManager.on("event", (event) => { - const push: WsPush = { - type: "push", - channel: WS_CHANNELS.providerEvent, - data: event, + providerManager.on("event", (rawEvent) => { + const currentSession = providerStreamStore.getSession(rawEvent.sessionId); + const normalized = providerEventNormalizer.normalize(rawEvent, currentSession); + const at = rawEvent.createdAt; + + const publish = (event: ProviderCoreEvent) => { + const frame = providerStreamStore.appendEvent(event, at); + providerStreamSubscriptions.publish(frame); }; - const message = JSON.stringify(push); - let recipients = 0; - for (const client of clients) { - if (client.readyState === client.OPEN) { - client.send(message); - recipients += 1; - } + + if (normalized.length === 0) { + publish(providerEventNormalizer.toDebugRaw(rawEvent)); + return; + } + + for (const event of normalized) { + publish(event); } - logOutgoingPush(push, recipients); }); // HTTP server — serves static files or redirects to Vite dev server @@ -213,10 +229,12 @@ export function createServer(options: ServerOptions) { }); ws.on("close", () => { + providerStreamSubscriptions.closeStream(ws); clients.delete(ws); }); ws.on("error", () => { + providerStreamSubscriptions.closeStream(ws); clients.delete(ws); }); }); @@ -236,7 +254,7 @@ export function createServer(options: ServerOptions) { } try { - const result = await routeRequest(request); + const result = await routeRequest(ws, request); const response: WsResponse = { id: request.id, result }; ws.send(JSON.stringify(response)); } catch (err) { @@ -249,7 +267,7 @@ export function createServer(options: ServerOptions) { } } - async function routeRequest(request: WsRequest): Promise { + async function routeRequest(ws: WebSocket, request: WsRequest): Promise { switch (request.method) { case WS_METHODS.providersStartSession: return providerManager.startSession(request.params as never); @@ -260,8 +278,8 @@ export function createServer(options: ServerOptions) { case WS_METHODS.providersInterruptTurn: return providerManager.interruptTurn(request.params as never); - case WS_METHODS.providersRespondToRequest: - return providerManager.respondToRequest(request.params as never); + case WS_METHODS.providersRespondToApproval: + return providerManager.respondToApproval(request.params as never); case WS_METHODS.providersStopSession: { providerManager.stopSession(request.params as never); @@ -271,6 +289,13 @@ export function createServer(options: ServerOptions) { case WS_METHODS.providersListSessions: return providerManager.listSessions(); + case WS_METHODS.providersOpenStream: + return providerStreamSubscriptions.openStream(ws, request.params as never); + + case WS_METHODS.providersCloseStream: + providerStreamSubscriptions.closeStream(ws); + return undefined; + case WS_METHODS.projectsList: return projectRegistry.list(); @@ -334,6 +359,7 @@ export function createServer(options: ServerOptions) { async function stop(): Promise { providerManager.stopAll(); providerManager.dispose(); + providerStreamSubscriptions.closeAll(); for (const client of clients) { client.close(); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index e12d563ab92d..4848191e9c2d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -12,15 +12,15 @@ import { onServerWelcome } from "./wsNativeApi"; function EventRouter() { const api = useMemo(() => readNativeApi(), []); const { dispatch } = useStore(); - const activeAssistantItemRef = useRef(null); + const activeAssistantMessageRef = useRef(null); useEffect(() => { if (!api) return; - return api.providers.onEvent((event) => { + return api.providers.onStream((frame) => { dispatch({ - type: "APPLY_EVENT", - event, - activeAssistantItemRef, + type: "APPLY_STREAM_FRAME", + frame, + activeAssistantMessageRef, }); }); }, [api, dispatch]); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3c28c7da5517..84634119a29b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,7 +1,6 @@ import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS, type ProviderApprovalDecision, - type ProviderEvent, } from "@t3tools/contracts"; import { type FormEvent, @@ -33,6 +32,7 @@ import { readNativeApi, } from "../session-logic"; import { useStore } from "../store"; +import type { ThreadEvent } from "../types"; import ChatMarkdown from "./ChatMarkdown"; function formatMessageMeta(createdAt: string, duration: string | null): string { @@ -61,8 +61,8 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { } interface PendingApprovalCard { - requestId: string; - requestKind: "command" | "file-change"; + approvalId: string; + approvalKind: "command" | "file_change"; createdAt: string; detail?: string; } @@ -75,57 +75,27 @@ interface EnsuredSessionInfo { continuityState: SessionContinuityState; } -function asRecord(value: unknown): Record | undefined { - if (!value || typeof value !== "object") return undefined; - return value as Record; -} - -function asString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -function approvalDetail(event: ProviderEvent): string | undefined { - const payload = asRecord(event.payload); - const command = asString(payload?.command); - if (command) return command; - return asString(payload?.reason); -} - -function derivePendingApprovals( - events: ProviderEvent[], -): PendingApprovalCard[] { +function derivePendingApprovals(events: ThreadEvent[]): PendingApprovalCard[] { const pending = new Map(); const ordered = [...events].toReversed(); - for (const event of ordered) { - if ( - event.method === "session/closed" || - event.method === "session/exited" - ) { - pending.clear(); - continue; - } - - const requestId = - event.requestId ?? asString(asRecord(event.payload)?.requestId); - if (!requestId) continue; - + for (const eventRecord of ordered) { + const event = eventRecord.event; if ( - event.kind === "request" && - (event.requestKind === "command" || event.requestKind === "file-change") + event.type === "approval.requested" && + (event.approvalKind === "command" || event.approvalKind === "file_change") ) { - const detail = approvalDetail(event); - pending.set(requestId, { - requestId, - requestKind: event.requestKind, - createdAt: event.createdAt, - ...(detail ? { detail } : {}), + pending.set(event.approvalId, { + approvalId: event.approvalId, + approvalKind: event.approvalKind, + createdAt: event.requestedAt, + ...(event.detail ? { detail: event.detail } : {}), }); continue; } - if (event.method === "item/requestApproval/decision") { - pending.delete(requestId); + if (event.type === "approval.resolved") { + pending.delete(event.approvalId); } } @@ -149,7 +119,7 @@ export default function ChatView() { const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING); const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); - const [respondingRequestIds, setRespondingRequestIds] = useState( + const [respondingApprovalIds, setRespondingApprovalIds] = useState( [], ); const [expandedWorkGroups, setExpandedWorkGroups] = useState< @@ -186,13 +156,13 @@ export default function ChatView() { () => derivePendingApprovals(activeThread?.events ?? []), [activeThread?.events], ); - const assistantCompletionByItemId = useMemo(() => { + const assistantCompletionByMessageId = useMemo(() => { const map = new Map(); const ordered = [...(activeThread?.events ?? [])].toReversed(); - for (const event of ordered) { - if (event.method !== "item/completed") continue; - if (!event.itemId) continue; - map.set(event.itemId, event.createdAt); + for (const eventRecord of ordered) { + const event = eventRecord.event; + if (event.type !== "message.completed") continue; + map.set(event.messageId, eventRecord.at); } return map; }, [activeThread?.events]); @@ -513,18 +483,18 @@ export default function ChatView() { }; const onRespondToApproval = async ( - requestId: string, + approvalId: string, decision: ProviderApprovalDecision, ) => { if (!api || !activeThread?.session) return; - setRespondingRequestIds((existing) => - existing.includes(requestId) ? existing : [...existing, requestId], + setRespondingApprovalIds((existing) => + existing.includes(approvalId) ? existing : [...existing, approvalId], ); try { - await api.providers.respondToRequest({ + await api.providers.respondToApproval({ sessionId: activeThread.session.sessionId, - requestId, + approvalId, decision, }); } catch (err) { @@ -537,8 +507,8 @@ export default function ChatView() { : "Failed to submit approval decision.", }); } finally { - setRespondingRequestIds((existing) => - existing.filter((id) => id !== requestId), + setRespondingApprovalIds((existing) => + existing.filter((id) => id !== approvalId), ); } }; @@ -642,16 +612,16 @@ export default function ChatView() { {pendingApprovals.length > 0 && (
{pendingApprovals.map((approval) => { - const isResponding = respondingRequestIds.includes( - approval.requestId, + const isResponding = respondingApprovalIds.includes( + approval.approvalId, ); return (

- {approval.requestKind === "command" + {approval.approvalKind === "command" ? "Command approval requested" : "File-change approval requested"}

@@ -669,7 +639,7 @@ export default function ChatView() { className="rounded-md border border-border bg-accent px-2 py-1 text-[11px] text-foreground transition-colors duration-150 hover:bg-accent/80 disabled:cursor-not-allowed disabled:opacity-50" disabled={isResponding} onClick={() => - void onRespondToApproval(approval.requestId, "accept") + void onRespondToApproval(approval.approvalId, "accept") } > Approve once @@ -680,7 +650,7 @@ export default function ChatView() { disabled={isResponding} onClick={() => void onRespondToApproval( - approval.requestId, + approval.approvalId, "acceptForSession", ) } @@ -692,7 +662,7 @@ export default function ChatView() { className="rounded-md border border-border px-2 py-1 text-[11px] text-foreground/90 transition-colors duration-150 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50" disabled={isResponding} onClick={() => - void onRespondToApproval(approval.requestId, "decline") + void onRespondToApproval(approval.approvalId, "decline") } > Decline @@ -702,7 +672,7 @@ export default function ChatView() { className="rounded-md border border-rose-300/30 bg-rose-500/[0.12] px-2 py-1 text-[11px] text-rose-100 transition-colors duration-150 hover:bg-rose-500/[0.2] disabled:cursor-not-allowed disabled:opacity-50" disabled={isResponding} onClick={() => - void onRespondToApproval(approval.requestId, "cancel") + void onRespondToApproval(approval.approvalId, "cancel") } > Cancel turn @@ -868,7 +838,7 @@ export default function ChatView() { ) : formatElapsed( timelineEntry.message.createdAt, - assistantCompletionByItemId.get( + assistantCompletionByMessageId.get( timelineEntry.message.id, ), ), diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 40fea5d77a79..06ed1ee7097d 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { ProviderEvent, ProviderSession } from "@t3tools/contracts"; +import type { ProviderCoreEvent, ProviderSession } from "@t3tools/contracts"; import { type WorkLogEntry, applyEventToMessages, @@ -8,17 +8,16 @@ import { deriveWorkLogEntries, evolveSession, } from "./session-logic"; -import type { ChatMessage } from "./types"; +import type { ChatMessage, ThreadEvent } from "./types"; -function makeEvent(overrides: Partial): ProviderEvent { +function makeEventRecord( + event: ProviderCoreEvent, + overrides: Partial = {}, +): ThreadEvent { return { - id: "evt-1", - kind: "notification", - provider: "codex", - sessionId: "sess-1", - createdAt: "2026-02-08T10:00:00.000Z", - method: "noop", - ...overrides, + seq: overrides.seq ?? 1, + at: overrides.at ?? "2026-02-08T10:00:00.000Z", + event, }; } @@ -60,7 +59,7 @@ describe("deriveTimelineEntries", () => { }, { id: "w-2", - label: "Preamble", + label: "Plan updated", createdAt: "2026-02-08T10:03:00.000Z", tone: "thinking", }, @@ -102,506 +101,263 @@ describe("deriveTimelineEntries", () => { }); describe("deriveWorkLogEntries", () => { - it("drops preamble/work events from the visible work log", () => { + it("shows approvals, activities, and runtime errors", () => { const entries = deriveWorkLogEntries( [ - makeEvent({ - id: "evt-1", - method: "item/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:00.000Z", - payload: { item: { type: "preamble", text: "thinking" } }, - }), - makeEvent({ - id: "evt-2", - method: "item/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:01.000Z", - payload: { item: { type: "work", text: "planning" } }, - }), - makeEvent({ - id: "evt-3", - method: "item/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:02.000Z", - payload: { item: { type: "tool_call", command: "ls -la" } }, - }), - ], - "turn-1", - ); - - expect(entries).toHaveLength(1); - expect(entries[0]?.label).toBe("Tool call"); - }); - - it("does not surface successful turn completion as a work-log row", () => { - const entries = deriveWorkLogEntries( - [ - makeEvent({ - id: "evt-start", - method: "turn/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:00.000Z", - }), - makeEvent({ - id: "evt-complete", - method: "turn/completed", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:10.000Z", - payload: { turn: { id: "turn-1", status: "completed" } }, - }), - ], - "turn-1", - ); - - expect(entries).toHaveLength(0); - }); - - it("shows failed turn completion in the work log", () => { - const entries = deriveWorkLogEntries( - [ - makeEvent({ - id: "evt-start", - method: "turn/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:00.000Z", - }), - makeEvent({ - id: "evt-complete", - method: "turn/completed", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:10.000Z", - payload: { - turn: { - id: "turn-1", - status: "failed", - error: { message: "sandbox denied" }, - }, + makeEventRecord( + { + type: "error", + sessionId: "sess-1", + threadId: "thread-1", + turnId: "turn-1", + code: "runtime/error", + message: "sandbox denied", }, - }), - ], - "turn-1", - ); - - expect(entries).toHaveLength(1); - expect(entries[0]?.label).toBe("Turn failed"); - expect(entries[0]?.detail).toBe("sandbox denied"); - }); - - it("hides reasoning and agent-message noise from the visible work log", () => { - const entries = deriveWorkLogEntries( - [ - makeEvent({ - id: "evt-turn-start", - method: "turn/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:00.000Z", - }), - makeEvent({ - id: "evt-summary-part", - method: "item/reasoning/summaryPartAdded", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:01.000Z", - }), - makeEvent({ - id: "evt-summary-delta", - method: "item/reasoning/summaryTextDelta", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:02.000Z", - }), - makeEvent({ - id: "evt-agent-start", - method: "item/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:03.000Z", - payload: { - item: { id: "item-msg", type: "agentMessage", text: "Working..." }, - }, - }), - makeEvent({ - id: "evt-agent-complete", - method: "item/completed", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:04.000Z", - payload: { - item: { - id: "item-msg", - type: "agentMessage", - text: "Done response", - }, - }, - }), - makeEvent({ - id: "evt-tool-start", - method: "item/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:05.000Z", - payload: { - item: { id: "item-tool", type: "tool_call", command: "ls -la" }, - }, - }), - makeEvent({ - id: "evt-tool-complete", - method: "item/completed", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:06.000Z", - payload: { - item: { id: "item-tool", type: "tool_call", command: "ls -la" }, + { seq: 3, at: "2026-02-08T10:00:02.000Z" }, + ), + makeEventRecord( + { + type: "activity", + sessionId: "sess-1", + threadId: "thread-1", + turnId: "turn-1", + activityId: "tool-1", + activityKind: "tool", + label: "Tool call", + detail: "spawnAgent", + status: "success", + completedAt: "2026-02-08T10:00:01.000Z", }, - }), - makeEvent({ - id: "evt-turn-complete", - method: "turn/completed", + { seq: 2, at: "2026-02-08T10:00:01.000Z" }, + ), + makeEventRecord({ + type: "approval.requested", + sessionId: "sess-1", + threadId: "thread-1", turnId: "turn-1", - createdAt: "2026-02-08T10:00:08.000Z", - payload: { turn: { id: "turn-1", status: "completed" } }, + approvalId: "approval-1", + approvalKind: "command", + title: "Command approval requested", + detail: "git status --short", + requestedAt: "2026-02-08T10:00:00.000Z", }), ], "turn-1", ); - expect(entries.map((entry) => entry.label)).toEqual(["Tool call"]); + expect(entries).toHaveLength(3); + expect(entries.map((entry) => entry.label)).toEqual([ + "Command approval requested", + "Tool call", + "Runtime error", + ]); }); - it("coalesces command start/completed lifecycle into one entry", () => { + it("suppresses created activity rows when a completion exists", () => { const entries = deriveWorkLogEntries( [ - makeEvent({ - id: "evt-command-start", - method: "item/started", + makeEventRecord({ + type: "activity", + sessionId: "sess-1", + threadId: "thread-1", turnId: "turn-1", - createdAt: "2026-02-08T10:00:01.000Z", - payload: { - item: { - id: "item-command", - type: "command_execution", - command: "git status --short", - }, - }, + activityId: "tool-1", + activityKind: "tool", + label: "Tool call", + detail: "ls -la", + status: "created", + startedAt: "2026-02-08T10:00:00.000Z", }), - makeEvent({ - id: "evt-command-complete", - method: "item/completed", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:02.000Z", - payload: { - item: { - id: "item-command", - type: "command_execution", - command: "git status --short", - }, + makeEventRecord( + { + type: "activity", + sessionId: "sess-1", + threadId: "thread-1", + turnId: "turn-1", + activityId: "tool-1", + activityKind: "tool", + label: "Tool call", + detail: "ls -la", + status: "success", + completedAt: "2026-02-08T10:00:01.000Z", }, - }), + { seq: 2, at: "2026-02-08T10:00:01.000Z" }, + ), ], "turn-1", ); expect(entries).toHaveLength(1); - expect(entries[0]?.label).toBe("Command run"); - expect(entries[0]?.detail).toBe("git status --short"); - }); - - it("preserves full tool-call detail text without data truncation", () => { - const longCommand = - 'node ./scripts/sync.js --project ct-round-5 --mode dry-run --include "very long argument with lots of detail and metadata to verify there is no hard truncation in session logic"'; - const entries = deriveWorkLogEntries( - [ - makeEvent({ - id: "evt-tool-start", - method: "item/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:05.000Z", - payload: { - item: { id: "item-tool", type: "tool_call", command: longCommand }, - }, - }), - ], - "turn-1", - ); - - expect(entries[0]?.detail).toBe(longCommand); - }); - - it("hides generic tool-call rows that have no identifying detail", () => { - const entries = deriveWorkLogEntries( - [ - makeEvent({ - id: "evt-tool-start", - method: "item/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:05.000Z", - payload: { - item: { id: "item-tool", type: "tool_call" }, - }, - }), - ], - "turn-1", - ); - - expect(entries).toHaveLength(0); + expect(entries[0]?.detail).toBe("ls -la"); }); - it("includes collab tool-call rows with tool name detail", () => { + it("shows failed turn completion", () => { const entries = deriveWorkLogEntries( [ - makeEvent({ - id: "evt-collab-start", - method: "item/started", + makeEventRecord({ + type: "turn.completed", + sessionId: "sess-1", + threadId: "thread-1", turnId: "turn-1", - createdAt: "2026-02-08T10:00:05.000Z", - payload: { - item: { - id: "item-collab", - type: "collabAgentToolCall", - tool: "spawnAgent", - }, - }, + completedAt: "2026-02-08T10:00:10.000Z", + outcome: "failed", + error: "boom", }), ], "turn-1", ); expect(entries).toHaveLength(1); - expect(entries[0]?.label).toBe("Tool call"); - expect(entries[0]?.detail).toBe("spawnAgent"); - }); - - it("can derive work-log entries across all turns when not scoped", () => { - const entries = deriveWorkLogEntries( - [ - makeEvent({ - id: "evt-turn-1-tool", - method: "item/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:01.000Z", - payload: { item: { type: "tool_call", command: "ls -la" } }, - }), - makeEvent({ - id: "evt-turn-2-tool", - method: "item/started", - turnId: "turn-2", - createdAt: "2026-02-08T10:00:02.000Z", - payload: { item: { type: "tool_call", command: "pwd" } }, - }), - ], - undefined, - ); - - expect(entries).toHaveLength(2); - expect(entries.map((entry) => entry.detail)).toEqual(["pwd", "ls -la"]); + expect(entries[0]?.label).toBe("Turn failed"); + expect(entries[0]?.detail).toBe("boom"); }); }); describe("evolveSession", () => { - it("updates thread id when thread starts", () => { + it("replaces session on session.updated", () => { const previous = makeSession(); const next = evolveSession( previous, - makeEvent({ - method: "thread/started", - createdAt: "2026-02-08T10:01:00.000Z", - payload: { thread: { id: "thread-1" } }, - }), + { + type: "session.updated", + session: { + ...previous, + status: "running", + threadId: "thread-1", + activeTurnId: "turn-1", + updatedAt: "2026-02-08T10:00:00.000Z", + }, + }, + "2026-02-08T10:00:00.000Z", ); + expect(next.status).toBe("running"); expect(next.threadId).toBe("thread-1"); - expect(next.updatedAt).toBe("2026-02-08T10:01:00.000Z"); + expect(next.activeTurnId).toBe("turn-1"); }); - it("moves to running and records active turn on turn start", () => { + it("moves to running on turn.started", () => { const previous = makeSession(); const next = evolveSession( previous, - makeEvent({ - method: "turn/started", + { + type: "turn.started", + sessionId: "sess-1", + threadId: "thread-1", turnId: "turn-1", - createdAt: "2026-02-08T10:02:00.000Z", - }), + startedAt: "2026-02-08T10:02:00.000Z", + }, + "2026-02-08T10:02:00.000Z", ); expect(next.status).toBe("running"); expect(next.activeTurnId).toBe("turn-1"); - expect(next.updatedAt).toBe("2026-02-08T10:02:00.000Z"); }); - it("returns to ready and clears active turn on successful completion", () => { + it("returns to ready on successful completion", () => { const previous = makeSession({ status: "running", activeTurnId: "turn-1", - lastError: "older error", }); const next = evolveSession( previous, - makeEvent({ - method: "turn/completed", - createdAt: "2026-02-08T10:03:00.000Z", - payload: { turn: { id: "turn-1", status: "completed" } }, - }), + { + type: "turn.completed", + sessionId: "sess-1", + threadId: "thread-1", + turnId: "turn-1", + completedAt: "2026-02-08T10:03:00.000Z", + outcome: "completed", + }, + "2026-02-08T10:03:00.000Z", ); expect(next.status).toBe("ready"); expect(next.activeTurnId).toBeUndefined(); - expect(next.lastError).toBe("older error"); }); - it("marks session as error when turn fails", () => { - const previous = makeSession({ - status: "running", - activeTurnId: "turn-1", - }); - const next = evolveSession( - previous, - makeEvent({ - method: "turn/completed", - createdAt: "2026-02-08T10:03:00.000Z", - payload: { - turn: { id: "turn-1", status: "failed", error: { message: "boom" } }, - }, - }), - ); - - expect(next.status).toBe("error"); - expect(next.activeTurnId).toBeUndefined(); - expect(next.lastError).toBe("boom"); - }); - - it("moves to error on runtime error events", () => { + it("marks session as error on runtime error", () => { const previous = makeSession(); const next = evolveSession( previous, - makeEvent({ - kind: "error", - method: "runtime/error", - createdAt: "2026-02-08T10:04:00.000Z", + { + type: "error", + sessionId: "sess-1", + code: "runtime/error", message: "runtime failure", - }), + }, + "2026-02-08T10:04:00.000Z", ); expect(next.status).toBe("error"); expect(next.lastError).toBe("runtime failure"); }); - - it("closes session on close lifecycle events", () => { - const previous = makeSession({ - status: "running", - activeTurnId: "turn-1", - }); - const next = evolveSession( - previous, - makeEvent({ - method: "session/closed", - createdAt: "2026-02-08T10:05:00.000Z", - message: "closed", - }), - ); - - expect(next.status).toBe("closed"); - expect(next.activeTurnId).toBeUndefined(); - expect(next.lastError).toBe("closed"); - }); }); describe("applyEventToMessages", () => { - it("handles start/delta/completed flow for assistant messages", () => { - const activeAssistantItemRef = { current: null as string | null }; - const started = applyEventToMessages( + it("handles delta/completed flow for assistant messages", () => { + const activeAssistantMessageRef = { current: null as string | null }; + const withDelta = applyEventToMessages( [], - makeEvent({ - method: "item/started", - createdAt: "2026-02-08T10:00:00.000Z", - payload: { - item: { - id: "item-1", - type: "agentMessage", - text: "Hello", - }, - }, - }), - activeAssistantItemRef, + { + type: "message.delta", + sessionId: "sess-1", + threadId: "thread-1", + turnId: "turn-1", + messageId: "message-1", + role: "assistant", + delta: "Hello", + }, + "2026-02-08T10:00:00.000Z", + activeAssistantMessageRef, ); - expect(started).toEqual([ + expect(withDelta).toEqual([ { - id: "item-1", + id: "message-1", role: "assistant", text: "Hello", createdAt: "2026-02-08T10:00:00.000Z", streaming: true, }, ]); - expect(activeAssistantItemRef.current).toBe("item-1"); - const withDelta = applyEventToMessages( - started, - makeEvent({ - method: "item/agentMessage/delta", - createdAt: "2026-02-08T10:00:01.000Z", - itemId: "item-1", - textDelta: " world", - }), - activeAssistantItemRef, - ); - expect(withDelta[0]?.text).toBe("Hello world"); - expect(withDelta[0]?.streaming).toBe(true); - - const completed = applyEventToMessages( + const withMoreDelta = applyEventToMessages( withDelta, - makeEvent({ - method: "item/completed", - createdAt: "2026-02-08T10:00:02.000Z", - payload: { - item: { - id: "item-1", - type: "agentMessage", - text: "Hello world!", - }, - }, - }), - activeAssistantItemRef, - ); - expect(completed[0]?.text).toBe("Hello world!"); - expect(completed[0]?.streaming).toBe(false); - expect(activeAssistantItemRef.current).toBeNull(); - }); - - it("supports out-of-order delta before started/completed", () => { - const activeAssistantItemRef = { current: null as string | null }; - const withDelta = applyEventToMessages( - [], - makeEvent({ - method: "item/agentMessage/delta", - createdAt: "2026-02-08T10:00:01.000Z", - itemId: "item-2", - textDelta: "Partial", - }), - activeAssistantItemRef, + { + type: "message.delta", + sessionId: "sess-1", + threadId: "thread-1", + turnId: "turn-1", + messageId: "message-1", + role: "assistant", + delta: " world", + }, + "2026-02-08T10:00:01.000Z", + activeAssistantMessageRef, ); - expect(withDelta[0]?.id).toBe("item-2"); - expect(withDelta[0]?.text).toBe("Partial"); - expect(withDelta[0]?.streaming).toBe(true); - expect(activeAssistantItemRef.current).toBe("item-2"); + expect(withMoreDelta[0]?.text).toBe("Hello world"); const completed = applyEventToMessages( - withDelta, - makeEvent({ - method: "item/completed", - createdAt: "2026-02-08T10:00:02.000Z", - payload: { - item: { - id: "item-2", - type: "agentMessage", - text: "Partial + final", - }, - }, - }), - activeAssistantItemRef, + withMoreDelta, + { + type: "message.completed", + sessionId: "sess-1", + threadId: "thread-1", + turnId: "turn-1", + messageId: "message-1", + role: "assistant", + text: "Hello world!", + }, + "2026-02-08T10:00:02.000Z", + activeAssistantMessageRef, ); - expect(completed[0]?.text).toBe("Partial + final"); + expect(completed[0]?.text).toBe("Hello world!"); expect(completed[0]?.streaming).toBe(false); }); - it("clears all streaming flags when turn completes", () => { + it("clears streaming flags when a turn completes", () => { const previous: ChatMessage[] = [ { id: "m-user", @@ -621,9 +377,15 @@ describe("applyEventToMessages", () => { const next = applyEventToMessages( previous, - makeEvent({ - method: "turn/completed", - }), + { + type: "turn.completed", + sessionId: "sess-1", + threadId: "thread-1", + turnId: "turn-1", + completedAt: "2026-02-08T10:00:02.000Z", + outcome: "completed", + }, + "2026-02-08T10:00:02.000Z", { current: null }, ); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 9f55647070b8..77c1df7bfafe 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -1,5 +1,10 @@ -import type { NativeApi, ProviderEvent, ProviderKind, ProviderSession } from "@t3tools/contracts"; -import type { ChatMessage, SessionPhase } from "./types"; +import type { + NativeApi, + ProviderCoreEvent, + ProviderKind, + ProviderSession, +} from "@t3tools/contracts"; +import type { ChatMessage, SessionPhase, ThreadEvent } from "./types"; import { createWsNativeApi } from "./wsNativeApi"; export const PROVIDER_OPTIONS: Array<{ @@ -98,302 +103,135 @@ function normalizeDetail(value: string | undefined): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } -function normalizeItemType(raw: string | undefined): string { - if (!raw) return "item"; - return raw - .replace(/([a-z0-9])([A-Z])/g, "$1 $2") - .replace(/[._/-]/g, " ") - .replace(/\s+/g, " ") - .trim() - .toLowerCase(); -} - -function shouldDropItemType(type: string): boolean { - if ( - type.includes("preamble") || - type.includes("reasoning") || - type.includes("thought") - ) { - return true; - } - - return type === "work" || type.startsWith("work "); -} - -function shouldShowItemLifecycle(type: string): boolean { - return ( - type.includes("tool") || - type.includes("command") || - type.includes("file change") - ); -} - -function shouldDropMethod(method: string): boolean { - return /(^|\/)(preamble|work|reasoning|thought)(\/|$)/i.test(method); -} - -function itemTypeMeta(type: string): { - label: string; - tone: WorkLogEntry["tone"]; -} { - if (type.includes("command")) { - return { label: "Command run", tone: "tool" }; +function eventTurnId(event: ProviderCoreEvent): string | undefined { + switch (event.type) { + case "turn.started": + case "turn.completed": + case "message.delta": + case "message.completed": + case "approval.requested": + case "activity": + case "error": + return event.turnId; + default: + return undefined; } - if (type.includes("file change")) { - return { label: "File change", tone: "tool" }; - } - if (type.includes("tool")) { - return { label: "Tool call", tone: "tool" }; - } - return { label: "Work item", tone: "info" }; } -interface ItemLifecycleCandidate { - id: string; - itemId?: string; - createdAt: string; - label: string; - detail?: string; - tone: WorkLogEntry["tone"]; - phase: "started" | "completed"; -} - -function extractDetail( - payload: Record | undefined, - item: Record | undefined, -): string | undefined { - const candidates = [ - asString(item?.command), - asString(item?.tool), - asString(item?.name), - asString(item?.title), - asString(item?.summary), - asString(item?.text), - asString(item?.prompt), - asString(payload?.message), - asString(payload?.prompt), - asString(payload?.command), - ]; - - for (const candidate of candidates) { - const detail = normalizeDetail(candidate); - if (detail) return detail; +function activityTone(event: Extract): WorkLogEntry["tone"] { + if (["failed", "denied", "timed_out"].includes(event.status)) { + return "error"; } - return undefined; -} -function lifecycleCandidateFromItemEvent( - event: ProviderEvent, -): ItemLifecycleCandidate | null { - const payload = asObject(event.payload); - const item = asObject(payload?.item); - const normalizedType = normalizeItemType(asString(item?.type)); - if (shouldDropItemType(normalizedType) || shouldDropMethod(event.method)) { - return null; - } - if (!shouldShowItemLifecycle(normalizedType)) { - return null; + if (event.activityKind === "plan") { + return "thinking"; } - const meta = itemTypeMeta(normalizedType); - const isStarted = event.method === "item/started"; - const isCompleted = event.method === "item/completed"; - if (!isStarted && !isCompleted) { - return null; + if (event.activityKind === "tool") { + return "tool"; } - const detail = extractDetail(payload, item); - const itemId = event.itemId ?? asString(item?.id); - return { - id: event.id, - ...(itemId ? { itemId } : {}), - createdAt: event.createdAt, - label: meta.label, - ...(detail ? { detail } : {}), - tone: meta.tone, - phase: isCompleted ? "completed" : "started", - }; + return "info"; } -function entryFromRequest(event: ProviderEvent): WorkLogEntry | null { - if (event.kind !== "request") return null; - if (shouldDropMethod(event.method)) return null; +function toWorkLogEntry(eventRecord: ThreadEvent): WorkLogEntry | null { + const event = eventRecord.event; + + if (event.type === "activity") { + const detail = normalizeDetail(event.detail); + if (event.label === "Tool call" && !detail) { + return null; + } - if (event.method.includes("commandExecution")) { return { - id: event.id, - createdAt: event.createdAt, - label: "Command approval requested", - tone: "tool", + id: `activity:${event.activityId}`, + createdAt: event.startedAt ?? event.completedAt ?? eventRecord.at, + label: event.label, + ...(detail ? { detail } : {}), + tone: activityTone(event), }; } - if (event.method.includes("fileChange")) { + if (event.type === "approval.requested") { + const detail = normalizeDetail(event.detail); + const label = + event.approvalKind === "command" + ? "Command approval requested" + : event.approvalKind === "file_change" + ? "File-change approval requested" + : "Tool requested user input"; + return { - id: event.id, - createdAt: event.createdAt, - label: "File-change approval requested", + id: `approval:${event.approvalId}`, + createdAt: event.requestedAt, + label, + ...(detail ? { detail } : {}), tone: "tool", }; } - if (event.method.includes("requestUserInput")) { + if (event.type === "turn.completed" && event.outcome === "failed") { return { - id: event.id, - createdAt: event.createdAt, - label: "Tool requested user input", - tone: "tool", + id: `turn-failed:${event.turnId}`, + createdAt: event.completedAt, + label: "Turn failed", + ...(event.error ? { detail: event.error } : {}), + tone: "error", }; } - return { - id: event.id, - createdAt: event.createdAt, - label: `Request: ${event.method}`, - tone: "info", - }; -} - -function entryFromNotification(event: ProviderEvent): WorkLogEntry | null { - if (event.kind !== "notification") return null; - if (shouldDropMethod(event.method)) return null; - if (event.method === "item/agentMessage/delta") return null; - if (event.method === "turn/started") { - return null; - } - if (event.method === "thread/started") return null; - - if (event.method === "turn/completed") { - const payload = asObject(event.payload); - const turn = asObject(payload?.turn); - const status = asString(turn?.status); - if (status !== "failed") { - return null; - } - const turnError = asObject(turn?.error); - const turnErrorMessage = asString(turnError?.message); - const turnErrorDetail = normalizeDetail(turnErrorMessage); - + if (event.type === "error") { + const detail = normalizeDetail(event.message); return { - id: event.id, - createdAt: event.createdAt, - label: "Turn failed", - ...(turnErrorDetail ? { detail: turnErrorDetail } : {}), + id: `error:${event.code}:${eventRecord.seq}`, + createdAt: eventRecord.at, + label: "Runtime error", + ...(detail ? { detail } : {}), tone: "error", }; } - if (event.method.startsWith("item/")) return null; - return null; } -function entryFromError(event: ProviderEvent): WorkLogEntry | null { - if (event.kind !== "error") return null; - if (shouldDropMethod(event.method)) return null; - const detail = normalizeDetail(event.message); - - return { - id: event.id, - createdAt: event.createdAt, - label: "Runtime error", - ...(detail ? { detail } : {}), - tone: "error", - }; -} - -function eventTurnId(event: ProviderEvent): string | undefined { - const payload = asObject(event.payload); - const turn = asObject(payload?.turn); - return event.turnId ?? asString(turn?.id); -} - export function deriveWorkLogEntries( - events: ProviderEvent[], + events: ThreadEvent[], turnId: string | undefined, ): WorkLogEntry[] { const ordered = [...events].toReversed(); - const entries: WorkLogEntry[] = []; - const turnStartedAtIso = turnId - ? ordered.find((event) => { - if (event.method !== "turn/started") return false; - return eventTurnId(event) === turnId; - })?.createdAt - : undefined; - const turnStartedAt = turnStartedAtIso ? Date.parse(turnStartedAtIso) : Number.NaN; - - const shouldIncludeEvent = (event: ProviderEvent): boolean => { - if (!turnId) return true; - const scopedTurnId = eventTurnId(event); - if (scopedTurnId && scopedTurnId !== turnId) { - return false; - } - - if (!scopedTurnId && !Number.isNaN(turnStartedAt)) { - const eventAt = Date.parse(event.createdAt); - if (!Number.isNaN(eventAt) && eventAt < turnStartedAt) { - return false; - } - } - - return true; - }; - - const completedLifecycleItemIds = new Set(); - for (const event of ordered) { - if (!shouldIncludeEvent(event)) continue; - const candidate = lifecycleCandidateFromItemEvent(event); - if (candidate?.phase === "completed" && candidate.itemId) { - completedLifecycleItemIds.add(candidate.itemId); + const completedActivityIds = new Set(); + + for (const eventRecord of ordered) { + const event = eventRecord.event; + if (event.type !== "activity") continue; + if (turnId && event.turnId && event.turnId !== turnId) continue; + if (["success", "failed", "denied", "timed_out"].includes(event.status)) { + completedActivityIds.add(event.activityId); } } - for (const event of ordered) { - if (!shouldIncludeEvent(event)) continue; + const entries: WorkLogEntry[] = []; + for (const eventRecord of ordered) { + const event = eventRecord.event; - const lifecycleCandidate = lifecycleCandidateFromItemEvent(event); - if (lifecycleCandidate) { - if ( - lifecycleCandidate.phase === "started" && - lifecycleCandidate.itemId && - completedLifecycleItemIds.has(lifecycleCandidate.itemId) - ) { - continue; - } - if ( - lifecycleCandidate.label === "Tool call" && - !lifecycleCandidate.detail - ) { + if (turnId) { + const scopedTurnId = eventTurnId(event); + if (scopedTurnId && scopedTurnId !== turnId) { continue; } - - entries.push({ - id: lifecycleCandidate.id, - createdAt: lifecycleCandidate.createdAt, - label: lifecycleCandidate.label, - ...(lifecycleCandidate.detail - ? { detail: lifecycleCandidate.detail } - : {}), - tone: lifecycleCandidate.tone, - }); - continue; - } - - const fromRequest = entryFromRequest(event); - if (fromRequest) { - entries.push(fromRequest); - continue; } - const fromNotification = entryFromNotification(event); - if (fromNotification) { - entries.push(fromNotification); + if ( + event.type === "activity" && + event.status === "created" && + completedActivityIds.has(event.activityId) + ) { continue; } - const fromError = entryFromError(event); - if (fromError) { - entries.push(fromError); + const entry = toWorkLogEntry(eventRecord); + if (entry) { + entries.push(entry); } } @@ -476,106 +314,73 @@ export function derivePhase(session: ProviderSession | null): SessionPhase { return "ready"; } -export function evolveSession(previous: ProviderSession, event: ProviderEvent): ProviderSession { - const payload = asObject(event.payload); - - if (event.method === "thread/started") { - const thread = asObject(payload?.thread); - return { - ...previous, - threadId: asString(thread?.id) ?? event.threadId ?? previous.threadId, - updatedAt: event.createdAt, - }; +export function evolveSession( + previous: ProviderSession, + event: ProviderCoreEvent, + frameAt: string, +): ProviderSession { + if (event.type === "session.updated") { + return event.session; } - if (event.method === "turn/started") { - const turn = asObject(payload?.turn); + if (event.type === "turn.started") { return { ...previous, status: "running", - activeTurnId: asString(turn?.id) ?? event.turnId ?? previous.activeTurnId, - updatedAt: event.createdAt, + threadId: event.threadId, + activeTurnId: event.turnId, + ...(event.model ? { model: event.model } : {}), + updatedAt: event.startedAt, }; } - if (event.method === "turn/completed") { - const turn = asObject(payload?.turn); - const status = asString(turn?.status); - const turnError = asObject(turn?.error); + if (event.type === "turn.completed") { return { ...previous, - status: status === "failed" ? "error" : "ready", + status: event.outcome === "failed" ? "error" : "ready", + threadId: event.threadId, activeTurnId: undefined, - lastError: asString(turnError?.message) ?? previous.lastError, - updatedAt: event.createdAt, + ...(event.error ? { lastError: event.error } : {}), + updatedAt: event.completedAt, }; } - if (event.kind === "error") { + if (event.type === "error") { return { ...previous, - status: "error", - lastError: event.message ?? previous.lastError, - updatedAt: event.createdAt, + ...(event.retryable ? {} : { status: "error" }), + lastError: event.message, + updatedAt: frameAt, }; } - if (event.method === "session/closed" || event.method === "session/exited") { - return { - ...previous, - status: "closed", - activeTurnId: undefined, - lastError: event.message ?? previous.lastError, - updatedAt: event.createdAt, - }; - } - - return { ...previous, updatedAt: event.createdAt }; + return { + ...previous, + updatedAt: frameAt, + }; } export function applyEventToMessages( previous: ChatMessage[], - event: ProviderEvent, - activeAssistantItemRef: { current: string | null }, + event: ProviderCoreEvent, + frameAt: string, + activeAssistantMessageRef: { current: string | null }, ): ChatMessage[] { - const payload = asObject(event.payload); - - if (event.method === "item/started") { - const item = asObject(payload?.item); - if (asString(item?.type) !== "agentMessage") return previous; - const itemId = asString(item?.id); - if (!itemId) return previous; - - activeAssistantItemRef.current = itemId; - const seedText = asString(item?.text) ?? ""; - const filtered = previous.filter((entry) => entry.id !== itemId); - return [ - ...filtered, - { - id: itemId, - role: "assistant", - text: seedText, - createdAt: event.createdAt, - streaming: true, - }, - ]; - } - - if (event.method === "item/agentMessage/delta") { - const itemId = event.itemId ?? asString(payload?.itemId); - const delta = event.textDelta ?? asString(payload?.delta) ?? ""; - if (!itemId || !delta) return previous; + if (event.type === "message.delta") { + const messageId = event.messageId; + const delta = event.delta; + if (!delta) return previous; - const existingIndex = previous.findIndex((entry) => entry.id === itemId); + const existingIndex = previous.findIndex((entry) => entry.id === messageId); if (existingIndex === -1) { - activeAssistantItemRef.current = itemId; + activeAssistantMessageRef.current = messageId; return [ ...previous, { - id: itemId, + id: messageId, role: "assistant", text: delta, - createdAt: event.createdAt, + createdAt: frameAt, streaming: true, }, ]; @@ -584,6 +389,7 @@ export function applyEventToMessages( const updated = [...previous]; const existing = updated[existingIndex]; if (!existing) return previous; + updated[existingIndex] = { ...existing, text: `${existing.text}${delta}`, @@ -592,22 +398,18 @@ export function applyEventToMessages( return updated; } - if (event.method === "item/completed") { - const item = asObject(payload?.item); - if (asString(item?.type) !== "agentMessage") return previous; - const itemId = asString(item?.id); - if (!itemId) return previous; + if (event.type === "message.completed") { + const messageId = event.messageId; + const existingIndex = previous.findIndex((entry) => entry.id === messageId); - const fullText = asString(item?.text); - const existingIndex = previous.findIndex((entry) => entry.id === itemId); if (existingIndex === -1) { return [ ...previous, { - id: itemId, + id: messageId, role: "assistant", - text: fullText ?? "", - createdAt: event.createdAt, + text: event.text, + createdAt: frameAt, streaming: false, }, ]; @@ -616,19 +418,21 @@ export function applyEventToMessages( const updated = [...previous]; const existing = updated[existingIndex]; if (!existing) return previous; + updated[existingIndex] = { ...existing, - text: fullText ?? existing.text, + text: event.text || existing.text, streaming: false, }; - if (activeAssistantItemRef.current === itemId) { - activeAssistantItemRef.current = null; + if (activeAssistantMessageRef.current === messageId) { + activeAssistantMessageRef.current = null; } + return updated; } - if (event.method === "turn/completed") { + if (event.type === "turn.completed") { return previous.map((entry) => ({ ...entry, streaming: false })); } diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index c10bfe8e7951..c5881fc2cddd 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -1,4 +1,8 @@ -import type { ProviderEvent, ProviderSession } from "@t3tools/contracts"; +import type { + ProviderCoreEvent, + ProviderSession, + ProviderStreamFrame, +} from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; import { type AppState, reducer } from "./store"; @@ -15,14 +19,15 @@ function makeSession(overrides: Partial = {}): ProviderSession }; } -function makeEvent(overrides: Partial = {}): ProviderEvent { +function makeEventFrame( + event: ProviderCoreEvent, + overrides: Partial> = {}, +): ProviderStreamFrame { return { - id: "evt-1", - kind: "notification", - provider: "codex", - sessionId: "sess-1", - createdAt: "2026-02-09T00:00:01.000Z", - method: "thread/started", + kind: "event", + seq: 1, + at: "2026-02-09T00:00:01.000Z", + data: event, ...overrides, }; } @@ -58,10 +63,11 @@ function makeState(thread: Thread): AppState { activeThreadId: thread.id, runtimeMode: "full-access", diffOpen: false, + lastProviderSeq: 0, }; } -describe("store reducer thread continuity", () => { +describe("store reducer stream integration", () => { it("stores codexThreadId from UPDATE_SESSION", () => { const state = makeState( makeThread({ @@ -77,57 +83,112 @@ describe("store reducer thread continuity", () => { expect(next.threads[0]?.codexThreadId).toBe("thr_123"); }); - it("backfills codexThreadId from routed provider events", () => { + it("backfills codexThreadId from session.updated stream events", () => { const state = makeState(makeThread({ codexThreadId: null })); const next = reducer(state, { - type: "APPLY_EVENT", - event: makeEvent({ - method: "thread/started", - payload: { thread: { id: "thr_backfilled" } }, + type: "APPLY_STREAM_FRAME", + frame: makeEventFrame({ + type: "session.updated", + session: makeSession({ + threadId: "thr_backfilled", + updatedAt: "2026-02-09T00:00:01.000Z", + }), }), - activeAssistantItemRef: { current: null }, + activeAssistantMessageRef: { current: null }, }); expect(next.threads[0]?.codexThreadId).toBe("thr_backfilled"); + expect(next.lastProviderSeq).toBe(1); }); it("ignores events from a foreign thread within the same session", () => { const state = makeState(makeThread({ codexThreadId: "thr_expected" })); const next = reducer(state, { - type: "APPLY_EVENT", - event: makeEvent({ - method: "turn/started", + type: "APPLY_STREAM_FRAME", + frame: makeEventFrame({ + type: "turn.started", + sessionId: "sess-1", threadId: "thr_unexpected", - payload: { turn: { id: "turn-1" } }, + turnId: "turn-1", + startedAt: "2026-02-09T00:00:01.000Z", }), - activeAssistantItemRef: { current: null }, + activeAssistantMessageRef: { current: null }, }); - expect(next).toBe(state); + expect(next.threads[0]).toEqual(state.threads[0]); + expect(next.lastProviderSeq).toBe(1); }); - it("rebases thread identity on thread/started during connect", () => { + it("applies snapshot frames as authoritative baseline", () => { const state = makeState( makeThread({ codexThreadId: "thr_old", session: makeSession({ - status: "connecting", threadId: "thr_old", + status: "running", + activeTurnId: "turn_old", }), }), ); + const next = reducer(state, { - type: "APPLY_EVENT", - event: makeEvent({ - method: "thread/started", - threadId: "thr_new", - payload: { thread: { id: "thr_new" } }, - }), - activeAssistantItemRef: { current: null }, + type: "APPLY_STREAM_FRAME", + frame: { + kind: "snapshot", + seq: 10, + at: "2026-02-09T00:00:10.000Z", + data: { + sessions: [ + makeSession({ + threadId: "thr_new", + status: "running", + activeTurnId: "turn_new", + updatedAt: "2026-02-09T00:00:10.000Z", + }), + ], + activeTurns: [ + { + sessionId: "sess-1", + threadId: "thr_new", + turnId: "turn_new", + startedAt: "2026-02-09T00:00:09.000Z", + }, + ], + activeMessages: [ + { + sessionId: "sess-1", + threadId: "thr_new", + turnId: "turn_new", + messageId: "msg-1", + role: "assistant", + text: "streaming", + startedAt: "2026-02-09T00:00:09.100Z", + updatedAt: "2026-02-09T00:00:09.500Z", + }, + ], + pendingApprovals: [ + { + sessionId: "sess-1", + threadId: "thr_new", + turnId: "turn_new", + approvalId: "approval-1", + approvalKind: "command", + title: "Command approval requested", + detail: "git status --short", + requestedAt: "2026-02-09T00:00:09.200Z", + }, + ], + }, + }, + activeAssistantMessageRef: { current: null }, }); + expect(next.lastProviderSeq).toBe(10); expect(next.threads[0]?.codexThreadId).toBe("thr_new"); - expect(next.threads[0]?.session?.threadId).toBe("thr_new"); + expect(next.threads[0]?.latestTurnId).toBe("turn_new"); + expect(next.threads[0]?.messages.at(-1)?.id).toBe("msg-1"); + expect(next.threads[0]?.messages.at(-1)?.streaming).toBe(true); + expect(next.threads[0]?.events[0]?.event.type).toBe("approval.requested"); }); it("reconciles project ids by cwd when syncing backend projects", () => { @@ -161,6 +222,7 @@ describe("store reducer thread continuity", () => { activeThreadId: "thread-b", runtimeMode: "full-access", diffOpen: false, + lastProviderSeq: 0, }; const next = reducer(state, { diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index f9b075b89e01..2b853bbb98a6 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -7,12 +7,23 @@ import { useEffect, useReducer, } from "react"; +import type { + ProviderCoreEvent, + ProviderSession, + ProviderSnapshot, + ProviderStreamFrame, +} from "@t3tools/contracts"; -import type { ProviderEvent, ProviderSession } from "@t3tools/contracts"; import { resolveModelSlug } from "./model-logic"; import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; -import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic"; -import { DEFAULT_RUNTIME_MODE, type Project, type RuntimeMode, type Thread } from "./types"; +import { applyEventToMessages, evolveSession } from "./session-logic"; +import { + DEFAULT_RUNTIME_MODE, + type Project, + type RuntimeMode, + type Thread, + type ThreadEvent, +} from "./types"; // ── Actions ────────────────────────────────────────────────────────── @@ -24,9 +35,9 @@ type Action = | { type: "SET_ACTIVE_THREAD"; threadId: string } | { type: "TOGGLE_DIFF" } | { - type: "APPLY_EVENT"; - event: ProviderEvent; - activeAssistantItemRef: { current: string | null }; + type: "APPLY_STREAM_FRAME"; + frame: ProviderStreamFrame; + activeAssistantMessageRef: { current: string | null }; } | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession } | { type: "PUSH_USER_MESSAGE"; threadId: string; id: string; text: string } @@ -43,6 +54,7 @@ export interface AppState { activeThreadId: string | null; runtimeMode: RuntimeMode; diffOpen: boolean; + lastProviderSeq: number; } const PERSISTED_STATE_KEY = "t3code:renderer-state:v4"; @@ -60,6 +72,7 @@ const initialState: AppState = { activeThreadId: null, runtimeMode: DEFAULT_RUNTIME_MODE, diffOpen: false, + lastProviderSeq: 0, }; // ── Helpers ────────────────────────────────────────────────────────── @@ -81,7 +94,7 @@ function readPersistedState(): AppState { ); if (!hydrated) return initialState; - return { ...hydrated, diffOpen: false }; + return { ...hydrated, diffOpen: false, lastProviderSeq: 0 }; } catch { return initialState; } @@ -112,78 +125,109 @@ function findThreadBySessionId(threads: Thread[], sessionId: string): Thread | u return threads.find((t) => t.session?.sessionId === sessionId); } -function getEventTurnId(event: ProviderEvent): string | undefined { - if (event.turnId) return event.turnId; - const payload = asObject(event.payload); - const turn = asObject(payload?.turn); - return asString(turn?.id); +function durationMs(startIso: string, endIso: string): number | undefined { + const start = Date.parse(startIso); + const end = Date.parse(endIso); + if (Number.isNaN(start) || Number.isNaN(end) || end < start) { + return undefined; + } + + return end - start; } -function getEventThreadId(event: ProviderEvent): string | undefined { - if (event.threadId) return event.threadId; - const payload = asObject(event.payload); - const payloadThread = asObject(payload?.thread); - const payloadMessage = asObject(payload?.msg); - return ( - asString(payload?.threadId) ?? - asString(payloadThread?.id) ?? - asString(payload?.conversationId) ?? - asString(payload?.thread_id) ?? - asString(payloadMessage?.thread_id) - ); +function eventSessionId(event: ProviderCoreEvent): string | undefined { + if (event.type === "session.updated") { + return event.session.sessionId; + } + + if (event.type === "debug.raw") { + return event.sessionId; + } + + return event.sessionId; } -function shouldIgnoreForeignThreadEvent(thread: Thread, event: ProviderEvent): boolean { - const eventThreadId = getEventThreadId(event); - if (!eventThreadId) { +function eventThreadId(event: ProviderCoreEvent): string | undefined { + if (event.type === "session.updated") { + return event.session.threadId; + } + + if (event.type === "turn.started") { + return event.threadId; + } + + if (event.type === "turn.completed") { + return event.threadId; + } + + if (event.type === "message.delta") { + return event.threadId; + } + + if (event.type === "message.completed") { + return event.threadId; + } + + if (event.type === "approval.requested") { + return event.threadId; + } + + if (event.type === "activity") { + return event.threadId; + } + + if (event.type === "error") { + return event.threadId; + } + + return undefined; +} + +function shouldIgnoreForeignThreadEvent(thread: Thread, event: ProviderCoreEvent): boolean { + const emittedThreadId = eventThreadId(event); + if (!emittedThreadId) { return false; } const expectedThreadId = thread.session?.threadId ?? thread.codexThreadId; - if (!expectedThreadId || eventThreadId === expectedThreadId) { + if (!expectedThreadId || emittedThreadId === expectedThreadId) { return false; } - // During connect, accept a thread/started notification as an identity rebind. - if (event.method === "thread/started" && thread.session?.status === "connecting") { + if (event.type === "session.updated" && thread.session?.status === "connecting") { return false; } return true; } -function durationMs(startIso: string, endIso: string): number | undefined { - const start = Date.parse(startIso); - const end = Date.parse(endIso); - if (Number.isNaN(start) || Number.isNaN(end) || end < start) { - return undefined; - } - - return end - start; -} - -function updateTurnFields(thread: Thread, event: ProviderEvent): Partial { - if (event.method === "turn/started") { +function updateTurnFields( + thread: Thread, + event: ProviderCoreEvent, +): Partial { + if (event.type === "turn.started") { return { - latestTurnId: getEventTurnId(event) ?? thread.latestTurnId, - latestTurnStartedAt: event.createdAt, + latestTurnId: event.turnId, + latestTurnStartedAt: event.startedAt, latestTurnCompletedAt: undefined, latestTurnDurationMs: undefined, }; } - if (event.method === "turn/completed") { - const completedTurnId = getEventTurnId(event) ?? thread.latestTurnId; + if (event.type === "turn.completed") { const startedAt = - completedTurnId && completedTurnId === thread.latestTurnId + event.turnId === thread.latestTurnId ? thread.latestTurnStartedAt : undefined; const elapsed = - startedAt && startedAt.length > 0 ? durationMs(startedAt, event.createdAt) : undefined; + event.durationMs ?? + (startedAt && startedAt.length > 0 + ? durationMs(startedAt, event.completedAt) + : undefined); return { - latestTurnId: completedTurnId ?? thread.latestTurnId, - latestTurnCompletedAt: event.createdAt, + latestTurnId: event.turnId, + latestTurnCompletedAt: event.completedAt, latestTurnDurationMs: elapsed, }; } @@ -191,6 +235,130 @@ function updateTurnFields(thread: Thread, event: ProviderEvent): Partial return {}; } +function shouldPersistThreadEvent(event: ProviderCoreEvent): boolean { + if (event.type === "message.delta") { + return false; + } + + if (event.type === "debug.raw") { + return false; + } + + if (event.type === "session.updated") { + return event.session.status === "error" || event.session.status === "closed"; + } + + return true; +} + +function appendThreadEvent( + events: ThreadEvent[], + eventRecord: ThreadEvent, +): ThreadEvent[] { + return [eventRecord, ...events].slice(0, 2_000); +} + +function approvalEventsFromSnapshot( + approvals: ProviderSnapshot["pendingApprovals"], + seq: number, +): ThreadEvent[] { + return approvals + .map((approval) => ({ + seq, + at: approval.requestedAt, + event: { + type: "approval.requested", + sessionId: approval.sessionId, + ...(approval.threadId ? { threadId: approval.threadId } : {}), + ...(approval.turnId ? { turnId: approval.turnId } : {}), + approvalId: approval.approvalId, + approvalKind: approval.approvalKind, + title: approval.title, + ...(approval.detail ? { detail: approval.detail } : {}), + ...(approval.payload !== undefined ? { payload: approval.payload } : {}), + ...(approval.timeoutAt ? { timeoutAt: approval.timeoutAt } : {}), + requestedAt: approval.requestedAt, + } satisfies ProviderCoreEvent, + })) + .toSorted((left, right) => Date.parse(right.at) - Date.parse(left.at)); +} + +function applySnapshotToThread( + thread: Thread, + snapshot: ProviderSnapshot, + seq: number, +): Thread { + if (!thread.session) { + return thread; + } + + const sessionId = thread.session.sessionId; + const snapshotSession = snapshot.sessions.find((session) => session.sessionId === sessionId); + if (!snapshotSession) { + return { + ...thread, + session: null, + messages: thread.messages.map((message) => ({ + ...message, + streaming: false, + })), + events: [], + error: null, + latestTurnId: undefined, + latestTurnStartedAt: undefined, + latestTurnCompletedAt: undefined, + latestTurnDurationMs: undefined, + }; + } + + const activeTurns = snapshot.activeTurns + .filter((turn) => turn.sessionId === sessionId) + .toSorted((left, right) => Date.parse(right.startedAt) - Date.parse(left.startedAt)); + const activeTurn = activeTurns[0]; + + const activeMessages = snapshot.activeMessages + .filter((message) => message.sessionId === sessionId) + .toSorted((left, right) => Date.parse(left.startedAt) - Date.parse(right.startedAt)); + const activeMessageIds = new Set(activeMessages.map((message) => message.messageId)); + + const keptMessages = thread.messages + .filter((message) => !message.streaming) + .filter((message) => !activeMessageIds.has(message.id)); + + const hydratedMessages = activeMessages.map((message) => ({ + id: message.messageId, + role: "assistant" as const, + text: message.text, + createdAt: message.startedAt, + streaming: true, + })); + + const pendingApprovals = snapshot.pendingApprovals.filter( + (approval) => approval.sessionId === sessionId, + ); + + return { + ...thread, + codexThreadId: snapshotSession.threadId ?? thread.codexThreadId, + session: snapshotSession, + messages: [...keptMessages, ...hydratedMessages], + events: approvalEventsFromSnapshot(pendingApprovals, seq), + error: snapshotSession.lastError ?? (snapshotSession.status === "error" ? thread.error : null), + latestTurnId: activeTurn?.turnId, + latestTurnStartedAt: activeTurn?.startedAt, + latestTurnCompletedAt: undefined, + latestTurnDurationMs: undefined, + }; +} + +function applySnapshotToThreads( + threads: Thread[], + snapshot: ProviderSnapshot, + seq: number, +): Thread[] { + return threads.map((thread) => applySnapshotToThread(thread, snapshot, seq)); +} + // ── Reducer ────────────────────────────────────────────────────────── export function reducer(state: AppState, action: Action): AppState { @@ -279,32 +447,89 @@ export function reducer(state: AppState, action: Action): AppState { case "TOGGLE_DIFF": return { ...state, diffOpen: !state.diffOpen }; - case "APPLY_EVENT": { - const { event, activeAssistantItemRef } = action; - const target = findThreadBySessionId(state.threads, event.sessionId); - if (!target) return state; - if (shouldIgnoreForeignThreadEvent(target, event)) return state; + case "APPLY_STREAM_FRAME": { + const { frame, activeAssistantMessageRef } = action; + if (frame.seq <= state.lastProviderSeq) { + return state; + } + + if (frame.kind === "gap") { + return { + ...state, + lastProviderSeq: frame.seq, + }; + } + + if (frame.kind === "snapshot") { + return { + ...state, + threads: applySnapshotToThreads(state.threads, frame.data, frame.seq), + lastProviderSeq: frame.seq, + }; + } + + const sessionId = eventSessionId(frame.data); + if (!sessionId) { + return { + ...state, + lastProviderSeq: frame.seq, + }; + } + + const target = findThreadBySessionId(state.threads, sessionId); + if (!target) { + return { + ...state, + lastProviderSeq: frame.seq, + }; + } + + if (shouldIgnoreForeignThreadEvent(target, frame.data)) { + return { + ...state, + lastProviderSeq: frame.seq, + }; + } return { ...state, - threads: updateThread(state.threads, target.id, (t) => ({ - ...t, - ...(() => { - const eventThreadId = getEventThreadId(event); - const shouldRebindIdentity = - event.method === "thread/started" && t.session?.status === "connecting"; - return { - codexThreadId: shouldRebindIdentity - ? (eventThreadId ?? t.codexThreadId) - : (t.codexThreadId ?? eventThreadId ?? null), - error: event.kind === "error" && event.message ? event.message : t.error, - }; - })(), - session: t.session ? evolveSession(t.session, event) : t.session, - messages: applyEventToMessages(t.messages, event, activeAssistantItemRef), - events: [event, ...t.events], - ...updateTurnFields(t, event), - })), + lastProviderSeq: frame.seq, + threads: updateThread(state.threads, target.id, (thread) => { + const nextSession = + frame.data.type === "session.updated" + ? frame.data.session + : thread.session + ? evolveSession(thread.session, frame.data, frame.at) + : thread.session; + + return { + ...thread, + codexThreadId: nextSession?.threadId ?? thread.codexThreadId, + session: nextSession, + messages: applyEventToMessages( + thread.messages, + frame.data, + frame.at, + activeAssistantMessageRef, + ), + events: shouldPersistThreadEvent(frame.data) + ? appendThreadEvent(thread.events, { + seq: frame.seq, + at: frame.at, + event: frame.data, + }) + : thread.events, + error: + frame.data.type === "error" + ? frame.data.message + : frame.data.type === "turn.completed" && frame.data.outcome === "failed" + ? (frame.data.error ?? thread.error) + : frame.data.type === "session.updated" && frame.data.session.status === "error" + ? (frame.data.session.lastError ?? thread.error) + : thread.error, + ...updateTurnFields(thread, frame.data), + }; + }), }; } diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index ebe3731d121b..b3623348588c 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -1,4 +1,4 @@ -import type { ProviderEvent, ProviderSession } from "@t3tools/contracts"; +import type { ProviderCoreEvent, ProviderSession } from "@t3tools/contracts"; export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; export type RuntimeMode = "approval-required" | "full-access"; @@ -28,7 +28,7 @@ export interface Thread { model: string; session: ProviderSession | null; messages: ChatMessage[]; - events: ProviderEvent[]; + events: ThreadEvent[]; error: string | null; createdAt: string; latestTurnId?: string | undefined; @@ -36,3 +36,9 @@ export interface Thread { latestTurnCompletedAt?: string | undefined; latestTurnDurationMs?: number | undefined; } + +export interface ThreadEvent { + seq: number; + at: string; + event: ProviderCoreEvent; +} diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 1ed5d5d2a1f6..2d5e6d2c8d6e 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -1,4 +1,13 @@ -import { type NativeApi, WS_CHANNELS, WS_METHODS, type WsWelcomePayload } from "@t3tools/contracts"; +import { + type NativeApi, + WS_CHANNELS, + WS_METHODS, + providerStreamFrameSchema, + type ProviderStreamFrame, + type ProvidersOpenStreamInput, + type ProvidersOpenStreamResult, + type WsWelcomePayload, +} from "@t3tools/contracts"; import { WsTransport } from "./wsTransport"; @@ -14,7 +23,6 @@ let lastWelcome: WsWelcomePayload | null = null; export function onServerWelcome(listener: (payload: WsWelcomePayload) => void): () => void { welcomeListeners.add(listener); - // Replay cached welcome for late subscribers if (lastWelcome) { try { listener(lastWelcome); @@ -28,10 +36,95 @@ export function onServerWelcome(listener: (payload: WsWelcomePayload) => void): }; } +function sanitizeOpenInput(input: ProvidersOpenStreamInput): ProvidersOpenStreamInput { + const sanitized: ProvidersOpenStreamInput = {}; + + if (input.afterSeq !== undefined) { + sanitized.afterSeq = input.afterSeq; + } + if (input.sessionIds && input.sessionIds.length > 0) { + sanitized.sessionIds = [...input.sessionIds]; + } + if (input.eventKinds && input.eventKinds.length > 0) { + sanitized.eventKinds = [...input.eventKinds]; + } + if (input.includeExtensions && input.includeExtensions.length > 0) { + sanitized.includeExtensions = [...input.includeExtensions]; + } + if (input.includeDebugRaw !== undefined) { + sanitized.includeDebugRaw = input.includeDebugRaw; + } + + return sanitized; +} + export function createWsNativeApi(): NativeApi { if (instance) return instance.api; const transport = new WsTransport(); + const providerStreamListeners = new Set<(frame: ProviderStreamFrame) => void>(); + let lastAppliedSeq = 0; + let streamConfig: ProvidersOpenStreamInput = {}; + let streamOpenInFlight: Promise | null = null; + let streamOpen = false; + let streamOpening = false; + + const openStream = async ( + overrides?: ProvidersOpenStreamInput, + ): Promise => { + if (overrides) { + streamConfig = sanitizeOpenInput(overrides); + } + + if (streamOpenInFlight) { + return streamOpenInFlight; + } + + const openInput: ProvidersOpenStreamInput = { + ...streamConfig, + ...(lastAppliedSeq > 0 ? { afterSeq: lastAppliedSeq } : {}), + }; + + streamOpening = true; + streamOpenInFlight = transport + .request(WS_METHODS.providersOpenStream, openInput) + .then((result) => { + streamOpen = true; + + if (result.mode !== "replay") { + // Resync modes can legally move the cursor backwards after server restart. + lastAppliedSeq = result.currentSeq; + } + + return result; + }) + .finally(() => { + streamOpening = false; + streamOpenInFlight = null; + }); + + return streamOpenInFlight; + }; + + const ensureStreamOpen = () => { + if (providerStreamListeners.size === 0 || streamOpen) { + return; + } + + void openStream().catch(() => { + // Ignore open failures. Reconnect lifecycle retries automatically. + }); + }; + + transport.onConnectionStateChange((state) => { + if (state === "open") { + streamOpen = false; + ensureStreamOpen(); + return; + } + + streamOpen = false; + }); // Listen for server welcome and forward to registered listeners. // Also cache it so late subscribers (React effects) get it immediately. @@ -47,6 +140,31 @@ export function createWsNativeApi(): NativeApi { } }); + transport.subscribe(WS_CHANNELS.providerStream, (data) => { + const parsed = providerStreamFrameSchema.safeParse(data); + if (!parsed.success) { + return; + } + + const frame = parsed.data; + + if (frame.kind === "snapshot" && streamOpening) { + lastAppliedSeq = frame.seq; + } else if (frame.seq <= lastAppliedSeq) { + return; + } else { + lastAppliedSeq = frame.seq; + } + + for (const listener of providerStreamListeners) { + try { + listener(frame); + } catch { + // Swallow listener errors + } + } + }); + const api: NativeApi = { todos: { list: async () => [], @@ -80,11 +198,29 @@ export function createWsNativeApi(): NativeApi { startSession: (input) => transport.request(WS_METHODS.providersStartSession, input), sendTurn: (input) => transport.request(WS_METHODS.providersSendTurn, input), interruptTurn: (input) => transport.request(WS_METHODS.providersInterruptTurn, input), - respondToRequest: (input) => transport.request(WS_METHODS.providersRespondToRequest, input), + respondToApproval: (input) => + transport.request(WS_METHODS.providersRespondToApproval, input), stopSession: (input) => transport.request(WS_METHODS.providersStopSession, input), listSessions: () => transport.request(WS_METHODS.providersListSessions), - onEvent: (callback) => - transport.subscribe(WS_CHANNELS.providerEvent, callback as (data: unknown) => void), + openStream: (input) => openStream(input), + closeStream: async () => { + streamOpen = false; + await transport.request(WS_METHODS.providersCloseStream); + }, + onStream: (callback) => { + providerStreamListeners.add(callback); + ensureStreamOpen(); + + return () => { + providerStreamListeners.delete(callback); + if (providerStreamListeners.size === 0) { + streamOpen = false; + void transport.request(WS_METHODS.providersCloseStream).catch(() => { + // Ignore close errors while transport reconnects. + }); + } + }; + }, }, projects: { list: () => transport.request(WS_METHODS.projectsList), diff --git a/apps/web/src/wsTransport.ts b/apps/web/src/wsTransport.ts index 20a40f8f05e7..a564a4b20599 100644 --- a/apps/web/src/wsTransport.ts +++ b/apps/web/src/wsTransport.ts @@ -1,6 +1,8 @@ import type { WsPush, WsRequest, WsResponse } from "@t3tools/contracts"; type PushListener = (data: unknown) => void; +type ConnectionState = "open" | "closed"; +type ConnectionStateListener = (state: ConnectionState) => void; interface PendingRequest { resolve: (result: unknown) => void; @@ -20,6 +22,7 @@ export class WsTransport { private reconnectTimer: ReturnType | null = null; private disposed = false; private readonly url: string; + private readonly connectionListeners = new Set(); constructor(url?: string) { const bridgeUrl = window.desktopBridge?.getWsUrl(); @@ -72,6 +75,17 @@ export class WsTransport { }; } + onConnectionStateChange(listener: ConnectionStateListener): () => void { + this.connectionListeners.add(listener); + if (this.ws?.readyState === WebSocket.OPEN) { + listener("open"); + } + + return () => { + this.connectionListeners.delete(listener); + }; + } + dispose() { this.disposed = true; if (this.reconnectTimer !== null) { @@ -85,6 +99,7 @@ export class WsTransport { this.pending.clear(); this.ws?.close(); this.ws = null; + this.emitConnectionState("closed"); } private connect() { @@ -95,6 +110,7 @@ export class WsTransport { ws.addEventListener("open", () => { this.ws = ws; this.reconnectAttempt = 0; + this.emitConnectionState("open"); }); ws.addEventListener("message", (event) => { @@ -103,6 +119,7 @@ export class WsTransport { ws.addEventListener("close", () => { this.ws = null; + this.emitConnectionState("closed"); this.scheduleReconnect(); }); @@ -194,4 +211,14 @@ export class WsTransport { this.connect(); }, delay); } + + private emitConnectionState(state: ConnectionState) { + for (const listener of this.connectionListeners) { + try { + listener(state); + } catch { + // Swallow listener errors + } + } + } } diff --git a/docs/provider-ws-api.md b/docs/provider-ws-api.md new file mode 100644 index 000000000000..034b2a9e1c27 --- /dev/null +++ b/docs/provider-ws-api.md @@ -0,0 +1,151 @@ +# Unified Provider WebSocket API + +This document defines the canonical provider stream API used by T3 Code. + +## Goals + +- Keep the UI provider-agnostic (`codex` now, `claudeCode` next). +- Stream only canonical events needed by product behavior. +- Support reconnect/resume safely with cursor replay. +- Keep raw provider noise out of default UI paths. + +## Request Methods + +Provider RPC methods: + +- `providers.startSession` +- `providers.sendTurn` +- `providers.interruptTurn` +- `providers.respondToApproval` +- `providers.stopSession` +- `providers.listSessions` +- `providers.openStream` +- `providers.closeStream` + +## Push Channel + +- `providers.stream` + +This is the only provider push channel. + +## Stream Open Contract + +`providers.openStream` input: + +- `afterSeq?: number` (exclusive cursor) +- `sessionIds?: string[]` (optional session filter) +- `eventKinds?: Array<"session" | "turn" | "message" | "approval" | "activity" | "error" | "debug.raw">` +- `includeExtensions?: string[]` (namespaced extension allowlist) +- `includeDebugRaw?: boolean` (default `false`) + +Result: + +- `mode: "snapshot" | "replay" | "snapshot_resync"` +- `currentSeq: number` +- `oldestSeq: number` +- `replayedCount: number` + +## Stream Frames + +`providers.stream` frames: + +- `snapshot`: authoritative state baseline +- `event`: canonical delta event +- `gap`: cursor invalid/stale/ahead/replay-limit signal + +Frame shape: + +- `kind` +- `seq` (global monotonic sequence) +- `at` (server timestamp) +- `data` (snapshot/event/gap payload) + +## Canonical Snapshot + +`ProviderSnapshot`: + +- `sessions: CanonicalSessionState[]` +- `activeTurns: CanonicalTurnState[]` +- `activeMessages: CanonicalMessageState[]` +- `pendingApprovals: CanonicalApprovalState[]` + +## Canonical Event Union + +`ProviderCoreEvent` variants: + +- `session.updated` +- `turn.started` +- `turn.completed` +- `message.delta` +- `message.completed` +- `approval.requested` +- `approval.resolved` +- `activity` +- `error` +- `debug.raw` (opt-in) + +## Codex Normalization Map + +Codex raw events are normalized by allowlist in `apps/server/src/providerEventNormalizer.ts`. + +Mapped examples: + +- `thread/started` -> `session.updated` +- `turn/started` -> `turn.started` + `session.updated` +- `turn/completed` -> `turn.completed` + `session.updated` +- `item/agentMessage/delta` -> `message.delta` +- `item/completed` (agent message) -> `message.completed` +- `item/commandExecution/requestApproval` -> `approval.requested` (`command`) +- `item/fileChange/requestApproval` -> `approval.requested` (`file_change`) +- `item/tool/requestUserInput` -> `approval.requested` (`user_input`) +- `item/requestApproval/decision` -> `approval.resolved` +- actionable `item/started` / `item/completed` -> `activity` +- `turn/plan/updated` -> `activity` (`plan` + extension) +- process/protocol/runtime errors -> `error` + +Unmapped raw methods are omitted from core stream. They are available only through `debug.raw` when `includeDebugRaw=true`. + +## Replay, Retention, and Gaps + +Server stream store behavior: + +- global monotonic `seq` +- replay retention: up to `20_000` events +- replay memory cap: `64MB` +- replay max age: `60m` +- per-open replay cap: `10_000` events + +Gap reasons: + +- `cursor_too_old` +- `cursor_ahead` +- `replay_limit_exceeded` + +When a gap is detected, server sends: + +1. `gap` frame +2. `snapshot` frame + +and `providers.openStream` returns `mode="snapshot_resync"`. + +## Backpressure and Reliability + +Per socket: + +- if `bufferedAmount > 2MB` for more than `5s`, server closes the socket with `1013` +- canonical events are not silently dropped +- clients reconnect and resume with `afterSeq` + +## Client Consumption Rules + +- Keep `lastAppliedSeq` per connection. +- Ignore any frame with `seq <= lastAppliedSeq`. +- Treat `snapshot` as authoritative baseline. +- Apply `event` frames in `seq` order. +- On `gap`, expect immediate `snapshot` resync. + +## Claude Compatibility Contract + +Claude adapter must emit only canonical events (`ProviderCoreEvent`). + +Adding Claude support must not require UI protocol changes. diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index ec007fc29fb1..870e128f5f9e 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -3,6 +3,7 @@ export * from "./ipc"; export * from "./agent"; export * from "./terminal"; export * from "./provider"; +export * from "./providerStream"; export * from "./model"; export * from "./ws"; export * from "./project"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 4138750510ce..53d12cd8d5b6 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1,14 +1,18 @@ import type { AgentConfig, AgentExit, OutputChunk } from "./agent"; import type { - ProviderEvent, ProviderInterruptTurnInput, - ProviderRespondToRequestInput, + ProviderRespondToApprovalInput, ProviderSendTurnInput, ProviderSession, ProviderSessionStartInput, ProviderStopSessionInput, ProviderTurnStartResult, } from "./provider"; +import type { + ProviderStreamFrame, + ProvidersOpenStreamInput, + ProvidersOpenStreamResult, +} from "./providerStream"; import type { ProjectAddInput, ProjectAddResult, @@ -49,10 +53,12 @@ export interface NativeApi { startSession: (input: ProviderSessionStartInput) => Promise; sendTurn: (input: ProviderSendTurnInput) => Promise; interruptTurn: (input: ProviderInterruptTurnInput) => Promise; - respondToRequest: (input: ProviderRespondToRequestInput) => Promise; + respondToApproval: (input: ProviderRespondToApprovalInput) => Promise; stopSession: (input: ProviderStopSessionInput) => Promise; listSessions: () => Promise; - onEvent: (callback: (event: ProviderEvent) => void) => () => void; + openStream: (input: ProvidersOpenStreamInput) => Promise; + closeStream: () => Promise; + onStream: (callback: (frame: ProviderStreamFrame) => void) => () => void; }; projects: { list: () => Promise; diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index 50b1f5562390..108a1627cdf9 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; import { - providerEventSchema, - providerRespondToRequestInputSchema, + providerRawEventSchema, + providerRespondToApprovalInputSchema, providerSendTurnInputSchema, providerSessionStartInputSchema, } from "./provider"; @@ -45,9 +45,9 @@ describe("providerSendTurnInputSchema", () => { }); }); -describe("providerEventSchema", () => { +describe("providerRawEventSchema", () => { it("accepts notification events with routing metadata", () => { - const parsed = providerEventSchema.parse({ + const parsed = providerRawEventSchema.parse({ id: "evt_1", kind: "notification", provider: "codex", @@ -63,7 +63,7 @@ describe("providerEventSchema", () => { }); it("accepts request approval metadata", () => { - const parsed = providerEventSchema.parse({ + const parsed = providerRawEventSchema.parse({ id: "evt_2", kind: "request", provider: "codex", @@ -78,11 +78,11 @@ describe("providerEventSchema", () => { }); }); -describe("providerRespondToRequestInputSchema", () => { +describe("providerRespondToApprovalInputSchema", () => { it("accepts valid decisions", () => { - const parsed = providerRespondToRequestInputSchema.parse({ + const parsed = providerRespondToApprovalInputSchema.parse({ sessionId: "sess_1", - requestId: "req_1", + approvalId: "approval_1", decision: "acceptForSession", }); expect(parsed.decision).toBe("acceptForSession"); @@ -90,9 +90,9 @@ describe("providerRespondToRequestInputSchema", () => { it("rejects unknown decisions", () => { expect(() => - providerRespondToRequestInputSchema.parse({ + providerRespondToApprovalInputSchema.parse({ sessionId: "sess_1", - requestId: "req_1", + approvalId: "approval_1", decision: "always", }), ).toThrow(); diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 0d0c30a1a85c..fb0243f61cd0 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -77,22 +77,22 @@ export const providerStopSessionInputSchema = z.object({ sessionId: z.string().min(1), }); -export const providerRespondToRequestInputSchema = z.object({ +export const providerRespondToApprovalInputSchema = z.object({ sessionId: z.string().min(1), - requestId: z.string().min(1), + approvalId: z.string().min(1), decision: providerApprovalDecisionSchema, }); -export const providerEventKindSchema = z.enum([ +export const providerRawEventKindSchema = z.enum([ "session", "notification", "request", "error", ]); -export const providerEventSchema = z.object({ +export const providerRawEventSchema = z.object({ id: z.string().min(1), - kind: providerEventKindSchema, + kind: providerRawEventKindSchema, provider: providerKindSchema, sessionId: z.string().min(1), createdAt: z.string().datetime(), @@ -127,8 +127,8 @@ export type ProviderInterruptTurnInput = z.input< export type ProviderStopSessionInput = z.input< typeof providerStopSessionInputSchema >; -export type ProviderRespondToRequestInput = z.input< - typeof providerRespondToRequestInputSchema +export type ProviderRespondToApprovalInput = z.input< + typeof providerRespondToApprovalInputSchema >; -export type ProviderEventKind = z.infer; -export type ProviderEvent = z.infer; +export type ProviderRawEventKind = z.infer; +export type ProviderRawEvent = z.infer; diff --git a/packages/contracts/src/providerStream.test.ts b/packages/contracts/src/providerStream.test.ts new file mode 100644 index 000000000000..d96216f6483e --- /dev/null +++ b/packages/contracts/src/providerStream.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; + +import { + providerCoreEventSchema, + providerStreamFrameSchema, + providersOpenStreamInputSchema, + providersOpenStreamResultSchema, +} from "./providerStream"; + +describe("providersOpenStreamInputSchema", () => { + it("accepts optional cursor and filters", () => { + const parsed = providersOpenStreamInputSchema.parse({ + afterSeq: 42, + sessionIds: ["sess_1"], + eventKinds: ["message", "turn"], + includeExtensions: ["codex.turn.plan"], + includeDebugRaw: true, + }); + expect(parsed.afterSeq).toBe(42); + expect(parsed.sessionIds).toEqual(["sess_1"]); + }); +}); + +describe("providerCoreEventSchema", () => { + it("accepts message delta events", () => { + const parsed = providerCoreEventSchema.parse({ + type: "message.delta", + sessionId: "sess_1", + threadId: "thr_1", + turnId: "turn_1", + messageId: "msg_1", + role: "assistant", + delta: "hello", + }); + expect(parsed.type).toBe("message.delta"); + }); + + it("accepts approval events with snake-case decisions", () => { + const parsed = providerCoreEventSchema.parse({ + type: "approval.resolved", + sessionId: "sess_1", + approvalId: "approval_1", + decision: "accept_for_session", + resolvedAt: "2026-01-01T00:00:00.000Z", + }); + expect(parsed.type).toBe("approval.resolved"); + if (parsed.type !== "approval.resolved") { + throw new Error("unexpected event variant"); + } + expect(parsed.decision).toBe("accept_for_session"); + }); +}); + +describe("providerStreamFrameSchema", () => { + it("accepts snapshot frames", () => { + const parsed = providerStreamFrameSchema.parse({ + kind: "snapshot", + seq: 100, + at: "2026-01-01T00:00:00.000Z", + data: { + sessions: [], + activeTurns: [], + activeMessages: [], + pendingApprovals: [], + }, + }); + expect(parsed.kind).toBe("snapshot"); + }); + + it("accepts open stream result payloads", () => { + const parsed = providersOpenStreamResultSchema.parse({ + mode: "replay", + currentSeq: 200, + oldestSeq: 150, + replayedCount: 10, + }); + expect(parsed.mode).toBe("replay"); + }); +}); diff --git a/packages/contracts/src/providerStream.ts b/packages/contracts/src/providerStream.ts new file mode 100644 index 000000000000..b97d429fae04 --- /dev/null +++ b/packages/contracts/src/providerStream.ts @@ -0,0 +1,294 @@ +import { z } from "zod"; + +import { providerKindSchema, providerSessionSchema } from "./provider"; + +export const providerStreamEventKindSchema = z.enum([ + "session", + "turn", + "message", + "approval", + "activity", + "error", + "debug.raw", +]); + +export const canonicalSessionStateSchema = providerSessionSchema; + +export const canonicalTurnStateSchema = z.object({ + sessionId: z.string().min(1), + threadId: z.string().min(1), + turnId: z.string().min(1), + startedAt: z.string().datetime(), + model: z.string().min(1).optional(), +}); + +export const canonicalMessageStateSchema = z.object({ + sessionId: z.string().min(1), + threadId: z.string().min(1), + turnId: z.string().min(1).optional(), + messageId: z.string().min(1), + role: z.literal("assistant"), + text: z.string(), + startedAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}); + +export const canonicalApprovalKindSchema = z.enum([ + "command", + "file_change", + "user_input", +]); + +export const canonicalApprovalStateSchema = z.object({ + sessionId: z.string().min(1), + threadId: z.string().min(1).optional(), + turnId: z.string().min(1).optional(), + approvalId: z.string().min(1), + approvalKind: canonicalApprovalKindSchema, + title: z.string().min(1), + detail: z.string().min(1).optional(), + payload: z.unknown().optional(), + timeoutAt: z.string().datetime().optional(), + requestedAt: z.string().datetime(), +}); + +export const providerCoreEventExtensionsSchema = z.record(z.string(), z.unknown()); + +export const sessionUpdatedEventSchema = z.object({ + type: z.literal("session.updated"), + session: canonicalSessionStateSchema, + extensions: providerCoreEventExtensionsSchema.optional(), +}); + +export const turnStartedEventSchema = z.object({ + type: z.literal("turn.started"), + sessionId: z.string().min(1), + threadId: z.string().min(1), + turnId: z.string().min(1), + startedAt: z.string().datetime(), + model: z.string().min(1).optional(), + extensions: providerCoreEventExtensionsSchema.optional(), +}); + +export const turnCompletedOutcomeSchema = z.enum([ + "completed", + "failed", + "interrupted", +]); + +export const turnCompletedEventSchema = z.object({ + type: z.literal("turn.completed"), + sessionId: z.string().min(1), + threadId: z.string().min(1), + turnId: z.string().min(1), + completedAt: z.string().datetime(), + outcome: turnCompletedOutcomeSchema, + error: z.string().min(1).optional(), + durationMs: z.number().nonnegative().optional(), + extensions: providerCoreEventExtensionsSchema.optional(), +}); + +export const messageDeltaEventSchema = z.object({ + type: z.literal("message.delta"), + sessionId: z.string().min(1), + threadId: z.string().min(1), + turnId: z.string().min(1).optional(), + messageId: z.string().min(1), + role: z.literal("assistant"), + delta: z.string(), + extensions: providerCoreEventExtensionsSchema.optional(), +}); + +export const messageCompletedEventSchema = z.object({ + type: z.literal("message.completed"), + sessionId: z.string().min(1), + threadId: z.string().min(1), + turnId: z.string().min(1).optional(), + messageId: z.string().min(1), + role: z.literal("assistant"), + text: z.string(), + extensions: providerCoreEventExtensionsSchema.optional(), +}); + +export const approvalRequestedEventSchema = z.object({ + type: z.literal("approval.requested"), + sessionId: z.string().min(1), + threadId: z.string().min(1).optional(), + turnId: z.string().min(1).optional(), + approvalId: z.string().min(1), + approvalKind: canonicalApprovalKindSchema, + title: z.string().min(1), + detail: z.string().min(1).optional(), + payload: z.unknown().optional(), + timeoutAt: z.string().datetime().optional(), + requestedAt: z.string().datetime(), + extensions: providerCoreEventExtensionsSchema.optional(), +}); + +export const approvalResolvedDecisionSchema = z.enum([ + "accept", + "accept_for_session", + "decline", + "cancel", + "timed_out", +]); + +export const approvalResolvedEventSchema = z.object({ + type: z.literal("approval.resolved"), + sessionId: z.string().min(1), + approvalId: z.string().min(1), + decision: approvalResolvedDecisionSchema, + resolvedAt: z.string().datetime(), + reason: z.string().min(1).optional(), + extensions: providerCoreEventExtensionsSchema.optional(), +}); + +export const activityKindSchema = z.enum(["tool", "plan", "system"]); + +export const activityStatusSchema = z.enum([ + "created", + "in_progress", + "success", + "failed", + "denied", + "timed_out", +]); + +export const activityEventSchema = z.object({ + type: z.literal("activity"), + sessionId: z.string().min(1), + threadId: z.string().min(1).optional(), + turnId: z.string().min(1).optional(), + activityId: z.string().min(1), + activityKind: activityKindSchema, + label: z.string().min(1), + detail: z.string().min(1).optional(), + status: activityStatusSchema, + startedAt: z.string().datetime().optional(), + completedAt: z.string().datetime().optional(), + durationMs: z.number().nonnegative().optional(), + extensions: providerCoreEventExtensionsSchema.optional(), +}); + +export const errorEventSchema = z.object({ + type: z.literal("error"), + sessionId: z.string().min(1).optional(), + threadId: z.string().min(1).optional(), + turnId: z.string().min(1).optional(), + code: z.string().min(1), + message: z.string().min(1), + retryable: z.boolean().optional(), + extensions: providerCoreEventExtensionsSchema.optional(), +}); + +export const debugRawEventSchema = z.object({ + type: z.literal("debug.raw"), + provider: providerKindSchema, + sessionId: z.string().min(1).optional(), + method: z.string().min(1), + payload: z.unknown(), +}); + +export const providerCoreEventSchema = z.discriminatedUnion("type", [ + sessionUpdatedEventSchema, + turnStartedEventSchema, + turnCompletedEventSchema, + messageDeltaEventSchema, + messageCompletedEventSchema, + approvalRequestedEventSchema, + approvalResolvedEventSchema, + activityEventSchema, + errorEventSchema, + debugRawEventSchema, +]); + +export const providerSnapshotSchema = z.object({ + sessions: z.array(canonicalSessionStateSchema), + activeTurns: z.array(canonicalTurnStateSchema), + activeMessages: z.array(canonicalMessageStateSchema), + pendingApprovals: z.array(canonicalApprovalStateSchema), +}); + +export const providerStreamGapReasonSchema = z.enum([ + "cursor_too_old", + "cursor_ahead", + "replay_limit_exceeded", +]); + +export const providerStreamGapSchema = z.object({ + reason: providerStreamGapReasonSchema, + oldestSeq: z.number().int().nonnegative(), + currentSeq: z.number().int().nonnegative(), +}); + +export const providerStreamFrameSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("snapshot"), + seq: z.number().int().nonnegative(), + at: z.string().datetime(), + data: providerSnapshotSchema, + }), + z.object({ + kind: z.literal("event"), + seq: z.number().int().nonnegative(), + at: z.string().datetime(), + data: providerCoreEventSchema, + }), + z.object({ + kind: z.literal("gap"), + seq: z.number().int().nonnegative(), + at: z.string().datetime(), + data: providerStreamGapSchema, + }), +]); + +export const providersOpenStreamInputSchema = z.object({ + afterSeq: z.number().int().nonnegative().optional(), + sessionIds: z.array(z.string().min(1)).optional(), + eventKinds: z.array(providerStreamEventKindSchema).optional(), + includeExtensions: z.array(z.string().min(1)).optional(), + includeDebugRaw: z.boolean().optional(), +}); + +export const providersOpenStreamModeSchema = z.enum([ + "snapshot", + "replay", + "snapshot_resync", +]); + +export const providersOpenStreamResultSchema = z.object({ + mode: providersOpenStreamModeSchema, + currentSeq: z.number().int().nonnegative(), + oldestSeq: z.number().int().nonnegative(), + replayedCount: z.number().int().nonnegative(), +}); + +export type ProviderStreamEventKind = z.infer; +export type CanonicalSessionState = z.infer; +export type CanonicalTurnState = z.infer; +export type CanonicalMessageState = z.infer; +export type CanonicalApprovalKind = z.infer; +export type CanonicalApprovalState = z.infer; +export type ProviderCoreEventExtensions = z.infer; +export type SessionUpdatedEvent = z.infer; +export type TurnStartedEvent = z.infer; +export type TurnCompletedOutcome = z.infer; +export type TurnCompletedEvent = z.infer; +export type MessageDeltaEvent = z.infer; +export type MessageCompletedEvent = z.infer; +export type ApprovalRequestedEvent = z.infer; +export type ApprovalResolvedDecision = z.infer; +export type ApprovalResolvedEvent = z.infer; +export type ActivityKind = z.infer; +export type ActivityStatus = z.infer; +export type ActivityEvent = z.infer; +export type ErrorEvent = z.infer; +export type DebugRawEvent = z.infer; +export type ProviderCoreEvent = z.infer; +export type ProviderSnapshot = z.infer; +export type ProviderStreamGapReason = z.infer; +export type ProviderStreamGap = z.infer; +export type ProviderStreamFrame = z.infer; +export type ProvidersOpenStreamInput = z.input; +export type ProvidersOpenStreamMode = z.infer; +export type ProvidersOpenStreamResult = z.infer; diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 3264ca0ebee8..8c66000eeec6 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -7,9 +7,11 @@ export const WS_METHODS = { providersStartSession: "providers.startSession", providersSendTurn: "providers.sendTurn", providersInterruptTurn: "providers.interruptTurn", - providersRespondToRequest: "providers.respondToRequest", + providersRespondToApproval: "providers.respondToApproval", providersStopSession: "providers.stopSession", providersListSessions: "providers.listSessions", + providersOpenStream: "providers.openStream", + providersCloseStream: "providers.closeStream", // Project registry methods projectsList: "projects.list", @@ -26,7 +28,7 @@ export const WS_METHODS = { // ── Push Event Channels ────────────────────────────────────────────── export const WS_CHANNELS = { - providerEvent: "providers.event", + providerStream: "providers.stream", serverWelcome: "server.welcome", } as const; From 58c10bb5ea01d2a20578399eb3d1363750fe1392 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 12 Feb 2026 00:38:28 -0800 Subject: [PATCH 2/2] progress but not really --- AGENTS.md | 5 + .../src/providerEventNormalizer.test.ts | 63 ++++ apps/server/src/providerEventNormalizer.ts | 74 +++- apps/web/src/store.test.ts | 18 + apps/web/src/store.ts | 10 +- apps/web/src/wsNativeApi.test.ts | 164 ++++++++ apps/web/src/wsNativeApi.ts | 22 +- docs/provider-ws-api.md | 351 +++++++++++++----- 8 files changed, 607 insertions(+), 100 deletions(-) create mode 100644 apps/web/src/wsNativeApi.test.ts diff --git a/AGENTS.md b/AGENTS.md index 43b5f2583950..ac7dd333dabe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,11 @@ If a tradeoff is required, choose correctness and robustness over short-term con - `apps/web`: React/Vite UI. Owns session UX, conversation/event rendering, and client-side state. Connects to the server via WebSocket. - `packages/contracts`: Shared Zod schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types. +## Provider WS API Docs Sync (Required) +- The canonical provider API spec lives at `docs/provider-ws-api.md`. +- Any change to provider WS contracts, methods/channels, canonical event shapes, normalization mappings, replay/backpressure behavior, or client apply semantics must update `docs/provider-ws-api.md` in the same PR. +- If code and docs diverge, treat it as a bug and resolve before merge. + ## Codex App Server (Important) T3 Code is currently Codex-first. The server starts `codex app-server` (JSON-RPC over stdio) per provider session, then streams structured events to the browser through WebSocket push messages. diff --git a/apps/server/src/providerEventNormalizer.test.ts b/apps/server/src/providerEventNormalizer.test.ts index dd7804df270a..04b6690c8e54 100644 --- a/apps/server/src/providerEventNormalizer.test.ts +++ b/apps/server/src/providerEventNormalizer.test.ts @@ -102,6 +102,69 @@ describe("ProviderEventNormalizer", () => { expect(normalized).toHaveLength(0); }); + it("maps message delta events when payload uses msg/message fallback fields", () => { + const normalizer = new ProviderEventNormalizer(); + + const normalized = normalizer.normalize( + makeRawEvent({ + method: "item/agentMessage/delta", + threadId: undefined, + turnId: undefined, + itemId: undefined, + payload: { + msg: { + id: "msg-1", + thread_id: "thread-1", + turn_id: "turn-1", + }, + delta: "hello", + }, + }), + makeSession(), + ); + + const delta = normalized.find((event) => event.type === "message.delta"); + expect(delta).toBeDefined(); + if (!delta || delta.type !== "message.delta") { + throw new Error("expected message.delta event"); + } + + expect(delta.messageId).toBe("msg-1"); + expect(delta.threadId).toBe("thread-1"); + expect(delta.turnId).toBe("turn-1"); + expect(delta.delta).toBe("hello"); + }); + + it("maps assistant message completion with assistantMessage type aliases", () => { + const normalizer = new ProviderEventNormalizer(); + + const normalized = normalizer.normalize( + makeRawEvent({ + method: "item/completed", + itemId: "msg_abc", + payload: { + threadId: "thread-1", + turnId: "turn-1", + item: { + id: "msg_abc", + type: "assistant_message", + text: "done", + }, + }, + }), + makeSession(), + ); + + const completed = normalized.find((event) => event.type === "message.completed"); + expect(completed).toBeDefined(); + if (!completed || completed.type !== "message.completed") { + throw new Error("expected message.completed event"); + } + + expect(completed.messageId).toBe("msg_abc"); + expect(completed.text).toBe("done"); + }); + it("creates debug.raw wrappers", () => { const normalizer = new ProviderEventNormalizer(); const raw = makeRawEvent({ method: "item/reasoning/summaryPartAdded" }); diff --git a/apps/server/src/providerEventNormalizer.ts b/apps/server/src/providerEventNormalizer.ts index 547b1010e48e..41df526acee1 100644 --- a/apps/server/src/providerEventNormalizer.ts +++ b/apps/server/src/providerEventNormalizer.ts @@ -17,14 +17,31 @@ function asBoolean(value: unknown): boolean | undefined { return typeof value === "boolean" ? value : undefined; } +function normalizeType(raw: string | undefined): string { + if (!raw) return ""; + return raw.replace(/[^a-zA-Z0-9]/g, "").toLowerCase(); +} + +function isAgentMessageType(raw: string | undefined): boolean { + const normalized = normalizeType(raw); + return normalized.includes("agentmessage") || normalized.includes("assistantmessage"); +} + function readThreadId(event: ProviderRawEvent): string | undefined { if (event.threadId) return event.threadId; const payload = asObject(event.payload); const thread = asObject(payload?.thread); + const message = asObject(payload?.message); + const msg = asObject(payload?.msg); return ( asString(payload?.threadId) ?? asString(payload?.thread_id) ?? - asString(thread?.id) + asString(payload?.conversationId) ?? + asString(thread?.id) ?? + asString(message?.threadId) ?? + asString(message?.thread_id) ?? + asString(msg?.threadId) ?? + asString(msg?.thread_id) ); } @@ -32,14 +49,36 @@ function readTurnId(event: ProviderRawEvent): string | undefined { if (event.turnId) return event.turnId; const payload = asObject(event.payload); const turn = asObject(payload?.turn); - return asString(payload?.turnId) ?? asString(payload?.turn_id) ?? asString(turn?.id); + const message = asObject(payload?.message); + const msg = asObject(payload?.msg); + return ( + asString(payload?.turnId) ?? + asString(payload?.turn_id) ?? + asString(turn?.id) ?? + asString(message?.turnId) ?? + asString(message?.turn_id) ?? + asString(msg?.turnId) ?? + asString(msg?.turn_id) + ); } function readItemId(event: ProviderRawEvent): string | undefined { if (event.itemId) return event.itemId; const payload = asObject(event.payload); const item = asObject(payload?.item); - return asString(payload?.itemId) ?? asString(payload?.item_id) ?? asString(item?.id); + const message = asObject(payload?.message); + const msg = asObject(payload?.msg); + return ( + asString(payload?.itemId) ?? + asString(payload?.item_id) ?? + asString(payload?.messageId) ?? + asString(payload?.message_id) ?? + asString(payload?.msgId) ?? + asString(payload?.msg_id) ?? + asString(item?.id) ?? + asString(message?.id) ?? + asString(msg?.id) + ); } function mapApprovalDecision(decision: string): "accept" | "accept_for_session" | "decline" | "cancel" | "timed_out" { @@ -252,7 +291,12 @@ export class ProviderEventNormalizer implements ProviderEventAdapter { } if (raw.method === "item/agentMessage/delta" && threadId && itemId) { - const delta = raw.textDelta ?? asString(payload?.delta) ?? ""; + const delta = + raw.textDelta ?? + asString(payload?.delta) ?? + asString(asObject(payload?.message)?.delta) ?? + asString(asObject(payload?.msg)?.delta) ?? + ""; if (delta.length > 0) { events.push({ type: "message.delta", @@ -268,7 +312,11 @@ export class ProviderEventNormalizer implements ProviderEventAdapter { if (raw.method === "item/completed" && threadId) { const item = asObject(payload?.item); - if (asString(item?.type) === "agentMessage") { + const itemType = asString(item?.type); + const isAgentMessage = + isAgentMessageType(itemType) || + (!!raw.itemId && raw.itemId.startsWith("msg_")); + if (isAgentMessage) { const messageId = asString(item?.id) ?? itemId; const text = asString(item?.text) ?? ""; if (messageId) { @@ -352,6 +400,22 @@ export class ProviderEventNormalizer implements ProviderEventAdapter { if (raw.method === "item/started" || raw.method === "item/completed") { const item = asObject(payload?.item); const itemType = asString(item?.type); + const itemText = asString(item?.text); + const isAgentMessage = isAgentMessageType(itemType); + if (raw.method === "item/started" && threadId && itemId && isAgentMessage) { + if (itemText && itemText.length > 0) { + events.push({ + type: "message.delta", + sessionId: raw.sessionId, + threadId, + ...(turnId ? { turnId } : {}), + messageId: itemId, + role: "assistant", + delta: itemText, + }); + } + } + if (threadId && itemId && isActionableItemType(itemType)) { events.push({ type: "activity", diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index c5881fc2cddd..4d871a0240cc 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -119,6 +119,24 @@ describe("store reducer stream integration", () => { expect(next.lastProviderSeq).toBe(1); }); + it("always applies session.updated even when thread id changes", () => { + const state = makeState(makeThread({ codexThreadId: "thr_old" })); + const next = reducer(state, { + type: "APPLY_STREAM_FRAME", + frame: makeEventFrame({ + type: "session.updated", + session: makeSession({ + threadId: "thr_new", + updatedAt: "2026-02-09T00:00:01.000Z", + }), + }), + activeAssistantMessageRef: { current: null }, + }); + + expect(next.threads[0]?.codexThreadId).toBe("thr_new"); + expect(next.threads[0]?.session?.threadId).toBe("thr_new"); + }); + it("applies snapshot frames as authoritative baseline", () => { const state = makeState( makeThread({ diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 2b853bbb98a6..586a9b6fd457 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -184,6 +184,12 @@ function eventThreadId(event: ProviderCoreEvent): string | undefined { } function shouldIgnoreForeignThreadEvent(thread: Thread, event: ProviderCoreEvent): boolean { + if (event.type === "session.updated") { + // Session snapshots are authoritative for thread identity and should never + // be blocked by stale local thread bindings. + return false; + } + const emittedThreadId = eventThreadId(event); if (!emittedThreadId) { return false; @@ -194,10 +200,6 @@ function shouldIgnoreForeignThreadEvent(thread: Thread, event: ProviderCoreEvent return false; } - if (event.type === "session.updated" && thread.session?.status === "connecting") { - return false; - } - return true; } diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts new file mode 100644 index 000000000000..a5462a2fa454 --- /dev/null +++ b/apps/web/src/wsNativeApi.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + WS_CHANNELS, + WS_METHODS, + type ProvidersOpenStreamResult, +} from "@t3tools/contracts"; + +type PushListener = (data: unknown) => void; +type ConnectionState = "open" | "closed"; +type ConnectionListener = (state: ConnectionState) => void; + +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; +} + +const transportState = { + openRequests: [] as Array<{ params: unknown }>, + closeRequests: [] as Array<{ params: unknown }>, + openDeferredQueue: [] as Array>>, + latest: null as MockWsTransport | null, + reset() { + this.openRequests = []; + this.closeRequests = []; + this.openDeferredQueue = []; + this.latest = null; + }, +}; + +class MockWsTransport { + private readonly listeners = new Map>(); + private readonly connectionListeners = new Set(); + + constructor() { + transportState.latest = this; + } + + request(method: string, params?: unknown): Promise { + if (method === WS_METHODS.providersOpenStream) { + transportState.openRequests.push({ params }); + const deferred = transportState.openDeferredQueue.shift(); + if (!deferred) { + return Promise.resolve({ + mode: "snapshot", + currentSeq: 0, + oldestSeq: 0, + replayedCount: 0, + } as T); + } + return deferred.promise as Promise; + } + + if (method === WS_METHODS.providersCloseStream) { + transportState.closeRequests.push({ params }); + return Promise.resolve(undefined as T); + } + + return Promise.resolve(undefined as T); + } + + subscribe(channel: string, listener: PushListener): () => void { + const listeners = this.listeners.get(channel) ?? new Set(); + listeners.add(listener); + this.listeners.set(channel, listeners); + + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + this.listeners.delete(channel); + } + }; + } + + onConnectionStateChange(listener: ConnectionListener): () => void { + this.connectionListeners.add(listener); + return () => { + this.connectionListeners.delete(listener); + }; + } + + emitPush(channel: string, data: unknown): void { + const listeners = this.listeners.get(channel); + if (!listeners) { + return; + } + + for (const listener of listeners) { + listener(data); + } + } +} + +vi.mock("./wsTransport", () => ({ + WsTransport: MockWsTransport, +})); + +async function flushMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe("createWsNativeApi stream lifecycle", () => { + beforeEach(() => { + vi.resetModules(); + transportState.reset(); + }); + + it("does not close the stream when open is in flight and a listener re-subscribes", async () => { + const openDeferred = createDeferred(); + transportState.openDeferredQueue.push(openDeferred); + + const { createWsNativeApi } = await import("./wsNativeApi"); + const api = createWsNativeApi(); + + const frames: unknown[] = []; + const unsubscribeA = api.providers.onStream((frame) => { + frames.push(frame); + }); + + expect(transportState.openRequests).toHaveLength(1); + + unsubscribeA(); + expect(transportState.closeRequests).toHaveLength(0); + + const unsubscribeB = api.providers.onStream((frame) => { + frames.push(frame); + }); + expect(transportState.openRequests).toHaveLength(1); + + openDeferred.resolve({ + mode: "snapshot", + currentSeq: 10, + oldestSeq: 1, + replayedCount: 0, + }); + await flushMicrotasks(); + + expect(transportState.closeRequests).toHaveLength(0); + + transportState.latest?.emitPush(WS_CHANNELS.providerStream, { + kind: "event", + seq: 11, + at: "2026-02-10T08:30:00.000Z", + data: { + type: "error", + code: "runtime/error", + message: "boom", + }, + }); + + expect(frames).toHaveLength(1); + + unsubscribeB(); + await flushMicrotasks(); + + expect(transportState.closeRequests).toHaveLength(1); + }); +}); diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 2d5e6d2c8d6e..c3cdff7725ae 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -68,6 +68,7 @@ export function createWsNativeApi(): NativeApi { let streamOpenInFlight: Promise | null = null; let streamOpen = false; let streamOpening = false; + let pendingCloseAfterOpen = false; const openStream = async ( overrides?: ProvidersOpenStreamInput, @@ -89,7 +90,17 @@ export function createWsNativeApi(): NativeApi { streamOpenInFlight = transport .request(WS_METHODS.providersOpenStream, openInput) .then((result) => { - streamOpen = true; + const hasActiveListeners = providerStreamListeners.size > 0; + const shouldCloseAfterOpen = pendingCloseAfterOpen && !hasActiveListeners; + + streamOpen = !shouldCloseAfterOpen; + pendingCloseAfterOpen = false; + + if (shouldCloseAfterOpen) { + void transport.request(WS_METHODS.providersCloseStream).catch(() => { + // Ignore close errors while reconnect logic restores transport. + }); + } if (result.mode !== "replay") { // Resync modes can legally move the cursor backwards after server restart. @@ -110,6 +121,7 @@ export function createWsNativeApi(): NativeApi { if (providerStreamListeners.size === 0 || streamOpen) { return; } + pendingCloseAfterOpen = false; void openStream().catch(() => { // Ignore open failures. Reconnect lifecycle retries automatically. @@ -204,17 +216,25 @@ export function createWsNativeApi(): NativeApi { listSessions: () => transport.request(WS_METHODS.providersListSessions), openStream: (input) => openStream(input), closeStream: async () => { + pendingCloseAfterOpen = false; streamOpen = false; await transport.request(WS_METHODS.providersCloseStream); }, onStream: (callback) => { providerStreamListeners.add(callback); + pendingCloseAfterOpen = false; ensureStreamOpen(); return () => { providerStreamListeners.delete(callback); if (providerStreamListeners.size === 0) { + if (streamOpenInFlight) { + pendingCloseAfterOpen = true; + return; + } + streamOpen = false; + pendingCloseAfterOpen = false; void transport.request(WS_METHODS.providersCloseStream).catch(() => { // Ignore close errors while transport reconnects. }); diff --git a/docs/provider-ws-api.md b/docs/provider-ws-api.md index 034b2a9e1c27..4ea270ae1140 100644 --- a/docs/provider-ws-api.md +++ b/docs/provider-ws-api.md @@ -1,17 +1,51 @@ # Unified Provider WebSocket API -This document defines the canonical provider stream API used by T3 Code. +This document is the canonical spec for the provider WebSocket API in T3 Code. -## Goals +## Status -- Keep the UI provider-agnostic (`codex` now, `claudeCode` next). -- Stream only canonical events needed by product behavior. -- Support reconnect/resume safely with cursor replay. -- Keep raw provider noise out of default UI paths. +- `providers.stream` is the only provider push channel. +- Stream model is `snapshot + delta` with cursor replay (`afterSeq`). +- Server normalizes provider-native events (currently Codex app-server raw events) into canonical UI events. +- Debug/raw provider events are opt-in. -## Request Methods +## Source-of-Truth Files -Provider RPC methods: +These files define behavior and must stay in sync with this document: + +- Contracts: + - `packages/contracts/src/providerStream.ts` + - `packages/contracts/src/ws.ts` + - `packages/contracts/src/ipc.ts` + - `packages/contracts/src/provider.ts` +- Server: + - `apps/server/src/providerEventNormalizer.ts` + - `apps/server/src/providerStreamStore.ts` + - `apps/server/src/providerStreamSubscriptionManager.ts` + - `apps/server/src/wsServer.ts` +- Web client: + - `apps/web/src/wsNativeApi.ts` + - `apps/web/src/store.ts` + - `apps/web/src/session-logic.ts` + +## Synchronization Policy (Required) + +Any change to provider WS contracts or behavior must update this doc in the same PR. + +Changes that require doc updates include: + +- New/removed/renamed provider RPC methods. +- New/removed channels or frame shapes. +- Changes to canonical event union or snapshot state fields. +- Normalization mapping changes. +- Replay retention or backpressure threshold changes. +- Client apply/dedupe/resync behavior changes. + +If code and docs diverge, treat it as a bug. + +## Wire Surface + +### Request Methods - `providers.startSession` - `providers.sendTurn` @@ -22,54 +56,84 @@ Provider RPC methods: - `providers.openStream` - `providers.closeStream` -## Push Channel +### Push Channels - `providers.stream` - -This is the only provider push channel. - -## Stream Open Contract - -`providers.openStream` input: - -- `afterSeq?: number` (exclusive cursor) -- `sessionIds?: string[]` (optional session filter) -- `eventKinds?: Array<"session" | "turn" | "message" | "approval" | "activity" | "error" | "debug.raw">` -- `includeExtensions?: string[]` (namespaced extension allowlist) -- `includeDebugRaw?: boolean` (default `false`) - -Result: - -- `mode: "snapshot" | "replay" | "snapshot_resync"` -- `currentSeq: number` -- `oldestSeq: number` -- `replayedCount: number` - -## Stream Frames - -`providers.stream` frames: - -- `snapshot`: authoritative state baseline -- `event`: canonical delta event -- `gap`: cursor invalid/stale/ahead/replay-limit signal - -Frame shape: - -- `kind` -- `seq` (global monotonic sequence) -- `at` (server timestamp) -- `data` (snapshot/event/gap payload) +- `server.welcome` (non-provider channel) + +`providers.event` is removed. + +## Stream Contracts + +### `providers.openStream` Input + +```ts +type ProvidersOpenStreamInput = { + afterSeq?: number; + sessionIds?: string[]; + eventKinds?: Array< + | "session" + | "turn" + | "message" + | "approval" + | "activity" + | "error" + | "debug.raw" + >; + includeExtensions?: string[]; + includeDebugRaw?: boolean; +}; +``` + +Defaults: + +- `afterSeq`: not set +- `sessionIds`: all sessions +- `eventKinds`: all kinds +- `includeExtensions`: none +- `includeDebugRaw`: `false` + +### `providers.openStream` Result + +```ts +type ProvidersOpenStreamResult = { + mode: "snapshot" | "replay" | "snapshot_resync"; + currentSeq: number; + oldestSeq: number; + replayedCount: number; +}; +``` + +### `providers.stream` Frames + +```ts +type ProviderStreamFrame = + | { kind: "snapshot"; seq: number; at: string; data: ProviderSnapshot } + | { kind: "event"; seq: number; at: string; data: ProviderCoreEvent } + | { + kind: "gap"; + seq: number; + at: string; + data: { + reason: "cursor_too_old" | "cursor_ahead" | "replay_limit_exceeded"; + oldestSeq: number; + currentSeq: number; + }; + }; +``` ## Canonical Snapshot -`ProviderSnapshot`: - -- `sessions: CanonicalSessionState[]` -- `activeTurns: CanonicalTurnState[]` -- `activeMessages: CanonicalMessageState[]` -- `pendingApprovals: CanonicalApprovalState[]` +```ts +type ProviderSnapshot = { + sessions: CanonicalSessionState[]; + activeTurns: CanonicalTurnState[]; + activeMessages: CanonicalMessageState[]; + pendingApprovals: CanonicalApprovalState[]; +}; +``` -## Canonical Event Union +## Canonical Events `ProviderCoreEvent` variants: @@ -82,70 +146,177 @@ Frame shape: - `approval.resolved` - `activity` - `error` -- `debug.raw` (opt-in) +- `debug.raw` -## Codex Normalization Map +### Event Invariants -Codex raw events are normalized by allowlist in `apps/server/src/providerEventNormalizer.ts`. +- Stream ordering is by increasing `seq`. +- `snapshot` is authoritative baseline state. +- `message.delta` and `message.completed` are assistant-only. +- `approval.resolved.decision` is snake_case: + - `accept` + - `accept_for_session` + - `decline` + - `cancel` + - `timed_out` +- `debug.raw` is never required for primary UI behavior. -Mapped examples: +## Server Pipeline + +### Flow + +1. `CodexAppServerManager` emits `ProviderRawEvent`. +2. `ProviderEventNormalizer` maps raw -> canonical allowlist events. +3. `ProviderStreamStore` assigns global `seq`, applies snapshot state, stores replay log. +4. `ProviderStreamSubscriptionManager` filters and pushes frames per socket. +5. `wsServer` exposes open/close RPC methods and broadcasts stream frames. + +### Unmapped Raw Methods + +- Unmapped methods produce no core canonical event. +- For unmapped methods, server emits `debug.raw` into the canonical stream pipeline. +- Subscribers only receive `debug.raw` if `includeDebugRaw=true` and filters allow it. + +## Codex Normalization Map + +Implemented in `apps/server/src/providerEventNormalizer.ts`. - `thread/started` -> `session.updated` -- `turn/started` -> `turn.started` + `session.updated` -- `turn/completed` -> `turn.completed` + `session.updated` +- `turn/started` -> `turn.started` + `session.updated(status=running)` +- `turn/completed` -> `turn.completed` + `session.updated(status=ready|error)` - `item/agentMessage/delta` -> `message.delta` -- `item/completed` (agent message) -> `message.completed` -- `item/commandExecution/requestApproval` -> `approval.requested` (`command`) -- `item/fileChange/requestApproval` -> `approval.requested` (`file_change`) -- `item/tool/requestUserInput` -> `approval.requested` (`user_input`) +- `item/completed` with `item.type=agentMessage` -> `message.completed` +- `item/commandExecution/requestApproval` -> `approval.requested(approvalKind=command)` +- `item/fileChange/requestApproval` -> `approval.requested(approvalKind=file_change)` +- `item/tool/requestUserInput` -> `approval.requested(approvalKind=user_input)` - `item/requestApproval/decision` -> `approval.resolved` - actionable `item/started` / `item/completed` -> `activity` -- `turn/plan/updated` -> `activity` (`plan` + extension) -- process/protocol/runtime errors -> `error` +- `turn/plan/updated` -> `activity(activityKind=plan)` + `extensions["codex.turn.plan"]` +- provider/runtime/protocol/process error signals -> `error` + +Notes: + +- `requestKind` raw values can be legacy (`file-change`) and are normalized at canonical layer (`file_change`). +- User-input requests are auto-answered in Codex manager and represented canonically as approval request/resolution semantics. + +## Replay and Cursor Semantics + +Implemented in `apps/server/src/providerStreamStore.ts` and `apps/server/src/providerStreamSubscriptionManager.ts`. + +### Sequence Rules + +- `seq` is global, process-local, monotonic. +- First emitted event has `seq=1`. +- `currentSeq=0` before first event. + +### Retention Limits + +- Max replay events: `20_000` +- Max replay bytes: `64MB` +- Max replay age: `60m` +- Max replay delivery per `openStream`: `10_000` + +### Cursor Validation + +Given `afterSeq`: + +- `afterSeq > currentSeq` -> `gap(reason=cursor_ahead)` + snapshot resync +- `afterSeq < oldestSeq - 1` -> `gap(reason=cursor_too_old)` + snapshot resync +- missing events count `> 10_000` -> `gap(reason=replay_limit_exceeded)` + snapshot resync +- otherwise -> replay only missing events (`seq > afterSeq`) + +### `openStream` Modes -Unmapped raw methods are omitted from core stream. They are available only through `debug.raw` when `includeDebugRaw=true`. +- `snapshot`: no cursor provided +- `replay`: valid cursor replay path +- `snapshot_resync`: invalid/stale/ahead/excessive cursor -## Replay, Retention, and Gaps +### Push Order on Resync -Server stream store behavior: +For resync paths server sends: -- global monotonic `seq` -- replay retention: up to `20_000` events -- replay memory cap: `64MB` -- replay max age: `60m` -- per-open replay cap: `10_000` events +1. `gap` +2. `snapshot` -Gap reasons: +## Filtering and Extensions -- `cursor_too_old` -- `cursor_ahead` -- `replay_limit_exceeded` +Per-socket filters (set by `openStream` input): -When a gap is detected, server sends: +- `sessionIds`: include only events/snapshot state for listed sessions +- `eventKinds`: include only selected canonical kinds +- `includeDebugRaw`: additionally required for `debug.raw` +- `includeExtensions`: allowlist of extension keys to keep -1. `gap` frame -2. `snapshot` frame +Extension behavior: -and `providers.openStream` returns `mode="snapshot_resync"`. +- Events with `extensions` drop all extensions by default. +- If `includeExtensions` is set, only requested keys are retained. -## Backpressure and Reliability +## Backpressure Behavior Per socket: -- if `bufferedAmount > 2MB` for more than `5s`, server closes the socket with `1013` -- canonical events are not silently dropped -- clients reconnect and resume with `afterSeq` +- If `bufferedAmount > 2MB` for more than `5s`, server closes socket with `1013`. +- Server does not silently drop canonical events for active subscribers. +- Client is expected to reconnect and resume via `afterSeq`. -## Client Consumption Rules +## Client Behavior -- Keep `lastAppliedSeq` per connection. -- Ignore any frame with `seq <= lastAppliedSeq`. -- Treat `snapshot` as authoritative baseline. -- Apply `event` frames in `seq` order. -- On `gap`, expect immediate `snapshot` resync. +Implemented in: + +- `apps/web/src/wsNativeApi.ts` +- `apps/web/src/store.ts` + +### Transport + Stream Lifecycle + +- Web client subscribes to `providers.stream` push channel. +- On connection open, it auto-opens stream if there are stream listeners. +- It reuses `lastAppliedSeq` for resume (`afterSeq`). + +### Dedupe and Apply Rules + +- Ignore frames with `seq <= lastAppliedSeq`. +- Apply `snapshot` as authoritative state reset for matching sessions. +- Apply `event` deltas in `seq` order. +- `gap` is a signal frame; subsequent snapshot is authoritative. + +## Process Restart Semantics + +- Replay guarantees are process-lifetime scoped. +- On server restart, `seq` and replay log reset. +- Clients recover by opening stream and applying snapshot baseline. ## Claude Compatibility Contract -Claude adapter must emit only canonical events (`ProviderCoreEvent`). +Future Claude adapter requirements: + +- Adapter emits only canonical `ProviderCoreEvent`. +- No UI protocol changes for adding Claude support. + +## Testing Expectations + +Required coverage when changing the API: + +- Contracts parse tests (`providerStream`, method/channel constants). +- Server tests for: + - open snapshot path + - replay path + - stale/ahead/excessive cursor -> gap + snapshot + - session/event/debug filtering + - backpressure close behavior + - normalizer mapping coverage +- Web tests for: + - stream reducer/snapshot behavior + - reconnect/dedupe logic + - approvals/messages/turn lifecycle from canonical events + +## Change Checklist (PR Gate) + +For any provider WS API change: -Adding Claude support must not require UI protocol changes. +1. Update contracts in `packages/contracts`. +2. Update this doc (`docs/provider-ws-api.md`). +3. Update server normalizer/store/subscriptions as needed. +4. Update client stream/reducer logic as needed. +5. Update tests in contracts/server/web. +6. Run `bun run typecheck` and `bun run test`.