From 43877f4719a02574fca59bd0d8daee7a248f2b38 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Fri, 28 Aug 2026 04:02:27 +0200 Subject: [PATCH 01/16] Define CodexWebUI 1.0 equality contract --- README.md | 4 + docs/web-1.0-contract.md | 249 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 docs/web-1.0-contract.md diff --git a/README.md b/README.md index 836daf9..1bd091e 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,10 @@ geometry, shell-output, Inspector, and desktop-integration decisions are documented in [`docs/ui-behavior.md`](docs/ui-behavior.md). +The browser architecture, native/web parity boundary, state ownership, and +version 1.0 delivery gates are documented in +[`docs/web-1.0-contract.md`](docs/web-1.0-contract.md). + ## License CodexUI is available under the LGPL-3.0-or-later OR MIT dual license. diff --git a/docs/web-1.0-contract.md b/docs/web-1.0-contract.md new file mode 100644 index 0000000..34adaca --- /dev/null +++ b/docs/web-1.0-contract.md @@ -0,0 +1,249 @@ +# CodexWebUI 1.0 Contract + +## Purpose + +CodexWebUI is the browser presentation of the existing CodexUI product. It is +not another backend and does not replace or reimplement `codex-bridge`. +Version 1.0 requires a production-built browser application that can perform +the complete web parity scope below against the canonical bridge. A web +feature is complete only after it independently proves behavioral equality +with the corresponding current C++ feature. + +The native Qt application remains supported. Native and browser frontends may +be connected to the same bridge at the same time and follow the same +controller/observer rules. + +## Fixed architecture + +```text +CodexWebUI (React presentation) + -> TypeScript presentation projection + -> framework-neutral TypeScript frontend SDK + -> browser WebSocket using the `codex` subprotocol + -> existing codex-bridge + -> Codex app-server +``` + +There is no web-specific application server, proxy controller, repository, +cache, event bus, or retained Codex-domain store. + +`codex-bridge` remains the only multi-frontend router. Its C++ implementation +continues to own controller policy, observer restrictions, upstream request-ID +translation, response ownership, server-request routing, provider lifecycle, +and notification fanout. None of that server-side routing is copied into +TypeScript. + +The TypeScript SDK implements only frontend responsibilities already present +in `ai::openai::codex::frontend::CodexBridge` and `ClientConnection`: + +- bridge-envelope classification and connection telemetry; +- frontend request/response correlation; +- notification and server-request dispatch; +- provider-generation and disconnect retirement; +- controller claim, release, and transfer commands; +- one attached browser WebSocket transport and its lifecycle. + +## Repository boundary + +AISuite owns a publishable, framework-neutral package: + +```text +packages/codex-frontend/ + src/protocol/generated.ts + src/protocol/envelope.ts + src/CodexBridgeClient.ts + src/WebSocketTransport.ts + src/errors.ts + src/index.ts +``` + +Its working package name is `@snodec/codex-frontend`. The existing pinned +Codex schema and generator are the only source for generated C++ and +TypeScript protocol declarations. + +CodexUI owns the browser application: + +```text +web/ + src/presentation/ + src/conversation/ + src/components/ + src/app/ +``` + +The web presentation normalizer and reducer live here because they express +CodexUI behavior, not bridge semantics. During development and CI CodexUI pins +the AISuite package to an exact revision, as the native build already does for +the C++ package. + +## Frontend technology + +CodexWebUI is a client-rendered TypeScript application using React and Vite. +It does not require server rendering: the product is an interactive remote +client, has no public content routes, and acquires its state after connecting +to a bridge. + +The initial application has one shell and no URL-routing dependency. Thread, +Inspector, disclosure, selection, draft, and scroll state are application +state, not pages. A router is added only if a later user-facing navigation +requirement proves one necessary. + +The application uses: + +- React components for compositional presentation and keyed card identity; +- ordinary CSS with CodexUI-owned tokens for visual styling; +- Vitest for SDK, reducer, projection, and component tests; +- Playwright for browser-level workflows and supported-browser qualification; +- npm lockfiles and reproducible production builds. + +No general-purpose state framework is introduced. A typed presentation model +is the sole retained normalized store. React subscribes to snapshots from that +model; components retain only interaction state they own. + +## Presentation boundary + +The semantic vocabulary and authority rules of `codexui.presentation` version +1 remain the shared native/web contract. In the browser they form an internal +TypeScript boundary rather than an additional network hop: + +```text +bridge/app-server input + -> TypeScript normalizer + -> codexui.presentation frame + -> TypeScript presentation model + -> typed visible projections + -> React +``` + +The TypeScript implementation must match the existing rules for stable IDs, +merge/replace/remove authority, generation retirement, unknown events, +incomplete reconstruction, child-thread ownership, and ordered items. Shared +JSON fixtures verify equivalent C++ and TypeScript normalization/reduction. + +Equality is judged by observable behavior and state transitions, not source +structure or pixel identity. For the same ordered inputs, native and web must +produce equivalent commands, authority decisions, retained identities, +ordering, lifecycle outcomes, visible projections, and user-action +eligibility. Platform-native geometry may differ only where the behavioral +contract explicitly permits it. + +The presentation model is an in-memory view of app-server publications. It is +not persistence or semantic authority and is rebuilt by discovery and +hydration after a new connection generation. + +## Version 1.0 parity + +### Required + +The following behavior is required before the combined product is called +version 1.0. + +| Area | Required web behavior | +| --- | --- | +| Connection | Configure a WebSocket URL, connect, disconnect, reconnect, and present bridge/provider state | +| Roles | Present controller/observer state and claim or release control | +| Threads | Discover, sort, select, hydrate, reload, create, rename, fork, archive, unarchive, and delete | +| Hierarchy | Preserve parent/child ownership and expandable thread branches | +| Turns | Start, steer, interrupt, resume when required, and recover after provider generations | +| Composer | Retain drafts, admit prompts locally, queue independently per thread, and acknowledge by correlated result | +| Settings | Model, reasoning, access, network, workspace path, approval, personality, profile, reviewer, service tier, summary, and collaboration mode | +| Conversation | User, assistant, reasoning, command, file-change, agent, image-metadata, plan, and fallback activity cards | +| Streaming | Update retained cards without reordering, rebuilding unchanged cards, or moving a paused reading position | +| Attention | Present and resolve every supported pending-request category | +| Inspector | Plan, Agents, Requests, State, and Protocol views | +| Diagnostics | Bounded notices and human-readable event labels without changing protocol values | +| Usability | Responsive desktop layout, keyboard operation, accessible names, contrast, and reduced-motion behavior | + +Prompt admission, stable first-response placement, per-thread scroll ownership, +card folding, safe Markdown, bounded command output, controller eligibility, +and reconnect hydration follow the native behavior documents. Visual geometry +may be browser-native; the behavioral invariant may not silently change. + +### Browser-specific representation + +These capabilities remain required but use an honest browser representation: + +- Workspace is an app-server path entered or selected from provider-supplied + data; it is not inferred from the browser machine. +- File-change conversation cards show the app-server publication. +- Image items show status and saved-path metadata when the saved file is not + available through a browser URL. +- Connection configuration exposes WebSocket/WSS only. + +### Native-only in 1.0 + +The following Qt capabilities depend on direct access to the native machine +and are explicitly outside browser parity: + +- Unix, raw TCP/TLS, IPv6, and RFCOMM frontend transports; +- the native filesystem picker and local-path attachment admission; +- libgit2 worktree discovery, filesystem watching, local Changes snapshots, + and the modeless native diff window; +- opening provider-side files with a desktop application; +- desktop entry, window manager, taskbar, and native icon integration; +- direct display of provider-local generated-image files. + +These exclusions must not be represented by controls that appear functional. +Adding remote file transfer, an asset endpoint, or provider-side Git service +would require a separately designed protocol capability and is not part of the +web implementation. + +## State ownership + +| State | Owner | +| --- | --- | +| Codex account, configuration, thread, turn, item, plan, request, and persistence | Codex app-server | +| Multi-client routing, provider generation, controller policy, request ownership | `codex-bridge` | +| JSON-RPC callbacks and connection snapshot | TypeScript frontend SDK | +| Normalized threads, turns, items, plans, agents, requests, and telemetry | Web presentation model | +| Selected thread/tab, drafts, folding, scroll anchors, focus, transient menus | React application | + +State never appears in two owners in the same layer. Components receive typed +projections and commands; they do not parse app-server methods or retain a +second copy of normalized domain collections. + +## Delivery commits and equality gates + +1. **Contract and parity**: this document fixes scope, ownership, repository + boundaries, and frontend structure. +2. **Generated TypeScript protocol**: deterministic TypeScript output from the + existing pinned schema, with generator drift tests. +3. **TypeScript frontend SDK**: frontend proxy and browser WebSocket transport, + with the C++ SDK lifecycle/routing invariants ported as tests. +4. **Web presentation model**: normalizer, reducer, command facade, and shared + native/web fixture corpus. +5. **Core web shell**: connection, thread navigation, conversation, composer, + streaming, and controller state. +6. **Advanced conversation behavior**: prompt admission, scrolling, folding, + tools, reasoning, agents, plans, Markdown, and interruption. +7. **Required supporting surfaces**: settings, pending requests, complete + thread actions, and the required Inspector tabs. +8. **Qualification**: browser workflows, resilience, accessibility, + responsive behavior, and measured large-thread/streaming performance. +9. **Release**: production packaging, documentation, complete native and web + suites, and the version 1.0 checklist. + +Every implementation commit must be independently reviewable and must add the +focused equality proof for the behavior it introduces. Depending on the layer, +that proof is one or more of: + +- the same JSON fixture corpus executed by C++ and TypeScript; +- equivalent C++ and TypeScript request/lifecycle scenario tests; +- visible-projection snapshots derived from the same normalized state; +- browser interaction tests that assert the corresponding native behavior + contract rather than only DOM structure. + +A commit is not complete while its focused equality test, the accumulated web +suite, or the repository's complete native suite fails. Cross-repository +contract changes pin compatible revisions and test both sides before either +dependency is advanced. + +## Explicit exclusions + +- a JavaScript/TypeScript port of the server-side `codex-bridge` router; +- a Node application server or browser-specific backend; +- protocol-value changes made for presentation convenience; +- duplicated controller, repository, provider, or Codex-domain state; +- speculative caches, controllers, repositories, or event systems; +- URL routing without an actual navigation requirement; +- native-only controls with simulated or incomplete browser behavior. From 738ec0716106f186cff591bdb07e41203c530ac0 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Fri, 28 Aug 2026 04:25:41 +0200 Subject: [PATCH 02/16] Port C++ presentation protocol normalizer --- web/.gitignore | 2 + web/package-lock.json | 24 + web/package.json | 13 + web/src/index.ts | 2 + web/src/presentation/PresentationProtocol.ts | 141 ++++++ web/src/presentation/ProtocolNormalizer.ts | 493 +++++++++++++++++++ web/tests/normalizer-parity.test.mjs | 321 ++++++++++++ web/tsconfig.json | 21 + 8 files changed, 1017 insertions(+) create mode 100644 web/.gitignore create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/src/index.ts create mode 100644 web/src/presentation/PresentationProtocol.ts create mode 100644 web/src/presentation/ProtocolNormalizer.ts create mode 100644 web/tests/normalizer-parity.test.mjs create mode 100644 web/tsconfig.json diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..3d2bc62 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,2 @@ +/dist/ +/node_modules/ diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..9cb893d --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,24 @@ +{ + "name": "@snodec/codex-ui-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@snodec/codex-ui-web", + "version": "0.1.0", + "dependencies": { + "@snodec/codex-frontend": "file:../../../AISuite-extraction/AISuite-final/packages/codex-frontend" + } + }, + "../../../AISuite-extraction/AISuite-final/packages/codex-frontend": { + "name": "@snodec/codex-frontend", + "version": "0.1.0", + "license": "LGPL-3.0-or-later OR MIT" + }, + "node_modules/@snodec/codex-frontend": { + "resolved": "../../../AISuite-extraction/AISuite-final/packages/codex-frontend", + "link": true + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..1ce18c0 --- /dev/null +++ b/web/package.json @@ -0,0 +1,13 @@ +{ + "name": "@snodec/codex-ui-web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "npm run build && node --test tests/*.test.mjs" + }, + "dependencies": { + "@snodec/codex-frontend": "file:../../../AISuite-extraction/AISuite-final/packages/codex-frontend" + } +} diff --git a/web/src/index.ts b/web/src/index.ts new file mode 100644 index 0000000..0510843 --- /dev/null +++ b/web/src/index.ts @@ -0,0 +1,2 @@ +export * from "./presentation/PresentationProtocol.js"; +export * from "./presentation/ProtocolNormalizer.js"; diff --git a/web/src/presentation/PresentationProtocol.ts b/web/src/presentation/PresentationProtocol.ts new file mode 100644 index 0000000..4f61713 --- /dev/null +++ b/web/src/presentation/PresentationProtocol.ts @@ -0,0 +1,141 @@ +export const PresentationProtocolName = "codexui.presentation"; +export const PresentationProtocolVersion = 1; + +export type Authority = "none" | "merge" | "replace" | "remove"; +export type JsonObject = Record; + +export interface PresentationFrame extends JsonObject { + protocol: typeof PresentationProtocolName; + version: typeof PresentationProtocolVersion; + kind: "command" | "event" | "result"; +} + +function baseFrame(kind: PresentationFrame["kind"]): PresentationFrame { + return { + protocol: PresentationProtocolName, + version: PresentationProtocolVersion, + kind, + }; +} + +function addAuthorityAndScope( + frame: PresentationFrame, + authority: Authority, + scope: JsonObject, +): void { + frame.authority = authority; + if (Object.keys(scope).length > 0) frame.scope = scope; +} + +export function command( + action: string, + data: JsonObject = {}, + correlationId = "", +): PresentationFrame { + const frame = baseFrame("command"); + frame.action = action; + frame.data = data; + if (correlationId !== "") frame.correlationId = correlationId; + return frame; +} + +export function result( + sequence: number, + generation: number, + action: string, + correlationId: string, + ok: boolean, + data: unknown, + authority: Authority = "none", + scope: JsonObject = {}, +): PresentationFrame { + const frame = baseFrame("result"); + frame.sequence = sequence; + frame.generation = generation; + frame.action = action; + frame.correlationId = correlationId; + frame.ok = ok; + frame[ok ? "data" : "error"] = data; + addAuthorityAndScope(frame, authority, scope); + return frame; +} + +export function event( + sequence: number, + generation: number, + type: string, + data: JsonObject = {}, + authority: Authority = "none", + scope: JsonObject = {}, +): PresentationFrame { + const frame = baseFrame("event"); + frame.sequence = sequence; + frame.generation = generation; + frame.type = type; + frame.data = data; + addAuthorityAndScope(frame, authority, scope); + return frame; +} + +export function isObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isUnsigned(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 0; +} + +export function isPresentationFrame(value: unknown): value is PresentationFrame { + if (!isObject(value)) return false; + if ( + value.protocol !== PresentationProtocolName + || value.version !== PresentationProtocolVersion + || typeof value.kind !== "string" + ) { + return false; + } + const stringField = (name: string): boolean => + typeof value[name] === "string" && value[name] !== ""; + if (value.kind === "command") { + return stringField("action") && isObject(value.data); + } + if (value.kind !== "event" && value.kind !== "result") return false; + if ( + !isUnsigned(value.sequence) + || !isUnsigned(value.generation) + || (value.authority !== "none" + && value.authority !== "merge" + && value.authority !== "replace" + && value.authority !== "remove") + || (Object.hasOwn(value, "scope") && !isObject(value.scope)) + ) { + return false; + } + if (value.kind === "event") { + return stringField("type") && isObject(value.data); + } + if ( + !stringField("action") + || !stringField("correlationId") + || typeof value.ok !== "boolean" + ) { + return false; + } + return value.ok + ? Object.hasOwn(value, "data") && !Object.hasOwn(value, "error") + : Object.hasOwn(value, "error") && !Object.hasOwn(value, "data"); +} + +export function stringMember(value: unknown, name: string): string { + return isObject(value) && typeof value[name] === "string" + ? value[name] + : ""; +} + +export function member( + value: unknown, + name: string, + fallback: unknown = null, +): unknown { + return isObject(value) && Object.hasOwn(value, name) ? value[name] : fallback; +} diff --git a/web/src/presentation/ProtocolNormalizer.ts b/web/src/presentation/ProtocolNormalizer.ts new file mode 100644 index 0000000..15c8fda --- /dev/null +++ b/web/src/presentation/ProtocolNormalizer.ts @@ -0,0 +1,493 @@ +import { + classifyJsonRpc, + jsonRpcMethod, + serverNotificationOperations, + serverRequestOperations, +} from "@snodec/codex-frontend"; + +import { + event, + isObject, + member, + result, + stringMember, +} from "./PresentationProtocol.js"; +import type { + Authority, + JsonObject, + PresentationFrame, +} from "./PresentationProtocol.js"; + +export type PresentationSink = (frame: PresentationFrame) => boolean; + +const stableScopeKeys = [ + "threadId", + "turnId", + "itemId", + "processId", + "requestId", +] as const; + +function stableScope(value: unknown): JsonObject { + const scope: JsonObject = {}; + if (!isObject(value)) return scope; + for (const key of stableScopeKeys) { + if (Object.hasOwn(value, key) && value[key] !== null) scope[key] = value[key]; + } + return scope; +} + +function errorValue(response: JsonObject): unknown { + return Object.hasOwn(response, "error") + ? response.error + : {code: -32000, message: "operation failed"}; +} + +function requestKind(method: string): string { + const categories: Readonly> = { + "item/commandExecution/requestApproval": "command-approval", + "item/fileChange/requestApproval": "file-change-approval", + "item/tool/requestUserInput": "user-input", + "mcpServer/elicitation/request": "mcp-elicitation", + "item/permissions/requestApproval": "permissions-approval", + "item/tool/call": "dynamic-tool-call", + "account/chatgptAuthTokens/refresh": "authentication-refresh", + "attestation/generate": "attestation", + applyPatchApproval: "legacy-patch-approval", + execCommandApproval: "legacy-command-approval", + }; + return categories[method] ?? "unsupported"; +} + +interface EventDescriptor { + readonly type: string; + readonly authority: Authority; +} + +const remainingNotifications: Readonly> = { + "thread/reverted": {type: "thread.reverted", authority: "merge"}, + "skills/changed": {type: "catalog.skills.invalidated", authority: "none"}, + "thread/goal/updated": {type: "thread.goal.changed", authority: "replace"}, + "thread/goal/cleared": {type: "thread.goal.removed", authority: "remove"}, + "thread/queue/changed": {type: "thread.queue.changed", authority: "replace"}, + "project/changed": {type: "workspace.project.changed", authority: "merge"}, + "thread/project/updated": {type: "thread.project.changed", authority: "replace"}, + "thread/environment/connected": {type: "thread.environment.connected", authority: "merge"}, + "thread/environment/disconnected": {type: "thread.environment.disconnected", authority: "merge"}, + "thread/settings/updated": {type: "thread.settings.changed", authority: "merge"}, + "hook/started": {type: "activity.hook.started", authority: "merge"}, + "hook/completed": {type: "activity.hook.completed", authority: "merge"}, + "turn/diff/updated": {type: "turn.diff.changed", authority: "replace"}, + "item/autoApprovalReview/started": {type: "approval.review.started", authority: "merge"}, + "item/autoApprovalReview/completed": {type: "approval.review.completed", authority: "merge"}, + "autoApprovalReview/strictReviewRequired": {type: "approval.strict-review.required", authority: "merge"}, + "command/exec/outputDelta": {type: "terminal.command.output-appended", authority: "merge"}, + "process/outputDelta": {type: "terminal.process.output-appended", authority: "merge"}, + "process/exited": {type: "terminal.process.completed", authority: "merge"}, + "item/commandExecution/terminalInteraction": {type: "conversation.command.interaction", authority: "merge"}, + "item/fileChange/outputDelta": {type: "conversation.file-change.output-appended", authority: "merge"}, + "item/fileChange/patchUpdated": {type: "conversation.file-change.patch-replaced", authority: "replace"}, + "item/mcpToolCall/progress": {type: "conversation.mcp.progress", authority: "merge"}, + "mcpServer/oauthLogin/completed": {type: "integration.mcp.login-completed", authority: "merge"}, + "mcpServer/startupStatus/updated": {type: "integration.mcp.status-changed", authority: "merge"}, + "mcpServer/event/stream/notification": {type: "integration.mcp.event", authority: "none"}, + "app/list/updated": {type: "catalog.apps.changed", authority: "replace"}, + "remoteControl/status/changed": {type: "connection.remote-control.changed", authority: "replace"}, + "externalAgentConfig/import/progress": {type: "settings.external-agent-import.progress", authority: "merge"}, + "externalAgentConfig/import/completed": {type: "settings.external-agent-import.completed", authority: "merge"}, + "fs/changed": {type: "workspace.files.changed", authority: "merge"}, + "item/reasoning/summaryPartAdded": {type: "conversation.reasoning.part-added", authority: "merge"}, + "thread/compacted": {type: "thread.compacted", authority: "merge"}, + "model/rerouted": {type: "model.rerouted", authority: "merge"}, + "model/verification": {type: "model.verification.changed", authority: "merge"}, + "turn/moderationMetadata": {type: "turn.moderation.changed", authority: "replace"}, + "model/safetyBuffering/updated": {type: "model.safety-buffering.changed", authority: "replace"}, + "fuzzyFileSearch/sessionUpdated": {type: "workspace.search.changed", authority: "merge"}, + "fuzzyFileSearch/sessionCompleted": {type: "workspace.search.completed", authority: "merge"}, + "thread/realtime/started": {type: "realtime.session.started", authority: "merge"}, + "thread/realtime/itemAdded": {type: "realtime.item.added", authority: "merge"}, + "thread/realtime/transcript/delta": {type: "realtime.transcript.appended", authority: "merge"}, + "thread/realtime/transcript/done": {type: "realtime.transcript.completed", authority: "merge"}, + "thread/realtime/outputAudio/delta": {type: "realtime.audio.appended", authority: "merge"}, + "thread/realtime/sdp": {type: "realtime.session-description.changed", authority: "replace"}, + "thread/realtime/error": {type: "realtime.session.failed", authority: "merge"}, + "thread/realtime/closed": {type: "realtime.session.closed", authority: "merge"}, + "windows/worldWritableWarning": {type: "system.windows-permission.warning", authority: "none"}, + "windowsSandbox/setupCompleted": {type: "system.windows-sandbox.completed", authority: "merge"}, + "account/login/completed": {type: "account.login.completed", authority: "merge"}, +}; + +export class ProtocolNormalizer { + private deliveryFailureHandler: (() => void) | undefined; + private deliveryFailed = false; + private connectionGeneration = 0; + private nextSequence = 1; + + public constructor(private readonly sink: PresentationSink) {} + + public setDeliveryFailureHandler(handler?: () => void): void { + this.deliveryFailureHandler = handler; + } + + public transportEvent(eventName: string, detail = ""): void { + if (eventName === "connected") ++this.connectionGeneration; + const data: JsonObject = {state: eventName}; + if (detail !== "") data.detail = detail; + this.emitEvent("connection.lifecycle", data); + } + + public connectionSettings(settings: JsonObject): void { + this.emitEvent("connection.settings.changed", settings, "replace"); + } + + public localOperationResult( + action: string, + correlationId: string, + ok: boolean, + data: unknown, + ): void { + this.emit(result( + this.nextSequence++, + this.connectionGeneration, + action, + correlationId, + ok, + data, + )); + } + + public bridgeEvent(value: unknown): void { + const kind = stringMember(value, "kind"); + if (kind === "bridge.connection") { + this.emitEvent("connection.bridge", { + state: stringMember(value, "event"), + connectionId: stringMember(value, "connectionId"), + role: stringMember(value, "role"), + }); + return; + } + if (kind === "bridge.controller") { + this.emitEvent( + "connection.controller", + {controllerConnectionId: member(value, "controllerConnectionId")}, + "replace", + ); + return; + } + if (kind === "bridge.provider") { + const generation = member(value, "providerGeneration"); + if ( + typeof generation !== "number" + || !Number.isInteger(generation) + || generation < 0 + ) { + this.diagnostic( + "bridge", + "invalid-provider-event", + "provider event has no unsigned generation", + isObject(value) ? value : {}, + ); + return; + } + const data: JsonObject = { + state: stringMember(value, "state"), + generation, + }; + const reason = stringMember(value, "reason"); + if (reason !== "") data.reason = reason; + this.emitEvent("connection.provider", data, "replace"); + return; + } + if (kind === "bridge.diagnostic") { + const details = member(value, "details", {}); + this.diagnostic( + "bridge", + stringMember(value, "code"), + stringMember(value, "message"), + isObject(details) ? details : {}, + ); + return; + } + this.diagnostic("bridge", "unknown-event", kind, isObject(value) ? value : {}); + } + + public serverNotification(method: string, params: JsonObject): void { + const scope = stableScope(params); + if (method === "thread/started") { + const thread = member(params, "thread", {}); + this.emitEvent("thread.upsert", {thread: isObject(thread) ? thread : {}}, "merge"); + } else if (method === "thread/status/changed") { + this.emitEvent("thread.status.changed", {status: member(params, "status")}, "merge", scope); + } else if (method === "thread/name/updated") { + this.emitEvent("thread.name.changed", {name: member(params, "threadName")}, "replace", scope); + } else if (method === "thread/deleted") { + this.emitEvent("thread.removed", {}, "remove", scope); + } else if ( + method === "thread/archived" + || method === "thread/unarchived" + || method === "thread/closed" + ) { + const state = method === "thread/archived" + ? "archived" + : method === "thread/unarchived" ? "unarchived" : "closed"; + this.emitEvent("thread.lifecycle", {state}, "merge", scope); + } else if (method === "turn/started" || method === "turn/completed") { + const turn = member(params, "turn", {}); + this.emitEvent("turn.upsert", { + lifecycle: method === "turn/started" ? "started" : "completed", + turn: isObject(turn) ? turn : {}, + }, "merge", scope); + } else if (method === "turn/plan/updated") { + const steps = member(params, "plan", []); + this.emitEvent("plan.replaced", { + explanation: member(params, "explanation"), + steps: Array.isArray(steps) ? steps : [], + }, "replace", scope); + } else if (method === "item/started" || method === "item/completed") { + const candidate = member(params, "item", {}); + const item = isObject(candidate) ? candidate : {}; + const itemScope = {...scope}; + if (!Object.hasOwn(itemScope, "itemId") && Object.hasOwn(item, "id") && item.id !== null) { + itemScope.itemId = item.id; + } + const lifecycle = method === "item/started" ? "started" : "completed"; + this.emitEvent("conversation.item.upsert", {lifecycle, item}, "merge", itemScope); + const itemType = stringMember(item, "type"); + if (itemType === "collabAgentToolCall" || itemType === "subAgentActivity") { + this.emitEvent("agents.activity.upsert", {lifecycle, activity: item}, "merge", itemScope); + } + } else if ( + method === "item/agentMessage/delta" + || method === "item/plan/delta" + || method === "item/reasoning/summaryTextDelta" + || method === "item/reasoning/textDelta" + || method === "item/commandExecution/outputDelta" + ) { + let field = "text"; + if (method === "item/commandExecution/outputDelta") field = "aggregatedOutput"; + else if (method === "item/reasoning/summaryTextDelta") field = "summary"; + else if (method === "item/reasoning/textDelta") field = "content"; + const data: JsonObject = {field, text: stringMember(params, "delta")}; + if (Object.hasOwn(params, "summaryIndex")) data.summaryIndex = params.summaryIndex; + if (Object.hasOwn(params, "contentIndex")) data.contentIndex = params.contentIndex; + this.emitEvent("conversation.item.append", data, "merge", scope); + } else if (method === "serverRequest/resolved") { + this.emitEvent("pending-request.removed", {}, "remove", scope); + } else if ( + method === "error" + || method === "warning" + || method === "guardianWarning" + || method === "configWarning" + || method === "deprecationNotice" + ) { + this.emitEvent("notice.added", { + severity: method === "error" ? "error" : "warning", + notice: params, + }, "none", scope); + } else if (method === "thread/tokenUsage/updated") { + const tokenUsage = member(params, "tokenUsage", {}); + this.emitEvent( + "thread.token-usage.changed", + {tokenUsage: isObject(tokenUsage) ? tokenUsage : {}}, + "replace", + scope, + ); + } else if (method === "account/updated") { + this.emitEvent("account.changed", {account: params}, "replace"); + } else if (method === "account/rateLimits/updated") { + this.emitEvent("account.rate-limits.changed", {rateLimits: params}, "replace"); + } else { + const descriptor = remainingNotifications[method]; + if (descriptor !== undefined) { + this.emitEvent(descriptor.type, params, descriptor.authority, scope); + } else { + this.diagnostic("appserver", "unmapped-notification", method); + } + } + } + + public serverRequest(method: string, requestId: unknown, params: JsonObject): void { + const scope = stableScope(params); + scope.requestId = requestId; + this.emitEvent("pending-request.upsert", { + requestId, + category: requestKind(method), + request: params, + }, "merge", scope); + } + + public observeRawInbound(message: unknown): void { + const method = jsonRpcMethod(message); + const kind = classifyJsonRpc(message); + if ( + (kind === "request" || kind === "notification") + && method !== undefined + && !this.knownServerMethod(method) + ) { + this.diagnostic("appserver", "unknown-method", method); + } + } + + public operationResult( + action: string, + correlationId: string, + context: JsonObject, + response: JsonObject, + startedAtSequence?: number, + ): void { + const ok = Object.hasOwn(response, "result"); + let data: unknown; + let authority: Authority = "none"; + const scope = stableScope(context); + if (ok) { + const rawValue = response.result; + const value = isObject(rawValue) ? rawValue : {}; + if (action === "threads.list") { + const threads = member(value, "data", []); + data = { + threads: Array.isArray(threads) ? threads : [], + nextCursor: member(value, "nextCursor"), + backwardsCursor: member(value, "backwardsCursor"), + }; + authority = "merge"; + } else if (action === "thread.read") { + const candidate = member(value, "thread", {}); + const thread = isObject(candidate) ? candidate : {}; + data = {thread}; + authority = startedAtSequence !== undefined + && startedAtSequence === this.nextSequence + ? "replace" + : "merge"; + const threadId = stringMember(thread, "id"); + if (threadId !== "") scope.threadId = threadId; + } else if ( + action === "thread.create" + || action === "thread.resume" + || action === "thread.fork" + ) { + const candidate = member(value, "thread", {}); + const thread: JsonObject = isObject(candidate) ? {...candidate} : {}; + for (const field of [ + "activePermissionProfile", + "approvalPolicy", + "approvalsReviewer", + "cwd", + "model", + "modelProvider", + "reasoningEffort", + "sandbox", + "serviceTier", + ]) { + if (Object.hasOwn(value, field)) thread[field] = value[field]; + } + data = {thread}; + authority = "merge"; + } else if (action === "models.list") { + const models = member(value, "data", []); + data = { + models: Array.isArray(models) ? models : [], + nextCursor: member(value, "nextCursor"), + }; + authority = "replace"; + } else if ( + action === "model-provider-capabilities.read" + || action === "account.read" + || action === "account.rate-limits.read" + || action === "account.token-usage.read" + || action === "config.read" + || action === "permission-profiles.list" + || action === "experimental-features.list" + || action === "skills.list" + || action === "hooks.list" + || action === "plugins.list" + || action === "apps.list" + || action === "mcp-servers.list" + || action.endsWith(".list") + || action.endsWith(".read") + || action.endsWith(".get") + || action === "plugins.installed" + || action === "apps.installed" + || action === "windows-sandbox.readiness" + ) { + data = rawValue; + authority = "replace"; + } else if (action === "turn.start") { + const turn = member(value, "turn", {}); + data = {turn: isObject(turn) ? turn : {}}; + authority = "merge"; + } else { + data = rawValue; + } + } else { + data = errorValue(response); + } + this.emit(result( + this.nextSequence++, + this.connectionGeneration, + action, + correlationId, + ok, + data, + authority, + scope, + )); + } + + public operationRejected( + action: string, + correlationId: string, + code: number, + message: string, + ): void { + this.emit(result( + this.nextSequence++, + this.connectionGeneration, + action, + correlationId, + false, + {code, message}, + )); + } + + public get sequence(): number { + return this.nextSequence; + } + + private emit(frame: PresentationFrame): boolean { + if (this.deliveryFailed) return false; + if (this.sink(frame)) return true; + this.deliveryFailed = true; + this.deliveryFailureHandler?.(); + return false; + } + + private emitEvent( + type: string, + data: JsonObject = {}, + authority: Authority = "none", + scope: JsonObject = {}, + ): boolean { + return this.emit(event( + this.nextSequence++, + this.connectionGeneration, + type, + data, + authority, + scope, + )); + } + + private diagnostic( + source: string, + code: string, + message: string, + details: JsonObject = {}, + ): void { + this.emitEvent("system.diagnostic", {source, code, message, details}); + } + + private knownServerMethod(method: string): boolean { + return Object.hasOwn(serverRequestOperations, method) + || Object.hasOwn(serverNotificationOperations, method); + } +} diff --git a/web/tests/normalizer-parity.test.mjs b/web/tests/normalizer-parity.test.mjs new file mode 100644 index 0000000..1f93293 --- /dev/null +++ b/web/tests/normalizer-parity.test.mjs @@ -0,0 +1,321 @@ +import assert from "node:assert/strict"; +import {test} from "node:test"; + +import { + command, + event, + isPresentationFrame, + ProtocolNormalizer, + result, +} from "../dist/index.js"; + +test("presentation frame grammar equals the C++ protocol boundary", () => { + const values = [ + command("threads.list", {}, "request"), + event(1, 2, "thread.upsert", {thread: {id: "thread"}}, "merge", {threadId: "thread"}), + result(2, 2, "threads.list", "request", true, {threads: []}, "merge"), + result(3, 2, "threads.list", "request", false, {code: -1}), + ]; + assert.equal(values.every(isPresentationFrame), true); + for (const invalid of [ + null, + {}, + {...values[0], data: []}, + {...values[1], sequence: -1}, + {...values[1], authority: "invalid"}, + {...values[1], data: []}, + {...values[2], error: {}}, + {...values[3], data: {}}, + ]) { + assert.equal(isPresentationFrame(invalid), false); + } +}); + +test("normalizer emits the exact ordered connection frames used by C++", () => { + const frames = []; + const normalizer = new ProtocolNormalizer((frame) => { + frames.push(frame); + return true; + }); + + normalizer.transportEvent("connected"); + normalizer.bridgeEvent({ + kind: "bridge.connection", + event: "connected", + connectionId: "frontend-test", + role: "observer", + }); + normalizer.bridgeEvent({ + kind: "bridge.controller", + controllerConnectionId: "frontend-test", + }); + normalizer.connectionSettings({selected: "ipv6"}); + + assert.deepEqual(frames, [ + event(1, 1, "connection.lifecycle", {state: "connected"}), + event(2, 1, "connection.bridge", { + state: "connected", + connectionId: "frontend-test", + role: "observer", + }), + event(3, 1, "connection.controller", { + controllerConnectionId: "frontend-test", + }, "replace"), + event(4, 1, "connection.settings.changed", {selected: "ipv6"}, "replace"), + ]); + assert.equal(normalizer.sequence, 5); +}); + +test("operation result authority and payload shaping equal C++", () => { + const frames = []; + const normalizer = new ProtocolNormalizer((frame) => { + frames.push(frame); + return true; + }); + normalizer.transportEvent("connected"); + + normalizer.operationResult("threads.list", "list", {}, { + id: "list", + result: {data: [{id: "thread"}], nextCursor: null}, + }); + const startedAtSequence = normalizer.sequence; + normalizer.operationResult("thread.read", "read", {threadId: "requested"}, { + id: "read", + result: {thread: {id: "returned", turns: []}}, + }, startedAtSequence); + normalizer.operationResult("thread.resume", "resume", {threadId: "returned"}, { + id: "resume", + result: { + thread: {id: "returned"}, + model: "gpt-current", + reasoningEffort: "high", + ignored: "not copied", + }, + }); + normalizer.operationResult("models.list", "models", {}, { + result: {data: [{id: "gpt"}], nextCursor: "next"}, + }); + normalizer.operationResult("account.read", "account", {}, { + result: {account: {type: "chatgpt"}}, + }); + normalizer.operationResult("turn.start", "turn", {threadId: "returned"}, { + result: {turn: {id: "turn"}}, + }); + normalizer.operationResult("turn.interrupt", "interrupt", {}, {result: null}); + normalizer.operationResult("thread.read", "failed", {}, { + error: {code: -1, message: "failed"}, + }); + + assert.deepEqual(frames.slice(1), [ + result(2, 1, "threads.list", "list", true, { + threads: [{id: "thread"}], + nextCursor: null, + backwardsCursor: null, + }, "merge"), + result(3, 1, "thread.read", "read", true, { + thread: {id: "returned", turns: []}, + }, "replace", {threadId: "returned"}), + result(4, 1, "thread.resume", "resume", true, { + thread: { + id: "returned", + model: "gpt-current", + reasoningEffort: "high", + }, + }, "merge", {threadId: "returned"}), + result(5, 1, "models.list", "models", true, { + models: [{id: "gpt"}], + nextCursor: "next", + }, "replace"), + result(6, 1, "account.read", "account", true, { + account: {type: "chatgpt"}, + }, "replace"), + result(7, 1, "turn.start", "turn", true, { + turn: {id: "turn"}, + }, "merge", {threadId: "returned"}), + result(8, 1, "turn.interrupt", "interrupt", true, null), + result(9, 1, "thread.read", "failed", false, { + code: -1, + message: "failed", + }), + ]); +}); + +test("core notification normalization preserves C++ scopes and authority", () => { + const frames = []; + const normalizer = new ProtocolNormalizer((frame) => { + frames.push(frame); + return true; + }); + normalizer.transportEvent("connected"); + normalizer.serverNotification("thread/started", {thread: {id: "thread"}}); + normalizer.serverNotification("turn/started", { + threadId: "thread", + turn: {id: "turn"}, + }); + normalizer.serverNotification("item/started", { + threadId: "thread", + turnId: "turn", + item: { + id: "agent", + type: "subAgentActivity", + agentThreadId: "child", + }, + }); + normalizer.serverNotification("item/reasoning/summaryTextDelta", { + threadId: "thread", + turnId: "turn", + itemId: "reasoning", + summaryIndex: 2, + delta: "summary", + }); + normalizer.serverNotification("turn/plan/updated", { + threadId: "thread", + turnId: "turn", + explanation: "Plan", + plan: [{step: "Work", status: "inProgress"}], + }); + normalizer.serverNotification("serverRequest/resolved", { + threadId: "thread", + requestId: 42, + }); + + assert.deepEqual(frames.slice(1), [ + event(2, 1, "thread.upsert", {thread: {id: "thread"}}, "merge"), + event(3, 1, "turn.upsert", { + lifecycle: "started", + turn: {id: "turn"}, + }, "merge", {threadId: "thread"}), + event(4, 1, "conversation.item.upsert", { + lifecycle: "started", + item: { + id: "agent", + type: "subAgentActivity", + agentThreadId: "child", + }, + }, "merge", {threadId: "thread", turnId: "turn", itemId: "agent"}), + event(5, 1, "agents.activity.upsert", { + lifecycle: "started", + activity: { + id: "agent", + type: "subAgentActivity", + agentThreadId: "child", + }, + }, "merge", {threadId: "thread", turnId: "turn", itemId: "agent"}), + event(6, 1, "conversation.item.append", { + field: "summary", + text: "summary", + summaryIndex: 2, + }, "merge", { + threadId: "thread", + turnId: "turn", + itemId: "reasoning", + }), + event(7, 1, "plan.replaced", { + explanation: "Plan", + steps: [{step: "Work", status: "inProgress"}], + }, "replace", {threadId: "thread", turnId: "turn"}), + event(8, 1, "pending-request.removed", {}, "remove", { + threadId: "thread", + requestId: 42, + }), + ]); +}); + +test("all remaining generated notification mappings equal the C++ vocabulary", () => { + const cases = { + "thread/reverted": ["thread.reverted", "merge"], + "skills/changed": ["catalog.skills.invalidated", "none"], + "thread/settings/updated": ["thread.settings.changed", "merge"], + "turn/diff/updated": ["turn.diff.changed", "replace"], + "item/fileChange/patchUpdated": ["conversation.file-change.patch-replaced", "replace"], + "item/mcpToolCall/progress": ["conversation.mcp.progress", "merge"], + "thread/compacted": ["thread.compacted", "merge"], + "model/safetyBuffering/updated": ["model.safety-buffering.changed", "replace"], + "thread/realtime/transcript/delta": ["realtime.transcript.appended", "merge"], + "windows/worldWritableWarning": ["system.windows-permission.warning", "none"], + "account/login/completed": ["account.login.completed", "merge"], + }; + const frames = []; + const normalizer = new ProtocolNormalizer((frame) => { + frames.push(frame); + return true; + }); + for (const method of Object.keys(cases)) { + normalizer.serverNotification(method, {threadId: "thread", marker: method}); + } + for (const [index, [method, [type, authority]]] of Object.entries(cases).entries()) { + assert.deepEqual(frames[index], event( + index + 1, + 0, + type, + {threadId: "thread", marker: method}, + authority, + {threadId: "thread"}, + )); + } +}); + +test("server request categories and unknown-method diagnostics equal C++", () => { + const frames = []; + const normalizer = new ProtocolNormalizer((frame) => { + frames.push(frame); + return true; + }); + const categories = { + "item/commandExecution/requestApproval": "command-approval", + "item/fileChange/requestApproval": "file-change-approval", + "item/tool/requestUserInput": "user-input", + "mcpServer/elicitation/request": "mcp-elicitation", + "item/permissions/requestApproval": "permissions-approval", + "item/tool/call": "dynamic-tool-call", + "account/chatgptAuthTokens/refresh": "authentication-refresh", + "attestation/generate": "attestation", + applyPatchApproval: "legacy-patch-approval", + execCommandApproval: "legacy-command-approval", + unsupported: "unsupported", + }; + let requestId = 1; + for (const [method, category] of Object.entries(categories)) { + normalizer.serverRequest(method, requestId, {threadId: "thread"}); + assert.deepEqual(frames.at(-1), event( + requestId, + 0, + "pending-request.upsert", + {requestId, category, request: {threadId: "thread"}}, + "merge", + {threadId: "thread", requestId}, + )); + ++requestId; + } + + const before = frames.length; + normalizer.observeRawInbound({method: "thread/started", params: {}}); + assert.equal(frames.length, before); + normalizer.observeRawInbound({method: "future/method", params: {}}); + assert.deepEqual(frames.at(-1), event( + requestId, + 0, + "system.diagnostic", + { + source: "appserver", + code: "unknown-method", + message: "future/method", + details: {}, + }, + )); +}); + +test("delivery failure is sticky and reported once like C++", () => { + let deliveries = 0; + let failures = 0; + const normalizer = new ProtocolNormalizer(() => { + ++deliveries; + return false; + }); + normalizer.setDeliveryFailureHandler(() => ++failures); + normalizer.transportEvent("connected"); + normalizer.transportEvent("disconnected"); + assert.equal(deliveries, 1); + assert.equal(failures, 1); + assert.equal(normalizer.sequence, 3, "sequence allocation continues before sticky rejection"); +}); diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..151cddc --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "declaration": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "module": "ES2022", + "moduleResolution": "Bundler", + "noEmitOnError": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noUncheckedIndexedAccess": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "target": "ES2022", + "verbatimModuleSyntax": true + }, + "include": [ + "src/**/*.ts" + ] +} From 45212c77f07144653d2ea05e6660899c11d782cd Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Fri, 28 Aug 2026 04:31:34 +0200 Subject: [PATCH 03/16] Match C++ presentation model behavior in TypeScript --- web/src/index.ts | 2 + web/src/presentation/PresentationModel.ts | 888 +++++++++++++++++++++ web/src/presentation/PresentationStatus.ts | 27 + web/tests/model-parity.test.mjs | 223 ++++++ 4 files changed, 1140 insertions(+) create mode 100644 web/src/presentation/PresentationModel.ts create mode 100644 web/src/presentation/PresentationStatus.ts create mode 100644 web/tests/model-parity.test.mjs diff --git a/web/src/index.ts b/web/src/index.ts index 0510843..b6d41c0 100644 --- a/web/src/index.ts +++ b/web/src/index.ts @@ -1,2 +1,4 @@ export * from "./presentation/PresentationProtocol.js"; +export * from "./presentation/PresentationStatus.js"; export * from "./presentation/ProtocolNormalizer.js"; +export * from "./presentation/PresentationModel.js"; diff --git a/web/src/presentation/PresentationModel.ts b/web/src/presentation/PresentationModel.ts new file mode 100644 index 0000000..4ba56a0 --- /dev/null +++ b/web/src/presentation/PresentationModel.ts @@ -0,0 +1,888 @@ +import { + isObject, + isPresentationFrame, + member, + stringMember, +} from "./PresentationProtocol.js"; +import type {JsonObject, PresentationFrame} from "./PresentationProtocol.js"; +import {classifyStatus, isActiveStatus, isTerminalTurnStatus} from "./PresentationStatus.js"; + +const MaximumRetainedTelemetry = 256; +const MaximumIndexedTextParts = 4096; + +export interface ItemPresentation { + id: string; + raw: JsonObject; + domains: Map; +} + +export interface TurnPresentation { + id: string; + status: string; + itemOrder: string[]; + items: Map; + plan: JsonObject; + raw: JsonObject; + domains: Map; +} + +export interface AgentPresentation { + id: string; + itemId: string; + ownerTurnId: string; + childThreadId: string; + status: string; + raw: JsonObject; +} + +export interface ChildThreadOwnership { + parentThreadId: string; + agentId: string; +} + +export interface ThreadPresentation { + id: string; + title: string; + preview: string; + cwd: string; + status: string; + createdAt?: number; + updatedAt?: number; + recencyAt?: number; + commandCwds: string[]; + changedPaths: string[]; + turnOrder: string[]; + turns: Map; + raw: JsonObject; + domains: Map; + latestSettingsUpdate: unknown; + settingsRevision: number; + agentOrder: string[]; + agents: Map; + childThreadOrder: string[]; + archived: boolean; +} + +export interface PendingRequestPresentation { + id: string; + kind: string; + threadId: string; + generation: number; + raw: unknown; +} + +export interface ConnectionPresentation { + connected: boolean; + retrying: boolean; + generation: number; + connectionId: string; + role: string; + controllerConnectionId: string; + detail: string; + providerGeneration: number; + providerState: string; + providerDetail: string; + settings: unknown; +} + +export interface TelemetryPresentation { + sequence: number; + generation: number; + type: string; + data: unknown; + scope: JsonObject; +} + +function clone(value: T): T { + return structuredClone(value); +} + +function objectMember(value: unknown, name: string): JsonObject { + const result = member(value, name, {}); + return isObject(result) ? result : {}; +} + +function boolValue(value: unknown, name: string, fallback = false): boolean { + return isObject(value) && typeof value[name] === "boolean" ? value[name] : fallback; +} + +function unsignedValue(value: unknown): number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0; +} + +function statusValue(value: unknown): string { + return typeof value === "string" ? value : stringMember(value, "type"); +} + +function requestKey(value: unknown): string { + return value === null ? "" : JSON.stringify(value); +} + +function appendUnique(values: string[], value: string, maximum: number): void { + if (value === "" || values.includes(value)) return; + if (values.length === maximum) values.shift(); + values.push(value); +} + +function retainRepositoryHints(thread: ThreadPresentation, item: JsonObject): void { + const type = stringMember(item, "type"); + if (type === "commandExecution") appendUnique(thread.commandCwds, stringMember(item, "cwd"), 64); + if (type !== "fileChange") return; + const changes = item.changes; + if (!Array.isArray(changes)) return; + for (const change of changes) appendUnique(thread.changedPaths, stringMember(change, "path"), 512); +} + +function isSpawnActivity(activity: unknown): boolean { + const type = stringMember(activity, "type"); + if (type === "subAgentActivity") { + const kind = stringMember(activity, "kind"); + return kind === "" || kind === "started"; + } + if (type !== "collabAgentToolCall") return false; + return ["spawn_agent", "spawnAgent", "spawn_agents_on_csv", "spawnAgentsOnCsv"] + .includes(stringMember(activity, "tool")); +} + +function childThreadIdentity(activity: unknown): string { + const direct = stringMember(activity, "agentThreadId"); + if (direct !== "") return direct; + const receivers = member(activity, "receiverThreadIds", []); + return Array.isArray(receivers) && receivers.length === 1 && typeof receivers[0] === "string" + ? receivers[0] + : ""; +} + +function agentIdentity(activity: unknown, scope: unknown): string { + return stringMember(scope, "itemId") || stringMember(activity, "id") || childThreadIdentity(activity); +} + +function isStaleAgentReplay( + owner: ThreadPresentation, + scope: unknown, + activity: unknown, + live: boolean, +): boolean { + if (live) return false; + const childThreadId = childThreadIdentity(activity); + const agent = owner.agents.get(agentIdentity(activity, scope)); + return childThreadId !== "" && agent !== undefined && agent.childThreadId !== "" + && agent.childThreadId !== childThreadId; +} + +function mergePreservingCompleteness(target: unknown, update: unknown): unknown { + if (!isObject(target) || !isObject(update)) return update !== null || target === null ? clone(update) : target; + for (const [key, value] of Object.entries(update)) { + if (!Object.hasOwn(target, key)) target[key] = clone(value); + else if (isObject(target[key]) && isObject(value)) mergePreservingCompleteness(target[key], value); + else if (value !== null || target[key] === null) target[key] = clone(value); + } + return target; +} + +function mergeExplicitMembers(target: unknown, update: unknown): unknown { + if (!isObject(target) || !isObject(update)) return clone(update); + for (const [key, value] of Object.entries(update)) { + if (isObject(target[key]) && isObject(value)) mergeExplicitMembers(target[key], value); + else target[key] = clone(value); + } + return target; +} + +function appendText(item: JsonObject, field: string, params: unknown): void { + const delta = stringMember(params, "delta"); + if (delta === "") return; + item[field] = (typeof item[field] === "string" ? item[field] : "") + delta; +} + +function appendIndexedText(item: JsonObject, field: string, params: unknown, indexField: string): void { + const rawIndex = isObject(params) ? params[indexField] : undefined; + const position = typeof rawIndex === "number" && Number.isInteger(rawIndex) && rawIndex >= 0 ? rawIndex : 0; + if (position >= MaximumIndexedTextParts) return; + const parts: unknown[] = Array.isArray(item[field]) ? item[field] : []; + item[field] = parts; + while (parts.length <= position) parts.push(""); + const delta = stringMember(params, "delta") || stringMember(params, "text"); + parts[position] = (typeof parts[position] === "string" ? parts[position] : "") + delta; +} + +function applyDomainAuthority( + domains: Map, + type: string, + data: unknown, + authority: string, +): void { + if (authority === "none") return; + if (authority === "remove") { + domains.delete(type); + return; + } + if (authority === "replace" || !domains.has(type)) { + domains.set(type, clone(data)); + return; + } + const current = domains.get(type); + domains.set(type, type === "thread.settings.changed" + ? mergeExplicitMembers(current, data) + : mergePreservingCompleteness(current, data)); +} + +function newThread(id: string): ThreadPresentation { + return { + id, title: "", preview: "", cwd: "", status: "", + commandCwds: [], changedPaths: [], turnOrder: [], turns: new Map(), raw: {}, domains: new Map(), + latestSettingsUpdate: {}, settingsRevision: 0, agentOrder: [], agents: new Map(), + childThreadOrder: [], archived: false, + }; +} + +function newTurn(id: string): TurnPresentation { + return {id, status: "", itemOrder: [], items: new Map(), plan: {}, raw: {}, domains: new Map()}; +} + +export class PresentationModel { + private readonly threads = new Map(); + private readonly childOwnerships = new Map(); + private readonly pendingRequests = new Map(); + private readonly connectionState: ConnectionPresentation = { + connected: false, retrying: false, generation: 0, connectionId: "", role: "", + controllerConnectionId: "", detail: "", providerGeneration: 0, + providerState: "", providerDetail: "", settings: {}, + }; + private orderedThreads: string[] = []; + private models: unknown = []; + private readonly retainedGlobalDomains = new Map(); + private readonly retainedTelemetry: TelemetryPresentation[] = []; + private lastSequence = 0; + + applyEvent(event: unknown): void { + try { + this.applyValidatedEvent(event); + } catch { + // Presentation mutation is an untrusted-data boundary. + } + } + + threadOrder(): readonly string[] { return this.orderedThreads; } + thread(threadId: string): ThreadPresentation | undefined { return this.threads.get(threadId); } + childOwnership(childThreadId: string): ChildThreadOwnership | undefined { + return this.childOwnerships.get(childThreadId); + } + activeTurnId(threadId: string): string | undefined { + const thread = this.thread(threadId); + if (!thread || (thread.status !== "" && !isActiveStatus(thread.status))) return undefined; + for (let index = thread.turnOrder.length - 1; index >= 0; --index) { + const id = thread.turnOrder[index]!; + if (isActiveStatus(thread.turns.get(id)?.status ?? "")) return id; + } + return undefined; + } + pendingRequestCount(): number { return this.pendingRequests.size; } + connection(): Readonly { return this.connectionState; } + modelCatalog(): unknown { return this.models; } + globalDomains(): ReadonlyMap { return this.retainedGlobalDomains; } + telemetry(): readonly TelemetryPresentation[] { return this.retainedTelemetry; } + pendingRequestPresentations(): ReadonlyMap { + return this.pendingRequests; + } + + private applyValidatedEvent(candidate: unknown): void { + if (!isPresentationFrame(candidate)) return; + const event = candidate as PresentationFrame; + const generation = unsignedValue(event.generation); + if (this.connectionState.generation !== 0 && generation !== 0 + && generation < this.connectionState.generation) return; + if (generation > this.connectionState.generation) { + this.connectionState.generation = generation; + this.lastSequence = 0; + this.pendingRequests.clear(); + } + const sequence = unsignedValue(event.sequence); + if (sequence !== 0) { + if (sequence <= this.lastSequence) return; + this.lastSequence = sequence; + } + const data = member(event, "data", {}); + const scope = objectMember(event, "scope"); + if (event.kind === "result") { + if (event.ok !== true) return; + const action = stringMember(event, "action"); + if (action === "threads.list") this.mergeThreadList(member(data, "threads", [])); + else if (action === "thread.read") { + this.upsertThread(objectMember(data, "thread"), stringMember(event, "authority") === "replace"); + } else if (["thread.create", "thread.resume", "thread.fork"].includes(action)) { + this.upsertThread(objectMember(data, "thread"), false); + } else if (action === "turn.start") { + const thread = this.threads.get(stringMember(scope, "threadId")); + if (thread) { + const turn = this.upsertTurn(thread, objectMember(data, "turn"), false); + if (isActiveStatus(turn.status)) thread.status = "active"; + } + } else if (action === "models.list") { + const listed = member(data, "models", []); + if (Array.isArray(listed)) this.models = clone(listed); + } else this.retainDomainEvent(`operation.${action}`, data, scope, stringMember(event, "authority")); + return; + } + if (event.kind !== "event") return; + const type = stringMember(event, "type"); + const authority = stringMember(event, "authority"); + if (authority === "none") { + if (this.retainedTelemetry.length === MaximumRetainedTelemetry) this.retainedTelemetry.shift(); + this.retainedTelemetry.push({sequence, generation, type, data: clone(data), scope: clone(scope)}); + } + if (type === "connection.lifecycle") { + this.connectionState.generation = generation; + const lifecycle = stringMember(data, "state"); + if (lifecycle === "connected") { + this.connectionState.connected = true; + this.connectionState.retrying = false; + this.connectionState.detail = ""; + } else if (lifecycle === "connecting" || lifecycle === "retrying") { + this.connectionState.connected = false; + this.connectionState.retrying = true; + this.clearConnectionIdentity(); + this.connectionState.detail = stringMember(data, "detail"); + this.pendingRequests.clear(); + } else if (lifecycle === "disconnected" || lifecycle === "failure") { + this.connectionState.connected = false; + this.connectionState.retrying = false; + this.clearConnectionIdentity(); + this.connectionState.detail = stringMember(data, "detail"); + this.pendingRequests.clear(); + } + return; + } + if (type === "connection.bridge") { + this.connectionState.connectionId = stringMember(data, "connectionId"); + this.connectionState.role = stringMember(data, "role"); + return; + } + if (type === "connection.controller") { + this.connectionState.controllerConnectionId = stringMember(data, "controllerConnectionId"); + if (this.connectionState.connectionId !== "") { + this.connectionState.role = this.connectionState.controllerConnectionId === this.connectionState.connectionId + ? "controller" : "observer"; + } + return; + } + if (type === "connection.provider") { + const incoming = isObject(data) ? data.generation : undefined; + if (typeof incoming !== "number" || !Number.isSafeInteger(incoming) || incoming < 0 + || incoming < this.connectionState.providerGeneration) return; + const state = stringMember(data, "state"); + if ((this.connectionState.providerGeneration !== 0 && incoming > this.connectionState.providerGeneration) + || state === "disconnected") this.clearProviderState(); + this.connectionState.providerGeneration = incoming; + this.connectionState.providerState = state; + this.connectionState.providerDetail = stringMember(data, "reason"); + return; + } + if (type === "connection.settings.changed") { + this.connectionState.settings = clone(data); + return; + } + if (type === "thread.upsert") { + this.upsertThread(objectMember(data, "thread"), false); + return; + } + if (type === "thread.name.changed") { + const thread = this.threads.get(stringMember(scope, "threadId")); + if (thread && isObject(data) && typeof data.name === "string") { + thread.title = data.name; + thread.raw.name = data.name; + } + return; + } + if (type === "thread.status.changed") { + const thread = this.threads.get(stringMember(scope, "threadId")); + if (thread) { + thread.status = statusValue(member(data, "status")); + thread.raw.status = clone(member(data, "status")); + this.updateOwningAgentStatus(thread.id, thread.status); + } + return; + } + if (type === "thread.lifecycle") { + const thread = this.threads.get(stringMember(scope, "threadId")); + if (thread) { + const lifecycle = stringMember(data, "state"); + thread.status = lifecycle; + if (lifecycle === "archived") thread.archived = true; + else if (lifecycle === "unarchived") thread.archived = false; + thread.raw.presentationLifecycle = lifecycle; + } + return; + } + if (type === "thread.removed") { + this.removeThread(stringMember(scope, "threadId")); + return; + } + if (type === "pending-request.upsert") { + if (!isObject(data) || !Object.hasOwn(data, "requestId") || data.requestId === null) return; + const key = requestKey(data.requestId); + this.pendingRequests.set(key, { + id: key, kind: stringMember(data, "category"), threadId: stringMember(scope, "threadId"), + generation, raw: clone(member(data, "request")), + }); + return; + } + if (type === "pending-request.removed") { + if (Object.hasOwn(scope, "requestId")) this.pendingRequests.delete(requestKey(scope.requestId)); + return; + } + + const threadId = stringMember(scope, "threadId"); + if (threadId === "") { + this.retainDomainEvent(type, data, scope, authority); + return; + } + let thread = this.threads.get(threadId); + if (!thread) { + this.upsertThread({id: threadId}, false); + thread = this.threads.get(threadId); + if (!thread) return; + } + this.retainDomainEvent(type, data, scope, authority); + if (type === "thread.settings.changed" && isObject(data)) { + thread.latestSettingsUpdate = clone(Object.hasOwn(data, "threadSettings") ? data.threadSettings : data); + ++thread.settingsRevision; + } + if (type === "turn.upsert") { + const rawTurn = objectMember(data, "turn"); + const turn = clone(rawTurn); + const lifecycle = stringMember(data, "lifecycle"); + const embeddedStatus = statusValue(member(turn, "status")); + if (lifecycle === "completed" && !isTerminalTurnStatus(embeddedStatus)) turn.status = "completed"; + else if (lifecycle === "started" && embeddedStatus === "") turn.status = "inProgress"; + const updated = this.upsertTurn(thread, turn, false); + if (lifecycle === "started" && isActiveStatus(updated.status)) thread.status = "active"; + return; + } + if (type === "plan.replaced") { + const turn = this.upsertTurn(thread, {id: stringMember(scope, "turnId")}, false); + turn.plan = {explanation: clone(member(data, "explanation")), steps: clone(member(data, "steps", []))}; + return; + } + if (type === "conversation.item.upsert") { + if (isObject(data) && Object.hasOwn(data, "item")) { + const turn = this.upsertTurn(thread, {id: stringMember(scope, "turnId")}, false); + this.upsertItem(thread, turn, data.item, true); + } + return; + } + if (type === "agents.activity.upsert") { + this.upsertAgentActivity(thread, scope, objectMember(data, "activity")); + return; + } + if (type === "conversation.reasoning.part-added") { + const item = this.findItem(scope); + const index = isObject(data) ? data.summaryIndex : undefined; + if (item && typeof index === "number" && Number.isInteger(index) && index >= 0 + && index < MaximumIndexedTextParts) { + const parts: unknown[] = Array.isArray(item.raw.summary) ? item.raw.summary : []; + item.raw.summary = parts; + while (parts.length <= index) parts.push(""); + } + return; + } + if (type === "conversation.file-change.output-appended") { + const item = this.findItem(scope); + if (item) appendText(item.raw, "output", {delta: stringMember(data, "delta")}); + return; + } + if (type === "conversation.file-change.patch-replaced") { + const item = this.findItem(scope); + if (item) { + item.raw.changes = clone(member(data, "changes", [])); + retainRepositoryHints(thread, item.raw); + } + return; + } + if (type === "conversation.mcp.progress") { + const item = this.findItem(scope); + if (item) { + const progress: unknown[] = Array.isArray(item.raw.progress) ? item.raw.progress : []; + item.raw.progress = progress; + if (progress.length < MaximumIndexedTextParts) progress.push(stringMember(data, "message")); + } + return; + } + if (type !== "conversation.item.append") return; + const identity = {...scope, delta: stringMember(data, "text")}; + const item = this.findItem(identity); + if (!item) return; + const field = stringMember(data, "field"); + if (field === "summary") appendIndexedText(item.raw, "summary", data, "summaryIndex"); + else if (field === "content") appendIndexedText(item.raw, "content", data, "contentIndex"); + else if (field !== "") appendText(item.raw, field, identity); + if (stringMember(item.raw, "type") === "agentMessage") + this.updateOwningAgentResult(threadId, stringMember(item.raw, "text")); + } + + private clearConnectionIdentity(): void { + this.connectionState.connectionId = ""; + this.connectionState.role = ""; + this.connectionState.controllerConnectionId = ""; + } + + private mergeThreadList(listedThreads: unknown): void { + if (!Array.isArray(listedThreads)) return; + const listedIds = new Set(); + const nextOrder: string[] = []; + for (const raw of listedThreads) { + const id = stringMember(raw, "id"); + if (id === "") continue; + this.upsertThread(isObject(raw) ? raw : {}, false, false); + if (!this.childOwnerships.has(id) && !listedIds.has(id)) { + listedIds.add(id); + nextOrder.push(id); + } + } + for (const id of this.orderedThreads) if (!listedIds.has(id)) nextOrder.push(id); + this.orderedThreads = nextOrder; + } + + private upsertThread(raw: JsonObject, replaceTurns: boolean, prependNewThread = true): ThreadPresentation { + const id = stringMember(raw, "id"); + if (id === "") return newThread(""); + let result = this.threads.get(id); + if (!result) { + result = newThread(id); + this.threads.set(id, result); + if (prependNewThread) this.orderedThreads.unshift(id); + } + const previousThreadStatus = result.status; + const terminalTurnStatuses = new Map(); + if (replaceTurns) { + for (const [turnId, turn] of result.turns) + if (isTerminalTurnStatus(turn.status)) terminalTurnStatuses.set(turnId, turn.status); + } + const threadFields = clone(raw); + delete threadFields.turns; + result.raw = replaceTurns ? threadFields : mergePreservingCompleteness(result.raw, threadFields) as JsonObject; + const name = stringMember(raw, "name"); + const preview = stringMember(raw, "preview"); + if (name !== "") result.title = name; + else if (preview !== "") result.title = preview.slice(0, 80); + else if (result.title === "") result.title = id.slice(0, 12); + if (preview !== "") result.preview = preview; + const cwd = stringMember(raw, "cwd"); + if (cwd !== "") result.cwd = cwd; + if (Object.hasOwn(raw, "status")) result.status = statusValue(raw.status); + for (const key of ["createdAt", "updatedAt", "recencyAt"] as const) { + if (typeof raw[key] === "number" && Number.isInteger(raw[key])) result[key] = raw[key]; + } + result.archived = boolValue(raw, "archived", result.archived); + if (Array.isArray(raw.turns)) { + let previouslyOwnedChildren: string[] = []; + if (replaceTurns) { + previouslyOwnedChildren = [...result.childThreadOrder]; + for (const child of previouslyOwnedChildren) this.releaseChildOwnership(child, false); + result.turnOrder = []; + result.turns.clear(); + result.agentOrder = []; + result.agents.clear(); + result.commandCwds = []; + result.changedPaths = []; + } + for (const turn of raw.turns) if (isObject(turn)) this.upsertTurn(result, turn, replaceTurns); + if (replaceTurns) { + for (const child of previouslyOwnedChildren) { + if (!this.childOwnerships.has(child) && this.threads.has(child) && !this.orderedThreads.includes(child)) + this.orderedThreads.push(child); + } + for (const [turnId, terminalStatus] of terminalTurnStatuses) { + const turn = result.turns.get(turnId); + if (turn && isActiveStatus(turn.status)) { + turn.status = terminalStatus; + turn.raw.status = terminalStatus; + } + } + } + const containsActiveTurn = [...result.turns.values()].some(turn => isActiveStatus(turn.status)); + if (!containsActiveTurn && isActiveStatus(result.status) + && classifyStatus(previousThreadStatus).kind === "completed") { + result.status = previousThreadStatus; + result.raw.status = previousThreadStatus; + } + this.synchronizeOwningAgent(id, replaceTurns); + } else if (Object.hasOwn(raw, "status") && result.status !== "notLoaded") { + this.updateOwningAgentStatus(id, result.status); + } + return result; + } + + private upsertTurn(thread: ThreadPresentation, raw: JsonObject, replaceItems: boolean): TurnPresentation { + const id = stringMember(raw, "id"); + if (id === "") return newTurn(""); + let result = thread.turns.get(id); + if (!result) { + result = newTurn(id); + thread.turns.set(id, result); + thread.turnOrder.push(id); + } + const turnFields = clone(raw); + delete turnFields.items; + result.raw = replaceItems ? turnFields : mergePreservingCompleteness(result.raw, turnFields) as JsonObject; + const status = statusValue(member(raw, "status")); + if (status !== "" && !(isTerminalTurnStatus(result.status) && isActiveStatus(status))) result.status = status; + if (isTerminalTurnStatus(result.status) && isActiveStatus(status)) result.raw.status = result.status; + if (Array.isArray(raw.items)) { + if (replaceItems) { + result.itemOrder = []; + result.items.clear(); + } + for (const item of raw.items) this.upsertItem(thread, result, item); + } + this.updateOwningAgentStatus(thread.id, result.status); + return result; + } + + private upsertItem(thread: ThreadPresentation, turn: TurnPresentation, rawValue: unknown, live = false): ItemPresentation { + const raw = isObject(rawValue) ? rawValue : {}; + const id = stringMember(raw, "id"); + if (id === "") return {id: "", raw: {}, domains: new Map()}; + const scope = {threadId: thread.id, turnId: turn.id, itemId: id}; + const incomingType = stringMember(raw, "type"); + if (["subAgentActivity", "collabAgentToolCall"].includes(incomingType) + && isStaleAgentReplay(thread, scope, raw, live)) { + return turn.items.get(id) ?? {id: "", raw: {}, domains: new Map()}; + } + let result = turn.items.get(id); + if (!result) { + result = {id, raw: clone(raw), domains: new Map()}; + turn.items.set(id, result); + turn.itemOrder.push(id); + } else mergePreservingCompleteness(result.raw, raw); + const type = stringMember(result.raw, "type"); + retainRepositoryHints(thread, result.raw); + if (["subAgentActivity", "collabAgentToolCall"].includes(type)) + this.upsertAgentActivity(thread, scope, result.raw, live); + if (type === "agentMessage") this.updateOwningAgentResult(thread.id, stringMember(result.raw, "text")); + return result; + } + + private upsertAgentActivity(owner: ThreadPresentation, scope: JsonObject, activity: JsonObject, live = true): void { + const type = stringMember(activity, "type"); + if (type === "subAgentActivity" && !isSpawnActivity(activity)) { + const child = childThreadIdentity(activity); + const existing = this.owningAgent(child); + if (!existing) return; + const agentPath = stringMember(activity, "agentPath"); + if (agentPath !== "") existing.agent.raw.agentPath = agentPath; + if (stringMember(activity, "kind") === "interrupted") this.updateOwningAgentStatus(child, "interrupted"); + return; + } + if (type === "collabAgentToolCall" && !isSpawnActivity(activity)) { + const states = member(activity, "agentsStates", {}); + if (!isObject(states)) return; + for (const [child, state] of Object.entries(states)) { + const existing = this.owningAgent(child); + if (!existing || !isObject(state)) continue; + const status = stringMember(state, "status"); + const message = stringMember(state, "message"); + if (status !== "") this.updateOwningAgentStatus(child, status); + if (message !== "") this.updateOwningAgentResult(child, message); + existing.agent.raw.agentState = clone(state); + } + return; + } + const childThreadId = childThreadIdentity(activity); + if (type === "collabAgentToolCall" && childThreadId === "") return; + const id = agentIdentity(activity, scope); + if (id === "" || isStaleAgentReplay(owner, scope, activity, live)) return; + let agent = owner.agents.get(id); + if (!agent) { + agent = {id, itemId: "", ownerTurnId: "", childThreadId: "", status: "", raw: {}}; + owner.agents.set(id, agent); + owner.agentOrder.push(id); + } + const changesChild = childThreadId !== "" && agent.childThreadId !== "" && agent.childThreadId !== childThreadId; + agent.itemId = stringMember(scope, "itemId"); + agent.ownerTurnId = stringMember(scope, "turnId"); + mergePreservingCompleteness(agent.raw, activity); + if (changesChild) { + agent.status = ""; + delete agent.raw.status; + const item = this.agentSourceItem(owner, agent); + if (item) delete item.raw.status; + this.clearAgentResult(owner, agent); + delete agent.raw.agentState; + } + const activityStatus = stringMember(activity, "status"); + const activityKind = stringMember(activity, "kind"); + let candidate = activityStatus; + if (candidate === "" && live && activityKind === "started") candidate = "inProgress"; + else if (candidate === "" && activityKind !== "") candidate = activityKind; + if (candidate !== "") this.setAgentStatus(owner, agent, + isTerminalTurnStatus(agent.status) && isActiveStatus(candidate) ? agent.status : candidate); + if (childThreadId !== "") this.assignChildOwnership(owner, agent, childThreadId, live); + } + + private assignChildOwnership(parent: ThreadPresentation, agent: AgentPresentation, child: string, live: boolean): void { + if (child === parent.id) return; + let ancestorId = parent.id; + const visited = new Set(); + while (!visited.has(ancestorId)) { + visited.add(ancestorId); + const ancestor = this.childOwnerships.get(ancestorId); + if (!ancestor) break; + ancestorId = ancestor.parentThreadId; + if (ancestorId === child) return; + } + if (agent.childThreadId !== "" && agent.childThreadId !== child) { + const previous = this.childOwnerships.get(agent.childThreadId); + if (previous?.parentThreadId === parent.id && previous.agentId === agent.id) + this.releaseChildOwnership(agent.childThreadId, true); + } + const previous = this.childOwnerships.get(child); + if (previous && (previous.parentThreadId !== parent.id || previous.agentId !== agent.id)) { + const previousAgent = this.threads.get(previous.parentThreadId)?.agents.get(previous.agentId); + if (!live && previousAgent?.childThreadId === child) return; + this.releaseChildOwnership(child, false); + } + agent.childThreadId = child; + agent.raw.childThreadId = child; + this.childOwnerships.set(child, {parentThreadId: parent.id, agentId: agent.id}); + if (!parent.childThreadOrder.includes(child)) parent.childThreadOrder.push(child); + if (!this.threads.has(child)) this.threads.set(child, newThread(child)); + this.orderedThreads = this.orderedThreads.filter(id => id !== child); + this.synchronizeOwningAgent(child); + } + + private releaseChildOwnership(child: string, promoteToRoot: boolean): void { + const previous = this.childOwnerships.get(child); + if (!previous) return; + const parent = this.threads.get(previous.parentThreadId); + if (parent) { + parent.childThreadOrder = parent.childThreadOrder.filter(id => id !== child); + const agent = parent.agents.get(previous.agentId); + if (agent?.childThreadId === child) { + agent.childThreadId = ""; + delete agent.raw.childThreadId; + } + } + this.childOwnerships.delete(child); + if (promoteToRoot && this.threads.has(child) && !this.orderedThreads.includes(child)) this.orderedThreads.push(child); + } + + private owningAgent(child: string): {parent: ThreadPresentation; agent: AgentPresentation} | undefined { + const ownership = this.childOwnerships.get(child); + if (!ownership) return undefined; + const parent = this.threads.get(ownership.parentThreadId); + const agent = parent?.agents.get(ownership.agentId); + return parent && agent ? {parent, agent} : undefined; + } + + private agentSourceItem(parent: ThreadPresentation, agent: AgentPresentation): ItemPresentation | undefined { + return parent.turns.get(agent.ownerTurnId)?.items.get(agent.itemId); + } + + private setAgentStatus(parent: ThreadPresentation, agent: AgentPresentation, status: string): void { + agent.status = status; + agent.raw.status = status; + const item = this.agentSourceItem(parent, agent); + if (item) item.raw.status = status; + } + + private setAgentResult(parent: ThreadPresentation, agent: AgentPresentation, resultText: string): void { + agent.raw.resultText = resultText; + const item = this.agentSourceItem(parent, agent); + if (item) item.raw.resultText = resultText; + } + + private clearAgentResult(parent: ThreadPresentation, agent: AgentPresentation): void { + delete agent.raw.resultText; + const item = this.agentSourceItem(parent, agent); + if (item) delete item.raw.resultText; + } + + private updateOwningAgentStatus(child: string, status: string): void { + if (status === "") return; + const owner = this.owningAgent(child); + if (!owner) return; + this.setAgentStatus(owner.parent, owner.agent, + isTerminalTurnStatus(owner.agent.status) && isActiveStatus(status) ? owner.agent.status : status); + } + + private updateOwningAgentResult(child: string, resultText: string): void { + if (resultText === "") return; + const owner = this.owningAgent(child); + if (owner) this.setAgentResult(owner.parent, owner.agent, resultText); + } + + private synchronizeOwningAgent(childId: string, clearMissingResult = false): void { + const owner = this.owningAgent(childId); + const child = this.threads.get(childId); + if (!owner || !child) return; + let childStatus = child.status === "notLoaded" ? "" : child.status; + let resultText = ""; + for (let turnIndex = child.turnOrder.length - 1; turnIndex >= 0; --turnIndex) { + const turn = child.turns.get(child.turnOrder[turnIndex]!); + if (!turn) continue; + if (childStatus === "" && turn.status !== "") childStatus = turn.status; + for (let itemIndex = turn.itemOrder.length - 1; itemIndex >= 0; --itemIndex) { + const item = turn.items.get(turn.itemOrder[itemIndex]!); + if (!item || stringMember(item.raw, "type") !== "agentMessage") continue; + resultText = stringMember(item.raw, "text"); + if (resultText !== "") break; + } + if (resultText !== "" && childStatus !== "") break; + } + this.updateOwningAgentStatus(childId, childStatus); + if (resultText === "" && clearMissingResult) this.clearAgentResult(owner.parent, owner.agent); + else if (resultText !== "") this.setAgentResult(owner.parent, owner.agent, resultText); + } + + private removeThread(threadId: string): void { + const thread = this.threads.get(threadId); + if (!thread) return; + const children = [...thread.childThreadOrder]; + const foundIndex = this.orderedThreads.indexOf(threadId); + const rootIndex = foundIndex < 0 ? this.orderedThreads.length : foundIndex; + for (const child of children) this.releaseChildOwnership(child, false); + this.releaseChildOwnership(threadId, false); + this.threads.delete(threadId); + this.orderedThreads = this.orderedThreads.filter(id => id !== threadId); + let insertion = Math.min(rootIndex, this.orderedThreads.length); + for (const child of children) { + if (!this.threads.has(child) || this.childOwnerships.has(child)) continue; + this.orderedThreads.splice(insertion++, 0, child); + } + } + + private clearProviderState(): void { + this.orderedThreads = []; + this.threads.clear(); + this.childOwnerships.clear(); + this.pendingRequests.clear(); + this.models = []; + this.retainedGlobalDomains.clear(); + } + + private retainDomainEvent(type: string, data: unknown, scope: JsonObject, authority: string): void { + const threadId = stringMember(scope, "threadId"); + const turnId = stringMember(scope, "turnId"); + const itemId = stringMember(scope, "itemId"); + if (itemId !== "") { + const item = this.findItem(scope); + if (item) applyDomainAuthority(item.domains, type, data, authority); + } else if (turnId !== "") { + const turn = this.findTurn(threadId, turnId); + if (turn) applyDomainAuthority(turn.domains, type, data, authority); + } else if (threadId !== "") { + const thread = this.threads.get(threadId); + if (thread) applyDomainAuthority(thread.domains, type, data, authority); + } else applyDomainAuthority(this.retainedGlobalDomains, type, data, authority); + } + + private findTurn(threadId: string, turnId: string): TurnPresentation | undefined { + return this.threads.get(threadId)?.turns.get(turnId); + } + + private findItem(params: unknown): ItemPresentation | undefined { + return this.findTurn(stringMember(params, "threadId"), stringMember(params, "turnId")) + ?.items.get(stringMember(params, "itemId")); + } +} diff --git a/web/src/presentation/PresentationStatus.ts b/web/src/presentation/PresentationStatus.ts new file mode 100644 index 0000000..20f5c84 --- /dev/null +++ b/web/src/presentation/PresentationStatus.ts @@ -0,0 +1,27 @@ +export type StatusKind = "unknown" | "active" | "completed" | "failed" | "interrupted"; + +export interface PresentationStatus { + readonly kind: StatusKind; + readonly text: string; + readonly tone: string; +} + +export function classifyStatus(status: string): PresentationStatus { + if (["active", "inProgress", "running", "started"].includes(status)) + return {kind: "active", text: "Running", tone: "active"}; + if (["completed", "idle"].includes(status)) + return {kind: "completed", text: "Completed", tone: "success"}; + if (["failed", "systemError"].includes(status)) + return {kind: "failed", text: "Failed", tone: "danger"}; + if (status === "interrupted") + return {kind: "interrupted", text: "Interrupted", tone: "warning"}; + return {kind: "unknown", text: status === "" ? "Unknown" : status, tone: ""}; +} + +export function isActiveStatus(status: string): boolean { + return classifyStatus(status).kind === "active"; +} + +export function isTerminalTurnStatus(status: string): boolean { + return ["completed", "failed", "interrupted"].includes(classifyStatus(status).kind); +} diff --git a/web/tests/model-parity.test.mjs b/web/tests/model-parity.test.mjs new file mode 100644 index 0000000..6ebe06a --- /dev/null +++ b/web/tests/model-parity.test.mjs @@ -0,0 +1,223 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + PresentationModel, + ProtocolNormalizer, + event, + result, +} from "../dist/index.js"; + +function pipeline() { + const model = new PresentationModel(); + const normalizer = new ProtocolNormalizer(frame => { + model.applyEvent(frame); + return true; + }); + return {model, normalizer}; +} + +test("C++ presentation pipeline state and replacement invariants", () => { + const {model, normalizer} = pipeline(); + normalizer.transportEvent("connected"); + normalizer.bridgeEvent({kind: "bridge.connection", event: "connected", connectionId: "frontend-test", role: "observer"}); + normalizer.bridgeEvent({kind: "bridge.controller", controllerConnectionId: "frontend-test"}); + normalizer.connectionSettings({selected: "ipv6", available: [{key: "ipv6", label: "IPv6"}]}); + normalizer.operationResult("threads.list", "list-1", {}, { + id: "list-1", result: {data: [{id: "thread-1", preview: "Architecture pipeline", cwd: "/workspace", status: {type: "idle"}}]}, + }); + normalizer.operationResult("thread.read", "read-1", {threadId: "thread-1"}, { + id: "read-1", result: {thread: { + id: "thread-1", preview: "Architecture pipeline", cwd: "/workspace", status: {type: "idle"}, + turns: [{id: "turn-1", status: "completed", items: [{id: "user-1", type: "userMessage"}]}], + }}, + }); + normalizer.serverNotification("turn/started", { + threadId: "thread-1", turn: {id: "turn-2", status: "inProgress", items: []}, + }); + normalizer.serverNotification("item/started", { + threadId: "thread-1", turnId: "turn-2", + item: {id: "command-1", type: "commandExecution", command: "printf PIPELINE_OK", cwd: "/workspace", status: "inProgress"}, + }); + normalizer.serverNotification("item/commandExecution/outputDelta", { + threadId: "thread-1", turnId: "turn-2", itemId: "command-1", delta: "PIPELINE_OK\n", + }); + normalizer.serverNotification("item/completed", { + threadId: "thread-1", turnId: "turn-2", + item: {id: "command-1", type: "commandExecution", command: "printf PIPELINE_OK", cwd: "/workspace", status: "completed", aggregatedOutput: "PIPELINE_OK\n", exitCode: 0}, + }); + normalizer.serverNotification("turn/diff/updated", {threadId: "thread-1", turnId: "turn-2", diff: "diff text"}); + normalizer.serverNotification("turn/plan/updated", { + threadId: "thread-1", turnId: "turn-2", explanation: "Keep live inspector state", + plan: [{step: "Retain the plan", status: "completed"}], + }); + normalizer.serverNotification("turn/completed", { + threadId: "thread-1", turn: {id: "turn-2", status: "completed", items: []}, + }); + normalizer.operationResult("thread.read", "read-2", {threadId: "thread-1"}, { + id: "read-2", result: {thread: {id: "thread-1", status: {type: "idle"}, turns: []}}, + }); + normalizer.operationResult("thread.resume", "resume-1", {threadId: "thread-1"}, { + id: "resume-1", result: {thread: {id: "thread-1"}, model: "gpt-current", reasoningEffort: "high"}, + }); + normalizer.serverNotification("thread/settings/updated", { + threadId: "thread-1", threadSettings: {model: "gpt-current", personality: "friendly"}, + }); + normalizer.serverNotification("thread/settings/updated", { + threadId: "thread-1", threadSettings: {personality: null}, + }); + + const thread = model.thread("thread-1"); + const turn = thread.turns.get("turn-2"); + const item = turn.items.get("command-1"); + assert.deepEqual(model.connection(), { + connected: true, retrying: false, generation: 1, connectionId: "frontend-test", role: "controller", + controllerConnectionId: "frontend-test", detail: "", providerGeneration: 0, providerState: "", + providerDetail: "", settings: {selected: "ipv6", available: [{key: "ipv6", label: "IPv6"}]}, + }); + assert.deepEqual(thread.turnOrder, ["turn-1", "turn-2"]); + assert.equal(thread.raw.model, "gpt-current"); + assert.equal(thread.raw.reasoningEffort, "high"); + assert.deepEqual(thread.domains.get("thread.settings.changed"), { + threadId: "thread-1", + threadSettings: {model: "gpt-current", personality: null}, + }); + assert.equal(thread.settingsRevision, 2); + assert.equal(turn.status, "completed"); + assert.deepEqual(turn.plan, {explanation: "Keep live inspector state", steps: [{step: "Retain the plan", status: "completed"}]}); + assert.equal(turn.domains.get("turn.diff.changed").diff, "diff text"); + assert.equal(item.raw.aggregatedOutput, "PIPELINE_OK\n"); + assert.equal(item.raw.exitCode, 0); + assert.equal(model.activeTurnId("thread-1"), undefined); + + normalizer.operationResult("thread.read", "stale-active", {threadId: "thread-1"}, { + id: "stale-active", result: {thread: {id: "thread-1", status: {type: "active"}, turns: [ + {id: "turn-1", status: "completed"}, {id: "turn-2", status: "inProgress"}, + ]}}, + }); + assert.equal(thread.turns.get("turn-2").status, "completed"); + assert.equal(thread.status, "idle"); + assert.equal(model.activeTurnId("thread-1"), undefined); +}); + +test("C++ ordering, domains, telemetry, generation, and repository hints", () => { + const model = new PresentationModel(); + model.applyEvent(event(1, 1, "thread.upsert", {thread: {id: "retained-a"}}, "merge", {threadId: "retained-a"})); + model.applyEvent(event(2, 1, "thread.upsert", {thread: {id: "retained-b"}}, "merge", {threadId: "retained-b"})); + model.applyEvent(result(3, 1, "threads.list", "list", true, { + threads: [{id: "provider-a"}, {id: "provider-b"}, {id: "provider-a"}], + }, "merge")); + assert.deepEqual(model.threadOrder(), ["provider-a", "provider-b", "retained-b", "retained-a"]); + + model.applyEvent(event(4, 1, "catalog.skills.invalidated", {revision: 1}, "none")); + model.applyEvent(event(5, 1, "catalog.models.changed", {models: ["a"]}, "replace")); + model.applyEvent(event(6, 1, "catalog.models.changed", {}, "remove")); + assert.equal(model.telemetry().length, 1); + assert.equal(model.globalDomains().has("catalog.models.changed"), false); + + model.applyEvent(result(7, 1, "thread.read", "hints", true, {thread: { + id: "repository-thread", cwd: "/workspace", turns: [{id: "repository-turn", items: [ + {id: "command", type: "commandExecution", cwd: "/workspace/project/src"}, + {id: "change", type: "fileChange", changes: [{path: "lib/example.cpp"}, {path: "removed.txt"}]}, + ]}], + }}, "replace", {threadId: "repository-thread"})); + assert.deepEqual(model.thread("repository-thread").commandCwds, ["/workspace/project/src"]); + assert.deepEqual(model.thread("repository-thread").changedPaths, ["lib/example.cpp", "removed.txt"]); + + model.applyEvent(event(1, 2, "connection.lifecycle", {state: "connected"}, "replace")); + model.applyEvent(event(8, 1, "thread.upsert", {thread: {id: "stale"}}, "merge", {threadId: "stale"})); + assert.equal(model.thread("stale"), undefined); + model.applyEvent(event(2, 2, "pending-request.upsert", {requestId: 42, category: "approval", request: {}}, "merge", {threadId: "provider-a"})); + assert.equal(model.pendingRequestCount(), 1); + model.applyEvent(event(3, 2, "connection.lifecycle", {state: "retrying", detail: "again"}, "replace")); + assert.equal(model.pendingRequestCount(), 0); + assert.equal(model.connection().retrying, true); +}); + +test("C++ child ownership, correlation, replacement, and removal invariants", () => { + const model = new PresentationModel(); + model.applyEvent(result(1, 1, "threads.list", "roots", true, { + threads: [{id: "parent"}, {id: "child-one"}, {id: "second-root"}], + }, "merge")); + const item = (sequence, threadId, turnId, id, child) => model.applyEvent(event( + sequence, 1, "conversation.item.upsert", + {item: {id, type: "subAgentActivity", status: "started", agentThreadId: child}}, + "merge", {threadId, turnId, itemId: id}, + )); + item(2, "parent", "parent-turn", "spawn-one", "child-one"); + item(3, "parent", "parent-turn", "spawn-two", "child-two"); + item(4, "child-one", "child-turn", "spawn-grandchild", "grandchild"); + assert.deepEqual(model.thread("parent").childThreadOrder, ["child-one", "child-two"]); + assert.deepEqual(model.thread("child-one").childThreadOrder, ["grandchild"]); + assert.deepEqual(model.threadOrder(), ["parent", "second-root"]); + assert.deepEqual(model.childOwnership("child-one"), {parentThreadId: "parent", agentId: "spawn-one"}); + + model.applyEvent(event(5, 1, "agents.activity.upsert", {activity: { + id: "peer", type: "subAgentActivity", kind: "interacted", agentPath: "/root/child-two", agentThreadId: "child-two", + }}, "merge", {threadId: "child-one", turnId: "child-turn", itemId: "peer"})); + assert.equal(model.thread("child-one").agents.has("peer"), false); + assert.equal(model.childOwnership("child-two").parentThreadId, "parent"); + assert.equal(model.thread("parent").agents.get("spawn-two").raw.agentPath, "/root/child-two"); + + model.applyEvent(event(6, 1, "turn.upsert", {turn: {id: "child-turn", status: "completed"}}, "merge", {threadId: "child-one", turnId: "child-turn"})); + model.applyEvent(event(7, 1, "conversation.item.upsert", {item: { + id: "child-answer", type: "agentMessage", text: "direct child result", + }}, "merge", {threadId: "child-one", turnId: "child-turn", itemId: "child-answer"})); + const parent = model.thread("parent"); + assert.equal(parent.agents.get("spawn-one").status, "completed"); + assert.equal(parent.agents.get("spawn-one").raw.resultText, "direct child result"); + assert.equal(parent.turns.get("parent-turn").items.get("spawn-one").raw.resultText, "direct child result"); + + model.applyEvent(result(8, 1, "thread.read", "replace-child", true, {thread: { + id: "child-one", status: {type: "idle"}, turns: [{id: "child-turn", status: "inProgress", items: [ + {id: "spawn-grandchild", type: "subAgentActivity", status: "started", agentThreadId: "grandchild"}, + ]}], + }}, "replace", {threadId: "child-one"})); + assert.equal(parent.agents.get("spawn-one").status, "idle"); + assert.equal(Object.hasOwn(parent.agents.get("spawn-one").raw, "resultText"), false); + + model.applyEvent(event(9, 1, "thread.removed", {}, "remove", {threadId: "child-one"})); + assert.equal(model.thread("child-one"), undefined); + assert.equal(model.childOwnership("grandchild"), undefined); + assert.deepEqual(model.threadOrder(), ["parent", "second-root", "grandchild"]); + model.applyEvent(event(10, 1, "thread.removed", {}, "remove", {threadId: "parent"})); + assert.deepEqual(model.threadOrder(), ["child-two", "second-root", "grandchild"]); +}); + +test("C++ live agent rebind and cycle protection invariants", () => { + const model = new PresentationModel(); + model.applyEvent(event(1, 1, "thread.upsert", {thread: {id: "rebind-parent"}}, "merge", {threadId: "rebind-parent"})); + model.applyEvent(event(2, 1, "conversation.item.upsert", {item: { + id: "stable-agent", type: "subAgentActivity", status: "completed", resultText: "old result", agentThreadId: "old-child", + }}, "merge", {threadId: "rebind-parent", turnId: "rebind-turn", itemId: "stable-agent"})); + model.applyEvent(event(3, 1, "agents.activity.upsert", {activity: { + type: "subAgentActivity", status: "started", agentThreadId: "new-child", + }}, "merge", {threadId: "rebind-parent", turnId: "rebind-turn", itemId: "stable-agent"})); + const parent = model.thread("rebind-parent"); + const agent = parent.agents.get("stable-agent"); + assert.deepEqual(parent.childThreadOrder, ["new-child"]); + assert.equal(model.childOwnership("old-child"), undefined); + assert.deepEqual(model.threadOrder(), ["rebind-parent", "old-child"]); + assert.equal(agent.childThreadId, "new-child"); + assert.equal(agent.status, "started"); + assert.equal(Object.hasOwn(agent.raw, "resultText"), false); + + model.applyEvent(event(4, 1, "turn.upsert", {turn: {id: "new-child-turn", status: "completed"}}, "merge", {threadId: "new-child", turnId: "new-child-turn"})); + model.applyEvent(event(5, 1, "conversation.item.upsert", {item: { + id: "answer", type: "agentMessage", text: "new child result", + }}, "merge", {threadId: "new-child", turnId: "new-child-turn", itemId: "answer"})); + model.applyEvent(result(6, 1, "thread.read", "stale", true, {thread: { + id: "rebind-parent", turns: [{id: "rebind-turn", items: [{ + id: "stable-agent", type: "subAgentActivity", status: "started", agentThreadId: "old-child", + }]}], + }}, "merge", {threadId: "rebind-parent"})); + assert.equal(agent.childThreadId, "new-child"); + assert.equal(agent.status, "completed"); + assert.equal(agent.raw.resultText, "new child result"); + + model.applyEvent(event(7, 1, "agents.activity.upsert", {activity: { + type: "subAgentActivity", status: "started", agentThreadId: "rebind-parent", + }}, "merge", {threadId: "new-child", turnId: "cycle-turn", itemId: "cycle-agent"})); + assert.equal(model.childOwnership("rebind-parent"), undefined); + assert.deepEqual(model.thread("new-child").childThreadOrder, []); +}); From 13c79bf98c94a8d823e3e29f2a13b1dc456102ff Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Fri, 28 Aug 2026 04:36:58 +0200 Subject: [PATCH 04/16] Match C++ conversation projection behavior --- .../conversation/ConversationProjection.ts | 189 +++++++++++++ web/src/conversation/MiddleTypes.ts | 111 ++++++++ web/src/conversation/PromptCoordinator.ts | 253 ++++++++++++++++++ web/src/index.ts | 3 + .../conversation-projection-parity.test.mjs | 144 ++++++++++ 5 files changed, 700 insertions(+) create mode 100644 web/src/conversation/ConversationProjection.ts create mode 100644 web/src/conversation/MiddleTypes.ts create mode 100644 web/src/conversation/PromptCoordinator.ts create mode 100644 web/tests/conversation-projection-parity.test.mjs diff --git a/web/src/conversation/ConversationProjection.ts b/web/src/conversation/ConversationProjection.ts new file mode 100644 index 0000000..16d5f3b --- /dev/null +++ b/web/src/conversation/ConversationProjection.ts @@ -0,0 +1,189 @@ +import type {ItemPresentation, ThreadPresentation} from "../presentation/PresentationModel.js"; +import {isObject, member, stringMember} from "../presentation/PresentationProtocol.js"; +import {AuthoritativeHistoryPageSize, terminalOutputHasVisibleText} from "./MiddleTypes.js"; +import type { + AgentActivityData, AgentMessageData, AuthoritativeItemKey, CardKey, CardKind, CardPayload, + CommandExecutionData, ConversationSnapshot, FileChangeData, FileChangesData, GenericActivityData, + ImageGenerationData, LocalPromptData, ReasoningData, UserMessageData, VisibleCardData, +} from "./MiddleTypes.js"; +import { + authoritativePosition, indexAuthoritativeItems, localCardVisible, +} from "./PromptCoordinator.js"; +import type {AuthoritativeItemIndex, PromptSubmission} from "./PromptCoordinator.js"; + +export const DefaultAuthoritativeItemLimit = AuthoritativeHistoryPageSize; + +function integerValue(object: unknown, key: string): number | undefined { + const value = isObject(object) ? object[key] : undefined; + return typeof value === "number" && Number.isInteger(value) ? value : undefined; +} +function messageText(item: unknown): string { + const type = stringMember(item, "type"); + if (type === "agentMessage" || type === "plan") return stringMember(item, "text"); + if (type !== "userMessage") return ""; + const parts: string[] = []; + const content = member(item, "content", []); + if (Array.isArray(content)) for (const entry of content) { + const value = stringMember(entry, "text"); if (value !== "") parts.push(value); + } + if (parts.length === 0) { const fallback = stringMember(item, "text"); if (fallback !== "") parts.push(fallback); } + return parts.join("\n"); +} +function messageImagePaths(item: unknown): string[] { + const content = member(item, "content", []); + return Array.isArray(content) ? content.filter(entry => stringMember(entry, "type") === "localImage") + .map(entry => stringMember(entry, "path")).filter(Boolean) : []; +} +function localImagePaths(submission: PromptSubmission): string[] { + return submission.attachments.filter(value => value.mimeType.startsWith("image/")).map(value => value.path); +} +function stringList(value: unknown): string[] { + return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : []; +} +function unifiedDiffCounts(diff: string): [number, number] { + let additions = 0, deletions = 0; + for (const line of diff.split("\n")) { + if (line.startsWith("+++ ") || line.startsWith("--- ")) continue; + if (line.startsWith("+")) ++additions; else if (line.startsWith("-")) ++deletions; + } + return [additions, deletions]; +} +function authoritativeCard(identity: AuthoritativeItemKey, presentation: ItemPresentation, visualKey: CardKey): VisibleCardData { + const item = presentation.raw; + const type = stringMember(item, "type"); + let kind: CardKind = "genericActivity"; + let payload: CardPayload = {type, raw: structuredClone(item)} satisfies GenericActivityData; + if (type === "userMessage") { + kind = "userMessage"; payload = {text: messageText(item), imagePaths: messageImagePaths(item)} satisfies UserMessageData; + } else if (type === "agentMessage") { + kind = "agentMessage"; + payload = {text: messageText(item), finalAnswer: stringMember(item, "phase") === "final_answer"} satisfies AgentMessageData; + } else if (type === "commandExecution") { + kind = "commandExecution"; + let output = stringMember(item, "aggregatedOutput") || stringMember(item, "output"); + if (!terminalOutputHasVisibleText(output)) output = ""; + const exitCode = integerValue(item, "exitCode"); + const durationMilliseconds = integerValue(item, "durationMs") ?? integerValue(item, "duration_ms"); + payload = { + command: stringMember(item, "command"), output, status: stringMember(item, "status"), cwd: stringMember(item, "cwd"), + ...(exitCode !== undefined ? {exitCode} : {}), + ...(durationMilliseconds !== undefined ? {durationMilliseconds} : {}), + } satisfies CommandExecutionData; + } else if (type === "collabAgentToolCall" || type === "subAgentActivity") { + kind = "agentActivity"; + payload = { + tool: stringMember(item, "tool"), status: stringMember(item, "status"), kind: stringMember(item, "kind"), + prompt: stringMember(item, "prompt"), resultText: stringMember(item, "resultText"), + receivers: stringList(member(item, "receiverThreadIds", [])), model: stringMember(item, "model"), + reasoningEffort: stringMember(item, "reasoningEffort"), childThreadId: stringMember(item, "agentThreadId"), + agentPath: stringMember(item, "agentPath"), senderThreadId: stringMember(item, "senderThreadId"), + } satisfies AgentActivityData; + } else if (type === "reasoning") { + kind = "reasoning"; payload = {summary: stringList(member(item, "summary", [])).join(", ")} satisfies ReasoningData; + } else if (type === "fileChange") { + kind = "fileChanges"; + const rawChanges = member(item, "changes", []); + const changes: FileChangeData[] = Array.isArray(rawChanges) ? rawChanges.map(change => { + const diff = stringMember(change, "diff"); + if (diff === "") return {path: stringMember(change, "path"), kind: stringMember(change, "kind")}; + const [additions, deletions] = unifiedDiffCounts(diff); + return {path: stringMember(change, "path"), kind: stringMember(change, "kind"), additions, deletions}; + }) : []; + payload = {status: stringMember(item, "status"), changes} satisfies FileChangesData; + } else if (type === "imageGeneration" || type === "imageView") { + kind = "imageGeneration"; + payload = { + path: stringMember(item, "path") || stringMember(item, "savedPath") || stringMember(item, "saved_path"), + status: stringMember(item, "status"), + revisedPrompt: stringMember(item, "revisedPrompt") || stringMember(item, "revised_prompt"), + } satisfies ImageGenerationData; + } else if (type === "plan" && messageText(item) !== "") { + kind = "plan"; payload = {explanation: "", steps: [], legacyText: messageText(item)}; + } + return {key: visualKey, kind, threadId: identity.threadId, turnId: identity.turnId, itemId: identity.itemId, payload}; +} +function sectionComponent(prefix: string, threadId: string, suffix: string): string { + return `${prefix}${threadId.length}:${threadId}${suffix.length}:${suffix}`; +} +function admissionBoundaryPosition(anchor: AuthoritativeItemKey | undefined, atStart: boolean, + index: AuthoritativeItemIndex): number | undefined { + if (anchor) { const position = authoritativePosition(index, anchor); if (position !== undefined) return (position + 1) * 2; } + return atStart ? 0 : undefined; +} +function submissionPosition(submission: PromptSubmission, index: AuthoritativeItemIndex, materialized?: number): number { + const admitted = admissionBoundaryPosition(submission.admissionAnchor, submission.admissionAtStart, index); + if (admitted !== undefined) return admitted; + if (materialized !== undefined) return materialized * 2 + 1; + return index.ordered.length * 2 + 2; +} +interface ProjectedNode { + position: number; tieBreaker: number; sectionKey: string; turnId: string; card: VisibleCardData; +} + +export function projectConversation( + source: AuthoritativeItemIndex | ThreadPresentation, + localSubmissions: readonly PromptSubmission[], + authoritativeItemLimit: number, + nowMilliseconds: number, + thread?: ThreadPresentation, +): ConversationSnapshot { + const authoritativeItems = "ordered" in source ? source : indexAuthoritativeItems(source.id, source); + const authoritativeThread = "ordered" in source ? thread : source; + const hidden = Math.max(0, authoritativeItems.ordered.length - authoritativeItemLimit); + const result: ConversationSnapshot = { + threadId: authoritativeItems.threadId, sections: [], hiddenAuthoritativeItemCount: hidden, hasMore: hidden > 0, + }; + const bindings = new Map(); + for (const submission of localSubmissions) if (submission.materializedItem) + bindings.set(`${submission.materializedItem.threadId}\0${submission.materializedItem.turnId}\0${submission.materializedItem.itemId}`, submission); + const nodes: ProjectedNode[] = []; + for (let index = hidden; index < authoritativeItems.ordered.length; ++index) { + const item = authoritativeItems.ordered[index]!; + const identity = `${item.key.threadId}\0${item.key.turnId}\0${item.key.itemId}`; + const binding = bindings.get(identity); + if (binding && localCardVisible(binding, nowMilliseconds)) continue; + let visualKey: CardKey = item.promptAlias?.key ?? item.key; + if (binding) visualKey = {kind: "prompt", submissionId: binding.id}; + let position = index * 2 + 1; + let tieBreaker = 0; + if (binding) { position = submissionPosition(binding, authoritativeItems, index); tieBreaker = binding.admissionOrdinal; } + else if (item.promptAlias) { + position = admissionBoundaryPosition(item.promptAlias.admissionAnchor, + item.promptAlias.admissionAnchor === undefined, authoritativeItems) ?? position; + tieBreaker = item.promptAlias.admissionOrdinal; + } + nodes.push({position, tieBreaker, sectionKey: sectionComponent("turn:", authoritativeItems.threadId, item.key.turnId), + turnId: item.key.turnId, card: authoritativeCard(item.key, item.presentation, visualKey)}); + } + for (const submission of localSubmissions) { + if (!localCardVisible(submission, nowMilliseconds)) continue; + const materialized = submission.materializedItem + ? authoritativePosition(authoritativeItems, submission.materializedItem) : undefined; + const position = submissionPosition(submission, authoritativeItems, materialized); + const knownTurn = authoritativeThread !== undefined && submission.expectedTurnId !== undefined + && authoritativeThread.turns.has(submission.expectedTurnId); + const turnId = submission.expectedTurnId ?? ""; + const sectionKey = knownTurn ? sectionComponent("turn:", authoritativeItems.threadId, turnId) : `pending:${submission.id}`; + const payload: LocalPromptData = { + submissionId: submission.id, prompt: submission.prompt, + state: submission.state === "queued" ? "inFlight" : submission.state, + acceptedAtMilliseconds: submission.acceptedAtMilliseconds, error: submission.error, + imagePaths: localImagePaths(submission), + }; + nodes.push({position, tieBreaker: submission.admissionOrdinal, sectionKey, turnId, card: { + key: {kind: "prompt", submissionId: submission.id}, kind: "localPrompt", threadId: authoritativeItems.threadId, + turnId, itemId: "", payload, + }}); + } + nodes.sort((left, right) => left.position - right.position || left.tieBreaker - right.tieBreaker); + const sectionIndexes = new Map(); + for (const node of nodes) { + let sectionIndex = sectionIndexes.get(node.sectionKey); + if (sectionIndex === undefined) { + sectionIndex = result.sections.length; sectionIndexes.set(node.sectionKey, sectionIndex); + result.sections.push({key: node.sectionKey, turnId: node.turnId, cards: []}); + } + result.sections[sectionIndex]!.cards.push(node.card); + } + return result; +} diff --git a/web/src/conversation/MiddleTypes.ts b/web/src/conversation/MiddleTypes.ts new file mode 100644 index 0000000..ffda526 --- /dev/null +++ b/web/src/conversation/MiddleTypes.ts @@ -0,0 +1,111 @@ +import type {JsonObject} from "../presentation/PresentationProtocol.js"; + +export const AcknowledgementTransitionMilliseconds = 500; +export const AuthoritativeHistoryPageSize = 80; + +export interface AuthoritativeItemKey {kind: "item"; threadId: string; turnId: string; itemId: string} +export interface LocalPromptKey {kind: "prompt"; submissionId: number} +export interface TurnPlanKey {kind: "plan"; threadId: string; turnId: string} +export type CardKey = AuthoritativeItemKey | LocalPromptKey | TurnPlanKey; +export type PromptState = "queued" | "inFlight" | "accepted" | "failed"; +export type CardKind = "userMessage" | "agentMessage" | "commandExecution" | "agentActivity" + | "reasoning" | "fileChanges" | "imageGeneration" | "plan" | "genericActivity" | "localPrompt"; + +export interface UserMessageData {text: string; imagePaths: string[]} +export interface AgentMessageData {text: string; finalAnswer: boolean} +export interface CommandExecutionData { + command: string; output: string; status: string; cwd: string; exitCode?: number; durationMilliseconds?: number; +} +export interface AgentActivityData { + tool: string; status: string; kind: string; prompt: string; resultText: string; receivers: string[]; + model: string; reasoningEffort: string; childThreadId: string; agentPath: string; senderThreadId: string; +} +export interface ReasoningData {summary: string} +export interface FileChangeData {path: string; kind: string; additions?: number; deletions?: number} +export interface FileChangesData {status: string; changes: FileChangeData[]} +export interface ImageGenerationData {path: string; status: string; revisedPrompt: string} +export interface PlanStepData {text: string; status: string} +export interface PlanData {explanation: string; steps: PlanStepData[]; legacyText: string} +export interface GenericActivityData {type: string; raw: JsonObject} +export interface LocalPromptData { + submissionId: number; prompt: string; state: PromptState; acceptedAtMilliseconds: number; + error: string; imagePaths: string[]; +} +export type CardPayload = UserMessageData | AgentMessageData | CommandExecutionData | AgentActivityData + | ReasoningData | FileChangesData | ImageGenerationData | PlanData | GenericActivityData | LocalPromptData; +export interface VisibleCardData { + key: CardKey; kind: CardKind; threadId: string; turnId: string; itemId: string; payload: CardPayload; +} +export interface TurnSection {key: string; turnId: string; cards: VisibleCardData[]} +export interface ConversationSnapshot { + threadId: string; sections: TurnSection[]; hiddenAuthoritativeItemCount: number; hasMore: boolean; +} + +function component(value: string): string { return `${value.length}:${value}`; } +export function stableKey(key: CardKey): string { + if (key.kind === "item") return `item:${component(key.threadId)}${component(key.turnId)}${component(key.itemId)}`; + if (key.kind === "plan") return `plan:${component(key.threadId)}${component(key.turnId)}`; + return `prompt:${key.submissionId}`; +} + +export function terminalOutputHasVisibleText(output: string): boolean { + for (let index = 0; index < output.length; ++index) { + const code = output.charCodeAt(index); + if (code === 0x9b) { + while (++index < output.length) { const c = output.charCodeAt(index); if (c >= 0x40 && c <= 0x7e) break; } + continue; + } + if ([0x90, 0x98, 0x9d, 0x9e, 0x9f].includes(code)) { + while (++index < output.length) { + const c = output.charCodeAt(index); + if (c === 0x07 || c === 0x9c) break; + if (c === 0x1b && output[index + 1] === "\\") { ++index; break; } + } + continue; + } + if (code === 0x1b) { + if (++index >= output.length) break; + const introducer = output[index]; + if (introducer === "[") { + while (++index < output.length) { const c = output.charCodeAt(index); if (c >= 0x40 && c <= 0x7e) break; } + continue; + } + if ("]P^_X".includes(introducer ?? "")) { + while (++index < output.length) { + const c = output.charCodeAt(index); + if (c === 0x07 || c === 0x9c) break; + if (c === 0x1b && output[index + 1] === "\\") { ++index; break; } + } + continue; + } + const introCode = introducer?.charCodeAt(0) ?? 0; + if (introCode >= 0x20 && introCode <= 0x2f) + while (++index < output.length) { const c = output.charCodeAt(index); if (c >= 0x30 && c <= 0x7e) break; } + continue; + } + if (code > 0x1f && code !== 0x7f && !/\s/u.test(output[index] ?? "")) return true; + } + return false; +} + +export function trimTrailingEmptyLines(text: string): string { + let end = text.length; + while (end > 0) { + while (end > 0 && (text[end - 1] === "\n" || text[end - 1] === "\r")) --end; + if (end === 0) break; + let start = end; + while (start > 0 && text[start - 1] !== "\n" && text[start - 1] !== "\r") --start; + if ([...text.slice(start, end)].some(character => !/\s/u.test(character))) break; + end = start; + } + return text.slice(0, end); +} + +export function cardKeys(snapshot: ConversationSnapshot): CardKey[] { + return snapshot.sections.flatMap(section => section.cards.map(card => card.key)); +} + +export function findCard(snapshot: ConversationSnapshot, key: CardKey): VisibleCardData | undefined { + const identity = stableKey(key); + return snapshot.sections.flatMap(section => section.cards).find(card => stableKey(card.key) === identity); +} diff --git a/web/src/conversation/PromptCoordinator.ts b/web/src/conversation/PromptCoordinator.ts new file mode 100644 index 0000000..2a5c5a4 --- /dev/null +++ b/web/src/conversation/PromptCoordinator.ts @@ -0,0 +1,253 @@ +import type {ThreadPresentation, ItemPresentation} from "../presentation/PresentationModel.js"; +import {isObject, stringMember} from "../presentation/PresentationProtocol.js"; +import {AcknowledgementTransitionMilliseconds} from "./MiddleTypes.js"; +import type {AuthoritativeItemKey, LocalPromptKey, PromptState} from "./MiddleTypes.js"; + +export interface AttachmentDraft {path: string; name: string; mimeType: string; size: number} +export interface PromptSubmission { + id: number; admissionOrdinal: number; threadId: string; clientUserMessageId: string; prompt: string; + attachments: AttachmentDraft[]; turnOptions: Record; state: PromptState; + acceptedAtMilliseconds: number; error: string; admissionAnchor?: AuthoritativeItemKey; + admissionAtStart: boolean; expectedTurnId?: string; materializedItem?: AuthoritativeItemKey; +} +export interface PromptDispatch { + id: number; threadId: string; clientUserMessageId: string; prompt: string; attachments: AttachmentDraft[]; + turnOptions: Record; expectedTurnId?: string; +} +export interface PromptVisualAlias {key: LocalPromptKey; admissionAnchor?: AuthoritativeItemKey; admissionOrdinal: number} +interface RetainedPromptVisualAlias extends PromptVisualAlias {materializedItem: AuthoritativeItemKey} +export interface AuthoritativeItem {key: AuthoritativeItemKey; presentation: ItemPresentation; promptAlias?: PromptVisualAlias} +export interface UserMessageByText {turnId: string; text: string; position: number} +export interface AuthoritativeItemIndex { + threadId: string; ordered: AuthoritativeItem[]; positions: Map; + userMessagesByClientId: Map; userMessagesByText: UserMessageByText[]; +} + +export function authoritativeKey(key: AuthoritativeItemKey): string { + return `${key.threadId.length}:${key.threadId}${key.turnId.length}:${key.turnId}${key.itemId.length}:${key.itemId}`; +} +export function authoritativePosition(index: AuthoritativeItemIndex, key: AuthoritativeItemKey): number | undefined { + return index.positions.get(authoritativeKey(key)); +} +function userMessageText(item: unknown): string { + const parts: string[] = []; + const content = isObject(item) ? item.content : undefined; + if (Array.isArray(content)) for (const entry of content) { + const text = stringMember(entry, "text"); + if (text !== "") parts.push(text); + } + if (parts.length === 0) { const fallback = stringMember(item, "text"); if (fallback !== "") parts.push(fallback); } + return parts.join("\n"); +} +export function indexAuthoritativeItems(threadId: string, thread?: ThreadPresentation): AuthoritativeItemIndex { + const result: AuthoritativeItemIndex = { + threadId, ordered: [], positions: new Map(), userMessagesByClientId: new Map(), userMessagesByText: [], + }; + if (!thread) return result; + for (const turnId of thread.turnOrder) { + const turn = thread.turns.get(turnId); + if (!turn) continue; + for (const itemId of turn.itemOrder) { + const presentation = turn.items.get(itemId); + if (!presentation) continue; + const position = result.ordered.length; + const key: AuthoritativeItemKey = {kind: "item", threadId, turnId, itemId}; + result.ordered.push({key, presentation}); + result.positions.set(authoritativeKey(key), position); + if (stringMember(presentation.raw, "type") === "userMessage") { + const clientId = stringMember(presentation.raw, "clientId"); + if (clientId !== "" && !result.userMessagesByClientId.has(clientId)) + result.userMessagesByClientId.set(clientId, position); + const text = userMessageText(presentation.raw).trim(); + result.userMessagesByText.push({turnId: "", text, position}, {turnId, text, position}); + } + } + } + result.userMessagesByText.sort((a, b) => a.turnId.localeCompare(b.turnId) || a.text.localeCompare(b.text) || a.position - b.position); + return result; +} + +export function promptWithFileLinks(prompt: string, attachments: readonly AttachmentDraft[]): string { + const links = attachments.filter(file => !file.mimeType.startsWith("image/") && !file.mimeType.startsWith("audio/")) + .map(file => { + const label = file.name.replaceAll("\\", "\\\\").replaceAll("[", "\\[").replaceAll("]", "\\]") + .replace(/[\r\n]/gu, " "); + const target = new URL(`file://${file.path.startsWith("/") ? "" : "/"}${file.path}`).href + .replaceAll("[", "%5B").replaceAll("]", "%5D").replaceAll("(", "%28").replaceAll(")", "%29"); + return `- [${label}](${target})`; + }); + return links.length === 0 ? prompt : `${prompt}\n\nAttached files:\n${links.join("\n")}`; +} + +export function acceptedTransitionActive(submission: PromptSubmission, now: number): boolean { + return submission.state === "accepted" && submission.acceptedAtMilliseconds > 0 + && now >= submission.acceptedAtMilliseconds + && now - submission.acceptedAtMilliseconds < AcknowledgementTransitionMilliseconds; +} +export function localCardVisible(submission: PromptSubmission, now: number): boolean { + return submission.state === "queued" || submission.state === "inFlight" || submission.state === "failed" + || submission.materializedItem === undefined || acceptedTransitionActive(submission, now); +} +function cloneKey(key: AuthoritativeItemKey): AuthoritativeItemKey { return {...key}; } + +export class PromptCoordinator { + private readonly byThread = new Map(); + private readonly visualAliasesByThread = new Map>(); + private nextSubmissionId = 1; + private nextAdmissionOrdinal = 1; + + admit(threadId: string, prompt: string, attachments: AttachmentDraft[], turnOptions: Record, + authoritativeThread: ThreadPresentation | undefined, activeTurnId: string | undefined, now: number): number { + const submission: PromptSubmission = { + id: this.nextSubmissionId++, admissionOrdinal: this.nextAdmissionOrdinal++, threadId, + clientUserMessageId: `codexui-${now}-${this.nextSubmissionId - 1}`, prompt, attachments: structuredClone(attachments), + turnOptions: structuredClone(turnOptions), state: "queued", acceptedAtMilliseconds: 0, error: "", + admissionAtStart: false, + }; + if (activeTurnId !== undefined) submission.expectedTurnId = activeTurnId; + if (authoritativeThread) { + const items = indexAuthoritativeItems(threadId, authoritativeThread); + const last = items.ordered.at(-1); + if (last) submission.admissionAnchor = cloneKey(last.key); + } + const list = this.byThread.get(threadId) ?? []; + if (!this.byThread.has(threadId)) this.byThread.set(threadId, list); + list.push(submission); + return submission.id; + } + + beginNext(threadId: string, activeTurnId?: string): PromptDispatch | undefined { + const list = this.byThread.get(threadId); + if (!list || list.some(value => value.state === "inFlight")) return undefined; + const next = list.find(value => value.state === "queued"); + if (!next) return undefined; + next.admissionAtStart = next.admissionAnchor === undefined; + next.state = "inFlight"; + if (activeTurnId === undefined) delete next.expectedTurnId; else next.expectedTurnId = activeTurnId; + const dispatch: PromptDispatch = { + id: next.id, threadId: next.threadId, clientUserMessageId: next.clientUserMessageId, + prompt: next.prompt, attachments: structuredClone(next.attachments), turnOptions: structuredClone(next.turnOptions), + }; + if (next.expectedTurnId !== undefined) dispatch.expectedTurnId = next.expectedTurnId; + return dispatch; + } + + acknowledge(threadId: string, id: number, turnId: string | undefined, now: number): boolean { + const pending = this.find(threadId, id); + if (!pending || pending.state !== "inFlight") return false; + pending.state = "accepted"; pending.acceptedAtMilliseconds = now; pending.error = ""; + if (turnId !== undefined) pending.expectedTurnId = turnId; + return true; + } + fail(threadId: string, id: number, error: string): boolean { + const pending = this.find(threadId, id); + if (!pending || (pending.state !== "inFlight" && pending.state !== "queued")) return false; + pending.state = "failed"; pending.error = error; return true; + } + requeue(threadId: string, id: number): boolean { + const pending = this.find(threadId, id); + if (!pending || pending.state !== "inFlight") return false; + pending.state = "queued"; pending.admissionAtStart = false; return true; + } + failQueued(threadId: string, error: string): number { + let count = 0; + for (const pending of this.byThread.get(threadId) ?? []) if (pending.state === "queued") { + pending.state = "failed"; pending.error = error; ++count; + } + return count; + } + reassignThread(from: string, to: string): boolean { + if (from === to) return true; + const source = this.byThread.get(from); + const target = this.byThread.get(to) ?? []; + if (source?.some(value => value.state === "inFlight") && target.some(value => value.state === "inFlight")) return false; + if (source) { + this.byThread.delete(from); + for (const submission of source) { + submission.threadId = to; + if (submission.admissionAnchor) submission.admissionAnchor.threadId = to; + if (submission.materializedItem) submission.materializedItem.threadId = to; + } + target.push(...source); target.sort((a, b) => a.admissionOrdinal - b.admissionOrdinal); this.byThread.set(to, target); + } + const aliases = this.visualAliasesByThread.get(from); + if (aliases) { + this.visualAliasesByThread.delete(from); + const destination = this.visualAliasesByThread.get(to) ?? new Map(); + for (const alias of aliases.values()) { + alias.materializedItem.threadId = to; + if (alias.admissionAnchor) alias.admissionAnchor.threadId = to; + destination.set(authoritativeKey(alias.materializedItem), alias); + } + this.visualAliasesByThread.set(to, destination); + } + return true; + } + + reconcile(threadId: string, threadOrIndex: ThreadPresentation | AuthoritativeItemIndex, now: number): AuthoritativeItemIndex { + const index = "ordered" in threadOrIndex ? threadOrIndex : indexAuthoritativeItems(threadId, threadOrIndex); + this.applyVisualAliases(threadId, index); + const submissions = this.byThread.get(threadId); + if (!submissions) return index; + const claimed = index.ordered.map(item => item.promptAlias !== undefined); + for (const submission of submissions) if (submission.materializedItem) { + const position = authoritativePosition(index, submission.materializedItem); if (position !== undefined) claimed[position] = true; + } + for (const submission of submissions) { + if (submission.materializedItem) continue; + if (!submission.admissionAnchor && submission.state === "queued" && index.ordered.length > 0) { + submission.admissionAnchor = cloneKey(index.ordered.at(-1)!.key); submission.admissionAtStart = false; + } + const exact = index.userMessagesByClientId.get(submission.clientUserMessageId); + if (exact === undefined || claimed[exact]) continue; + submission.materializedItem = cloneKey(index.ordered[exact]!.key); + submission.expectedTurnId = submission.materializedItem.turnId; + claimed[exact] = true; + } + for (const submission of submissions) { + if (submission.materializedItem || submission.state !== "accepted") continue; + let first = 0; + if (submission.admissionAnchor) first = (authoritativePosition(index, submission.admissionAnchor) ?? -1) + 1; + const turnId = submission.expectedTurnId ?? ""; + const candidate = index.userMessagesByText.find(value => value.turnId === turnId + && value.text === submission.prompt.trim() && value.position >= first && !claimed[value.position]); + if (!candidate) continue; + submission.materializedItem = cloneKey(index.ordered[candidate.position]!.key); + submission.expectedTurnId ??= submission.materializedItem.turnId; + claimed[candidate.position] = true; + } + const aliases = this.visualAliasesByThread.get(threadId) ?? new Map(); + for (const submission of submissions) { + if (submission.state !== "accepted" || !submission.materializedItem || acceptedTransitionActive(submission, now)) continue; + aliases.set(authoritativeKey(submission.materializedItem), { + key: {kind: "prompt", submissionId: submission.id}, admissionOrdinal: submission.admissionOrdinal, + materializedItem: cloneKey(submission.materializedItem), + ...(submission.admissionAnchor ? {admissionAnchor: cloneKey(submission.admissionAnchor)} : {}), + }); + } + this.visualAliasesByThread.set(threadId, aliases); + this.byThread.set(threadId, submissions.filter(submission => submission.state !== "accepted" + || !submission.materializedItem || acceptedTransitionActive(submission, now))); + this.applyVisualAliases(threadId, index); + return index; + } + + submissions(threadId: string): readonly PromptSubmission[] { return this.byThread.get(threadId) ?? []; } + submission(threadId: string, id: number): PromptSubmission | undefined { return this.find(threadId, id); } + hasInFlight(threadId: string): boolean { return this.submissions(threadId).some(value => value.state === "inFlight"); } + queuedThreadIds(): string[] { + return [...this.byThread].filter(([, values]) => values.some(value => value.state === "queued")).map(([id]) => id).sort(); + } + clearThread(threadId: string): void { this.byThread.delete(threadId); this.visualAliasesByThread.delete(threadId); } + private find(threadId: string, id: number): PromptSubmission | undefined { + return this.byThread.get(threadId)?.find(value => value.id === id); + } + private applyVisualAliases(threadId: string, index: AuthoritativeItemIndex): void { + const aliases = this.visualAliasesByThread.get(threadId); + if (!aliases) return; + for (const [key, retained] of aliases) { + const position = index.positions.get(key); + if (position !== undefined) index.ordered[position]!.promptAlias = retained; + } + } +} diff --git a/web/src/index.ts b/web/src/index.ts index b6d41c0..509a81b 100644 --- a/web/src/index.ts +++ b/web/src/index.ts @@ -2,3 +2,6 @@ export * from "./presentation/PresentationProtocol.js"; export * from "./presentation/PresentationStatus.js"; export * from "./presentation/ProtocolNormalizer.js"; export * from "./presentation/PresentationModel.js"; +export * from "./conversation/MiddleTypes.js"; +export * from "./conversation/PromptCoordinator.js"; +export * from "./conversation/ConversationProjection.js"; diff --git a/web/tests/conversation-projection-parity.test.mjs b/web/tests/conversation-projection-parity.test.mjs new file mode 100644 index 0000000..14f4c0f --- /dev/null +++ b/web/tests/conversation-projection-parity.test.mjs @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + PromptCoordinator, cardKeys, findCard, indexAuthoritativeItems, projectConversation, + promptWithFileLinks, stableKey, terminalOutputHasVisibleText, trimTrailingEmptyLines, +} from "../dist/index.js"; + +function item(id, raw) { return {id, raw, domains: new Map()}; } +function baseThread(id) { + const thread = { + id, title: "", preview: "", cwd: "", status: "", commandCwds: [], changedPaths: [], + turnOrder: ["turn-1"], turns: new Map(), raw: {}, domains: new Map(), latestSettingsUpdate: {}, + settingsRevision: 0, agentOrder: [], agents: new Map(), childThreadOrder: [], archived: false, + }; + thread.turns.set("turn-1", { + id: "turn-1", status: "completed", itemOrder: ["user-old", "answer-old"], items: new Map([ + ["user-old", item("user-old", {type: "userMessage", content: [{type: "text", text: "old prompt"}]})], + ["answer-old", item("answer-old", {type: "agentMessage", phase: "final_answer", text: "old answer"})], + ]), plan: {}, raw: {}, domains: new Map(), + }); + return thread; +} +function addTurn(thread, id, status = "inProgress") { + thread.turnOrder.push(id); + thread.turns.set(id, {id, status, itemOrder: [], items: new Map(), plan: {}, raw: {}, domains: new Map()}); +} +function append(thread, turnId, id, raw) { + const turn = thread.turns.get(turnId); turn.itemOrder.push(id); turn.items.set(id, item(id, raw)); +} +function keys(snapshot) { return cardKeys(snapshot).map(stableKey); } + +test("C++ canonical grouping, payload projection, and history limit", () => { + const thread = baseThread("thread-a"); + addTurn(thread, "turn-2"); + append(thread, "turn-2", "command", {type: "commandExecution", command: "true", status: "completed", aggregatedOutput: " \n\t\x1b[0m"}); + append(thread, "turn-2", "files", {type: "fileChange", status: "completed", changes: [ + {path: "src/card.cpp", kind: "update", diff: "--- a\n+++ b\n-old\n+new\n++++literal\n+extra\n"}, + ]}); + append(thread, "turn-2", "image", {type: "imageGeneration", status: "completed", savedPath: "/tmp/generated.png", revised_prompt: "proposal", result: "A".repeat(10000)}); + const snapshot = projectConversation(thread, [], 80, 10); + assert.equal(snapshot.sections.length, 2); + assert.equal(snapshot.sections[0].cards.length, 2); + assert.equal(snapshot.sections[1].cards.length, 3); + assert.equal(snapshot.sections[1].cards[0].payload.output, ""); + assert.deepEqual(snapshot.sections[1].cards[1].payload.changes[0], {path: "src/card.cpp", kind: "update", additions: 3, deletions: 1}); + assert.deepEqual(snapshot.sections[1].cards[2].payload, {path: "/tmp/generated.png", status: "completed", revisedPrompt: "proposal"}); + assert.notEqual(stableKey(snapshot.sections[0].cards[0].key), stableKey(snapshot.sections[0].cards[1].key)); + const limited = projectConversation(thread, [], 1, 10); + assert.equal(limited.hasMore, true); + assert.equal(limited.hiddenAuthoritativeItemCount, 4); + assert.equal(keys(limited).length, 1); +}); + +test("C++ prompt queue isolation and callback-only acknowledgement", () => { + const first = baseThread("thread-a"); + const second = baseThread("thread-b"); + const prompts = new PromptCoordinator(); + const firstId = prompts.admit(first.id, "same", [], {}, first, undefined, 100); + const secondId = prompts.admit(second.id, "other", [], {}, second, undefined, 101); + assert.equal(prompts.beginNext(first.id).id, firstId); + assert.equal(prompts.beginNext(first.id), undefined); + assert.equal(prompts.beginNext(second.id).id, secondId); + addTurn(first, "turn-2"); + append(first, "turn-2", "user-new", {type: "userMessage", content: [{type: "text", text: "same"}]}); + prompts.reconcile(first.id, first, 199); + assert.equal(prompts.submission(first.id, firstId).state, "inFlight"); + assert.equal(prompts.submission(first.id, firstId).materializedItem, undefined); + assert.equal(prompts.acknowledge(first.id, firstId, "turn-2", 200), true); + prompts.reconcile(first.id, first, 200); + assert.equal(prompts.submission(first.id, firstId).materializedItem.itemId, "user-new"); + const transitioning = projectConversation(first, prompts.submissions(first.id), 80, 699); + const localKey = {kind: "prompt", submissionId: firstId}; + assert.equal(findCard(transitioning, localKey).kind, "localPrompt"); + const materialized = projectConversation(first, prompts.submissions(first.id), 80, 700); + assert.equal(findCard(materialized, localKey).kind, "userMessage"); + const index = indexAuthoritativeItems(first.id, first); + prompts.reconcile(first.id, index, 700); + const compacted = projectConversation(index, prompts.submissions(first.id), 80, 701, first); + assert.equal(prompts.submission(first.id, firstId), undefined); + assert.equal(findCard(compacted, localKey).kind, "userMessage"); +}); + +test("C++ first response order remains at the local prompt admission boundary", () => { + const thread = baseThread("thread-reasoning-first"); + thread.turnOrder = []; + thread.turns.clear(); + const prompts = new PromptCoordinator(); + const promptId = prompts.admit(thread.id, "new prompt", [], {}, thread, undefined, 600); + const dispatch = prompts.beginNext(thread.id); + addTurn(thread, "turn-new"); + append(thread, "turn-new", "reasoning", {type: "reasoning", summary: []}); + prompts.reconcile(thread.id, thread, 601); + const promptKey = stableKey({kind: "prompt", submissionId: promptId}); + const reasoningKey = stableKey({kind: "item", threadId: thread.id, turnId: "turn-new", itemId: "reasoning"}); + assert.deepEqual(keys(projectConversation(thread, prompts.submissions(thread.id), 80, 601)), [promptKey, reasoningKey]); + + append(thread, "turn-new", "user-new", { + type: "userMessage", clientId: dispatch.clientUserMessageId, content: [{type: "text", text: "new prompt"}], + }); + prompts.reconcile(thread.id, thread, 602); + assert.deepEqual(keys(projectConversation(thread, prompts.submissions(thread.id), 80, 602)), [promptKey, reasoningKey]); + assert.equal(prompts.acknowledge(thread.id, promptId, "turn-new", 700), true); + prompts.reconcile(thread.id, thread, 700); + assert.deepEqual(keys(projectConversation(thread, prompts.submissions(thread.id), 80, 700)), [promptKey, reasoningKey]); + const index = indexAuthoritativeItems(thread.id, thread); + prompts.reconcile(thread.id, index, 1200); + const blue = projectConversation(index, prompts.submissions(thread.id), 80, 1200, thread); + assert.deepEqual(keys(blue), [promptKey, reasoningKey]); + assert.equal(findCard(blue, {kind: "prompt", submissionId: promptId}).kind, "userMessage"); +}); + +test("C++ duplicate prompts bind in admission order and share one turn section", () => { + const thread = baseThread("duplicates"); + const prompts = new PromptCoordinator(); + const first = prompts.admit(thread.id, "repeat", [], {}, thread, undefined, 1000); + const second = prompts.admit(thread.id, "repeat", [], {}, thread, undefined, 1001); + prompts.beginNext(thread.id); prompts.acknowledge(thread.id, first, "turn-2", 1010); + prompts.beginNext(thread.id, "turn-2"); prompts.acknowledge(thread.id, second, "turn-2", 1020); + addTurn(thread, "turn-2"); + append(thread, "turn-2", "repeat-1", {type: "userMessage", content: [{type: "text", text: "repeat"}]}); + append(thread, "turn-2", "repeat-2", {type: "userMessage", content: [{type: "text", text: "repeat"}]}); + prompts.reconcile(thread.id, thread, 1021); + assert.equal(prompts.submission(thread.id, first).materializedItem.itemId, "repeat-1"); + assert.equal(prompts.submission(thread.id, second).materializedItem.itemId, "repeat-2"); + const snapshot = projectConversation(thread, prompts.submissions(thread.id), 80, 1021); + const visible = keys(snapshot); + assert.ok(visible.indexOf(`prompt:${first}`) < visible.indexOf(`prompt:${second}`)); + assert.equal(snapshot.sections[1].turnId, "turn-2"); + assert.equal(snapshot.sections[1].cards.length, 2); +}); + +test("C++ terminal text and canonical attachment links", () => { + assert.equal(terminalOutputHasVisibleText(""), false); + assert.equal(terminalOutputHasVisibleText(" \n\t"), false); + assert.equal(terminalOutputHasVisibleText("\x1b[0m\x1b]0;title\x07"), false); + assert.equal(terminalOutputHasVisibleText("done\n"), true); + assert.equal(trimTrailingEmptyLines("first\nsecond\n\n \t\r\n"), "first\nsecond"); + assert.equal(trimTrailingEmptyLines(" meaningful spacing "), " meaningful spacing "); + assert.equal(promptWithFileLinks("Review this", [ + {path: "/tmp/review notes [final] (2).pdf", name: "review notes [final] (2).pdf", mimeType: "application/pdf", size: 10}, + {path: "/tmp/image.png", name: "image.png", mimeType: "image/png", size: 10}, + ]), "Review this\n\nAttached files:\n- [review notes \\[final\\] (2).pdf](file:///tmp/review%20notes%20%5Bfinal%5D%20%282%29.pdf)"); +}); From 24e3878a90295660205074a1d2f1d24c75946c8f Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Fri, 28 Aug 2026 04:47:34 +0200 Subject: [PATCH 05/16] Build browser shell on the equal frontend pipeline --- web/.gitignore | 1 + web/index.html | 13 + web/package-lock.json | 358 +++++++++++++++++++++- web/package.json | 11 +- web/src/app/App.tsx | 168 ++++++++++ web/src/app/BrowserFrontendSession.ts | 260 ++++++++++++++++ web/src/index.ts | 1 + web/src/main.tsx | 10 + web/src/styles.css | 101 ++++++ web/src/vite-env.d.ts | 1 + web/tests/browser-session-parity.test.mjs | 82 +++++ web/tsconfig.json | 4 +- web/vite.config.ts | 6 + 13 files changed, 1013 insertions(+), 3 deletions(-) create mode 100644 web/index.html create mode 100644 web/src/app/App.tsx create mode 100644 web/src/app/BrowserFrontendSession.ts create mode 100644 web/src/main.tsx create mode 100644 web/src/styles.css create mode 100644 web/src/vite-env.d.ts create mode 100644 web/tests/browser-session-parity.test.mjs create mode 100644 web/vite.config.ts diff --git a/web/.gitignore b/web/.gitignore index 3d2bc62..55a4d2f 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -1,2 +1,3 @@ /dist/ +/app-dist/ /node_modules/ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..e668125 --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + CodexUI + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json index 9cb893d..59cb131 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,7 +8,14 @@ "name": "@snodec/codex-ui-web", "version": "0.1.0", "dependencies": { - "@snodec/codex-frontend": "file:../../../AISuite-extraction/AISuite-final/packages/codex-frontend" + "@snodec/codex-frontend": "file:../../../AISuite-extraction/AISuite-final/packages/codex-frontend", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@types/react": "18.3.27", + "@types/react-dom": "18.3.7", + "vite": "5.4.21" } }, "../../../AISuite-extraction/AISuite-final/packages/codex-frontend": { @@ -16,9 +23,358 @@ "version": "0.1.0", "license": "LGPL-3.0-or-later OR MIT" }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", + "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@snodec/codex-frontend": { "resolved": "../../../AISuite-extraction/AISuite-final/packages/codex-frontend", "link": true + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.27", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/rollup": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", + "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.54.0", + "@rollup/rollup-android-arm64": "4.54.0", + "@rollup/rollup-darwin-arm64": "4.54.0", + "@rollup/rollup-darwin-x64": "4.54.0", + "@rollup/rollup-freebsd-arm64": "4.54.0", + "@rollup/rollup-freebsd-x64": "4.54.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", + "@rollup/rollup-linux-arm-musleabihf": "4.54.0", + "@rollup/rollup-linux-arm64-gnu": "4.54.0", + "@rollup/rollup-linux-arm64-musl": "4.54.0", + "@rollup/rollup-linux-loong64-gnu": "4.54.0", + "@rollup/rollup-linux-ppc64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-musl": "4.54.0", + "@rollup/rollup-linux-s390x-gnu": "4.54.0", + "@rollup/rollup-linux-x64-gnu": "4.54.0", + "@rollup/rollup-linux-x64-musl": "4.54.0", + "@rollup/rollup-openharmony-arm64": "4.54.0", + "@rollup/rollup-win32-arm64-msvc": "4.54.0", + "@rollup/rollup-win32-ia32-msvc": "4.54.0", + "@rollup/rollup-win32-x64-gnu": "4.54.0", + "@rollup/rollup-win32-x64-msvc": "4.54.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } } } } diff --git a/web/package.json b/web/package.json index 1ce18c0..b2efefe 100644 --- a/web/package.json +++ b/web/package.json @@ -5,9 +5,18 @@ "type": "module", "scripts": { "build": "tsc -p tsconfig.json", + "build:app": "vite build", + "dev": "vite --host 0.0.0.0", "test": "npm run build && node --test tests/*.test.mjs" }, "dependencies": { - "@snodec/codex-frontend": "file:../../../AISuite-extraction/AISuite-final/packages/codex-frontend" + "@snodec/codex-frontend": "file:../../../AISuite-extraction/AISuite-final/packages/codex-frontend", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@types/react": "18.3.27", + "@types/react-dom": "18.3.7", + "vite": "5.4.21" } } diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx new file mode 100644 index 0000000..f018c87 --- /dev/null +++ b/web/src/app/App.tsx @@ -0,0 +1,168 @@ +import {useMemo, useState, useSyncExternalStore} from "react"; +import type {FormEvent, ReactNode} from "react"; +import {classifyStatus, stableKey} from "../index.js"; +import type { + AgentActivityData, CommandExecutionData, FileChangesData, LocalPromptData, + ReasoningData, UserMessageData, AgentMessageData, VisibleCardData, +} from "../index.js"; +import type {BrowserFrontendSession} from "./BrowserFrontendSession.js"; + +function humanize(value: string): string { + if (value === "contextCompaction") return "Context compaction"; + const spaced = value.replaceAll(/[._/-]+/gu, " ").replace(/([a-z\d])([A-Z])/gu, "$1 $2").trim(); + return spaced === "" ? "Activity" : spaced[0]!.toUpperCase() + spaced.slice(1); +} + +function StatusDot({tone}: {tone: string}) { return