diff --git a/.project-docs/30-worklog/tasks/20260823-pi-renderer-store-b7e2c4a1.md b/.project-docs/30-worklog/tasks/20260823-pi-renderer-store-b7e2c4a1.md new file mode 100644 index 0000000..2146f3e --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260823-pi-renderer-store-b7e2c4a1.md @@ -0,0 +1,158 @@ +# Task: Implement PI-110 Renderer Snapshot Patch Store + +## Identity + +- Task ID: 20260823-pi-renderer-store-b7e2c4a1 +- Mode: Feature +- Branch: codex/20260823-pi-renderer-store-b7e2c4a1-pi-renderer-store +- Worktree: D:\Datas\OthersProjects\makelore-pi-renderer-store-b7e2c4a1 +- Base commit: a0722574f3b36cb73ec8833787711e6bef1c5c54 +- Owner: codex-root +- Status: In Progress + +## Scope + +- Implement PI-110's vendor-neutral Renderer facade and one Zustand store keyed + by stable Makelore `conversationId`. +- Consume the shared PI-010 Snapshot/Patch reducer for snapshot-first SSE, + stale-generation discard, target-only gap recovery, and reconnect. +- Own per-Conversation snapshot/run/error/queue/model/draft/unread/cursor plus + optimistic prompt request state and fine-grained selectors. +- Add focused facade/store/SSE/render-count tests. Do not migrate Chat UI, + feature-complete controls, or remove the old OpenCode store in this ticket. + +## Intent And Constraints + +- Base is the planner-approved PI-100 cumulative HEAD `a072257`; the exact and + sole Ready Frontier is `{PI-110}`. +- Renderer access goes only through `src/lib/host-api.ts`; the SSE facade must + initialize the cached Host token/base before constructing EventSource. +- Reuse the PI-010 contract/reducer as the single product normalization seam. + The first real Renderer consumer exposed that its Electron-only source + location requires unavailable composite-project declarations in a clean + worktree, so the canonical modules now live in `shared/` and Electron keeps + thin re-exports. Do not duplicate Pi/OpenCode event mapping or import vendor + types, paths, wire enums, credentials, or raw diagnostics. +- A generation/sequence gap invalidates and reloads only its target + Conversation. Old generations are ignored and accepted prompts are never + replayed because an event stream reconnects. +- Optimistic user identity is stable across durable reconciliation by + `clientRequestId`. Definite rejection and uncertain acceptance retain enough + request/draft/attachment state for explicit UI recovery; neither path + silently resends. +- Store updates replace only the target Conversation entry. Timeline selectors + for the selected Conversation must not rerender when a hidden Conversation + streams; hidden/sidebar consumers use lightweight summaries. +- PI-120 owns Composer and timeline UI; PI-130 owns model/thinking/queue/ + compaction/interaction/subagent/changes UI; PI-140 owns old OpenCode removal. +- Real Provider remains Explicitly Waived / Accepted Risk with + `realTurnVerified=false`; macOS remains deferred to mandatory PI-150. Neither + is Pass evidence. No subagents are authorized. + +## Project Context Loaded + +- Task identity: `20260823-pi-renderer-store-b7e2c4a1`, feature mode, isolated + branch/worktree above, base `a0722574f3b36cb73ec8833787711e6bef1c5c54`. +- Concurrent Task Gate passed. The only semantically adjacent planning task, + `20260820-partner-create-model-parity-c83d`, still has no defined scope or + owned files. The canonical main integration task is Blocked and is not a + write target. Other active tasks concern unrelated Canvas/Robot/Learning or + review scopes. +- Planning Gate inputs read: memory index, project positioning/success fields, + current state, decision/system/architecture/domain documents, evidence/ + reflection/commitment/stale indexes, PI-010 and PI-100 records, and the exact + PI-110 ticket plus relevant Spec sections. +- The canonical project-positioning fields remain placeholders; repository + guidance and the planner-owned Pi Spec/ticket are authoritative here. +- Confirmed seams: vendor-neutral contracts and strict reducer in + `electron/coding-runtime`, snapshot-first `/api/coding/events`, target GET + snapshot, `hostApiFetch`, `ensureHostApiToken`, and Zustand selector patterns. +- No accepted ADR changes product positioning or conflicts with this task. + Existing real OpenCode/provider commitments remain historical and do not + authorize a compatibility store or runtime fallback. +- Gate result: Passed. + +## Plan + +1. Add a Renderer-owned type surface and minimal typed `/api/coding` facade for + target snapshot, prompt acceptance, and initialized global/target SSE. +2. Implement a dependency-injectable Zustand store that keeps reducer state, + summary, draft, unread, request lifecycle, loading/recovery and errors per + Conversation while owning selection and one global event stream. +3. Route snapshot and ordered patch events through the shared reducer; ignore + stale generation, reload only a gapped target, and never replay mutations on + reconnect. +4. Add optimistic user-node/draft/attachment reconciliation and granular + selectors that preserve unrelated Conversation object identity. +5. Add facade, two-Conversation SSE, gap/reconnect, optimistic failure, vendor + boundary and render-count regressions; then run focused/full verification, + build, documentation gates, and planner review. + +## Outcome + +- Added a Renderer-owned product type surface and minimal typed Host facade for + target snapshots, prompt acceptance, and EventSource creation. EventSource + construction first hydrates the cached Host token/base, including a cold + Renderer process. +- Added one dependency-injectable vanilla Zustand store with React selectors. + Each Conversation owns its reducer snapshot/cursor, load and target error, + unread state, lightweight summary, draft revision/attachments, and prompt + request lifecycle. Store updates preserve unrelated Conversation identity. +- Added snapshot-first SSE ingestion, native reconnect state, stale-generation + discard, per-target sequence/generation-gap recovery, load single-flight, + and connection-generation protection. Reconnect never replays a prompt and + an obsolete connection flight cannot clear a newer one. +- Added optimistic user nodes keyed by `clientRequestId`. Durable upsert keeps + the optimistic UI node id; definite rejection restores an untouched draft + and marks the node failed; uncertain delivery restores the draft while + retaining the request/node for explicit reconciliation without resend. +- Promoted the existing product-neutral PI-010 contracts, reducer, product + detail validator, and subagent detail validator to `shared/`. Existing + Electron paths are compatibility-free source re-exports of the same + functions, so Main, Renderer, fixtures, and tests cannot drift into separate + reducer implementations. +- Added facade/store tests for two interleaved Conversations, target-only gap + recovery, stale generations, native reconnect, connection-flight ABA, + optimistic accepted/rejected/uncertain outcomes, attachment restoration, + vendor-neutral source boundaries, shared reducer identity, and render-count/ + summary identity under hidden streaming. +- PI-120/130/140 boundaries remain unchanged. No Chat page or legacy OpenCode + store consumer was changed by PI-110. + +## Verification + +- `pnpm exec vitest run tests/unit/coding-conversations-facade.test.ts tests/unit/coding-conversations-store.test.tsx`: + 2 files / 12 tests passed. +- `pnpm exec vitest run` for the two PI-110 tests plus PI-010 contracts, + coding core routes, event projector, Conversation runtime, product tools, + and subagent suites: 8 files / 75 tests passed. +- `pnpm run typecheck`: passed after the shared product seam removed the clean + Renderer -> composite Electron declaration dependency. +- `pnpm run lint:check`: passed with zero errors and the same six unrelated + Renderer warnings in `ExecutionGraphCard.tsx`, Home, and Makelore. +- `pnpm test`: 211 files / 2275 passed / 2 skipped (2277 total). +- `pnpm run build:vite`: Renderer, Electron Main, Preload, and release utility + production builds passed; existing dynamic-import and large-chunk warnings + remain. +- Electron E2E is not applicable yet: PI-110 intentionally exposes no page or + user-visible interaction. The real Chat/Composer binding and its E2E belong + to PI-120; the render-count store acceptance is covered directly here. +- Real Provider remains Explicitly Waived / Accepted Risk with + `realTurnVerified=false`. macOS x64/arm64 remains deferred to mandatory + PI-150. Neither is recorded as Pass. + +## Follow-ups + +- PI-120 should bind the Composer/timeline to this store without restoring any + read from `src/stores/opencode.ts`. + +## Promotion Candidates + +- Type: Architecture. Proposal: keep Makelore-owned Conversation contracts and + the strict Snapshot/Patch reducer in `shared/`, with Main and Renderer + importing/re-exporting that single implementation rather than depending on + Electron composite build artifacts or copying normalization logic. Evidence: + clean `pnpm run typecheck`, shared/Main function identity regression, 8-file + 75-test impacted suite, and full 211-file unit/build verification. Future + impact: PI-120/130 can consume product DTOs without vendor/Main imports; any + contract amendment must retain Main/Renderer reducer equivalence. diff --git a/electron/coding-runtime/contracts.ts b/electron/coding-runtime/contracts.ts index 49cb690..2ba4ce0 100644 --- a/electron/coding-runtime/contracts.ts +++ b/electron/coding-runtime/contracts.ts @@ -1,435 +1 @@ -export type ConversationThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high'; - -export interface ProductModelRef { - accountId: string; - modelId: string; - thinkingLevel: ConversationThinkingLevel; -} - -export interface ConversationModelState { - model: ProductModelRef | null; - modelResolution: 'resolved' | 'required'; -} - -export interface PublicUsage { - inputTokens: number; - outputTokens: number; - cacheReadTokens?: number; - cacheWriteTokens?: number; -} - -export type CodingRuntimeErrorCode = - | 'CODING_RUNTIME_START_FAILED' - | 'CODING_RUNTIME_READY_TIMEOUT' - | 'CODING_RUNTIME_PROTOCOL_ERROR' - | 'CODING_PROVIDER_AUTH_REQUIRED' - | 'CODING_MODEL_UNAVAILABLE' - | 'CODING_SESSION_UNREADABLE' - | 'CODING_STORAGE_WRITE_FAILED' - | 'CODING_REQUEST_UNCERTAIN' - | 'CODING_MIGRATION_MODEL_REQUIRED' - | 'CODING_CONVERSATION_NOT_FOUND'; - -export interface CodingRuntimePublicError { - code: CodingRuntimeErrorCode; - message: string; - recoverable: boolean; -} - -export type ConversationRunStatus = - | 'idle' - | 'preparing' - | 'queued' - | 'running' - | 'retrying' - | 'compacting' - | 'aborting' - | 'error'; - -export type PromptMode = 'prompt' | 'steer' | 'follow-up'; - -export interface ConversationRunState { - status: ConversationRunStatus; - runId?: string; - mode?: PromptMode; - startedAt?: number; - settledAt?: number; - terminalReason?: 'completed' | 'aborted' | 'failed'; - retry?: { - attempt: number; - delayMs: number; - }; - error?: CodingRuntimePublicError; -} - -export interface ConversationQueueItem { - id: string; - clientRequestId: string; - mode: 'steer' | 'follow-up'; - text: string; - attachmentIds: string[]; -} - -export interface ConversationQueueState { - items: ConversationQueueItem[]; -} - -export interface ConversationContextState { - usedTokens: number; - contextWindow: number; - outputLimit?: number; - compaction: 'idle' | 'running'; - lastCompactionId?: string; - recalculating?: boolean; -} - -export interface ConversationInteractionOption { - id: string; - label: string; - description?: string; -} - -export interface ConversationInteraction { - id: string; - conversationId: string; - runId: string; - kind: 'select' | 'confirm' | 'input' | 'editor'; - title: string; - message?: string; - options?: ConversationInteractionOption[]; - status: 'pending' | 'answered' | 'rejected' | 'cancelled'; -} - -export interface PublicWorkerState { - status: 'stopped' | 'starting' | 'ready' | 'recovering' | 'error'; - generation: number; - error?: CodingRuntimePublicError; -} - -export type ConversationContentBlock = - | { - kind: 'text'; - id: string; - text: string; - status: 'streaming' | 'complete'; - } - | { - kind: 'thinking'; - id: string; - text: string; - status: 'streaming' | 'complete'; - } - | { - kind: 'image'; - id: string; - attachmentId: string; - mime: string; - }; - -export interface ConversationMessageNode { - kind: 'message'; - id: string; - sourceEntryId?: string; - clientRequestId?: string; - role: 'user' | 'assistant'; - status: 'optimistic' | 'streaming' | 'complete' | 'error' | 'aborted'; - blocks: ConversationContentBlock[]; - usage?: PublicUsage; - stopReason?: 'stop' | 'length' | 'tool-use' | 'error' | 'aborted'; -} - -export interface ChangedFileDetailsV1 { - schema: 'changed-file.v1'; - paths: string[]; -} - -export interface TaskStateDetailsV1 { - schema: 'task-state.v1'; - tasks: Array<{ - id: string; - title: string; - status: 'pending' | 'running' | 'complete' | 'error'; - }>; -} - -export interface WriteLeaseDetailsV1 { - schema: 'write-lease.v1'; - status: 'waiting' | 'held' | 'released'; -} - -export interface AgentBrowserDetailsV1 { - schema: 'agent-browser.v1'; - action: 'open' | 'status' | 'close' | 'reset_profile' | 'navigate' | 'send_cdp' | 'read_events' | 'read_payload'; - attachmentId?: string; - mime?: string; -} - -export interface GameAssetsDetailsV1 { - schema: 'game-assets.v1'; - invocationId: string; - candidateIds: string[]; - status: 'pending' | 'resolved'; - pendingAssetIds: string[]; - approvedAssetIds: string[]; - discardedAssetIds: string[]; -} - -export interface RuntimeContextDetailsV1 { - schema: 'runtime-context.v1'; - skills: Array<{ - id: string; - name: string; - description: string; - selected: boolean; - }>; - commands: Array<{ - name: string; - title: string; - description: string; - source: 'makelore' | 'pi' | 'skill'; - skillId?: string; - }>; -} - -export interface SubagentDetailsV1 { - schema: 'subagent.v1'; - dispatchId: string; - mode: 'single' | 'parallel' | 'chain'; - tasks: Array<{ - taskId: string; - agentId: string; - toolProfile: 'read-only' | 'coding'; - status: 'queued' | 'running' | 'complete' | 'error' | 'aborted' | 'skipped'; - summary?: string; - errorCode?: string; - usage?: PublicUsage; - }>; -} - -export type KnownToolDetails = - | ChangedFileDetailsV1 - | TaskStateDetailsV1 - | WriteLeaseDetailsV1 - | AgentBrowserDetailsV1 - | GameAssetsDetailsV1 - | RuntimeContextDetailsV1 - | SubagentDetailsV1; - -export interface ConversationToolNode { - kind: 'tool'; - id: string; - toolCallId: string; - toolName: string; - title: string; - inputText: string; - status: 'declared' | 'waiting' | 'running' | 'complete' | 'error' | 'aborted'; - output: ConversationContentBlock[]; - details?: KnownToolDetails; -} - -export interface ConversationCompactionNode { - kind: 'compaction'; - id: string; - runId: string; - source: 'manual' | 'automatic'; - status: 'running' | 'complete' | 'error'; - willRetry: boolean; - summary?: string; -} - -export interface ConversationBoundaryNode { - kind: 'boundary'; - id: string; - runId: string; - boundary: 'turn-start' | 'turn-end' | 'retry'; - attempt?: number; - delayMs?: number; -} - -export interface ConversationSubagentNode { - kind: 'subagent'; - id: string; - runId: string; - details: SubagentDetailsV1; -} - -export interface ConversationNoticeNode { - kind: 'notice'; - id: string; - code: string; - level: 'info' | 'warning' | 'error'; - message: string; -} - -export type ConversationNode = - | ConversationMessageNode - | ConversationToolNode - | ConversationCompactionNode - | ConversationBoundaryNode - | ConversationSubagentNode - | ConversationNoticeNode; - -export interface ConversationSnapshot { - schemaVersion: 1; - conversation: { - id: string; - projectId: string; - agentId: string; - title: string; - model: ConversationModelState; - }; - nodes: ConversationNode[]; - run: ConversationRunState; - queue: ConversationQueueState; - context: ConversationContextState; - pendingInteractions: ConversationInteraction[]; - worker: PublicWorkerState; - cursor: { - workerGeneration: number; - seq: number; - leafEntryId?: string; - }; -} - -export type ConversationPatch = - | { op: 'worker.state'; state: PublicWorkerState } - | { op: 'run.state'; run: ConversationRunState } - | { op: 'message.upsert'; node: ConversationMessageNode } - | { op: 'message.block-delta'; messageId: string; blockId: string; delta: string } - | { op: 'tool.upsert'; node: ConversationToolNode } - | { op: 'compaction.upsert'; node: ConversationCompactionNode } - | { op: 'boundary.upsert'; node: ConversationBoundaryNode } - | { op: 'subagent.upsert'; node: ConversationSubagentNode } - | { op: 'queue.replace'; queue: ConversationQueueState } - | { op: 'interaction.upsert'; interaction: ConversationInteraction } - | { op: 'interaction.remove'; interactionId: string } - | { op: 'context.replace'; context: ConversationContextState } - | { op: 'snapshot.invalidated'; reason: string }; - -export interface ConversationPatchEnvelope { - conversationId: string; - workerGeneration: number; - runId?: string; - seq: number; - at: number; - patch: ConversationPatch; -} - -export interface PrepareConversationInput { - conversationId: string; - projectId: string; - agentId: string; - title: string; - model: ConversationModelState; -} - -export interface ConversationRuntimeState { - conversationId: string; - status: PublicWorkerState['status']; - workerGeneration: number; - error?: CodingRuntimePublicError; -} - -export interface PromptConversationInput { - clientRequestId: string; - conversationId: string; - mode: PromptMode; - text: string; - attachments: Array<{ attachmentId: string }>; -} - -export interface QueueMessageInput { - clientRequestId: string; - conversationId: string; - text: string; - attachments: Array<{ attachmentId: string }>; -} - -export interface PromptAcceptance { - accepted: true; - conversationId: string; - clientRequestId: string; - runId: string; - mode: PromptMode; - queuePosition?: number; -} - -export interface QueueAcceptance { - accepted: true; - conversationId: string; - clientRequestId: string; - mode: 'steer' | 'follow-up'; - queuePosition: number; -} - -export interface SetConversationModelInput { - conversationId: string; - accountId: string; - modelId: string; -} - -export interface SetThinkingLevelInput { - conversationId: string; - thinkingLevel: ConversationThinkingLevel; -} - -export interface ForkConversationInput { - sourceConversationId: string; - sourceEntryId?: string; - conversation: PrepareConversationInput; -} - -export interface ForkResult { - conversationId: string; - snapshot: ConversationSnapshot; -} - -export interface CodingRuntimeCommand { - name: string; - description?: string; -} - -export type ConversationInteractionResponse = - | { interactionId: string; cancelled: true } - | { interactionId: string; optionId: string } - | { interactionId: string; confirmed: boolean } - | { interactionId: string; value: string }; - -export interface CodingRuntimeDiagnostics { - revision: { - provider: number; - resources: number; - }; - workers: Array<{ - conversationId: string; - generation: number; - state: 'spawning' | 'ready' | 'queued' | 'running' | 'idle' | 'crashed'; - stage: 'starting' | 'idle' | 'queued' | 'running' | 'failed'; - }>; -} - -export interface CodingConversationRuntime { - prepare(input: PrepareConversationInput): Promise; - getSnapshot(conversationId: string): Promise; - prompt(input: PromptConversationInput): Promise; - steer(input: QueueMessageInput): Promise; - followUp(input: QueueMessageInput): Promise; - abort(conversationId: string): Promise; - validateModel(model: ProductModelRef): Promise; - setModel(input: SetConversationModelInput): Promise; - setThinking(input: SetThinkingLevelInput): Promise; - compact(conversationId: string): Promise; - fork(input: ForkConversationInput): Promise; - recover(conversationId: string): Promise; - dispose(conversationId: string): Promise; - listCommands(conversationId: string): Promise; - listInteractions(conversationId?: string): Promise; - respondInteraction( - conversationId: string, - response: ConversationInteractionResponse, - ): Promise; - getDiagnostics(): CodingRuntimeDiagnostics; - markProviderStale(): void; - markResourcesStale(): void; - subscribe(listener: (patch: ConversationPatchEnvelope) => void): () => void; -} +export * from '../../shared/coding-conversation-contracts'; diff --git a/electron/coding-runtime/conversation-reducer.ts b/electron/coding-runtime/conversation-reducer.ts index b04748c..f25f87f 100644 --- a/electron/coding-runtime/conversation-reducer.ts +++ b/electron/coding-runtime/conversation-reducer.ts @@ -1,554 +1 @@ -import type { - CodingRuntimePublicError, - ConversationContentBlock, - ConversationInteraction, - ConversationMessageNode, - ConversationNode, - ConversationPatch, - ConversationPatchEnvelope, - ConversationQueueState, - ConversationRunState, - ConversationSnapshot, - ConversationSubagentNode, - ConversationToolNode, - KnownToolDetails, - PublicWorkerState, -} from './contracts'; -import { productToolDetails } from './product-tool-protocol'; -import { projectSubagentDetailsV1 } from './subagent-protocol'; - -export type ConversationInvalidationCode = - | 'snapshot-required' - | 'unsupported-schema' - | 'malformed-snapshot' - | 'malformed-envelope' - | 'unknown-patch' - | 'generation-gap' - | 'sequence-gap' - | 'patch-target-missing' - | 'explicit-invalidation'; - -export interface ConversationInvalidation { - code: ConversationInvalidationCode; - reason: string; - expectedGeneration?: number; - actualGeneration?: number; - expectedSeq?: number; - actualSeq?: number; -} - -export interface ConversationReducerState { - snapshot: ConversationSnapshot | null; - invalidation: ConversationInvalidation | null; -} - -const RUN_STATUSES = new Set([ - 'idle', - 'preparing', - 'queued', - 'running', - 'retrying', - 'compacting', - 'aborting', - 'error', -]); - -const WORKER_STATUSES = new Set(['stopped', 'starting', 'ready', 'recovering', 'error']); -const ERROR_CODES = new Set([ - 'CODING_RUNTIME_START_FAILED', - 'CODING_RUNTIME_READY_TIMEOUT', - 'CODING_RUNTIME_PROTOCOL_ERROR', - 'CODING_PROVIDER_AUTH_REQUIRED', - 'CODING_MODEL_UNAVAILABLE', - 'CODING_SESSION_UNREADABLE', - 'CODING_STORAGE_WRITE_FAILED', - 'CODING_REQUEST_UNCERTAIN', - 'CODING_MIGRATION_MODEL_REQUIRED', - 'CODING_CONVERSATION_NOT_FOUND', -]); -const PATCH_OPS = new Set([ - 'worker.state', - 'run.state', - 'message.upsert', - 'message.block-delta', - 'tool.upsert', - 'compaction.upsert', - 'boundary.upsert', - 'subagent.upsert', - 'queue.replace', - 'interaction.upsert', - 'interaction.remove', - 'context.replace', - 'snapshot.invalidated', -]); - -function clone(value: T): T { - return structuredClone(value); -} - -function asRecord(value: unknown): Record | null { - return value !== null && typeof value === 'object' && !Array.isArray(value) - ? value as Record - : null; -} - -function isNonEmptyString(value: unknown): value is string { - return typeof value === 'string' && value.length > 0; -} - -function isNonNegativeInteger(value: unknown): value is number { - return Number.isInteger(value) && Number(value) >= 0; -} - -function isOptionalNumber(value: unknown): boolean { - return value === undefined || typeof value === 'number'; -} - -function isPublicError(value: unknown): value is CodingRuntimePublicError { - const record = asRecord(value); - return record !== null - && ERROR_CODES.has(String(record.code)) - && isNonEmptyString(record.message) - && typeof record.recoverable === 'boolean'; -} - -function isModelState(value: unknown): boolean { - const record = asRecord(value); - if (!record || !['resolved', 'required'].includes(String(record.modelResolution))) return false; - if (record.model === null) return record.modelResolution === 'required'; - const model = asRecord(record.model); - return model !== null - && isNonEmptyString(model.accountId) - && isNonEmptyString(model.modelId) - && ['off', 'minimal', 'low', 'medium', 'high'].includes(String(model.thinkingLevel)) - && record.modelResolution === 'resolved'; -} - -function isUsage(value: unknown): boolean { - const record = asRecord(value); - return record !== null - && typeof record.inputTokens === 'number' - && typeof record.outputTokens === 'number' - && isOptionalNumber(record.cacheReadTokens) - && isOptionalNumber(record.cacheWriteTokens); -} - -function isContentBlock(value: unknown): value is ConversationContentBlock { - const record = asRecord(value); - if (!record || !isNonEmptyString(record.id) || !isNonEmptyString(record.kind)) return false; - if (record.kind === 'image') { - return isNonEmptyString(record.attachmentId) && isNonEmptyString(record.mime); - } - return (record.kind === 'text' || record.kind === 'thinking') - && typeof record.text === 'string' - && (record.status === 'streaming' || record.status === 'complete'); -} - -function isKnownToolDetails(value: unknown): value is KnownToolDetails { - const record = asRecord(value); - if (!record || !isNonEmptyString(record.schema)) return false; - if (productToolDetails(record)) return true; - if (record.schema === 'write-lease.v1') { - return record.status === 'waiting' || record.status === 'held' || record.status === 'released'; - } - if (record.schema === 'subagent.v1') { - return projectSubagentDetailsV1(record) !== undefined; - } - return false; -} - -function isMessageNode(value: unknown): value is ConversationMessageNode { - const record = asRecord(value); - return record !== null - && record.kind === 'message' - && isNonEmptyString(record.id) - && (record.role === 'user' || record.role === 'assistant') - && ['optimistic', 'streaming', 'complete', 'error', 'aborted'].includes(String(record.status)) - && Array.isArray(record.blocks) - && record.blocks.every(isContentBlock) - && (record.usage === undefined || isUsage(record.usage)); -} - -function isToolNode(value: unknown): value is ConversationToolNode { - const record = asRecord(value); - return record !== null - && record.kind === 'tool' - && isNonEmptyString(record.id) - && isNonEmptyString(record.toolCallId) - && isNonEmptyString(record.toolName) - && typeof record.title === 'string' - && typeof record.inputText === 'string' - && ['declared', 'waiting', 'running', 'complete', 'error', 'aborted'] - .includes(String(record.status)) - && Array.isArray(record.output) - && record.output.every(isContentBlock) - && (record.details === undefined || isKnownToolDetails(record.details)); -} - -function isSubagentNode(value: unknown): value is ConversationSubagentNode { - const record = asRecord(value); - return record !== null - && record.kind === 'subagent' - && isNonEmptyString(record.id) - && isNonEmptyString(record.runId) - && isKnownToolDetails(record.details) - && record.details.schema === 'subagent.v1'; -} - -function isConversationNode(value: unknown): value is ConversationNode { - const record = asRecord(value); - if (!record || !isNonEmptyString(record.kind) || !isNonEmptyString(record.id)) return false; - if (record.kind === 'message') return isMessageNode(record); - if (record.kind === 'tool') return isToolNode(record); - if (record.kind === 'subagent') return isSubagentNode(record); - if (record.kind === 'compaction') { - return isNonEmptyString(record.runId) - && ['manual', 'automatic'].includes(String(record.source)) - && ['running', 'complete', 'error'].includes(String(record.status)) - && typeof record.willRetry === 'boolean'; - } - if (record.kind === 'boundary') { - return isNonEmptyString(record.runId) - && ['turn-start', 'turn-end', 'retry'].includes(String(record.boundary)); - } - if (record.kind === 'notice') { - return isNonEmptyString(record.code) - && ['info', 'warning', 'error'].includes(String(record.level)) - && typeof record.message === 'string'; - } - return false; -} - -function isInteraction(value: unknown): value is ConversationInteraction { - const record = asRecord(value); - return record !== null - && isNonEmptyString(record.id) - && isNonEmptyString(record.conversationId) - && isNonEmptyString(record.runId) - && ['select', 'confirm', 'input', 'editor'].includes(String(record.kind)) - && typeof record.title === 'string' - && ['pending', 'answered', 'rejected', 'cancelled'].includes(String(record.status)); -} - -function isRunState(value: unknown): value is ConversationRunState { - const record = asRecord(value); - return record !== null - && RUN_STATUSES.has(String(record.status)) - && (record.error === undefined || isPublicError(record.error)); -} - -function isQueueState(value: unknown): value is ConversationQueueState { - const record = asRecord(value); - return record !== null && Array.isArray(record.items) && record.items.every((item) => { - const queueItem = asRecord(item); - return queueItem !== null - && isNonEmptyString(queueItem.id) - && isNonEmptyString(queueItem.clientRequestId) - && ['steer', 'follow-up'].includes(String(queueItem.mode)) - && typeof queueItem.text === 'string' - && Array.isArray(queueItem.attachmentIds) - && queueItem.attachmentIds.every(isNonEmptyString); - }); -} - -function isWorkerState(value: unknown): value is PublicWorkerState { - const record = asRecord(value); - return record !== null - && WORKER_STATUSES.has(String(record.status)) - && isNonNegativeInteger(record.generation) - && (record.error === undefined || isPublicError(record.error)); -} - -export function isConversationSnapshot(value: unknown): value is ConversationSnapshot { - const record = asRecord(value); - const conversation = asRecord(record?.conversation); - const cursor = asRecord(record?.cursor); - const context = asRecord(record?.context); - const worker = record?.worker; - return record !== null - && record.schemaVersion === 1 - && conversation !== null - && isNonEmptyString(conversation.id) - && isNonEmptyString(conversation.projectId) - && isNonEmptyString(conversation.agentId) - && typeof conversation.title === 'string' - && isModelState(conversation.model) - && Array.isArray(record.nodes) - && record.nodes.every(isConversationNode) - && isRunState(record.run) - && isQueueState(record.queue) - && context !== null - && typeof context.usedTokens === 'number' - && typeof context.contextWindow === 'number' - && ['idle', 'running'].includes(String(context.compaction)) - && (context.recalculating === undefined || typeof context.recalculating === 'boolean') - && Array.isArray(record.pendingInteractions) - && record.pendingInteractions.every(isInteraction) - && isWorkerState(worker) - && cursor !== null - && isNonNegativeInteger(cursor.workerGeneration) - && isNonNegativeInteger(cursor.seq) - && asRecord(worker)?.generation === cursor.workerGeneration; -} - -function isPatch(value: unknown): value is ConversationPatch { - const record = asRecord(value); - if (!record || !PATCH_OPS.has(String(record.op))) return false; - switch (record.op) { - case 'worker.state': - return isWorkerState(record.state); - case 'run.state': - return isRunState(record.run); - case 'message.upsert': - return isMessageNode(record.node); - case 'message.block-delta': - return isNonEmptyString(record.messageId) - && isNonEmptyString(record.blockId) - && typeof record.delta === 'string'; - case 'tool.upsert': - return isToolNode(record.node); - case 'compaction.upsert': - return isConversationNode(record.node) && record.node.kind === 'compaction'; - case 'boundary.upsert': - return isConversationNode(record.node) && record.node.kind === 'boundary'; - case 'subagent.upsert': - return isSubagentNode(record.node); - case 'queue.replace': - return isQueueState(record.queue); - case 'interaction.upsert': - return isInteraction(record.interaction); - case 'interaction.remove': - return isNonEmptyString(record.interactionId); - case 'context.replace': { - const context = asRecord(record.context); - return context !== null - && typeof context.usedTokens === 'number' - && typeof context.contextWindow === 'number' - && ['idle', 'running'].includes(String(context.compaction)) - && (context.recalculating === undefined || typeof context.recalculating === 'boolean'); - } - case 'snapshot.invalidated': - return isNonEmptyString(record.reason); - default: - return false; - } -} - -function parseEnvelopeShell(value: unknown): Omit & { patch: unknown } | null { - const record = asRecord(value); - if (!record - || !isNonEmptyString(record.conversationId) - || !isNonNegativeInteger(record.workerGeneration) - || !isNonNegativeInteger(record.seq) - || typeof record.at !== 'number') { - return null; - } - return record as unknown as Omit & { patch: unknown }; -} - -function invalidate( - state: ConversationReducerState, - invalidation: ConversationInvalidation, -): ConversationReducerState { - return { snapshot: state.snapshot, invalidation }; -} - -export function createConversationReducerState(snapshot?: unknown): ConversationReducerState { - if (snapshot === undefined) return { snapshot: null, invalidation: null }; - return replaceConversationSnapshot({ snapshot: null, invalidation: null }, snapshot); -} - -export function replaceConversationSnapshot( - state: ConversationReducerState, - snapshot: unknown, -): ConversationReducerState { - const record = asRecord(snapshot); - if (record?.schemaVersion !== 1) { - return invalidate(state, { - code: 'unsupported-schema', - reason: 'Conversation snapshot schemaVersion must equal 1', - }); - } - if (!isConversationSnapshot(snapshot)) { - return invalidate(state, { - code: 'malformed-snapshot', - reason: 'Conversation snapshot does not match the product contract', - }); - } - return { snapshot: clone(snapshot), invalidation: null }; -} - -function upsertMessage(nodes: ConversationNode[], incoming: ConversationMessageNode): ConversationNode[] { - const index = nodes.findIndex((node) => node.kind === 'message' && ( - node.id === incoming.id - || (incoming.sourceEntryId !== undefined && node.sourceEntryId === incoming.sourceEntryId) - || (incoming.clientRequestId !== undefined && node.clientRequestId === incoming.clientRequestId) - )); - if (index < 0) return [...nodes, clone(incoming)]; - const existing = nodes[index] as ConversationMessageNode; - const next = [...nodes]; - next[index] = { ...clone(incoming), id: existing.id }; - return next; -} - -function upsertTool(nodes: ConversationNode[], incoming: ConversationToolNode): ConversationNode[] { - const index = nodes.findIndex((node) => node.kind === 'tool' && ( - node.id === incoming.id || node.toolCallId === incoming.toolCallId - )); - if (index < 0) return [...nodes, clone(incoming)]; - const existing = nodes[index] as ConversationToolNode; - const next = [...nodes]; - next[index] = { ...clone(incoming), id: existing.id }; - return next; -} - -function upsertById(nodes: ConversationNode[], incoming: ConversationNode): ConversationNode[] { - const index = nodes.findIndex((node) => node.kind === incoming.kind && node.id === incoming.id); - if (index < 0) return [...nodes, clone(incoming)]; - const next = [...nodes]; - next[index] = clone(incoming); - return next; -} - -function applyPatch( - snapshot: ConversationSnapshot, - patch: ConversationPatch, -): { snapshot: ConversationSnapshot } | { missingTarget: string } { - switch (patch.op) { - case 'worker.state': - return { snapshot: { ...snapshot, worker: clone(patch.state) } }; - case 'run.state': - return { snapshot: { ...snapshot, run: clone(patch.run) } }; - case 'message.upsert': - return { snapshot: { ...snapshot, nodes: upsertMessage(snapshot.nodes, patch.node) } }; - case 'message.block-delta': { - const messageIndex = snapshot.nodes.findIndex( - (node) => node.kind === 'message' && node.id === patch.messageId, - ); - if (messageIndex < 0) return { missingTarget: `message:${patch.messageId}` }; - const message = snapshot.nodes[messageIndex] as ConversationMessageNode; - const blockIndex = message.blocks.findIndex((block) => block.id === patch.blockId); - if (blockIndex < 0) return { missingTarget: `block:${patch.blockId}` }; - const block = message.blocks[blockIndex]; - if (block.kind === 'image') return { missingTarget: `text-block:${patch.blockId}` }; - const blocks = [...message.blocks]; - blocks[blockIndex] = { ...block, text: `${block.text}${patch.delta}` }; - const nodes = [...snapshot.nodes]; - nodes[messageIndex] = { ...message, blocks }; - return { snapshot: { ...snapshot, nodes } }; - } - case 'tool.upsert': - return { snapshot: { ...snapshot, nodes: upsertTool(snapshot.nodes, patch.node) } }; - case 'compaction.upsert': - case 'boundary.upsert': - case 'subagent.upsert': - return { snapshot: { ...snapshot, nodes: upsertById(snapshot.nodes, patch.node) } }; - case 'queue.replace': - return { snapshot: { ...snapshot, queue: clone(patch.queue) } }; - case 'interaction.upsert': { - const index = snapshot.pendingInteractions.findIndex((item) => item.id === patch.interaction.id); - const pendingInteractions = [...snapshot.pendingInteractions]; - if (index < 0) pendingInteractions.push(clone(patch.interaction)); - else pendingInteractions[index] = clone(patch.interaction); - return { snapshot: { ...snapshot, pendingInteractions } }; - } - case 'interaction.remove': - return { - snapshot: { - ...snapshot, - pendingInteractions: snapshot.pendingInteractions.filter( - (item) => item.id !== patch.interactionId, - ), - }, - }; - case 'context.replace': - return { snapshot: { ...snapshot, context: clone(patch.context) } }; - case 'snapshot.invalidated': - return { missingTarget: patch.reason }; - default: - return { missingTarget: 'unknown patch' }; - } -} - -export function reduceConversationPatch( - state: ConversationReducerState, - value: unknown, -): ConversationReducerState { - if (state.invalidation) return state; - if (!state.snapshot) { - return invalidate(state, { - code: 'snapshot-required', - reason: 'A Conversation snapshot is required before live patches', - }); - } - - const envelope = parseEnvelopeShell(value); - if (!envelope) { - return invalidate(state, { - code: 'malformed-envelope', - reason: 'Conversation patch envelope is malformed', - }); - } - if (envelope.conversationId !== state.snapshot.conversation.id) return state; - - const currentGeneration = state.snapshot.cursor.workerGeneration; - if (envelope.workerGeneration < currentGeneration) return state; - if (envelope.workerGeneration > currentGeneration) { - return invalidate(state, { - code: 'generation-gap', - reason: 'A newer worker generation requires a fresh snapshot', - expectedGeneration: currentGeneration, - actualGeneration: envelope.workerGeneration, - }); - } - - const currentSeq = state.snapshot.cursor.seq; - if (envelope.seq <= currentSeq) return state; - if (envelope.seq !== currentSeq + 1) { - return invalidate(state, { - code: 'sequence-gap', - reason: 'Conversation patch sequence has a gap', - expectedSeq: currentSeq + 1, - actualSeq: envelope.seq, - }); - } - - const patchRecord = asRecord(envelope.patch); - if (!patchRecord || !PATCH_OPS.has(String(patchRecord.op))) { - return invalidate(state, { - code: 'unknown-patch', - reason: 'Conversation patch operation is not supported', - }); - } - if (!isPatch(envelope.patch)) { - return invalidate(state, { - code: 'unknown-patch', - reason: 'Conversation patch payload does not match its operation', - }); - } - if (envelope.patch.op === 'snapshot.invalidated') { - return invalidate(state, { - code: 'explicit-invalidation', - reason: envelope.patch.reason, - }); - } - - const applied = applyPatch(state.snapshot, envelope.patch); - if ('missingTarget' in applied) { - return invalidate(state, { - code: 'patch-target-missing', - reason: `Conversation patch target is missing: ${applied.missingTarget}`, - }); - } - return { - snapshot: { - ...applied.snapshot, - cursor: { - ...applied.snapshot.cursor, - workerGeneration: envelope.workerGeneration, - seq: envelope.seq, - }, - }, - invalidation: null, - }; -} +export * from '../../shared/coding-conversation-reducer'; diff --git a/electron/coding-runtime/product-tool-protocol.ts b/electron/coding-runtime/product-tool-protocol.ts index 164e39c..22a9382 100644 --- a/electron/coding-runtime/product-tool-protocol.ts +++ b/electron/coding-runtime/product-tool-protocol.ts @@ -1,156 +1 @@ -import type { - AgentBrowserDetailsV1, - ChangedFileDetailsV1, - GameAssetsDetailsV1, - KnownToolDetails, - RuntimeContextDetailsV1, - TaskStateDetailsV1, -} from './contracts'; - -const PRODUCT_TOOL_NAMES = new Set([ - 'agent_browser', - 'game_asset_browser', - 'game_asset_review', - 'task_state', - 'changed_file', - 'runtime_context', -]); - -function record(value: unknown): Record | null { - return value !== null && typeof value === 'object' && !Array.isArray(value) - ? value as Record - : null; -} - -function text(value: unknown, max = 4096): string | null { - return typeof value === 'string' && value.trim() && value.length <= max ? value : null; -} - -function strings(value: unknown, maxItems = 200): string[] | null { - if (!Array.isArray(value) || value.length > maxItems) return null; - const result: string[] = []; - for (const item of value) { - const normalized = text(item); - if (!normalized) return null; - result.push(normalized); - } - return result; -} - -function relativePaths(value: unknown): string[] | null { - const paths = strings(value); - if (!paths) return null; - const result: string[] = []; - for (const candidate of paths) { - const normalized = candidate.replaceAll('\\', '/').replace(/^\.\//, ''); - if (!normalized || normalized.startsWith('/') || /^[A-Za-z]:\//.test(normalized) - || normalized.split('/').some((segment) => segment === '..')) return null; - result.push(normalized); - } - return result; -} - -function browserDetails(value: Record): AgentBrowserDetailsV1 | null { - const actions = new Set([ - 'open', 'status', 'close', 'reset_profile', 'navigate', 'send_cdp', 'read_events', 'read_payload', - ]); - if (!actions.has(String(value.action))) return null; - const attachmentId = value.attachmentId === undefined ? undefined : text(value.attachmentId, 128); - const mime = value.mime === undefined ? undefined : text(value.mime, 128); - if ((value.attachmentId !== undefined && !attachmentId) || (value.mime !== undefined && !mime)) return null; - if (Boolean(attachmentId) !== Boolean(mime) || (attachmentId && value.action !== 'send_cdp')) return null; - return { - schema: 'agent-browser.v1', - action: value.action as AgentBrowserDetailsV1['action'], - ...(attachmentId ? { attachmentId } : {}), - ...(mime ? { mime } : {}), - }; -} - -function gameAssetDetails(value: Record): GameAssetsDetailsV1 | null { - const invocationId = text(value.invocationId, 200); - const candidateIds = strings(value.candidateIds); - const pendingAssetIds = strings(value.pendingAssetIds); - const approvedAssetIds = strings(value.approvedAssetIds); - const discardedAssetIds = strings(value.discardedAssetIds); - if (!invocationId || !candidateIds || !pendingAssetIds || !approvedAssetIds || !discardedAssetIds) return null; - if (value.status !== 'pending' && value.status !== 'resolved') return null; - return { - schema: 'game-assets.v1', - invocationId, - candidateIds, - status: value.status, - pendingAssetIds, - approvedAssetIds, - discardedAssetIds, - }; -} - -function taskDetails(value: Record): TaskStateDetailsV1 | null { - if (!Array.isArray(value.tasks) || value.tasks.length === 0 || value.tasks.length > 100) return null; - const tasks: TaskStateDetailsV1['tasks'] = []; - for (const candidate of value.tasks) { - const item = record(candidate); - const id = text(item?.id, 128); - const title = text(item?.title, 500); - if (!item || !id || !title || !['pending', 'running', 'complete', 'error'].includes(String(item.status))) { - return null; - } - tasks.push({ id, title, status: item.status as TaskStateDetailsV1['tasks'][number]['status'] }); - } - return { schema: 'task-state.v1', tasks }; -} - -function runtimeContextDetails(value: Record): RuntimeContextDetailsV1 | null { - if (!Array.isArray(value.skills) || value.skills.length > 100 - || !Array.isArray(value.commands) || value.commands.length > 200) return null; - const skills: RuntimeContextDetailsV1['skills'] = []; - const commands: RuntimeContextDetailsV1['commands'] = []; - for (const candidate of value.skills) { - const item = record(candidate); - const id = text(item?.id, 128); - const name = text(item?.name, 200); - if (!item || !id || !name || typeof item.description !== 'string' || item.description.length > 2000 - || typeof item.selected !== 'boolean') return null; - skills.push({ id, name, description: item.description, selected: item.selected }); - } - for (const candidate of value.commands) { - const item = record(candidate); - const name = text(item?.name, 128); - const title = text(item?.title, 200); - if (!item || !name || !title || typeof item.description !== 'string' || item.description.length > 2000 - || !['makelore', 'pi', 'skill'].includes(String(item.source))) return null; - const skillId = item.skillId === undefined ? undefined : text(item.skillId, 128); - if (item.skillId !== undefined && !skillId) return null; - commands.push({ - name, - title, - description: item.description, - source: item.source as RuntimeContextDetailsV1['commands'][number]['source'], - ...(skillId ? { skillId } : {}), - }); - } - return { schema: 'runtime-context.v1', skills, commands }; -} - -export function productToolDetails(value: unknown): Exclude | null { - const details = record(value); - if (!details) return null; - if (details.schema === 'changed-file.v1') { - const paths = relativePaths(details.paths); - return paths ? { schema: 'changed-file.v1', paths } satisfies ChangedFileDetailsV1 : null; - } - if (details.schema === 'task-state.v1') return taskDetails(details); - if (details.schema === 'agent-browser.v1') return browserDetails(details); - if (details.schema === 'game-assets.v1') return gameAssetDetails(details); - if (details.schema === 'runtime-context.v1') return runtimeContextDetails(details); - return null; -} - -export function productToolDetailsOfResult(value: unknown) { - return productToolDetails(record(value)?.details); -} - -export function isProductToolName(value: string): boolean { - return PRODUCT_TOOL_NAMES.has(value); -} +export * from '../../shared/coding-conversation-product-tool-protocol'; diff --git a/electron/coding-runtime/subagent-protocol.ts b/electron/coding-runtime/subagent-protocol.ts index de1af59..95fe169 100644 --- a/electron/coding-runtime/subagent-protocol.ts +++ b/electron/coding-runtime/subagent-protocol.ts @@ -1,82 +1 @@ -import type { PublicUsage, SubagentDetailsV1 } from './contracts'; - -const STATUSES = new Set(['queued', 'running', 'complete', 'error', 'aborted', 'skipped']); - -function recordValue(value: unknown): Record | null { - return value !== null && typeof value === 'object' && !Array.isArray(value) - ? value as Record - : null; -} - -function publicUsage(value: unknown): PublicUsage | undefined { - const usage = recordValue(value); - if (!usage - || typeof usage.inputTokens !== 'number' - || typeof usage.outputTokens !== 'number' - || !Number.isFinite(usage.inputTokens) - || !Number.isFinite(usage.outputTokens) - || usage.inputTokens < 0 - || usage.outputTokens < 0 - || (usage.cacheReadTokens !== undefined - && (typeof usage.cacheReadTokens !== 'number' - || !Number.isFinite(usage.cacheReadTokens) - || usage.cacheReadTokens < 0)) - || (usage.cacheWriteTokens !== undefined - && (typeof usage.cacheWriteTokens !== 'number' - || !Number.isFinite(usage.cacheWriteTokens) - || usage.cacheWriteTokens < 0))) { - return undefined; - } - return { - inputTokens: usage.inputTokens, - outputTokens: usage.outputTokens, - ...(typeof usage.cacheReadTokens === 'number' ? { cacheReadTokens: usage.cacheReadTokens } : {}), - ...(typeof usage.cacheWriteTokens === 'number' ? { cacheWriteTokens: usage.cacheWriteTokens } : {}), - }; -} - -export function projectSubagentDetailsV1(value: unknown): SubagentDetailsV1 | undefined { - const record = recordValue(value); - if (!record - || record.schema !== 'subagent.v1' - || typeof record.dispatchId !== 'string' - || !record.dispatchId.trim() - || !['single', 'parallel', 'chain'].includes(String(record.mode)) - || !Array.isArray(record.tasks) - || record.tasks.length === 0 - || record.tasks.length > 8) return undefined; - const tasks: SubagentDetailsV1['tasks'] = []; - for (const value of record.tasks) { - const task = recordValue(value); - if (!task - || typeof task.taskId !== 'string' - || !task.taskId.trim() - || typeof task.agentId !== 'string' - || !task.agentId.trim() - || (task.toolProfile !== 'read-only' && task.toolProfile !== 'coding') - || !STATUSES.has(String(task.status)) - || (task.summary !== undefined && typeof task.summary !== 'string') - || (task.errorCode !== undefined && typeof task.errorCode !== 'string')) return undefined; - const usage = task.usage === undefined ? undefined : publicUsage(task.usage); - if (task.usage !== undefined && !usage) return undefined; - tasks.push({ - taskId: task.taskId, - agentId: task.agentId, - toolProfile: task.toolProfile, - status: task.status as SubagentDetailsV1['tasks'][number]['status'], - ...(typeof task.summary === 'string' ? { summary: task.summary.slice(0, 4_000) } : {}), - ...(typeof task.errorCode === 'string' ? { errorCode: task.errorCode.slice(0, 64) } : {}), - ...(usage ? { usage } : {}), - }); - } - return { - schema: 'subagent.v1', - dispatchId: record.dispatchId, - mode: record.mode as SubagentDetailsV1['mode'], - tasks, - }; -} - -export function subagentDetailsOfResult(value: unknown): SubagentDetailsV1 | undefined { - return projectSubagentDetailsV1(recordValue(value)?.details); -} +export * from '../../shared/coding-conversation-subagent-protocol'; diff --git a/shared/coding-conversation-contracts.ts b/shared/coding-conversation-contracts.ts new file mode 100644 index 0000000..49cb690 --- /dev/null +++ b/shared/coding-conversation-contracts.ts @@ -0,0 +1,435 @@ +export type ConversationThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high'; + +export interface ProductModelRef { + accountId: string; + modelId: string; + thinkingLevel: ConversationThinkingLevel; +} + +export interface ConversationModelState { + model: ProductModelRef | null; + modelResolution: 'resolved' | 'required'; +} + +export interface PublicUsage { + inputTokens: number; + outputTokens: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; +} + +export type CodingRuntimeErrorCode = + | 'CODING_RUNTIME_START_FAILED' + | 'CODING_RUNTIME_READY_TIMEOUT' + | 'CODING_RUNTIME_PROTOCOL_ERROR' + | 'CODING_PROVIDER_AUTH_REQUIRED' + | 'CODING_MODEL_UNAVAILABLE' + | 'CODING_SESSION_UNREADABLE' + | 'CODING_STORAGE_WRITE_FAILED' + | 'CODING_REQUEST_UNCERTAIN' + | 'CODING_MIGRATION_MODEL_REQUIRED' + | 'CODING_CONVERSATION_NOT_FOUND'; + +export interface CodingRuntimePublicError { + code: CodingRuntimeErrorCode; + message: string; + recoverable: boolean; +} + +export type ConversationRunStatus = + | 'idle' + | 'preparing' + | 'queued' + | 'running' + | 'retrying' + | 'compacting' + | 'aborting' + | 'error'; + +export type PromptMode = 'prompt' | 'steer' | 'follow-up'; + +export interface ConversationRunState { + status: ConversationRunStatus; + runId?: string; + mode?: PromptMode; + startedAt?: number; + settledAt?: number; + terminalReason?: 'completed' | 'aborted' | 'failed'; + retry?: { + attempt: number; + delayMs: number; + }; + error?: CodingRuntimePublicError; +} + +export interface ConversationQueueItem { + id: string; + clientRequestId: string; + mode: 'steer' | 'follow-up'; + text: string; + attachmentIds: string[]; +} + +export interface ConversationQueueState { + items: ConversationQueueItem[]; +} + +export interface ConversationContextState { + usedTokens: number; + contextWindow: number; + outputLimit?: number; + compaction: 'idle' | 'running'; + lastCompactionId?: string; + recalculating?: boolean; +} + +export interface ConversationInteractionOption { + id: string; + label: string; + description?: string; +} + +export interface ConversationInteraction { + id: string; + conversationId: string; + runId: string; + kind: 'select' | 'confirm' | 'input' | 'editor'; + title: string; + message?: string; + options?: ConversationInteractionOption[]; + status: 'pending' | 'answered' | 'rejected' | 'cancelled'; +} + +export interface PublicWorkerState { + status: 'stopped' | 'starting' | 'ready' | 'recovering' | 'error'; + generation: number; + error?: CodingRuntimePublicError; +} + +export type ConversationContentBlock = + | { + kind: 'text'; + id: string; + text: string; + status: 'streaming' | 'complete'; + } + | { + kind: 'thinking'; + id: string; + text: string; + status: 'streaming' | 'complete'; + } + | { + kind: 'image'; + id: string; + attachmentId: string; + mime: string; + }; + +export interface ConversationMessageNode { + kind: 'message'; + id: string; + sourceEntryId?: string; + clientRequestId?: string; + role: 'user' | 'assistant'; + status: 'optimistic' | 'streaming' | 'complete' | 'error' | 'aborted'; + blocks: ConversationContentBlock[]; + usage?: PublicUsage; + stopReason?: 'stop' | 'length' | 'tool-use' | 'error' | 'aborted'; +} + +export interface ChangedFileDetailsV1 { + schema: 'changed-file.v1'; + paths: string[]; +} + +export interface TaskStateDetailsV1 { + schema: 'task-state.v1'; + tasks: Array<{ + id: string; + title: string; + status: 'pending' | 'running' | 'complete' | 'error'; + }>; +} + +export interface WriteLeaseDetailsV1 { + schema: 'write-lease.v1'; + status: 'waiting' | 'held' | 'released'; +} + +export interface AgentBrowserDetailsV1 { + schema: 'agent-browser.v1'; + action: 'open' | 'status' | 'close' | 'reset_profile' | 'navigate' | 'send_cdp' | 'read_events' | 'read_payload'; + attachmentId?: string; + mime?: string; +} + +export interface GameAssetsDetailsV1 { + schema: 'game-assets.v1'; + invocationId: string; + candidateIds: string[]; + status: 'pending' | 'resolved'; + pendingAssetIds: string[]; + approvedAssetIds: string[]; + discardedAssetIds: string[]; +} + +export interface RuntimeContextDetailsV1 { + schema: 'runtime-context.v1'; + skills: Array<{ + id: string; + name: string; + description: string; + selected: boolean; + }>; + commands: Array<{ + name: string; + title: string; + description: string; + source: 'makelore' | 'pi' | 'skill'; + skillId?: string; + }>; +} + +export interface SubagentDetailsV1 { + schema: 'subagent.v1'; + dispatchId: string; + mode: 'single' | 'parallel' | 'chain'; + tasks: Array<{ + taskId: string; + agentId: string; + toolProfile: 'read-only' | 'coding'; + status: 'queued' | 'running' | 'complete' | 'error' | 'aborted' | 'skipped'; + summary?: string; + errorCode?: string; + usage?: PublicUsage; + }>; +} + +export type KnownToolDetails = + | ChangedFileDetailsV1 + | TaskStateDetailsV1 + | WriteLeaseDetailsV1 + | AgentBrowserDetailsV1 + | GameAssetsDetailsV1 + | RuntimeContextDetailsV1 + | SubagentDetailsV1; + +export interface ConversationToolNode { + kind: 'tool'; + id: string; + toolCallId: string; + toolName: string; + title: string; + inputText: string; + status: 'declared' | 'waiting' | 'running' | 'complete' | 'error' | 'aborted'; + output: ConversationContentBlock[]; + details?: KnownToolDetails; +} + +export interface ConversationCompactionNode { + kind: 'compaction'; + id: string; + runId: string; + source: 'manual' | 'automatic'; + status: 'running' | 'complete' | 'error'; + willRetry: boolean; + summary?: string; +} + +export interface ConversationBoundaryNode { + kind: 'boundary'; + id: string; + runId: string; + boundary: 'turn-start' | 'turn-end' | 'retry'; + attempt?: number; + delayMs?: number; +} + +export interface ConversationSubagentNode { + kind: 'subagent'; + id: string; + runId: string; + details: SubagentDetailsV1; +} + +export interface ConversationNoticeNode { + kind: 'notice'; + id: string; + code: string; + level: 'info' | 'warning' | 'error'; + message: string; +} + +export type ConversationNode = + | ConversationMessageNode + | ConversationToolNode + | ConversationCompactionNode + | ConversationBoundaryNode + | ConversationSubagentNode + | ConversationNoticeNode; + +export interface ConversationSnapshot { + schemaVersion: 1; + conversation: { + id: string; + projectId: string; + agentId: string; + title: string; + model: ConversationModelState; + }; + nodes: ConversationNode[]; + run: ConversationRunState; + queue: ConversationQueueState; + context: ConversationContextState; + pendingInteractions: ConversationInteraction[]; + worker: PublicWorkerState; + cursor: { + workerGeneration: number; + seq: number; + leafEntryId?: string; + }; +} + +export type ConversationPatch = + | { op: 'worker.state'; state: PublicWorkerState } + | { op: 'run.state'; run: ConversationRunState } + | { op: 'message.upsert'; node: ConversationMessageNode } + | { op: 'message.block-delta'; messageId: string; blockId: string; delta: string } + | { op: 'tool.upsert'; node: ConversationToolNode } + | { op: 'compaction.upsert'; node: ConversationCompactionNode } + | { op: 'boundary.upsert'; node: ConversationBoundaryNode } + | { op: 'subagent.upsert'; node: ConversationSubagentNode } + | { op: 'queue.replace'; queue: ConversationQueueState } + | { op: 'interaction.upsert'; interaction: ConversationInteraction } + | { op: 'interaction.remove'; interactionId: string } + | { op: 'context.replace'; context: ConversationContextState } + | { op: 'snapshot.invalidated'; reason: string }; + +export interface ConversationPatchEnvelope { + conversationId: string; + workerGeneration: number; + runId?: string; + seq: number; + at: number; + patch: ConversationPatch; +} + +export interface PrepareConversationInput { + conversationId: string; + projectId: string; + agentId: string; + title: string; + model: ConversationModelState; +} + +export interface ConversationRuntimeState { + conversationId: string; + status: PublicWorkerState['status']; + workerGeneration: number; + error?: CodingRuntimePublicError; +} + +export interface PromptConversationInput { + clientRequestId: string; + conversationId: string; + mode: PromptMode; + text: string; + attachments: Array<{ attachmentId: string }>; +} + +export interface QueueMessageInput { + clientRequestId: string; + conversationId: string; + text: string; + attachments: Array<{ attachmentId: string }>; +} + +export interface PromptAcceptance { + accepted: true; + conversationId: string; + clientRequestId: string; + runId: string; + mode: PromptMode; + queuePosition?: number; +} + +export interface QueueAcceptance { + accepted: true; + conversationId: string; + clientRequestId: string; + mode: 'steer' | 'follow-up'; + queuePosition: number; +} + +export interface SetConversationModelInput { + conversationId: string; + accountId: string; + modelId: string; +} + +export interface SetThinkingLevelInput { + conversationId: string; + thinkingLevel: ConversationThinkingLevel; +} + +export interface ForkConversationInput { + sourceConversationId: string; + sourceEntryId?: string; + conversation: PrepareConversationInput; +} + +export interface ForkResult { + conversationId: string; + snapshot: ConversationSnapshot; +} + +export interface CodingRuntimeCommand { + name: string; + description?: string; +} + +export type ConversationInteractionResponse = + | { interactionId: string; cancelled: true } + | { interactionId: string; optionId: string } + | { interactionId: string; confirmed: boolean } + | { interactionId: string; value: string }; + +export interface CodingRuntimeDiagnostics { + revision: { + provider: number; + resources: number; + }; + workers: Array<{ + conversationId: string; + generation: number; + state: 'spawning' | 'ready' | 'queued' | 'running' | 'idle' | 'crashed'; + stage: 'starting' | 'idle' | 'queued' | 'running' | 'failed'; + }>; +} + +export interface CodingConversationRuntime { + prepare(input: PrepareConversationInput): Promise; + getSnapshot(conversationId: string): Promise; + prompt(input: PromptConversationInput): Promise; + steer(input: QueueMessageInput): Promise; + followUp(input: QueueMessageInput): Promise; + abort(conversationId: string): Promise; + validateModel(model: ProductModelRef): Promise; + setModel(input: SetConversationModelInput): Promise; + setThinking(input: SetThinkingLevelInput): Promise; + compact(conversationId: string): Promise; + fork(input: ForkConversationInput): Promise; + recover(conversationId: string): Promise; + dispose(conversationId: string): Promise; + listCommands(conversationId: string): Promise; + listInteractions(conversationId?: string): Promise; + respondInteraction( + conversationId: string, + response: ConversationInteractionResponse, + ): Promise; + getDiagnostics(): CodingRuntimeDiagnostics; + markProviderStale(): void; + markResourcesStale(): void; + subscribe(listener: (patch: ConversationPatchEnvelope) => void): () => void; +} diff --git a/shared/coding-conversation-product-tool-protocol.ts b/shared/coding-conversation-product-tool-protocol.ts new file mode 100644 index 0000000..396f5ab --- /dev/null +++ b/shared/coding-conversation-product-tool-protocol.ts @@ -0,0 +1,156 @@ +import type { + AgentBrowserDetailsV1, + ChangedFileDetailsV1, + GameAssetsDetailsV1, + KnownToolDetails, + RuntimeContextDetailsV1, + TaskStateDetailsV1, +} from './coding-conversation-contracts'; + +const PRODUCT_TOOL_NAMES = new Set([ + 'agent_browser', + 'game_asset_browser', + 'game_asset_review', + 'task_state', + 'changed_file', + 'runtime_context', +]); + +function record(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null; +} + +function text(value: unknown, max = 4096): string | null { + return typeof value === 'string' && value.trim() && value.length <= max ? value : null; +} + +function strings(value: unknown, maxItems = 200): string[] | null { + if (!Array.isArray(value) || value.length > maxItems) return null; + const result: string[] = []; + for (const item of value) { + const normalized = text(item); + if (!normalized) return null; + result.push(normalized); + } + return result; +} + +function relativePaths(value: unknown): string[] | null { + const paths = strings(value); + if (!paths) return null; + const result: string[] = []; + for (const candidate of paths) { + const normalized = candidate.replaceAll('\\', '/').replace(/^\.\//, ''); + if (!normalized || normalized.startsWith('/') || /^[A-Za-z]:\//.test(normalized) + || normalized.split('/').some((segment) => segment === '..')) return null; + result.push(normalized); + } + return result; +} + +function browserDetails(value: Record): AgentBrowserDetailsV1 | null { + const actions = new Set([ + 'open', 'status', 'close', 'reset_profile', 'navigate', 'send_cdp', 'read_events', 'read_payload', + ]); + if (!actions.has(String(value.action))) return null; + const attachmentId = value.attachmentId === undefined ? undefined : text(value.attachmentId, 128); + const mime = value.mime === undefined ? undefined : text(value.mime, 128); + if ((value.attachmentId !== undefined && !attachmentId) || (value.mime !== undefined && !mime)) return null; + if (Boolean(attachmentId) !== Boolean(mime) || (attachmentId && value.action !== 'send_cdp')) return null; + return { + schema: 'agent-browser.v1', + action: value.action as AgentBrowserDetailsV1['action'], + ...(attachmentId ? { attachmentId } : {}), + ...(mime ? { mime } : {}), + }; +} + +function gameAssetDetails(value: Record): GameAssetsDetailsV1 | null { + const invocationId = text(value.invocationId, 200); + const candidateIds = strings(value.candidateIds); + const pendingAssetIds = strings(value.pendingAssetIds); + const approvedAssetIds = strings(value.approvedAssetIds); + const discardedAssetIds = strings(value.discardedAssetIds); + if (!invocationId || !candidateIds || !pendingAssetIds || !approvedAssetIds || !discardedAssetIds) return null; + if (value.status !== 'pending' && value.status !== 'resolved') return null; + return { + schema: 'game-assets.v1', + invocationId, + candidateIds, + status: value.status, + pendingAssetIds, + approvedAssetIds, + discardedAssetIds, + }; +} + +function taskDetails(value: Record): TaskStateDetailsV1 | null { + if (!Array.isArray(value.tasks) || value.tasks.length === 0 || value.tasks.length > 100) return null; + const tasks: TaskStateDetailsV1['tasks'] = []; + for (const candidate of value.tasks) { + const item = record(candidate); + const id = text(item?.id, 128); + const title = text(item?.title, 500); + if (!item || !id || !title || !['pending', 'running', 'complete', 'error'].includes(String(item.status))) { + return null; + } + tasks.push({ id, title, status: item.status as TaskStateDetailsV1['tasks'][number]['status'] }); + } + return { schema: 'task-state.v1', tasks }; +} + +function runtimeContextDetails(value: Record): RuntimeContextDetailsV1 | null { + if (!Array.isArray(value.skills) || value.skills.length > 100 + || !Array.isArray(value.commands) || value.commands.length > 200) return null; + const skills: RuntimeContextDetailsV1['skills'] = []; + const commands: RuntimeContextDetailsV1['commands'] = []; + for (const candidate of value.skills) { + const item = record(candidate); + const id = text(item?.id, 128); + const name = text(item?.name, 200); + if (!item || !id || !name || typeof item.description !== 'string' || item.description.length > 2000 + || typeof item.selected !== 'boolean') return null; + skills.push({ id, name, description: item.description, selected: item.selected }); + } + for (const candidate of value.commands) { + const item = record(candidate); + const name = text(item?.name, 128); + const title = text(item?.title, 200); + if (!item || !name || !title || typeof item.description !== 'string' || item.description.length > 2000 + || !['makelore', 'pi', 'skill'].includes(String(item.source))) return null; + const skillId = item.skillId === undefined ? undefined : text(item.skillId, 128); + if (item.skillId !== undefined && !skillId) return null; + commands.push({ + name, + title, + description: item.description, + source: item.source as RuntimeContextDetailsV1['commands'][number]['source'], + ...(skillId ? { skillId } : {}), + }); + } + return { schema: 'runtime-context.v1', skills, commands }; +} + +export function productToolDetails(value: unknown): Exclude | null { + const details = record(value); + if (!details) return null; + if (details.schema === 'changed-file.v1') { + const paths = relativePaths(details.paths); + return paths ? { schema: 'changed-file.v1', paths } satisfies ChangedFileDetailsV1 : null; + } + if (details.schema === 'task-state.v1') return taskDetails(details); + if (details.schema === 'agent-browser.v1') return browserDetails(details); + if (details.schema === 'game-assets.v1') return gameAssetDetails(details); + if (details.schema === 'runtime-context.v1') return runtimeContextDetails(details); + return null; +} + +export function productToolDetailsOfResult(value: unknown) { + return productToolDetails(record(value)?.details); +} + +export function isProductToolName(value: string): boolean { + return PRODUCT_TOOL_NAMES.has(value); +} diff --git a/shared/coding-conversation-reducer.ts b/shared/coding-conversation-reducer.ts new file mode 100644 index 0000000..c56c345 --- /dev/null +++ b/shared/coding-conversation-reducer.ts @@ -0,0 +1,554 @@ +import type { + CodingRuntimePublicError, + ConversationContentBlock, + ConversationInteraction, + ConversationMessageNode, + ConversationNode, + ConversationPatch, + ConversationPatchEnvelope, + ConversationQueueState, + ConversationRunState, + ConversationSnapshot, + ConversationSubagentNode, + ConversationToolNode, + KnownToolDetails, + PublicWorkerState, +} from './coding-conversation-contracts'; +import { productToolDetails } from './coding-conversation-product-tool-protocol'; +import { projectSubagentDetailsV1 } from './coding-conversation-subagent-protocol'; + +export type ConversationInvalidationCode = + | 'snapshot-required' + | 'unsupported-schema' + | 'malformed-snapshot' + | 'malformed-envelope' + | 'unknown-patch' + | 'generation-gap' + | 'sequence-gap' + | 'patch-target-missing' + | 'explicit-invalidation'; + +export interface ConversationInvalidation { + code: ConversationInvalidationCode; + reason: string; + expectedGeneration?: number; + actualGeneration?: number; + expectedSeq?: number; + actualSeq?: number; +} + +export interface ConversationReducerState { + snapshot: ConversationSnapshot | null; + invalidation: ConversationInvalidation | null; +} + +const RUN_STATUSES = new Set([ + 'idle', + 'preparing', + 'queued', + 'running', + 'retrying', + 'compacting', + 'aborting', + 'error', +]); + +const WORKER_STATUSES = new Set(['stopped', 'starting', 'ready', 'recovering', 'error']); +const ERROR_CODES = new Set([ + 'CODING_RUNTIME_START_FAILED', + 'CODING_RUNTIME_READY_TIMEOUT', + 'CODING_RUNTIME_PROTOCOL_ERROR', + 'CODING_PROVIDER_AUTH_REQUIRED', + 'CODING_MODEL_UNAVAILABLE', + 'CODING_SESSION_UNREADABLE', + 'CODING_STORAGE_WRITE_FAILED', + 'CODING_REQUEST_UNCERTAIN', + 'CODING_MIGRATION_MODEL_REQUIRED', + 'CODING_CONVERSATION_NOT_FOUND', +]); +const PATCH_OPS = new Set([ + 'worker.state', + 'run.state', + 'message.upsert', + 'message.block-delta', + 'tool.upsert', + 'compaction.upsert', + 'boundary.upsert', + 'subagent.upsert', + 'queue.replace', + 'interaction.upsert', + 'interaction.remove', + 'context.replace', + 'snapshot.invalidated', +]); + +function clone(value: T): T { + return structuredClone(value); +} + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +function isNonNegativeInteger(value: unknown): value is number { + return Number.isInteger(value) && Number(value) >= 0; +} + +function isOptionalNumber(value: unknown): boolean { + return value === undefined || typeof value === 'number'; +} + +function isPublicError(value: unknown): value is CodingRuntimePublicError { + const record = asRecord(value); + return record !== null + && ERROR_CODES.has(String(record.code)) + && isNonEmptyString(record.message) + && typeof record.recoverable === 'boolean'; +} + +function isModelState(value: unknown): boolean { + const record = asRecord(value); + if (!record || !['resolved', 'required'].includes(String(record.modelResolution))) return false; + if (record.model === null) return record.modelResolution === 'required'; + const model = asRecord(record.model); + return model !== null + && isNonEmptyString(model.accountId) + && isNonEmptyString(model.modelId) + && ['off', 'minimal', 'low', 'medium', 'high'].includes(String(model.thinkingLevel)) + && record.modelResolution === 'resolved'; +} + +function isUsage(value: unknown): boolean { + const record = asRecord(value); + return record !== null + && typeof record.inputTokens === 'number' + && typeof record.outputTokens === 'number' + && isOptionalNumber(record.cacheReadTokens) + && isOptionalNumber(record.cacheWriteTokens); +} + +function isContentBlock(value: unknown): value is ConversationContentBlock { + const record = asRecord(value); + if (!record || !isNonEmptyString(record.id) || !isNonEmptyString(record.kind)) return false; + if (record.kind === 'image') { + return isNonEmptyString(record.attachmentId) && isNonEmptyString(record.mime); + } + return (record.kind === 'text' || record.kind === 'thinking') + && typeof record.text === 'string' + && (record.status === 'streaming' || record.status === 'complete'); +} + +function isKnownToolDetails(value: unknown): value is KnownToolDetails { + const record = asRecord(value); + if (!record || !isNonEmptyString(record.schema)) return false; + if (productToolDetails(record)) return true; + if (record.schema === 'write-lease.v1') { + return record.status === 'waiting' || record.status === 'held' || record.status === 'released'; + } + if (record.schema === 'subagent.v1') { + return projectSubagentDetailsV1(record) !== undefined; + } + return false; +} + +function isMessageNode(value: unknown): value is ConversationMessageNode { + const record = asRecord(value); + return record !== null + && record.kind === 'message' + && isNonEmptyString(record.id) + && (record.role === 'user' || record.role === 'assistant') + && ['optimistic', 'streaming', 'complete', 'error', 'aborted'].includes(String(record.status)) + && Array.isArray(record.blocks) + && record.blocks.every(isContentBlock) + && (record.usage === undefined || isUsage(record.usage)); +} + +function isToolNode(value: unknown): value is ConversationToolNode { + const record = asRecord(value); + return record !== null + && record.kind === 'tool' + && isNonEmptyString(record.id) + && isNonEmptyString(record.toolCallId) + && isNonEmptyString(record.toolName) + && typeof record.title === 'string' + && typeof record.inputText === 'string' + && ['declared', 'waiting', 'running', 'complete', 'error', 'aborted'] + .includes(String(record.status)) + && Array.isArray(record.output) + && record.output.every(isContentBlock) + && (record.details === undefined || isKnownToolDetails(record.details)); +} + +function isSubagentNode(value: unknown): value is ConversationSubagentNode { + const record = asRecord(value); + return record !== null + && record.kind === 'subagent' + && isNonEmptyString(record.id) + && isNonEmptyString(record.runId) + && isKnownToolDetails(record.details) + && record.details.schema === 'subagent.v1'; +} + +function isConversationNode(value: unknown): value is ConversationNode { + const record = asRecord(value); + if (!record || !isNonEmptyString(record.kind) || !isNonEmptyString(record.id)) return false; + if (record.kind === 'message') return isMessageNode(record); + if (record.kind === 'tool') return isToolNode(record); + if (record.kind === 'subagent') return isSubagentNode(record); + if (record.kind === 'compaction') { + return isNonEmptyString(record.runId) + && ['manual', 'automatic'].includes(String(record.source)) + && ['running', 'complete', 'error'].includes(String(record.status)) + && typeof record.willRetry === 'boolean'; + } + if (record.kind === 'boundary') { + return isNonEmptyString(record.runId) + && ['turn-start', 'turn-end', 'retry'].includes(String(record.boundary)); + } + if (record.kind === 'notice') { + return isNonEmptyString(record.code) + && ['info', 'warning', 'error'].includes(String(record.level)) + && typeof record.message === 'string'; + } + return false; +} + +function isInteraction(value: unknown): value is ConversationInteraction { + const record = asRecord(value); + return record !== null + && isNonEmptyString(record.id) + && isNonEmptyString(record.conversationId) + && isNonEmptyString(record.runId) + && ['select', 'confirm', 'input', 'editor'].includes(String(record.kind)) + && typeof record.title === 'string' + && ['pending', 'answered', 'rejected', 'cancelled'].includes(String(record.status)); +} + +function isRunState(value: unknown): value is ConversationRunState { + const record = asRecord(value); + return record !== null + && RUN_STATUSES.has(String(record.status)) + && (record.error === undefined || isPublicError(record.error)); +} + +function isQueueState(value: unknown): value is ConversationQueueState { + const record = asRecord(value); + return record !== null && Array.isArray(record.items) && record.items.every((item) => { + const queueItem = asRecord(item); + return queueItem !== null + && isNonEmptyString(queueItem.id) + && isNonEmptyString(queueItem.clientRequestId) + && ['steer', 'follow-up'].includes(String(queueItem.mode)) + && typeof queueItem.text === 'string' + && Array.isArray(queueItem.attachmentIds) + && queueItem.attachmentIds.every(isNonEmptyString); + }); +} + +function isWorkerState(value: unknown): value is PublicWorkerState { + const record = asRecord(value); + return record !== null + && WORKER_STATUSES.has(String(record.status)) + && isNonNegativeInteger(record.generation) + && (record.error === undefined || isPublicError(record.error)); +} + +export function isConversationSnapshot(value: unknown): value is ConversationSnapshot { + const record = asRecord(value); + const conversation = asRecord(record?.conversation); + const cursor = asRecord(record?.cursor); + const context = asRecord(record?.context); + const worker = record?.worker; + return record !== null + && record.schemaVersion === 1 + && conversation !== null + && isNonEmptyString(conversation.id) + && isNonEmptyString(conversation.projectId) + && isNonEmptyString(conversation.agentId) + && typeof conversation.title === 'string' + && isModelState(conversation.model) + && Array.isArray(record.nodes) + && record.nodes.every(isConversationNode) + && isRunState(record.run) + && isQueueState(record.queue) + && context !== null + && typeof context.usedTokens === 'number' + && typeof context.contextWindow === 'number' + && ['idle', 'running'].includes(String(context.compaction)) + && (context.recalculating === undefined || typeof context.recalculating === 'boolean') + && Array.isArray(record.pendingInteractions) + && record.pendingInteractions.every(isInteraction) + && isWorkerState(worker) + && cursor !== null + && isNonNegativeInteger(cursor.workerGeneration) + && isNonNegativeInteger(cursor.seq) + && asRecord(worker)?.generation === cursor.workerGeneration; +} + +function isPatch(value: unknown): value is ConversationPatch { + const record = asRecord(value); + if (!record || !PATCH_OPS.has(String(record.op))) return false; + switch (record.op) { + case 'worker.state': + return isWorkerState(record.state); + case 'run.state': + return isRunState(record.run); + case 'message.upsert': + return isMessageNode(record.node); + case 'message.block-delta': + return isNonEmptyString(record.messageId) + && isNonEmptyString(record.blockId) + && typeof record.delta === 'string'; + case 'tool.upsert': + return isToolNode(record.node); + case 'compaction.upsert': + return isConversationNode(record.node) && record.node.kind === 'compaction'; + case 'boundary.upsert': + return isConversationNode(record.node) && record.node.kind === 'boundary'; + case 'subagent.upsert': + return isSubagentNode(record.node); + case 'queue.replace': + return isQueueState(record.queue); + case 'interaction.upsert': + return isInteraction(record.interaction); + case 'interaction.remove': + return isNonEmptyString(record.interactionId); + case 'context.replace': { + const context = asRecord(record.context); + return context !== null + && typeof context.usedTokens === 'number' + && typeof context.contextWindow === 'number' + && ['idle', 'running'].includes(String(context.compaction)) + && (context.recalculating === undefined || typeof context.recalculating === 'boolean'); + } + case 'snapshot.invalidated': + return isNonEmptyString(record.reason); + default: + return false; + } +} + +function parseEnvelopeShell(value: unknown): Omit & { patch: unknown } | null { + const record = asRecord(value); + if (!record + || !isNonEmptyString(record.conversationId) + || !isNonNegativeInteger(record.workerGeneration) + || !isNonNegativeInteger(record.seq) + || typeof record.at !== 'number') { + return null; + } + return record as unknown as Omit & { patch: unknown }; +} + +function invalidate( + state: ConversationReducerState, + invalidation: ConversationInvalidation, +): ConversationReducerState { + return { snapshot: state.snapshot, invalidation }; +} + +export function createConversationReducerState(snapshot?: unknown): ConversationReducerState { + if (snapshot === undefined) return { snapshot: null, invalidation: null }; + return replaceConversationSnapshot({ snapshot: null, invalidation: null }, snapshot); +} + +export function replaceConversationSnapshot( + state: ConversationReducerState, + snapshot: unknown, +): ConversationReducerState { + const record = asRecord(snapshot); + if (record?.schemaVersion !== 1) { + return invalidate(state, { + code: 'unsupported-schema', + reason: 'Conversation snapshot schemaVersion must equal 1', + }); + } + if (!isConversationSnapshot(snapshot)) { + return invalidate(state, { + code: 'malformed-snapshot', + reason: 'Conversation snapshot does not match the product contract', + }); + } + return { snapshot: clone(snapshot), invalidation: null }; +} + +function upsertMessage(nodes: ConversationNode[], incoming: ConversationMessageNode): ConversationNode[] { + const index = nodes.findIndex((node) => node.kind === 'message' && ( + node.id === incoming.id + || (incoming.sourceEntryId !== undefined && node.sourceEntryId === incoming.sourceEntryId) + || (incoming.clientRequestId !== undefined && node.clientRequestId === incoming.clientRequestId) + )); + if (index < 0) return [...nodes, clone(incoming)]; + const existing = nodes[index] as ConversationMessageNode; + const next = [...nodes]; + next[index] = { ...clone(incoming), id: existing.id }; + return next; +} + +function upsertTool(nodes: ConversationNode[], incoming: ConversationToolNode): ConversationNode[] { + const index = nodes.findIndex((node) => node.kind === 'tool' && ( + node.id === incoming.id || node.toolCallId === incoming.toolCallId + )); + if (index < 0) return [...nodes, clone(incoming)]; + const existing = nodes[index] as ConversationToolNode; + const next = [...nodes]; + next[index] = { ...clone(incoming), id: existing.id }; + return next; +} + +function upsertById(nodes: ConversationNode[], incoming: ConversationNode): ConversationNode[] { + const index = nodes.findIndex((node) => node.kind === incoming.kind && node.id === incoming.id); + if (index < 0) return [...nodes, clone(incoming)]; + const next = [...nodes]; + next[index] = clone(incoming); + return next; +} + +function applyPatch( + snapshot: ConversationSnapshot, + patch: ConversationPatch, +): { snapshot: ConversationSnapshot } | { missingTarget: string } { + switch (patch.op) { + case 'worker.state': + return { snapshot: { ...snapshot, worker: clone(patch.state) } }; + case 'run.state': + return { snapshot: { ...snapshot, run: clone(patch.run) } }; + case 'message.upsert': + return { snapshot: { ...snapshot, nodes: upsertMessage(snapshot.nodes, patch.node) } }; + case 'message.block-delta': { + const messageIndex = snapshot.nodes.findIndex( + (node) => node.kind === 'message' && node.id === patch.messageId, + ); + if (messageIndex < 0) return { missingTarget: `message:${patch.messageId}` }; + const message = snapshot.nodes[messageIndex] as ConversationMessageNode; + const blockIndex = message.blocks.findIndex((block) => block.id === patch.blockId); + if (blockIndex < 0) return { missingTarget: `block:${patch.blockId}` }; + const block = message.blocks[blockIndex]; + if (block.kind === 'image') return { missingTarget: `text-block:${patch.blockId}` }; + const blocks = [...message.blocks]; + blocks[blockIndex] = { ...block, text: `${block.text}${patch.delta}` }; + const nodes = [...snapshot.nodes]; + nodes[messageIndex] = { ...message, blocks }; + return { snapshot: { ...snapshot, nodes } }; + } + case 'tool.upsert': + return { snapshot: { ...snapshot, nodes: upsertTool(snapshot.nodes, patch.node) } }; + case 'compaction.upsert': + case 'boundary.upsert': + case 'subagent.upsert': + return { snapshot: { ...snapshot, nodes: upsertById(snapshot.nodes, patch.node) } }; + case 'queue.replace': + return { snapshot: { ...snapshot, queue: clone(patch.queue) } }; + case 'interaction.upsert': { + const index = snapshot.pendingInteractions.findIndex((item) => item.id === patch.interaction.id); + const pendingInteractions = [...snapshot.pendingInteractions]; + if (index < 0) pendingInteractions.push(clone(patch.interaction)); + else pendingInteractions[index] = clone(patch.interaction); + return { snapshot: { ...snapshot, pendingInteractions } }; + } + case 'interaction.remove': + return { + snapshot: { + ...snapshot, + pendingInteractions: snapshot.pendingInteractions.filter( + (item) => item.id !== patch.interactionId, + ), + }, + }; + case 'context.replace': + return { snapshot: { ...snapshot, context: clone(patch.context) } }; + case 'snapshot.invalidated': + return { missingTarget: patch.reason }; + default: + return { missingTarget: 'unknown patch' }; + } +} + +export function reduceConversationPatch( + state: ConversationReducerState, + value: unknown, +): ConversationReducerState { + if (state.invalidation) return state; + if (!state.snapshot) { + return invalidate(state, { + code: 'snapshot-required', + reason: 'A Conversation snapshot is required before live patches', + }); + } + + const envelope = parseEnvelopeShell(value); + if (!envelope) { + return invalidate(state, { + code: 'malformed-envelope', + reason: 'Conversation patch envelope is malformed', + }); + } + if (envelope.conversationId !== state.snapshot.conversation.id) return state; + + const currentGeneration = state.snapshot.cursor.workerGeneration; + if (envelope.workerGeneration < currentGeneration) return state; + if (envelope.workerGeneration > currentGeneration) { + return invalidate(state, { + code: 'generation-gap', + reason: 'A newer worker generation requires a fresh snapshot', + expectedGeneration: currentGeneration, + actualGeneration: envelope.workerGeneration, + }); + } + + const currentSeq = state.snapshot.cursor.seq; + if (envelope.seq <= currentSeq) return state; + if (envelope.seq !== currentSeq + 1) { + return invalidate(state, { + code: 'sequence-gap', + reason: 'Conversation patch sequence has a gap', + expectedSeq: currentSeq + 1, + actualSeq: envelope.seq, + }); + } + + const patchRecord = asRecord(envelope.patch); + if (!patchRecord || !PATCH_OPS.has(String(patchRecord.op))) { + return invalidate(state, { + code: 'unknown-patch', + reason: 'Conversation patch operation is not supported', + }); + } + if (!isPatch(envelope.patch)) { + return invalidate(state, { + code: 'unknown-patch', + reason: 'Conversation patch payload does not match its operation', + }); + } + if (envelope.patch.op === 'snapshot.invalidated') { + return invalidate(state, { + code: 'explicit-invalidation', + reason: envelope.patch.reason, + }); + } + + const applied = applyPatch(state.snapshot, envelope.patch); + if ('missingTarget' in applied) { + return invalidate(state, { + code: 'patch-target-missing', + reason: `Conversation patch target is missing: ${applied.missingTarget}`, + }); + } + return { + snapshot: { + ...applied.snapshot, + cursor: { + ...applied.snapshot.cursor, + workerGeneration: envelope.workerGeneration, + seq: envelope.seq, + }, + }, + invalidation: null, + }; +} diff --git a/shared/coding-conversation-subagent-protocol.ts b/shared/coding-conversation-subagent-protocol.ts new file mode 100644 index 0000000..22e41e0 --- /dev/null +++ b/shared/coding-conversation-subagent-protocol.ts @@ -0,0 +1,82 @@ +import type { PublicUsage, SubagentDetailsV1 } from './coding-conversation-contracts'; + +const STATUSES = new Set(['queued', 'running', 'complete', 'error', 'aborted', 'skipped']); + +function recordValue(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null; +} + +function publicUsage(value: unknown): PublicUsage | undefined { + const usage = recordValue(value); + if (!usage + || typeof usage.inputTokens !== 'number' + || typeof usage.outputTokens !== 'number' + || !Number.isFinite(usage.inputTokens) + || !Number.isFinite(usage.outputTokens) + || usage.inputTokens < 0 + || usage.outputTokens < 0 + || (usage.cacheReadTokens !== undefined + && (typeof usage.cacheReadTokens !== 'number' + || !Number.isFinite(usage.cacheReadTokens) + || usage.cacheReadTokens < 0)) + || (usage.cacheWriteTokens !== undefined + && (typeof usage.cacheWriteTokens !== 'number' + || !Number.isFinite(usage.cacheWriteTokens) + || usage.cacheWriteTokens < 0))) { + return undefined; + } + return { + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + ...(typeof usage.cacheReadTokens === 'number' ? { cacheReadTokens: usage.cacheReadTokens } : {}), + ...(typeof usage.cacheWriteTokens === 'number' ? { cacheWriteTokens: usage.cacheWriteTokens } : {}), + }; +} + +export function projectSubagentDetailsV1(value: unknown): SubagentDetailsV1 | undefined { + const record = recordValue(value); + if (!record + || record.schema !== 'subagent.v1' + || typeof record.dispatchId !== 'string' + || !record.dispatchId.trim() + || !['single', 'parallel', 'chain'].includes(String(record.mode)) + || !Array.isArray(record.tasks) + || record.tasks.length === 0 + || record.tasks.length > 8) return undefined; + const tasks: SubagentDetailsV1['tasks'] = []; + for (const value of record.tasks) { + const task = recordValue(value); + if (!task + || typeof task.taskId !== 'string' + || !task.taskId.trim() + || typeof task.agentId !== 'string' + || !task.agentId.trim() + || (task.toolProfile !== 'read-only' && task.toolProfile !== 'coding') + || !STATUSES.has(String(task.status)) + || (task.summary !== undefined && typeof task.summary !== 'string') + || (task.errorCode !== undefined && typeof task.errorCode !== 'string')) return undefined; + const usage = task.usage === undefined ? undefined : publicUsage(task.usage); + if (task.usage !== undefined && !usage) return undefined; + tasks.push({ + taskId: task.taskId, + agentId: task.agentId, + toolProfile: task.toolProfile, + status: task.status as SubagentDetailsV1['tasks'][number]['status'], + ...(typeof task.summary === 'string' ? { summary: task.summary.slice(0, 4_000) } : {}), + ...(typeof task.errorCode === 'string' ? { errorCode: task.errorCode.slice(0, 64) } : {}), + ...(usage ? { usage } : {}), + }); + } + return { + schema: 'subagent.v1', + dispatchId: record.dispatchId, + mode: record.mode as SubagentDetailsV1['mode'], + tasks, + }; +} + +export function subagentDetailsOfResult(value: unknown): SubagentDetailsV1 | undefined { + return projectSubagentDetailsV1(recordValue(value)?.details); +} diff --git a/src/lib/coding-conversations.ts b/src/lib/coding-conversations.ts new file mode 100644 index 0000000..6230eb3 --- /dev/null +++ b/src/lib/coding-conversations.ts @@ -0,0 +1,55 @@ +import type { + ConversationSnapshot, + PromptAcceptance, + PromptMode, +} from '@/types/coding-conversation'; +import { + createHostEventSource, + ensureHostApiToken, + hostApiFetch, +} from './host-api'; + +export interface SubmitCodingConversationPromptInput { + conversationId: string; + clientRequestId: string; + mode: PromptMode; + text: string; + attachments: Array<{ attachmentId: string }>; +} + +export async function getCodingConversationSnapshot( + conversationId: string, +): Promise { + const response = await hostApiFetch<{ snapshot: ConversationSnapshot }>( + `/api/coding/conversations/${encodeURIComponent(conversationId)}/snapshot`, + ); + return response.snapshot; +} + +export async function submitCodingConversationPrompt( + input: SubmitCodingConversationPromptInput, +): Promise { + const response = await hostApiFetch<{ acceptance: PromptAcceptance }>( + `/api/coding/conversations/${encodeURIComponent(input.conversationId)}/prompt`, + { + method: 'POST', + body: JSON.stringify({ + clientRequestId: input.clientRequestId, + mode: input.mode, + text: input.text, + attachments: input.attachments, + }), + }, + ); + return response.acceptance; +} + +export async function openCodingConversationEvents( + conversationId?: string, +): Promise { + await ensureHostApiToken(); + const params = new URLSearchParams(); + if (conversationId) params.set('conversationId', conversationId); + const query = params.toString(); + return createHostEventSource(`/api/coding/events${query ? `?${query}` : ''}`); +} diff --git a/src/stores/coding-conversations.ts b/src/stores/coding-conversations.ts new file mode 100644 index 0000000..e35386d --- /dev/null +++ b/src/stores/coding-conversations.ts @@ -0,0 +1,657 @@ +import { useStore } from 'zustand'; +import { createStore, type StoreApi } from 'zustand/vanilla'; +import { + createConversationReducerState, + reduceConversationPatch, + replaceConversationSnapshot, + type ConversationReducerState, +} from '../../shared/coding-conversation-reducer'; +import { AppError } from '@/lib/error-model'; +import { + getCodingConversationSnapshot, + openCodingConversationEvents, + submitCodingConversationPrompt, + type SubmitCodingConversationPromptInput, +} from '@/lib/coding-conversations'; +import type { + CodingConversationDraft, + CodingConversationPatchEvent, + CodingConversationSnapshotEvent, + CodingConversationSummary, + CodingDraftAttachment, + CodingPromptRequestState, + ConversationMessageNode, + ConversationPatchEnvelope, + ConversationSnapshot, + PromptAcceptance, + PromptMode, +} from '@/types/coding-conversation'; + +export type CodingConversationLoadState = + | 'empty' + | 'loading' + | 'live' + | 'recovering' + | 'error'; + +export interface CodingConversationEntry { + reducer: ConversationReducerState; + loadState: CodingConversationLoadState; + error: string | null; + unread: boolean; +} + +export interface SubmitCodingPromptInput { + conversationId: string; + mode: PromptMode; + text?: string; + attachments?: CodingDraftAttachment[]; +} + +interface CodingConversationStoreDependencies { + getSnapshot(conversationId: string): Promise; + openEvents(conversationId?: string): Promise; + submitPrompt(input: SubmitCodingConversationPromptInput): Promise; + createId(kind: 'request' | 'node'): string; +} + +export interface CodingConversationStoreState { + selectedConversationId: string | null; + entriesByConversationId: Record; + summariesByConversationId: Record; + draftsByConversationId: Record; + requestsByConversationId: Record>; + connectionState: 'disconnected' | 'connecting' | 'live' | 'reconnecting' | 'error'; + globalError: string | null; + selectConversation(conversationId: string): Promise; + loadSnapshot(conversationId: string, recovering?: boolean): Promise; + connectEvents(): Promise; + disconnectEvents(): void; + setDraft(conversationId: string, text: string, attachments?: CodingDraftAttachment[]): void; + markUnread(conversationId: string, unread: boolean): void; + submitPrompt(input: SubmitCodingPromptInput): Promise; + applySnapshotEvent(event: CodingConversationSnapshotEvent): void; + applyPatchEvent(event: CodingConversationPatchEvent): void; +} + +const DEFAULT_DRAFT: CodingConversationDraft = { + text: '', + attachments: [], + revision: 0, +}; +const EMPTY_REQUESTS: Record = {}; + +function cloneDraft(draft: CodingConversationDraft): CodingConversationDraft { + return { + text: draft.text, + attachments: draft.attachments.map((attachment) => ({ ...attachment })), + revision: draft.revision, + }; +} + +function emptyEntry(): CodingConversationEntry { + return { + reducer: createConversationReducerState(), + loadState: 'empty', + error: null, + unread: false, + }; +} + +function errorDetails(error: unknown): { code?: string; message: string } { + if (error instanceof AppError) { + return { + ...(typeof error.details?.backendCode === 'string' + ? { code: error.details.backendCode } + : {}), + message: error.message, + }; + } + return { message: error instanceof Error ? error.message : String(error) }; +} + +function summaryOf(entry: CodingConversationEntry): CodingConversationSummary | null { + const snapshot = entry.reducer.snapshot; + if (!snapshot) return null; + return { + conversationId: snapshot.conversation.id, + title: snapshot.conversation.title, + runStatus: snapshot.run.status, + runError: snapshot.run.error ?? null, + workerStatus: snapshot.worker.status, + model: snapshot.conversation.model, + unread: entry.unread, + }; +} + +function summariesWithEntry( + summaries: Record, + entry: CodingConversationEntry, +): Record { + const next = summaryOf(entry); + if (!next) return summaries; + const current = summaries[next.conversationId]; + if (current + && current.title === next.title + && current.runStatus === next.runStatus + && current.runError === next.runError + && current.workerStatus === next.workerStatus + && current.model === next.model + && current.unread === next.unread) { + return summaries; + } + return { ...summaries, [next.conversationId]: next }; +} + +function requestIdsInSnapshot(snapshot: ConversationSnapshot): Set { + return new Set(snapshot.nodes.flatMap((node) => ( + node.kind === 'message' && node.clientRequestId ? [node.clientRequestId] : [] + ))); +} + +function withoutReconciledRequests( + requests: Record | undefined, + snapshot: ConversationSnapshot, +): Record { + const reconciled = requestIdsInSnapshot(snapshot); + if (!requests || reconciled.size === 0) return requests ?? {}; + return Object.fromEntries( + Object.entries(requests).filter(([clientRequestId]) => !reconciled.has(clientRequestId)), + ); +} + +function optimisticNode( + nodeId: string, + clientRequestId: string, + draft: CodingConversationDraft, +): ConversationMessageNode { + return { + kind: 'message', + id: nodeId, + clientRequestId, + role: 'user', + status: 'optimistic', + blocks: [ + ...(draft.text ? [{ + kind: 'text' as const, + id: `${nodeId}:text`, + text: draft.text, + status: 'complete' as const, + }] : []), + ...draft.attachments.map((attachment, index) => ({ + kind: 'image' as const, + id: `${nodeId}:attachment:${index}`, + attachmentId: attachment.attachmentId, + mime: attachment.mime, + })), + ], + }; +} + +function withOptimisticNode( + reducer: ConversationReducerState, + node: ConversationMessageNode, +): ConversationReducerState { + const snapshot = reducer.snapshot; + if (!snapshot) return reducer; + return replaceConversationSnapshot(reducer, { + ...snapshot, + nodes: [...snapshot.nodes, node], + }); +} + +function withRejectedNode( + reducer: ConversationReducerState, + nodeId: string | undefined, +): ConversationReducerState { + const snapshot = reducer.snapshot; + if (!snapshot || !nodeId) return reducer; + return replaceConversationSnapshot(reducer, { + ...snapshot, + nodes: snapshot.nodes.map((node) => ( + node.kind === 'message' && node.id === nodeId + ? { ...node, status: 'error' as const } + : node + )), + }); +} + +function snapshotIsOlder( + current: ConversationSnapshot | null, + incoming: ConversationSnapshot, +): boolean { + if (!current) return false; + if (incoming.cursor.workerGeneration !== current.cursor.workerGeneration) { + return incoming.cursor.workerGeneration < current.cursor.workerGeneration; + } + return incoming.cursor.seq < current.cursor.seq; +} + +function defaultDependencies(): CodingConversationStoreDependencies { + return { + getSnapshot: getCodingConversationSnapshot, + openEvents: openCodingConversationEvents, + submitPrompt: submitCodingConversationPrompt, + createId: () => crypto.randomUUID(), + }; +} + +export function createCodingConversationStore( + dependencies: Partial = {}, +): StoreApi { + const deps = { ...defaultDependencies(), ...dependencies }; + const snapshotLoads = new Map>(); + let eventSource: EventSource | null = null; + let connectFlight: Promise | null = null; + let connectionGeneration = 0; + + return createStore((set, get) => ({ + selectedConversationId: null, + entriesByConversationId: {}, + summariesByConversationId: {}, + draftsByConversationId: {}, + requestsByConversationId: {}, + connectionState: 'disconnected', + globalError: null, + + async selectConversation(conversationId) { + set((state) => { + const current = state.entriesByConversationId[conversationId] ?? emptyEntry(); + const entry = current.unread ? { ...current, unread: false } : current; + return { + selectedConversationId: conversationId, + entriesByConversationId: { + ...state.entriesByConversationId, + [conversationId]: entry, + }, + summariesByConversationId: summariesWithEntry(state.summariesByConversationId, entry), + }; + }); + const entry = get().entriesByConversationId[conversationId]; + if (!entry?.reducer.snapshot || entry.reducer.invalidation) { + await get().loadSnapshot(conversationId, Boolean(entry?.reducer.invalidation)); + } + await get().connectEvents(); + }, + + loadSnapshot(conversationId, recovering = false) { + const prior = snapshotLoads.get(conversationId); + if (prior) return prior; + set((state) => { + const entry = state.entriesByConversationId[conversationId] ?? emptyEntry(); + return { + entriesByConversationId: { + ...state.entriesByConversationId, + [conversationId]: { + ...entry, + loadState: recovering ? 'recovering' : 'loading', + error: null, + }, + }, + }; + }); + const flight = deps.getSnapshot(conversationId) + .then((snapshot) => { + get().applySnapshotEvent({ + type: 'snapshot', + conversationId, + workerGeneration: snapshot.cursor.workerGeneration, + seq: snapshot.cursor.seq, + snapshot, + }); + return snapshot; + }) + .catch((error) => { + const failure = errorDetails(error); + set((state) => { + const entry = state.entriesByConversationId[conversationId] ?? emptyEntry(); + return { + entriesByConversationId: { + ...state.entriesByConversationId, + [conversationId]: { ...entry, loadState: 'error', error: failure.message }, + }, + }; + }); + throw error; + }) + .finally(() => { + if (snapshotLoads.get(conversationId) === flight) snapshotLoads.delete(conversationId); + }); + snapshotLoads.set(conversationId, flight); + return flight; + }, + + async connectEvents() { + if (eventSource) return; + if (connectFlight) return await connectFlight; + const generation = ++connectionGeneration; + set({ connectionState: 'connecting', globalError: null }); + let flight: Promise; + flight = deps.openEvents() + .then((source) => { + if (generation !== connectionGeneration) { + source.close(); + return; + } + eventSource = source; + const parse = (event: Event): T => JSON.parse((event as MessageEvent).data) as T; + source.addEventListener('snapshot', (event) => { + try { + get().applySnapshotEvent(parse(event)); + } catch { + set({ connectionState: 'error', globalError: 'Conversation 事件快照无法读取。' }); + } + }); + source.addEventListener('patch', (event) => { + try { + get().applyPatchEvent(parse(event)); + } catch { + set({ connectionState: 'error', globalError: 'Conversation 事件更新无法读取。' }); + } + }); + source.onopen = () => { + if (eventSource === source) set({ connectionState: 'live', globalError: null }); + }; + source.onerror = () => { + if (eventSource === source) { + set({ + connectionState: 'reconnecting', + globalError: 'Conversation 事件连接中断,正在重连。', + }); + } + }; + }) + .catch((error) => { + if (generation !== connectionGeneration) return; + set({ connectionState: 'error', globalError: errorDetails(error).message }); + throw error; + }) + .finally(() => { + if (connectFlight === flight) connectFlight = null; + }); + connectFlight = flight; + await flight; + }, + + disconnectEvents() { + connectionGeneration += 1; + eventSource?.close(); + eventSource = null; + connectFlight = null; + set({ connectionState: 'disconnected', globalError: null }); + }, + + setDraft(conversationId, text, attachments = []) { + set((state) => { + const current = state.draftsByConversationId[conversationId] ?? DEFAULT_DRAFT; + return { + draftsByConversationId: { + ...state.draftsByConversationId, + [conversationId]: { + text, + attachments: attachments.map((attachment) => ({ ...attachment })), + revision: current.revision + 1, + }, + }, + }; + }); + }, + + markUnread(conversationId, unread) { + set((state) => { + const current = state.entriesByConversationId[conversationId] ?? emptyEntry(); + if (current.unread === unread) return state; + const entry = { ...current, unread }; + return { + entriesByConversationId: { + ...state.entriesByConversationId, + [conversationId]: entry, + }, + summariesByConversationId: summariesWithEntry(state.summariesByConversationId, entry), + }; + }); + }, + + async submitPrompt(input) { + let entry = get().entriesByConversationId[input.conversationId]; + if (!entry?.reducer.snapshot || entry.reducer.invalidation) { + await get().loadSnapshot(input.conversationId, Boolean(entry?.reducer.invalidation)); + entry = get().entriesByConversationId[input.conversationId]; + } + if (!entry?.reducer.snapshot) throw new Error('Conversation snapshot is unavailable'); + + const existingDraft = get().draftsByConversationId[input.conversationId] ?? DEFAULT_DRAFT; + const submittedDraft: CodingConversationDraft = { + text: input.text ?? existingDraft.text, + attachments: (input.attachments ?? existingDraft.attachments).map((attachment) => ({ ...attachment })), + revision: existingDraft.revision, + }; + const clientRequestId = deps.createId('request'); + const nodeId = input.mode === 'prompt' ? deps.createId('node') : undefined; + const clearedRevision = existingDraft.revision + 1; + const request: CodingPromptRequestState = { + clientRequestId, + mode: input.mode, + ...(nodeId ? { nodeId } : {}), + status: 'pending', + submittedDraft: cloneDraft(submittedDraft), + }; + + set((state) => { + const currentEntry = state.entriesByConversationId[input.conversationId] ?? emptyEntry(); + const reducer = nodeId + ? withOptimisticNode( + currentEntry.reducer, + optimisticNode(nodeId, clientRequestId, submittedDraft), + ) + : currentEntry.reducer; + const nextEntry = { ...currentEntry, reducer, error: null }; + return { + entriesByConversationId: { + ...state.entriesByConversationId, + [input.conversationId]: nextEntry, + }, + summariesByConversationId: summariesWithEntry( + state.summariesByConversationId, + nextEntry, + ), + draftsByConversationId: { + ...state.draftsByConversationId, + [input.conversationId]: { text: '', attachments: [], revision: clearedRevision }, + }, + requestsByConversationId: { + ...state.requestsByConversationId, + [input.conversationId]: { + ...(state.requestsByConversationId[input.conversationId] ?? {}), + [clientRequestId]: request, + }, + }, + }; + }); + + try { + const acceptance = await deps.submitPrompt({ + conversationId: input.conversationId, + clientRequestId, + mode: input.mode, + text: submittedDraft.text, + attachments: submittedDraft.attachments.map(({ attachmentId }) => ({ attachmentId })), + }); + set((state) => { + const requests = state.requestsByConversationId[input.conversationId] ?? {}; + const current = requests[clientRequestId]; + if (!current) return state; + return { + requestsByConversationId: { + ...state.requestsByConversationId, + [input.conversationId]: { + ...requests, + [clientRequestId]: { ...current, status: 'accepted' }, + }, + }, + }; + }); + return acceptance; + } catch (error) { + const failure = errorDetails(error); + const uncertain = failure.code === 'CODING_REQUEST_UNCERTAIN'; + set((state) => { + const currentEntry = state.entriesByConversationId[input.conversationId] ?? emptyEntry(); + const requests = state.requestsByConversationId[input.conversationId] ?? {}; + const currentRequest = requests[clientRequestId] ?? request; + const currentDraft = state.draftsByConversationId[input.conversationId] ?? DEFAULT_DRAFT; + const canRestore = currentDraft.revision === clearedRevision + && !currentDraft.text + && currentDraft.attachments.length === 0; + const reducer = uncertain + ? currentEntry.reducer + : withRejectedNode(currentEntry.reducer, nodeId); + const nextEntry = { ...currentEntry, reducer, error: failure.message }; + return { + entriesByConversationId: { + ...state.entriesByConversationId, + [input.conversationId]: nextEntry, + }, + summariesByConversationId: summariesWithEntry( + state.summariesByConversationId, + nextEntry, + ), + draftsByConversationId: canRestore + ? { + ...state.draftsByConversationId, + [input.conversationId]: { + ...cloneDraft(submittedDraft), + revision: clearedRevision + 1, + }, + } + : state.draftsByConversationId, + requestsByConversationId: { + ...state.requestsByConversationId, + [input.conversationId]: { + ...requests, + [clientRequestId]: { + ...currentRequest, + status: uncertain ? 'uncertain' : 'rejected', + ...(failure.code ? { errorCode: failure.code } : {}), + errorMessage: failure.message, + }, + }, + }, + }; + }); + throw error; + } + }, + + applySnapshotEvent(event) { + const snapshot = event.snapshot; + if (event.type !== 'snapshot' + || event.conversationId !== snapshot.conversation.id + || event.workerGeneration !== snapshot.cursor.workerGeneration + || event.seq !== snapshot.cursor.seq) { + return; + } + set((state) => { + const current = state.entriesByConversationId[event.conversationId] ?? emptyEntry(); + if (!current.reducer.invalidation && snapshotIsOlder(current.reducer.snapshot, snapshot)) { + return state; + } + const reducer = replaceConversationSnapshot(current.reducer, snapshot); + const entry: CodingConversationEntry = { + ...current, + reducer, + loadState: reducer.invalidation ? 'error' : 'live', + error: reducer.invalidation?.reason ?? null, + unread: state.selectedConversationId === event.conversationId ? false : current.unread, + }; + return { + entriesByConversationId: { + ...state.entriesByConversationId, + [event.conversationId]: entry, + }, + summariesByConversationId: summariesWithEntry(state.summariesByConversationId, entry), + requestsByConversationId: { + ...state.requestsByConversationId, + [event.conversationId]: withoutReconciledRequests( + state.requestsByConversationId[event.conversationId], + snapshot, + ), + }, + }; + }); + }, + + applyPatchEvent(event) { + if (event.type !== 'patch') return; + let recover = false; + set((state) => { + const current = state.entriesByConversationId[event.conversationId] ?? emptyEntry(); + const reducer = reduceConversationPatch(current.reducer, event as ConversationPatchEnvelope); + if (reducer === current.reducer) return state; + recover = Boolean(reducer.invalidation); + const incomingMessage = event.patch.op === 'message.upsert' ? event.patch.node : null; + const unread = state.selectedConversationId !== event.conversationId + && incomingMessage?.role === 'assistant' + ? true + : current.unread; + const entry: CodingConversationEntry = { + ...current, + reducer, + loadState: reducer.invalidation ? 'recovering' : 'live', + error: reducer.invalidation?.reason ?? null, + unread, + }; + const requests = state.requestsByConversationId[event.conversationId] ?? {}; + const nextRequests = incomingMessage?.clientRequestId + ? Object.fromEntries( + Object.entries(requests).filter(([id]) => id !== incomingMessage.clientRequestId), + ) + : requests; + return { + entriesByConversationId: { + ...state.entriesByConversationId, + [event.conversationId]: entry, + }, + summariesByConversationId: summariesWithEntry(state.summariesByConversationId, entry), + requestsByConversationId: nextRequests === requests + ? state.requestsByConversationId + : { + ...state.requestsByConversationId, + [event.conversationId]: nextRequests, + }, + }; + }); + if (recover) { + void get().loadSnapshot(event.conversationId, true).catch(() => undefined); + } + }, + })); +} + +export const codingConversationStore = createCodingConversationStore(); + +export function useCodingConversationStore( + selector: (state: CodingConversationStoreState) => T, +): T { + return useStore(codingConversationStore, selector); +} + +export const selectCodingConversationSnapshot = (conversationId: string) => ( + state: CodingConversationStoreState, +): ConversationSnapshot | null => ( + state.entriesByConversationId[conversationId]?.reducer.snapshot ?? null +); + +export const selectCodingConversationSummary = (conversationId: string) => ( + state: CodingConversationStoreState, +): CodingConversationSummary | null => state.summariesByConversationId[conversationId] ?? null; + +export const selectCodingConversationDraft = (conversationId: string) => ( + state: CodingConversationStoreState, +): CodingConversationDraft => state.draftsByConversationId[conversationId] ?? DEFAULT_DRAFT; + +export const selectCodingConversationRequests = (conversationId: string) => ( + state: CodingConversationStoreState, +): Record => ( + state.requestsByConversationId[conversationId] ?? EMPTY_REQUESTS +); diff --git a/src/types/coding-conversation.ts b/src/types/coding-conversation.ts new file mode 100644 index 0000000..3e50b9a --- /dev/null +++ b/src/types/coding-conversation.ts @@ -0,0 +1,77 @@ +export type { + CodingRuntimePublicError, + ConversationMessageNode, + ConversationModelState, + ConversationNode, + ConversationPatch, + ConversationPatchEnvelope, + ConversationQueueState, + ConversationRunState, + ConversationRunStatus, + ConversationSnapshot, + ConversationThinkingLevel, + ProductModelRef, + PromptAcceptance, + PromptMode, + PublicWorkerState, +} from '../../shared/coding-conversation-contracts'; + +import type { + ConversationPatchEnvelope, + ConversationSnapshot, + PromptMode, +} from '../../shared/coding-conversation-contracts'; + +export interface CodingConversationSnapshotEvent { + type: 'snapshot'; + conversationId: string; + workerGeneration: number; + seq: number; + snapshot: ConversationSnapshot; +} + +export type CodingConversationPatchEvent = { + type: 'patch'; +} & ConversationPatchEnvelope; + +export type CodingConversationStreamEvent = + | CodingConversationSnapshotEvent + | CodingConversationPatchEvent; + +export interface CodingDraftAttachment { + attachmentId: string; + mime: string; + previewUrl?: string; +} + +export interface CodingConversationDraft { + text: string; + attachments: CodingDraftAttachment[]; + revision: number; +} + +export type CodingPromptRequestStatus = + | 'pending' + | 'accepted' + | 'rejected' + | 'uncertain'; + +export interface CodingPromptRequestState { + clientRequestId: string; + mode: PromptMode; + nodeId?: string; + status: CodingPromptRequestStatus; + submittedDraft: CodingConversationDraft; + errorCode?: string; + errorMessage?: string; +} + +export interface CodingConversationSummary { + conversationId: string; + title: string; + runStatus: ConversationSnapshot['run']['status']; + runError: ConversationSnapshot['run']['error'] | null; + workerStatus: ConversationSnapshot['worker']['status']; + model: ConversationSnapshot['conversation']['model']; + unread: boolean; +} diff --git a/tests/unit/coding-conversations-facade.test.ts b/tests/unit/coding-conversations-facade.test.ts new file mode 100644 index 0000000..1f87e82 --- /dev/null +++ b/tests/unit/coding-conversations-facade.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const hostApi = vi.hoisted(() => ({ + fetch: vi.fn(), + ensureToken: vi.fn(), + createEvents: vi.fn(), +})); + +vi.mock('@/lib/host-api', () => ({ + hostApiFetch: hostApi.fetch, + ensureHostApiToken: hostApi.ensureToken, + createHostEventSource: hostApi.createEvents, +})); + +describe('coding Conversations Host facade', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('loads the target snapshot through the encoded Host route', async () => { + const snapshot = { conversation: { id: 'conversation/a' } }; + hostApi.fetch.mockResolvedValueOnce({ snapshot }); + const { getCodingConversationSnapshot } = await import('@/lib/coding-conversations'); + + await expect(getCodingConversationSnapshot('conversation/a')).resolves.toBe(snapshot); + expect(hostApi.fetch).toHaveBeenCalledWith( + '/api/coding/conversations/conversation%2Fa/snapshot', + ); + }); + + it('submits only the product prompt DTO and returns its acceptance', async () => { + const acceptance = { + accepted: true, + conversationId: 'conversation/a', + clientRequestId: 'request-1', + runId: 'run-1', + mode: 'prompt', + }; + hostApi.fetch.mockResolvedValueOnce({ acceptance }); + const { submitCodingConversationPrompt } = await import('@/lib/coding-conversations'); + + await expect(submitCodingConversationPrompt({ + conversationId: 'conversation/a', + clientRequestId: 'request-1', + mode: 'prompt', + text: 'Build it', + attachments: [{ attachmentId: 'attachment-1' }], + })).resolves.toBe(acceptance); + expect(hostApi.fetch).toHaveBeenCalledWith( + '/api/coding/conversations/conversation%2Fa/prompt', + { + method: 'POST', + body: JSON.stringify({ + clientRequestId: 'request-1', + mode: 'prompt', + text: 'Build it', + attachments: [{ attachmentId: 'attachment-1' }], + }), + }, + ); + }); + + it('hydrates the Host token before constructing the EventSource URL', async () => { + const order: string[] = []; + const source = { close: vi.fn() } as unknown as EventSource; + hostApi.ensureToken.mockImplementationOnce(async () => { + order.push('token'); + return 'host-token'; + }); + hostApi.createEvents.mockImplementationOnce((path: string) => { + order.push(`events:${path}`); + return source; + }); + const { openCodingConversationEvents } = await import('@/lib/coding-conversations'); + + await expect(openCodingConversationEvents('conversation/a')).resolves.toBe(source); + expect(order).toEqual([ + 'token', + 'events:/api/coding/events?conversationId=conversation%2Fa', + ]); + }); +}); diff --git a/tests/unit/coding-conversations-store.test.tsx b/tests/unit/coding-conversations-store.test.tsx new file mode 100644 index 0000000..85b4ec6 --- /dev/null +++ b/tests/unit/coding-conversations-store.test.tsx @@ -0,0 +1,443 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { useStore } from 'zustand'; +import { describe, expect, it, vi } from 'vitest'; +import { AppError } from '@/lib/error-model'; +import { + createCodingConversationStore, + selectCodingConversationSnapshot, +} from '@/stores/coding-conversations'; +import type { + CodingConversationPatchEvent, + CodingConversationSnapshotEvent, + ConversationPatch, + ConversationSnapshot, + PromptAcceptance, +} from '@/types/coding-conversation'; +import { createProductSnapshot } from '../fixtures/coding-conversation-product-fixtures'; + +class FakeEventSource { + onopen: ((event: Event) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + readonly close = vi.fn(); + private readonly listeners = new Map>(); + + addEventListener(type: string, listener: EventListenerOrEventListenerObject): void { + const listeners = this.listeners.get(type) ?? new Set(); + listeners.add(listener); + this.listeners.set(type, listeners); + } + + emit(type: 'snapshot' | 'patch', value: unknown): void { + const event = new MessageEvent(type, { data: JSON.stringify(value) }); + for (const listener of this.listeners.get(type) ?? []) { + if (typeof listener === 'function') listener(event); + else listener.handleEvent(event); + } + } + + open(): void { + this.onopen?.(new Event('open')); + } + + fail(): void { + this.onerror?.(new Event('error')); + } +} + +function snapshot( + conversationId: string, + workerGeneration = 1, + seq = 0, +): ConversationSnapshot { + const value = createProductSnapshot(conversationId, workerGeneration); + return { + ...value, + conversation: { + ...value.conversation, + title: `Conversation ${conversationId}`, + }, + cursor: { ...value.cursor, seq }, + }; +} + +function snapshotEvent(value: ConversationSnapshot): CodingConversationSnapshotEvent { + return { + type: 'snapshot', + conversationId: value.conversation.id, + workerGeneration: value.cursor.workerGeneration, + seq: value.cursor.seq, + snapshot: value, + }; +} + +function patchEvent( + conversationId: string, + seq: number, + patch: ConversationPatch, + workerGeneration = 1, +): CodingConversationPatchEvent { + return { + type: 'patch', + conversationId, + workerGeneration, + seq, + at: 1_000 + seq, + patch, + }; +} + +function acceptance( + conversationId: string, + clientRequestId: string, +): PromptAcceptance { + return { + accepted: true, + conversationId, + clientRequestId, + runId: 'run-1', + mode: 'prompt', + }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function ids() { + const counters = { request: 0, node: 0 }; + return (kind: 'request' | 'node') => `${kind}-${++counters[kind]}`; +} + +describe('coding Conversation store', () => { + it('isolates interleaved snapshot and patch state for two Conversations', async () => { + const source = new FakeEventSource(); + const getSnapshot = vi.fn(async (conversationId: string) => snapshot(conversationId)); + const store = createCodingConversationStore({ + getSnapshot, + openEvents: vi.fn(async () => source as unknown as EventSource), + submitPrompt: vi.fn(), + createId: ids(), + }); + + await store.getState().selectConversation('conversation-a'); + source.open(); + source.emit('snapshot', snapshotEvent(snapshot('conversation-b'))); + const aBeforeB = selectCodingConversationSnapshot('conversation-a')(store.getState()); + + source.emit('patch', patchEvent('conversation-a', 1, { + op: 'run.state', + run: { status: 'running', runId: 'run-a', mode: 'prompt' }, + })); + const aAfterOwnPatch = selectCodingConversationSnapshot('conversation-a')(store.getState()); + source.emit('patch', patchEvent('conversation-b', 1, { + op: 'message.upsert', + node: { + kind: 'message', + id: 'assistant-b', + role: 'assistant', + status: 'streaming', + blocks: [{ kind: 'text', id: 'text-b', text: 'B', status: 'streaming' }], + }, + })); + + expect(aBeforeB?.run.status).toBe('idle'); + expect(aAfterOwnPatch?.run.status).toBe('running'); + expect(selectCodingConversationSnapshot('conversation-a')(store.getState())) + .toBe(aAfterOwnPatch); + expect(selectCodingConversationSnapshot('conversation-b')(store.getState())?.nodes) + .toHaveLength(1); + expect(store.getState().summariesByConversationId['conversation-a'].unread).toBe(false); + expect(store.getState().summariesByConversationId['conversation-b'].unread).toBe(true); + expect(getSnapshot).toHaveBeenCalledTimes(1); + expect(store.getState().connectionState).toBe('live'); + }); + + it('refreshes only the gapped target and drops an older worker generation', async () => { + const recoveredA = snapshot('conversation-a', 1, 2); + const getSnapshot = vi.fn(async (conversationId: string) => { + expect(conversationId).toBe('conversation-a'); + return recoveredA; + }); + const store = createCodingConversationStore({ + getSnapshot, + openEvents: vi.fn(), + submitPrompt: vi.fn(), + createId: ids(), + }); + store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a'))); + store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-b'))); + const bBefore = selectCodingConversationSnapshot('conversation-b')(store.getState()); + + store.getState().applyPatchEvent(patchEvent('conversation-b', 1, { + op: 'run.state', + run: { status: 'running' }, + }, 0)); + store.getState().applyPatchEvent(patchEvent('conversation-a', 2, { + op: 'run.state', + run: { status: 'running' }, + })); + + await waitFor(() => { + expect(getSnapshot).toHaveBeenCalledTimes(1); + expect(store.getState().entriesByConversationId['conversation-a'].loadState).toBe('live'); + }); + expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.cursor.seq).toBe(2); + expect(selectCodingConversationSnapshot('conversation-b')(store.getState())).toBe(bBefore); + expect(store.getState().entriesByConversationId['conversation-b'].reducer.invalidation).toBeNull(); + }); + + it('uses native EventSource reconnect without reopening or replaying a mutation', async () => { + const source = new FakeEventSource(); + const openEvents = vi.fn(async () => source as unknown as EventSource); + const submitPrompt = vi.fn(async (input) => acceptance(input.conversationId, input.clientRequestId)); + const store = createCodingConversationStore({ + getSnapshot: vi.fn(async (conversationId: string) => snapshot(conversationId)), + openEvents, + submitPrompt, + createId: ids(), + }); + await store.getState().selectConversation('conversation-a'); + store.getState().setDraft('conversation-a', 'one request'); + await store.getState().submitPrompt({ conversationId: 'conversation-a', mode: 'prompt' }); + + source.fail(); + expect(store.getState().connectionState).toBe('reconnecting'); + source.open(); + + expect(store.getState().connectionState).toBe('live'); + expect(openEvents).toHaveBeenCalledTimes(1); + expect(submitPrompt).toHaveBeenCalledTimes(1); + }); + + it('does not let a cancelled connection clear a newer connection flight', async () => { + const first = deferred(); + const second = deferred(); + const firstSource = new FakeEventSource(); + const secondSource = new FakeEventSource(); + const openEvents = vi.fn() + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => second.promise); + const store = createCodingConversationStore({ + getSnapshot: vi.fn(), + openEvents, + submitPrompt: vi.fn(), + createId: ids(), + }); + + const firstConnect = store.getState().connectEvents(); + store.getState().disconnectEvents(); + const secondConnect = store.getState().connectEvents(); + first.resolve(firstSource as unknown as EventSource); + await firstConnect; + const joinedSecondConnect = store.getState().connectEvents(); + + expect(openEvents).toHaveBeenCalledTimes(2); + expect(firstSource.close).toHaveBeenCalledOnce(); + second.resolve(secondSource as unknown as EventSource); + await Promise.all([secondConnect, joinedSecondConnect]); + expect(openEvents).toHaveBeenCalledTimes(2); + }); + + it('reconciles an accepted optimistic node by clientRequestId without changing its UI id', async () => { + const pending = deferred(); + const store = createCodingConversationStore({ + getSnapshot: vi.fn(), + openEvents: vi.fn(), + submitPrompt: vi.fn(() => pending.promise), + createId: ids(), + }); + store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a'))); + store.getState().setDraft('conversation-a', 'Build it', [{ + attachmentId: 'attachment-1', + mime: 'image/png', + previewUrl: 'blob:preview', + }]); + + const submission = store.getState().submitPrompt({ + conversationId: 'conversation-a', + mode: 'prompt', + }); + const optimistic = selectCodingConversationSnapshot('conversation-a')(store.getState())?.nodes[0]; + expect(optimistic).toMatchObject({ id: 'node-1', clientRequestId: 'request-1', status: 'optimistic' }); + expect(store.getState().draftsByConversationId['conversation-a']).toMatchObject({ + text: '', + attachments: [], + }); + + pending.resolve(acceptance('conversation-a', 'request-1')); + await submission; + expect(store.getState().requestsByConversationId['conversation-a']['request-1'].status) + .toBe('accepted'); + store.getState().applyPatchEvent(patchEvent('conversation-a', 1, { + op: 'message.upsert', + node: { + kind: 'message', + id: 'durable-user-1', + sourceEntryId: 'entry-user-1', + clientRequestId: 'request-1', + role: 'user', + status: 'complete', + blocks: [{ kind: 'text', id: 'text-1', text: 'Build it', status: 'complete' }], + }, + })); + + expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.nodes[0]) + .toMatchObject({ id: 'node-1', sourceEntryId: 'entry-user-1', status: 'complete' }); + expect(store.getState().requestsByConversationId['conversation-a']).toEqual({}); + }); + + it('restores draft attachments and marks the optimistic node on definite rejection', async () => { + const submitPrompt = vi.fn(async () => { + throw new AppError('RUNTIME', 'Model unavailable', undefined, { + backendCode: 'CODING_MODEL_UNAVAILABLE', + }); + }); + const store = createCodingConversationStore({ + getSnapshot: vi.fn(), + openEvents: vi.fn(), + submitPrompt, + createId: ids(), + }); + store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a'))); + store.getState().setDraft('conversation-a', 'Try again', [{ + attachmentId: 'attachment-1', + mime: 'image/png', + previewUrl: 'blob:preview', + }]); + + await expect(store.getState().submitPrompt({ + conversationId: 'conversation-a', + mode: 'prompt', + })).rejects.toThrow('Model unavailable'); + + expect(store.getState().draftsByConversationId['conversation-a']).toMatchObject({ + text: 'Try again', + attachments: [{ attachmentId: 'attachment-1', mime: 'image/png', previewUrl: 'blob:preview' }], + }); + expect(store.getState().requestsByConversationId['conversation-a']['request-1']) + .toMatchObject({ status: 'rejected', errorCode: 'CODING_MODEL_UNAVAILABLE' }); + expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.nodes[0]) + .toMatchObject({ id: 'node-1', status: 'error' }); + }); + + it('keeps an uncertain optimistic request recoverable without replaying it on reconnect', async () => { + const source = new FakeEventSource(); + const submitPrompt = vi.fn(async () => { + throw new AppError('NETWORK', 'Delivery is uncertain', undefined, { + backendCode: 'CODING_REQUEST_UNCERTAIN', + }); + }); + const store = createCodingConversationStore({ + getSnapshot: vi.fn(async (conversationId: string) => snapshot(conversationId)), + openEvents: vi.fn(async () => source as unknown as EventSource), + submitPrompt, + createId: ids(), + }); + await store.getState().selectConversation('conversation-a'); + store.getState().setDraft('conversation-a', 'Do not replay'); + + await expect(store.getState().submitPrompt({ + conversationId: 'conversation-a', + mode: 'prompt', + })).rejects.toThrow('Delivery is uncertain'); + source.fail(); + source.open(); + + expect(store.getState().draftsByConversationId['conversation-a'].text).toBe('Do not replay'); + expect(store.getState().requestsByConversationId['conversation-a']['request-1']) + .toMatchObject({ status: 'uncertain', errorCode: 'CODING_REQUEST_UNCERTAIN' }); + expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.nodes[0]) + .toMatchObject({ id: 'node-1', status: 'optimistic' }); + expect(submitPrompt).toHaveBeenCalledTimes(1); + }); + + it('does not rerender a selected timeline when a hidden Conversation streams', () => { + const store = createCodingConversationStore({ + getSnapshot: vi.fn(), + openEvents: vi.fn(), + submitPrompt: vi.fn(), + createId: ids(), + }); + store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a'))); + store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-b'))); + const selectANodes = (state: ReturnType) => ( + selectCodingConversationSnapshot('conversation-a')(state)?.nodes ?? [] + ); + let renderCount = 0; + const { result } = renderHook(() => { + renderCount += 1; + return useStore(store, selectANodes); + }); + const initialRenderCount = renderCount; + const aNodes = result.current; + + act(() => { + store.getState().applyPatchEvent(patchEvent('conversation-b', 1, { + op: 'message.upsert', + node: { + kind: 'message', + id: 'assistant-b', + role: 'assistant', + status: 'streaming', + blocks: [{ kind: 'text', id: 'text-b', text: 'token', status: 'streaming' }], + }, + })); + }); + expect(renderCount).toBe(initialRenderCount); + expect(result.current).toBe(aNodes); + const streamingSummary = store.getState().summariesByConversationId['conversation-b']; + act(() => { + store.getState().applyPatchEvent(patchEvent('conversation-b', 2, { + op: 'message.block-delta', + messageId: 'assistant-b', + blockId: 'text-b', + delta: ' two', + })); + }); + expect(store.getState().summariesByConversationId['conversation-b']).toBe(streamingSummary); + expect(renderCount).toBe(initialRenderCount); + + act(() => { + store.getState().applyPatchEvent(patchEvent('conversation-a', 1, { + op: 'message.upsert', + node: { + kind: 'message', + id: 'assistant-a', + role: 'assistant', + status: 'streaming', + blocks: [{ kind: 'text', id: 'text-a', text: 'token', status: 'streaming' }], + }, + })); + }); + expect(renderCount).toBe(initialRenderCount + 1); + expect(result.current).not.toBe(aNodes); + }); + + it('keeps the Renderer surface vendor-neutral and both runtimes on the same reducer', async () => { + const files = [ + '../../src/types/coding-conversation.ts', + '../../src/lib/coding-conversations.ts', + '../../src/stores/coding-conversations.ts', + ]; + for (const relativePath of files) { + const source = await readFile(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8'); + expect(source).not.toMatch(/opencode|(?:import|export)[\s\S]*?from\s+['"][^'"]*pi(?:-|\/)/i); + expect(source).not.toMatch(/\bPi[A-Z][A-Za-z]*/); + } + + const [shared, main] = await Promise.all([ + import('../../shared/coding-conversation-reducer'), + import('../../electron/coding-runtime/conversation-reducer'), + ]); + expect(main.createConversationReducerState).toBe(shared.createConversationReducerState); + expect(main.reduceConversationPatch).toBe(shared.reduceConversationPatch); + }); +});