From 23ce5e5a670cedeed6d2ce6737c9eef03a02acb5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 03:00:26 -0700 Subject: [PATCH 1/5] feat: wire Claude structured sessions --- .../claude-structured-item-translation.ts | 158 ++++++++ ...ude-structured-journal-translation.test.ts | 212 +++++++++++ .../claude-structured-journal-translation.ts | 275 ++++++++++++++ .../claude/claude-structured-prompt-items.ts | 108 ++++++ .../claude-structured-session-adapter.ts | 10 +- .../claude/claude-structured-session-close.ts | 13 +- .../claude-structured-session-publication.ts | 3 + .../claude/claude-structured-session-state.ts | 2 + ...structured-agent-session-adapter-router.ts | 89 +++++ .../structured-agent-session-adapter.ts | 2 + .../structured-agent-session-host.ts | 17 +- ...ructured-agent-session-provider-routing.ts | 35 ++ .../structured-agent-session-read-restore.ts | 6 +- .../runtime/agent-session-claim-key-state.ts | 46 +++ ...session-provider-handle-transition.test.ts | 48 +++ ...gent-session-provider-handle-transition.ts | 41 ++ .../runtime/agent-session-record-store.ts | 41 +- ...ude-structured-session-integration.test.ts | 358 ++++++++++++++++++ ...ctured-agent-session-create-intent.test.ts | 52 +++ src/main/runtime/orca-runtime.ts | 41 +- .../structured-agent-session-schemas.ts | 4 +- .../rpc/methods/structured-agent-session.ts | 2 +- .../structured-agent-session-runtime.ts | 45 ++- src/shared/runtime-types.ts | 2 +- 24 files changed, 1547 insertions(+), 63 deletions(-) create mode 100644 src/main/claude/claude-structured-item-translation.ts create mode 100644 src/main/claude/claude-structured-journal-translation.test.ts create mode 100644 src/main/claude/claude-structured-journal-translation.ts create mode 100644 src/main/claude/claude-structured-prompt-items.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-provider-routing.ts create mode 100644 src/main/runtime/agent-session-claim-key-state.ts create mode 100644 src/main/runtime/agent-session-provider-handle-transition.test.ts create mode 100644 src/main/runtime/agent-session-provider-handle-transition.ts create mode 100644 src/main/runtime/claude-structured-session-integration.test.ts diff --git a/src/main/claude/claude-structured-item-translation.ts b/src/main/claude/claude-structured-item-translation.ts new file mode 100644 index 000000000000..a69130f5c98d --- /dev/null +++ b/src/main/claude/claude-structured-item-translation.ts @@ -0,0 +1,158 @@ +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalMessageItem +} from '../../shared/agent-session-journal-types' +import type { NativeChatBlock } from '../../shared/native-chat-types' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' + +export type ClaudeMessageEnvelope = { + sessionId: string + uuid: string + role: 'assistant' | 'user' + content: unknown[] +} + +export type ClaudeToolUse = { id: string; name: string; input: unknown } +export type ClaudeToolResult = { toolUseId: string; output: string; failed: boolean } + +export function claudeRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null +} + +export function claudeText(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} + +export function readClaudeMessageEnvelope( + frame: Record +): ClaudeMessageEnvelope | null { + if (frame.type !== 'assistant' && frame.type !== 'user') { + return null + } + const message = claudeRecord(frame.message) + const sessionId = claudeText(frame.session_id) + const uuid = claudeText(frame.uuid) + const role = message?.role + return sessionId && uuid && (role === 'assistant' || role === 'user') + ? { + sessionId, + uuid, + role, + content: Array.isArray(message?.content) ? message.content : [] + } + : null +} + +export function claudeMessageIdentity( + envelope: Pick +): AgentJournalItemIdentity { + return { provider: 'claude', sessionId: envelope.sessionId, uuid: envelope.uuid } +} + +function messageBlocks(envelope: ClaudeMessageEnvelope): NativeChatBlock[] { + const blocks: NativeChatBlock[] = [] + for (const value of envelope.content) { + const part = claudeRecord(value) + const text = claudeText(part?.text) + if (part?.type === 'text' && text) { + blocks.push({ type: 'text', text }) + continue + } + const source = claudeRecord(part?.source) + const url = claudeText(source?.url) + if (part?.type === 'image' && source?.type === 'url' && url) { + blocks.push({ type: 'image-ref', url }) + } + } + return blocks +} + +export function claudeMessageBody(envelope: ClaudeMessageEnvelope): AgentJournalMessageItem | null { + const blocks = messageBlocks(envelope) + return blocks.length > 0 ? { kind: 'message', role: envelope.role, blocks } : null +} + +export function claudeToolUses(envelope: ClaudeMessageEnvelope): ClaudeToolUse[] { + return envelope.content.flatMap((value) => { + const part = claudeRecord(value) + const id = claudeText(part?.id) + const name = claudeText(part?.name) + return part?.type === 'tool_use' && id && name ? [{ id, name, input: part.input ?? null }] : [] + }) +} + +function resultText(value: unknown): string { + if (typeof value === 'string') { + return value + } + if (!Array.isArray(value)) { + return value === undefined ? '' : JSON.stringify(value) + } + return value + .flatMap((entry) => { + if (typeof entry === 'string') { + return [entry] + } + const part = claudeRecord(entry) + return part?.type === 'text' && typeof part.text === 'string' ? [part.text] : [] + }) + .join('\n') +} + +export function claudeToolResults(envelope: ClaudeMessageEnvelope): ClaudeToolResult[] { + return envelope.content.flatMap((value) => { + const part = claudeRecord(value) + const toolUseId = claudeText(part?.tool_use_id) + return part?.type === 'tool_result' && toolUseId + ? [ + { + toolUseId, + output: resultText(part.content), + failed: part.is_error === true + } + ] + : [] + }) +} + +export function claudeThinkingText(envelope: ClaudeMessageEnvelope): string | null { + const parts = envelope.content.flatMap((value) => { + const part = claudeRecord(value) + const thinking = claudeText(part?.thinking) + return part?.type === 'thinking' && thinking ? [thinking] : [] + }) + return parts.length > 0 ? parts.join('\n') : null +} + +export function claudeToolBody(input: { + tool: ClaudeToolUse + result?: ClaudeToolResult +}): AgentJournalItemBody { + return { + kind: 'tool-call', + name: input.tool.name, + input: input.tool.input, + state: input.result ? (input.result.failed ? 'failed' : 'completed') : 'running', + ...(input.result + ? { output: boundInlineText(input.result.output, DEFAULT_JOURNAL_PAYLOAD_LIMITS).bounded } + : {}) + } +} + +export function claudeStreamingMessageBody(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } +} + +export function claudeToolIdentity(sessionId: string, toolUseId: string): AgentJournalItemIdentity { + return { provider: 'orca', clientMessageId: `claude-tool:${sessionId}:${toolUseId}` } +} + +export function claudeThinkingIdentity(sessionId: string, uuid: string): AgentJournalItemIdentity { + return { provider: 'orca', clientMessageId: `claude-thinking:${sessionId}:${uuid}` } +} diff --git a/src/main/claude/claude-structured-journal-translation.test.ts b/src/main/claude/claude-structured-journal-translation.test.ts new file mode 100644 index 000000000000..e29310432a26 --- /dev/null +++ b/src/main/claude/claude-structured-journal-translation.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { ClaudePendingPrompt } from './claude-structured-prompt-replies' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +function sinkState() { + const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] + const tombstones: AgentJournalItemIdentity[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body) => items.push({ identity, body }), + appendTombstone: (identity) => tombstones.push(identity), + publish: vi.fn() + } + return { sink, items, tombstones } +} + +function message( + type: 'assistant' | 'user', + uuid: string, + content: unknown[], + parentToolUseId: string | null = null +) { + return { + type: 'message' as const, + sessionId: 'orca-session', + message: { + type, + uuid, + session_id: 'claude-session', + parent_tool_use_id: parentToolUseId, + message: { role: type, content } + } + } +} + +describe('Claude structured journal translation', () => { + it('reuses the shared coalescer and finalizes the provider-keyed message row', () => { + const state = sinkState() + let scheduled: (() => void) | null = null + const translator = createClaudeJournalTranslator({ + sink: state.sink, + schedule: (run, delay) => { + expect(delay).toBe(60) + scheduled = run + return () => { + scheduled = null + } + } + }) + + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { + type: 'stream_event', + uuid: 'assistant-1', + session_id: 'claude-session', + event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'Hel' } } + } + }) + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { + type: 'stream_event', + uuid: 'assistant-1', + session_id: 'claude-session', + event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'lo' } } + } + }) + expect(state.items).toEqual([]) + + const run = scheduled as (() => void) | null + run?.() + expect(state.items.at(-1)).toEqual({ + identity: { provider: 'claude', sessionId: 'claude-session', uuid: 'assistant-1' }, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Hello' }] } + }) + + translator.handle(message('assistant', 'assistant-1', [{ type: 'text', text: 'Hello!' }])) + expect(state.items.at(-1)).toEqual({ + identity: { provider: 'claude', sessionId: 'claude-session', uuid: 'assistant-1' }, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Hello!' }] } + }) + }) + + it('journals turn lifecycle and updates one tool row through its result', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle(message('user', 'user-1', [{ type: 'text', text: 'List files' }])) + translator.handle( + message('assistant', 'assistant-tool', [ + { type: 'tool_use', id: 'tool-1', name: 'Bash', input: { command: 'ls' } } + ]) + ) + translator.handle( + message( + 'user', + 'tool-result-1', + [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'a.ts\nb.ts' }], + 'tool-1' + ) + ) + + const keyed = new Map( + state.items.map((item) => [agentJournalItemKey(item.identity), item.body]) + ) + expect(keyed.get('claude:claude-session:user-1')).toMatchObject({ + kind: 'message', + role: 'user' + }) + expect(keyed.get('orca:claude-tool%3Aclaude-session%3Atool-1')).toMatchObject({ + kind: 'tool-call', + name: 'Bash', + state: 'completed', + output: { head: 'a.ts\nb.ts', truncated: false } + }) + expect( + state.items.some( + (item) => item.body.kind === 'status' && item.body.turnLifecycle?.turnId === 'user-1' + ) + ).toBe(true) + + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { type: 'result', session_id: 'claude-session', uuid: 'result-1' } + }) + expect(state.tombstones.at(-1)).toMatchObject({ + provider: 'legacy', + agent: 'claude', + recordId: 'turn-lifecycle:user-1' + }) + }) + + it('creates addressable approval and multi-question cards and cancels them durably', () => { + const state = sinkState() + const bindings: unknown[][] = [] + const translator = createClaudeJournalTranslator({ + sink: state.sink, + bindPromptItemId: (...args) => bindings.push(args) + }) + const approval = prompt({ + requestId: 'permission-1', + promptKey: 'permission-1', + toolUseId: 'tool-1', + toolName: 'Bash', + kind: 'approval', + input: { command: 'git status' }, + questionIds: [] + }) + translator.handle({ type: 'prompt', sessionId: 'orca-session', prompt: approval }) + expect(state.items.at(-1)?.body).toMatchObject({ + kind: 'approval', + title: 'Allow Bash?', + options: expect.arrayContaining([{ id: 'allow', label: 'Allow' }]) + }) + expect(bindings[0]).toEqual([ + 'orca:claude-prompt%3Aorca-session%3Apermission-1', + 'permission-1' + ]) + + const questions = prompt({ + requestId: 'questions-1', + promptKey: 'questions-1', + toolUseId: 'tool-q', + toolName: 'AskUserQuestion', + kind: 'question', + input: { + questions: [ + { question: 'Library?', options: [{ label: 'Luxon' }] }, + { question: 'Ship?', options: [{ label: 'Yes' }] } + ] + }, + questionIds: ['Library?', 'Ship?'] + }) + translator.handle({ type: 'prompt', sessionId: 'orca-session', prompt: questions }) + expect(state.items.filter((item) => item.body.kind === 'question')).toHaveLength(2) + expect(bindings.at(-1)).toEqual([ + 'orca:claude-prompt%3Aorca-session%3Aquestions-1%3AShip%3F', + 'questions-1', + 'Ship?' + ]) + + translator.handle({ + type: 'prompt-cancelled', + sessionId: 'orca-session', + promptKey: 'questions-1' + }) + expect(state.tombstones).toHaveLength(2) + }) +}) + +function prompt( + input: Pick< + ClaudePendingPrompt, + 'requestId' | 'promptKey' | 'toolUseId' | 'toolName' | 'kind' | 'input' | 'questionIds' + > +): ClaudePendingPrompt { + return { + ...input, + suggestions: [], + answers: new Map(), + request: { subtype: 'can_use_tool' } + } +} diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts new file mode 100644 index 000000000000..5a7d71de94fb --- /dev/null +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -0,0 +1,275 @@ +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + createAgentSessionDeltaCoalescer, + type AgentSessionDeltaCoalescerDeps +} from '../native-chat/agent-session-wire/agent-session-delta-coalescer' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' +import { + claudeMessageBody, + claudeMessageIdentity, + claudeRecord, + claudeStreamingMessageBody, + claudeText, + claudeThinkingIdentity, + claudeThinkingText, + claudeToolBody, + claudeToolIdentity, + claudeToolResults, + claudeToolUses, + readClaudeMessageEnvelope, + type ClaudeToolUse +} from './claude-structured-item-translation' +import { + claudeApprovalItem, + claudePromptIdentity, + claudeQuestionItems +} from './claude-structured-prompt-items' +import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' + +export type ClaudeJournalTranslatorDeps = { + sink: StructuredAgentSessionEventSink + bindPromptItemId?: (journalItemId: string, promptKey: string, questionId?: string) => void + coalesceMs?: number + schedule?: AgentSessionDeltaCoalescerDeps['schedule'] +} + +export type ClaudeJournalTranslator = { + handle: (event: ClaudeStructuredSessionEvent) => void + flush: () => void + dispose: () => void +} + +export function createClaudeSessionJournalTranslator( + sink: StructuredAgentSessionEventSink | undefined, + prompts: ClaudePromptRegistry +): ClaudeJournalTranslator | null { + return sink + ? createClaudeJournalTranslator({ + sink, + bindPromptItemId: (itemId, promptKey, questionId) => + prompts.bindJournalItemId(itemId, promptKey, questionId) + }) + : null +} + +function lifecycleIdentity(sessionId: string, turnId: string): AgentJournalItemIdentity { + return { + provider: 'legacy', + agent: 'claude', + sessionId, + recordId: `turn-lifecycle:${turnId}` + } +} + +function streamDelta(message: Record): string | null { + if (message.type !== 'stream_event') { + return null + } + const event = claudeRecord(message.event) + if (event?.type === 'content_block_start') { + return claudeText(claudeRecord(event.content_block)?.text) + } + if (event?.type !== 'content_block_delta') { + return null + } + const delta = claudeRecord(event.delta) + return delta?.type === 'text_delta' ? claudeText(delta.text) : null +} + +export function createClaudeJournalTranslator( + deps: ClaudeJournalTranslatorDeps +): ClaudeJournalTranslator { + const tools = new Map() + const promptItems = new Map() + const streamIdentities = new Map() + const latestStreamText = new Map() + const checkpointLengths = new Map() + let currentTurn: { sessionId: string; turnId: string } | null = null + + const publishLifecycle = (sessionId: string, turnId: string, running: boolean): void => { + const identity = lifecycleIdentity(sessionId, turnId) + if (running) { + deps.sink.appendItem(identity, { + kind: 'status', + text: 'Claude is working…', + turnLifecycle: { turnId, state: 'running' } + }) + } else { + deps.sink.appendTombstone(identity) + } + deps.sink.publish() + } + + const persistStream = (key: string, text: string, force: boolean): void => { + latestStreamText.set(key, text) + const checkpointLength = checkpointLengths.get(key) ?? 0 + const nextLength = Math.max(checkpointLength + 32, Math.ceil(checkpointLength * 1.125)) + if (!force && checkpointLength > 0 && text.length < nextLength) { + return + } + const identity = streamIdentities.get(key) + if (!identity) { + return + } + checkpointLengths.set(key, text.length) + deps.sink.appendItem(identity, claudeStreamingMessageBody(text)) + deps.sink.publish() + } + + const coalescer = createAgentSessionDeltaCoalescer({ + windowMs: deps.coalesceMs, + schedule: deps.schedule, + emit: (key, text) => persistStream(key, text, false) + }) + + const flushStreams = (): void => { + coalescer.flushAll() + for (const [key, text] of latestStreamText) { + if (checkpointLengths.get(key) !== text.length) { + persistStream(key, text, true) + } + } + } + + const handleStream = (message: Record): boolean => { + const delta = streamDelta(message) + const sessionId = claudeText(message.session_id) + const uuid = claudeText(message.uuid) + if (!delta || !sessionId || !uuid) { + return false + } + const key = agentJournalItemKey({ provider: 'claude', sessionId, uuid }) + streamIdentities.set(key, { provider: 'claude', sessionId, uuid }) + coalescer.append(key, delta) + return true + } + + const handleMessage = (message: Record): void => { + const envelope = readClaudeMessageEnvelope(message) + if (!envelope) { + return + } + const identity = claudeMessageIdentity(envelope) + const key = agentJournalItemKey(identity) + coalescer.forget(key) + latestStreamText.delete(key) + checkpointLengths.delete(key) + streamIdentities.delete(key) + let changed = false + const body = claudeMessageBody(envelope) + if (body) { + deps.sink.appendItem(identity, body) + changed = true + } + for (const tool of claudeToolUses(envelope)) { + tools.set(tool.id, tool) + deps.sink.appendItem( + claudeToolIdentity(envelope.sessionId, tool.id), + claudeToolBody({ tool }) + ) + changed = true + } + for (const result of claudeToolResults(envelope)) { + const tool = tools.get(result.toolUseId) ?? { + id: result.toolUseId, + name: 'tool', + input: null + } + deps.sink.appendItem( + claudeToolIdentity(envelope.sessionId, result.toolUseId), + claudeToolBody({ tool, result }) + ) + changed = true + } + const thinking = claudeThinkingText(envelope) + if (thinking) { + deps.sink.appendItem(claudeThinkingIdentity(envelope.sessionId, envelope.uuid), { + kind: 'status', + text: thinking + }) + changed = true + } + if (envelope.role === 'user' && body && message.parent_tool_use_id === null) { + if (currentTurn) { + publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false) + } + currentTurn = { sessionId: envelope.sessionId, turnId: envelope.uuid } + publishLifecycle(envelope.sessionId, envelope.uuid, true) + } + if (changed) { + deps.sink.publish() + } + } + + const handlePrompt = (event: Extract): void => { + const identities: AgentJournalItemIdentity[] = [] + if (event.prompt.kind === 'question') { + for (const question of claudeQuestionItems({ + sessionId: event.sessionId, + prompt: event.prompt + })) { + identities.push(question.identity) + deps.sink.appendItem(question.identity, question.body) + deps.bindPromptItemId?.( + agentJournalItemKey(question.identity), + event.prompt.promptKey, + question.questionId + ) + } + } else { + const identity = claudePromptIdentity({ + sessionId: event.sessionId, + promptKey: event.prompt.promptKey + }) + identities.push(identity) + deps.sink.appendItem(identity, claudeApprovalItem(event.prompt)) + deps.bindPromptItemId?.(agentJournalItemKey(identity), event.prompt.promptKey) + } + promptItems.set(event.prompt.promptKey, identities) + deps.sink.publish() + } + + return { + handle: (event) => { + if (event.type === 'ended') { + flushStreams() + if (currentTurn) { + publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false) + currentTurn = null + } + return + } + if (event.type === 'message' && handleStream(event.message)) { + return + } + flushStreams() + if (event.type === 'prompt') { + handlePrompt(event) + } else if (event.type === 'prompt-cancelled') { + for (const identity of promptItems.get(event.promptKey) ?? []) { + deps.sink.appendTombstone(identity) + } + promptItems.delete(event.promptKey) + deps.sink.publish() + } else if (event.type === 'message' && event.message.type === 'result') { + if (currentTurn) { + publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false) + currentTurn = null + } + } else if (event.type === 'message') { + handleMessage(event.message) + } + }, + flush: flushStreams, + dispose: () => { + coalescer.dispose() + tools.clear() + promptItems.clear() + streamIdentities.clear() + latestStreamText.clear() + checkpointLengths.clear() + } + } +} diff --git a/src/main/claude/claude-structured-prompt-items.ts b/src/main/claude/claude-structured-prompt-items.ts new file mode 100644 index 000000000000..e568c0d383f3 --- /dev/null +++ b/src/main/claude/claude-structured-prompt-items.ts @@ -0,0 +1,108 @@ +import type { + AgentJournalApprovalItem, + AgentJournalItemIdentity, + AgentJournalPromptOption, + AgentJournalQuestionItem +} from '../../shared/agent-session-journal-types' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +import { claudeRecord, claudeText } from './claude-structured-item-translation' +import { + CLAUDE_APPROVAL_DECISIONS, + encodeClaudeQuestionOptionId, + type ClaudeApprovalDecision, + type ClaudePendingPrompt +} from './claude-structured-prompt-replies' + +const APPROVAL_LABELS: Record = { + allow: 'Allow', + allowForSession: 'Allow for this session', + deny: 'Deny', + cancel: 'Stop' +} + +const PENDING = { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null +} as const + +export function claudePromptIdentity(input: { + sessionId: string + promptKey: string + questionId?: string +}): AgentJournalItemIdentity { + const suffix = input.questionId ? `:${input.questionId}` : '' + return { + provider: 'orca', + clientMessageId: `claude-prompt:${input.sessionId}:${input.promptKey}${suffix}` + } +} + +export function claudeApprovalItem(prompt: ClaudePendingPrompt): AgentJournalApprovalItem { + const serialized = JSON.stringify(prompt.input) + return { + kind: 'approval', + title: `Allow ${prompt.toolName}?`, + detail: serialized ? boundInlineText(serialized, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text : null, + options: CLAUDE_APPROVAL_DECISIONS.map((decision) => ({ + id: decision, + label: APPROVAL_LABELS[decision] + })), + resolution: { ...PENDING } + } +} + +export type ClaudeQuestionItem = { + questionId: string + identity: AgentJournalItemIdentity + body: AgentJournalQuestionItem +} + +function questionOptions( + question: Record, + questionId: string +): AgentJournalPromptOption[] { + if (!Array.isArray(question.options)) { + return [] + } + return question.options.flatMap((value) => { + const option = claudeRecord(value) + const label = claudeText(option?.label) + return label ? [{ id: encodeClaudeQuestionOptionId(questionId, label), label }] : [] + }) +} + +export function claudeQuestionItems(input: { + sessionId: string + prompt: ClaudePendingPrompt +}): ClaudeQuestionItem[] { + const questions = Array.isArray(input.prompt.input.questions) ? input.prompt.input.questions : [] + return questions.flatMap((value, index) => { + const question = claudeRecord(value) + const questionId = input.prompt.questionIds[index] + const text = claudeText(question?.question) ?? claudeText(question?.header) + return question && questionId && text + ? [ + { + questionId, + identity: claudePromptIdentity({ + sessionId: input.sessionId, + promptKey: input.prompt.promptKey, + questionId + }), + body: { + kind: 'question', + question: text, + options: questionOptions(question, questionId), + freeTextQuestionId: questionId, + resolution: { ...PENDING } + } + } + ] + : [] + }) +} diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 8a8741324194..d4df1d9b6f39 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -24,6 +24,7 @@ import { setClaudeStructuredOption } from './claude-structured-options' import { ClaudePromptRegistry } from './claude-structured-prompt-replies' import { readClaudeStructuredSessionOptions } from './claude-structured-session-options' import { createClaudeSessionPublication } from './claude-structured-session-publication' +import { createClaudeSessionJournalTranslator } from './claude-structured-journal-translation' import { cancelClaudeAcquisitionAttempt, ClaudeAcquisitionRegistry, @@ -34,7 +35,7 @@ import { } from './claude-structured-session-state' import { closeClaudePublishedSession, - settleClaudeDispatchWaiters + settleClaudeExitedSession } from './claude-structured-session-close' export type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' @@ -63,6 +64,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda }): Promise { const sessionId = input.identity.sessionId const prompts = new ClaudePromptRegistry() + const translator = createClaudeSessionJournalTranslator(input.events, prompts) const { previous, attempt } = this.acquisitions.start(sessionId, prompts) let liveSession: ClaudeSession | null = null let observedLeafUuid: string | null = null @@ -183,6 +185,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda fence: input.fence, resumed: launch.resumed, prompts, + translator, events: input.events, process, ...(this.deps.mintLinkId ? { linkId: this.deps.mintLinkId() } : {}), @@ -202,6 +205,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda initDeadline.clear() this.acquisitions.deleteIfCurrent(sessionId, attempt) if (this.sessions.get(sessionId)?.connection !== attempt.connection) { + translator?.dispose() prompts.clear() await attempt.connection?.close() } @@ -227,9 +231,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda return } this.sessions.delete(sessionId) - settleClaudeDispatchWaiters(session) - session.prompts.clear() this.emit(session, session.events, { type: 'ended', sessionId, reason: error.message }) + settleClaudeExitedSession(session) } private emit( @@ -237,6 +240,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda _events: StructuredAgentSessionEventSink | undefined, event: ClaudeStructuredSessionEvent ): void { + _session?.translator?.handle(event) this.deps.onEvent?.(event) } diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index c486923dbc9e..590e175a6d3d 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -7,6 +7,12 @@ export function settleClaudeDispatchWaiters(session: ClaudeSession): void { } } +export function settleClaudeExitedSession(session: ClaudeSession): void { + settleClaudeDispatchWaiters(session) + session.prompts.clear() + session.translator?.dispose() +} + export async function closeClaudePublishedSession(input: { sessions: Map sessionId: string @@ -53,11 +59,14 @@ export async function closeClaudePublishedSession(input: { } catch (error) { persistenceError = error } finally { - input.onEvent?.({ + const ended = { type: 'ended', sessionId: input.sessionId, reason: 'claude session closed' - }) + } as const + session.translator?.handle(ended) + input.onEvent?.(ended) + session.translator?.dispose() await session.connection.close() } if (persistenceError) { diff --git a/src/main/claude/claude-structured-session-publication.ts b/src/main/claude/claude-structured-session-publication.ts index 521a08dd8399..9b1567c03803 100644 --- a/src/main/claude/claude-structured-session-publication.ts +++ b/src/main/claude/claude-structured-session-publication.ts @@ -2,6 +2,7 @@ import type { AgentSessionAcquisition } from '../native-chat/agent-session-wire/ import { readClaudeFrameString, type ClaudeInitObservation } from './claude-structured-init-proof' import { claudeProviderHandleLink } from './claude-structured-owner-identity' import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' +import type { ClaudeJournalTranslator } from './claude-structured-journal-translation' import type { ClaudeSession } from './claude-structured-session-state' export function createClaudeSessionPublication(input: { @@ -11,6 +12,7 @@ export function createClaudeSessionPublication(input: { fence: number resumed: boolean prompts: ClaudePromptRegistry + translator: ClaudeJournalTranslator | null events: ClaudeSession['events'] process: AgentSessionAcquisition['process'] linkId?: string @@ -42,6 +44,7 @@ export function createClaudeSessionPublication(input: { ...(model ? { model } : {}), ...(effort ? { effort } : {}) }, + translator: input.translator, events: input.events } } diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index ad2277a8b762..a94a03917afb 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -5,6 +5,7 @@ import type { openClaudeStreamJsonConnection } from './claude-stream-json-connection' import type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' +import type { ClaudeJournalTranslator } from './claude-structured-journal-translation' import type { ClaudePendingPrompt, ClaudePromptRegistry } from './claude-structured-prompt-replies' export type ClaudeAuthDiagnostic = { @@ -63,6 +64,7 @@ export type ClaudeSession = { dispatchWaiters: ClaudeDispatchWaiter[] options: Map reportedOptions: { model?: string; effort?: string } + translator: ClaudeJournalTranslator | null events: StructuredAgentSessionEventSink | undefined } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts new file mode 100644 index 000000000000..1cc3809847fd --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -0,0 +1,89 @@ +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' + +type RoutedAgent = 'claude' | 'codex' + +export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessionAdapter { + private readonly owners = new Map() + + constructor( + private readonly adapters: Record, + private readonly closeAdapters: () => Promise + ) {} + + supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => { + const adapter = this.adapterForAgent(agent) + return adapter ? (adapter.supportsLocation?.(location) ?? false) : false + } + + supportsLocation = (location: AgentSessionExecutionLocation): boolean => + Object.values(this.adapters).some((adapter) => adapter.supportsLocation?.(location) ?? false) + + async acquire(input: Parameters[0]) { + const adapter = this.requireAgent(input.identity) + const acquired = await adapter.acquire(input) + this.owners.set(input.identity.sessionId, adapter) + return acquired + } + + async releaseAcquisition(input: { sessionId: string }): Promise { + const adapter = this.owners.get(input.sessionId) + if (adapter) { + await adapter.releaseAcquisition?.(input) + this.owners.delete(input.sessionId) + return + } + for (const candidate of Object.values(this.adapters)) { + await candidate.releaseAcquisition?.(input) + } + } + + dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => + this.owner(input.sessionId).dispatch(input) + + cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = (input) => + this.owner(input.sessionId).cancelTurn(input) + + answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (input) => + this.owner(input.sessionId).answerPrompt(input) + + setOption: StructuredAgentSessionAdapter['setOption'] = (input) => + this.owner(input.sessionId).setOption(input) + + readOptions = (input: { sessionId: string; fence: number }) => { + const reader = this.owner(input.sessionId).readOptions + if (!reader) { + throw new Error(`structured session ${input.sessionId} does not report options`) + } + return reader(input) + } + + historyFilePath = (input: { identity: AgentSessionJournalIdentity }) => + this.requireAgent(input.identity).historyFilePath?.(input) ?? Promise.resolve(null) + + async closeAll(): Promise { + this.owners.clear() + await this.closeAdapters() + } + + private owner(sessionId: string): StructuredAgentSessionAdapter { + const adapter = this.owners.get(sessionId) + if (!adapter) { + throw new Error(`no live structured adapter owns ${sessionId}`) + } + return adapter + } + + private requireAgent(identity: AgentSessionJournalIdentity): StructuredAgentSessionAdapter { + const adapter = this.adapterForAgent(identity.agent) + if (!adapter) { + throw new Error(`structured sessions do not support ${identity.agent}`) + } + return adapter + } + + private adapterForAgent(agent: string): StructuredAgentSessionAdapter | null { + return agent === 'claude' || agent === 'codex' ? this.adapters[agent] : null + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index eea191011c51..f40848f60040 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -34,6 +34,8 @@ export type AgentSessionDispatchOutcome = | { state: 'unknown'; reason: string } export type StructuredAgentSessionAdapter = { + /** Provider-aware capability check for hosts that route more than one adapter. */ + supportsCreate?(location: AgentSessionExecutionLocation, agent: string): boolean /** Provider/runtime support, kept here so remote enablement changes adapter data, not UI logic. */ supportsLocation?(location: AgentSessionExecutionLocation): boolean /** Makes the reservation real. Called once per reservation, with the spawn diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index b3cd6d78f93e..6975c8d69021 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -59,6 +59,7 @@ import { } from './structured-agent-session-subscribers' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' +import * as providerRouting from './structured-agent-session-provider-routing' export type StructuredAgentSessionCaller = { /** Stable per-client identity; scopes the operation ledger and records who @@ -109,18 +110,14 @@ export class StructuredAgentSessionHost { } supportsCreate(location: AgentSessionExecutionLocation, agent: string): boolean { - return agent === 'codex' && (this.deps.adapter.supportsLocation?.(location) ?? false) + return providerRouting.adapterSupportsAgentSessionCreate(this.deps.adapter, location, agent) } - listSessionTabs(): { - sessionId: string - workspaceId: string - agent: 'codex' - }[] { + listSessionTabs(): providerRouting.StructuredAgentSessionTab[] { return [...this.sessions.entries()].map(([sessionId, session]) => ({ sessionId, workspaceId: session.params.location.workspaceId, - agent: 'codex' as const + agent: providerRouting.structuredAgentSessionTabAgent(session.params.agent) })) } @@ -128,7 +125,11 @@ export class StructuredAgentSessionHost { await restoreStructuredAgentSessionsOnRestart({ store: this.deps.store, journalRoot: this.deps.journalRoot, - records: this.deps.store.listRecords().filter((record) => record.provider === 'codex'), + records: this.deps.store + .listRecords() + .filter((record) => + providerRouting.adapterSupportsAgentSessionRecord(this.deps.adapter, record) + ), reconcile: this.reconcileLeases, operationId: () => `${Math.trunc(this.now()).toString().padStart(13, '0')}-${randomUUID().replaceAll('-', '')}`, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-routing.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-routing.ts new file mode 100644 index 000000000000..8025fb30075d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-routing.ts @@ -0,0 +1,35 @@ +import type { + AgentSessionExecutionLocation, + AgentSessionRecord +} from '../../../shared/agent-session-record' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' + +export type StructuredAgentSessionTab = { + sessionId: string + workspaceId: string + agent: 'claude' | 'codex' +} + +export function adapterSupportsAgentSessionCreate( + adapter: StructuredAgentSessionAdapter, + location: AgentSessionExecutionLocation, + agent: string +): boolean { + return ( + adapter.supportsCreate?.(location, agent) ?? + (agent === 'codex' && (adapter.supportsLocation?.(location) ?? false)) + ) +} + +export function adapterSupportsAgentSessionRecord( + adapter: StructuredAgentSessionAdapter, + record: AgentSessionRecord +): boolean { + return adapter.supportsCreate + ? adapter.supportsCreate(record.location, record.provider) + : record.provider === 'codex' +} + +export function structuredAgentSessionTabAgent(agent: string): 'claude' | 'codex' { + return agent === 'claude' ? 'claude' : 'codex' +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts index 82d3116c9ad8..a5f19cc8e558 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts @@ -25,7 +25,7 @@ export async function restoreStructuredAgentSessionRead( sessionId: string ): Promise { const record = store.getRecord(sessionId) - if (!record || record.provider !== 'codex') { + if (!record) { return null } const params = attachParamsForRecord(record, { @@ -59,8 +59,8 @@ export function attachParamsForRecord( payloadFingerprint: '' }, location: record.location, - provider: 'codex', - agent: 'codex', + provider: record.provider, + agent: record.provider, accountHome: record.accountHome, runtimeKind: record.lease.runtimeKind } diff --git a/src/main/runtime/agent-session-claim-key-state.ts b/src/main/runtime/agent-session-claim-key-state.ts new file mode 100644 index 000000000000..f9fe77cbdc58 --- /dev/null +++ b/src/main/runtime/agent-session-claim-key-state.ts @@ -0,0 +1,46 @@ +import { classifyObservedAgentSessionSpawnToken } from '../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { AgentSessionStoreState } from './agent-session-record-store-file' + +export function isVerifiable( + state: AgentSessionStoreState, + keyId: string, + now: number, + retentionMs: number +): boolean { + const retired = state.retiredClaimKeys.find((entry) => entry.keyId === keyId) + return !retired || now - retired.retiredAt <= retentionMs +} + +export function markConflicted(record: AgentSessionRecord, now: number): AgentSessionRecord { + return { + ...record, + updatedAt: now, + // A conflicted key must remain conflicted after its observing process exits. + lease: { ...record.lease, claimStatus: 'conflicted', handoffStage: 'manual-recovery' } + } +} + +export function retire( + state: AgentSessionStoreState, + keyId: string, + now: number, + retentionMs: number +): void { + if (!state.retiredClaimKeys.some((entry) => entry.keyId === keyId)) { + state.retiredClaimKeys.push({ keyId, retiredAt: now }) + } + state.retiredClaimKeys = state.retiredClaimKeys.filter( + (entry) => now - entry.retiredAt <= retentionMs + ) +} + +export function listOrphanSpawnTokens( + records: readonly AgentSessionRecord[], + observedTokens: readonly string[] +): string[] { + const leases = records.map((record) => record.lease) + return observedTokens.filter( + (spawnToken) => classifyObservedAgentSessionSpawnToken({ spawnToken, leases }) === 'orphan' + ) +} diff --git a/src/main/runtime/agent-session-provider-handle-transition.test.ts b/src/main/runtime/agent-session-provider-handle-transition.test.ts new file mode 100644 index 000000000000..5904547936bc --- /dev/null +++ b/src/main/runtime/agent-session-provider-handle-transition.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { recordAgentSessionProviderHandle } from './agent-session-provider-handle-transition' + +function resumedLink(fence: number): AgentSessionProviderHandleLink { + return { + linkId: 'link-2', + handle: { provider: 'claude', sessionId: 'session-alpha-1', leafUuid: 'leaf-2' }, + origin: 'resumed', + mintedAtFence: fence, + observedAt: 4_000 + } +} + +describe('recordAgentSessionProviderHandle', () => { + it('advances a live Claude chain head and its proof', () => { + const record = agentSessionRecordFixture() + const next = recordAgentSessionProviderHandle({ + record, + fence: record.lease.runtimeFence, + link: resumedLink(record.lease.runtimeFence), + now: 4_000 + }) + expect(next.providerHandleChain.at(-1)?.handle).toMatchObject({ leafUuid: 'leaf-2' }) + expect(next.lease.provenHandleLinkId).toBe('link-2') + }) + + it('records a leaf during proof without granting ownership', () => { + const lease = agentSessionLeaseFixture({ + runtimeFence: 8, + claimStatus: 'reserved', + handoffStage: 'new-owner-proving', + provenHandleLinkId: null + }) + const next = recordAgentSessionProviderHandle({ + record: agentSessionRecordFixture(lease), + fence: lease.runtimeFence, + link: resumedLink(lease.runtimeFence), + now: 4_000 + }) + expect(next.providerHandleChain.at(-1)?.handle).toMatchObject({ leafUuid: 'leaf-2' }) + expect(next.lease).toMatchObject({ claimStatus: 'reserved', provenHandleLinkId: null }) + }) +}) diff --git a/src/main/runtime/agent-session-provider-handle-transition.ts b/src/main/runtime/agent-session-provider-handle-transition.ts new file mode 100644 index 000000000000..474e4be71351 --- /dev/null +++ b/src/main/runtime/agent-session-provider-handle-transition.ts @@ -0,0 +1,41 @@ +import { + appendAgentSessionProviderHandleLink, + type AgentSessionProviderHandleLink +} from '../../shared/agent-session-provider-handle' +import type { AgentSessionRecord } from '../../shared/agent-session-record' + +export function recordAgentSessionProviderHandle(args: { + record: AgentSessionRecord + fence: number + link: AgentSessionProviderHandleLink + now: number +}): AgentSessionRecord { + const { record } = args + if (record.lease.runtimeFence !== args.fence) { + throw new Error('agent_session_stale_fence') + } + if (args.link.handle.provider !== record.provider || args.link.mintedAtFence !== args.fence) { + throw new Error('agent_session_provider_handle_invalid') + } + if (record.lease.claimStatus !== 'live' && record.lease.handoffStage !== 'new-owner-proving') { + throw new Error('agent_session_ownership_unknown') + } + const providerHandleChain = appendAgentSessionProviderHandleLink( + record.providerHandleChain, + args.link + ) + const head = providerHandleChain.at(-1) + if (!head) { + throw new Error('agent_session_provider_handle_invalid') + } + return { + ...record, + providerHandleChain, + lease: { + ...record.lease, + ...(record.lease.claimStatus === 'live' ? { provenHandleLinkId: head.linkId } : {}), + lastRenewedAt: args.now + }, + updatedAt: args.now + } +} diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 15fcef3c85ed..32347c6d529f 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -21,7 +21,6 @@ import { type AgentSessionOperationAdmission } from './agent-session-operation-admission' import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' -import { classifyObservedAgentSessionSpawnToken } from '../../shared/agent-session-lease-adjudication' import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' import { agentSessionScopeKey, @@ -41,6 +40,7 @@ import { setAgentSessionHandoffStage, setAgentSessionJournalCheckpoint } from './agent-session-lease-transitions' +import { recordAgentSessionProviderHandle } from './agent-session-provider-handle-transition' import { applyAgentSessionReservation, evaluateAgentSessionReserveOperation, @@ -58,6 +58,7 @@ import { AgentSessionStoreTransactionQueue, markAgentSessionStoreLeasesUnreconciled } from './agent-session-store-transaction-queue' +import * as claimKeyState from './agent-session-claim-key-state' export type { AgentSessionReserveRequest, @@ -131,16 +132,12 @@ export class AgentSessionRecordStore { } isClaimKeyVerifiable(keyId: string, now: number): boolean { - const retired = this.state.retiredClaimKeys.find((entry) => entry.keyId === keyId) - return !retired || now - retired.retiredAt <= AGENT_SESSION_CLAIM_KEY_RETENTION_MS + return claimKeyState.isVerifiable(this.state, keyId, now, AGENT_SESSION_CLAIM_KEY_RETENTION_MS) } /** Spawn tokens observed on the host with no matching lease. Stop them; never adopt them. */ listOrphanSpawnTokens(observedTokens: readonly string[]): string[] { - const leases = this.listRecords().map((record) => record.lease) - return observedTokens.filter( - (spawnToken) => classifyObservedAgentSessionSpawnToken({ spawnToken, leases }) === 'orphan' - ) + return claimKeyState.listOrphanSpawnTokens(this.listRecords(), observedTokens) } /** @@ -200,6 +197,17 @@ export class AgentSessionRecordStore { ) } + async recordProviderHandle(args: { + sessionId: string + fence: number + link: AgentSessionProviderHandleLink + now: number + }): Promise { + return this.mutate(args.sessionId, (record) => + recordAgentSessionProviderHandle({ ...args, record }) + ) + } + async renewLease(args: { sessionId: string fence: number @@ -307,24 +315,13 @@ export class AgentSessionRecordStore { } async markClaimConflicted(sessionId: string, now: number): Promise { - return this.mutate(sessionId, (record) => ({ - ...record, - updatedAt: now, - // Why: a conflicted key must stay conflicted across a restart; it cannot resolve to free - // merely because the process that observed the conflict is gone. - lease: { ...record.lease, claimStatus: 'conflicted', handoffStage: 'manual-recovery' } - })) + return this.mutate(sessionId, (record) => claimKeyState.markConflicted(record, now)) } async retireClaimKey(keyId: string, now: number): Promise { - await this.transact(() => { - if (!this.state.retiredClaimKeys.some((entry) => entry.keyId === keyId)) { - this.state.retiredClaimKeys.push({ keyId, retiredAt: now }) - } - this.state.retiredClaimKeys = this.state.retiredClaimKeys.filter( - (entry) => now - entry.retiredAt <= AGENT_SESSION_CLAIM_KEY_RETENTION_MS - ) - }) + await this.transact(() => + claimKeyState.retire(this.state, keyId, now, AGENT_SESSION_CLAIM_KEY_RETENTION_MS) + ) } private async mutate( diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts new file mode 100644 index 000000000000..646f55fbbe75 --- /dev/null +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -0,0 +1,358 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../shared/agent-session-wire' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import type { + ClaudeStreamJsonConnection, + ClaudeStreamJsonConnectionHandlers, + ClaudeStreamJsonLaunch, + openClaudeStreamJsonConnection +} from '../claude/claude-stream-json-connection' +import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution' +import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import type { OrcaRuntimeService } from './orca-runtime' +import type { RpcRequest, RpcResponse } from './rpc/core' +import { RpcDispatcher } from './rpc/dispatcher' +import { STRUCTURED_AGENT_SESSION_METHODS } from './rpc/methods/structured-agent-session' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +const SESSION = 'claude-integration-1' +const PROVIDER_SESSION = claudeSessionIdForOrcaSession(SESSION) +const WORKSPACE = 'workspace-claude' +const CLIENT = { + clientKind: 'mobile' as const, + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] +} + +type FakeClaudeConnection = Omit & { + closed: boolean + launch: ClaudeStreamJsonLaunch + handlers: ClaudeStreamJsonConnectionHandlers + calls: { subtype: string; params?: Record }[] + sent: Record[] + replies: { requestId: string; response: unknown }[] +} + +function fakeClaude() { + const connections: FakeClaudeConnection[] = [] + const openConnection = (async (launch, handlers = {}) => { + const connection: FakeClaudeConnection = { + launch, + handlers, + calls: [], + sent: [], + replies: [], + pid: 4321 + connections.length, + closed: false, + request: async (subtype, params) => { + connection.calls.push({ subtype, params }) + if (subtype === 'initialize') { + handlers.onMessage?.({ + type: 'system', + subtype: 'init', + session_id: PROVIDER_SESSION, + ...(connections.length === 0 ? { uuid: 'init-leaf' } : {}), + model: 'claude-sonnet-5', + apiKeySource: 'none' + }) + return { models: [{ value: 'sonnet', displayName: 'Sonnet' }] } + } + return subtype === 'get_settings' ? { env: {} } : {} + }, + send: async (message) => { + connection.sent.push(message) + if (message.type === 'user') { + handlers.onMessage?.({ ...message, uuid: 'user-1' }) + } + }, + respond: async (requestId, response) => { + connection.replies.push({ requestId, response }) + }, + respondWithError: async () => {}, + close: async () => { + connection.closed = true + } + } + connections.push(connection) + return connection + }) as typeof openClaudeStreamJsonConnection + const live = (): FakeClaudeConnection => { + const connection = connections.at(-1) + if (!connection) { + throw new Error('no Claude connection') + } + return connection + } + return { connections, openConnection, live } +} + +let operations = 0 + +function operationId(): string { + operations += 1 + return `${Date.now()}-${operations.toString(16).padStart(32, '0')}` +} + +function envelope(method: string, fields: Record, fence: number | null) { + return { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +function createIntentParams() { + const worktree = `id:${WORKSPACE}` + const fields = { worktree, agent: 'claude' } + return { envelope: envelope('agentSession.create', fields, null), ...fields } +} + +function ensureParams(fence: number) { + const params = { + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKSPACE, + workspaceKind: 'git-worktree' as const + }, + provider: 'claude' as const, + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR' as const, path: '/accounts/claude' }, + runtimeKind: 'native' as const, + providerHandle: { + kind: 'claude' as const, + sessionId: PROVIDER_SESSION, + leafUuid: 'assistant-leaf' + } + } + const base = { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: '' + } + return { + ...params, + envelope: { + ...base, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: SESSION, + fields: attachFingerprintFields({ ...params, envelope: base } as never) + }) + } + } +} + +let claude: ReturnType +let root: string +let dispatcher: RpcDispatcher +let cleanups: Map void> + +async function call(method: string, params: unknown): Promise { + const replies: RpcResponse[] = [] + const request: RpcRequest = { id: `req-${operations}`, authToken: 'token', method, params } + await dispatcher.dispatchStreaming(request, (raw) => replies.push(JSON.parse(raw)), CLIENT) + if (!replies[0]) { + throw new Error(`no reply for ${method}`) + } + return replies[0] +} + +async function ok(method: string, params: unknown): Promise { + const response = await call(method, params) + expect(response, JSON.stringify(response)).toMatchObject({ ok: true }) + const result = (response as { result: { ok: boolean; value?: T } }).result + expect(result).toMatchObject({ ok: true }) + return result.value as T +} + +async function subscribe(): Promise { + const frames: AgentSessionSubscribeEvent[] = [] + await dispatcher.dispatchStreaming( + { + id: 'subscribe-1', + authToken: 'token', + method: 'agentSession.subscribe', + params: { sessionId: SESSION } + }, + (raw) => { + const response = JSON.parse(raw) as { ok: boolean; result?: AgentSessionSubscribeEvent } + if (response.ok && response.result) { + frames.push(response.result) + } + }, + CLIENT + ) + return frames +} + +function itemsOf(frames: AgentSessionSubscribeEvent[]): AgentJournalRenderItem[] { + const items = new Map() + for (const frame of frames) { + const rows = + frame.type === 'snapshot' || frame.type === 'reset' + ? frame.snapshot.items + : frame.type === 'batch' + ? frame.batch.items + : [] + for (const row of rows) { + items.set(row.itemId, row) + } + } + return [...items.values()] +} + +function textOf(item: AgentJournalRenderItem): string { + return item.body?.kind === 'message' + ? item.body.blocks.map((block) => (block.type === 'text' ? block.text : '')).join('') + : '' +} + +beforeEach(async () => { + operations = 0 + root = await mkdtemp(join(tmpdir(), 'orca-claude-structured-integration-')) + claude = fakeClaude() + cleanups = new Map() + const runtime = { + getRuntimeId: () => 'runtime-1', + getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), + resolveStructuredAgentSessionCreateIntent: async (input: { envelope: unknown }) => ({ + ...ensureParams(1), + envelope: input.envelope, + providerHandle: undefined + }), + publishStructuredAgentSessionTab: vi.fn(), + ensureStructuredAgentSessionHost: () => + ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, + resolveCodexCommand: () => '/usr/local/bin/codex', + resolveClaudeCommand: () => '/usr/local/bin/claude', + openClaudeConnection: claude.openConnection + }).then(() => undefined), + registerSubscriptionCleanup: (id: string, dispose: () => void) => cleanups.set(id, dispose), + cleanupSubscription: (id: string) => cleanups.get(id)?.(), + cleanupSubscriptionsByPrefix: () => {} + } + dispatcher = new RpcDispatcher({ + runtime: runtime as unknown as OrcaRuntimeService, + methods: STRUCTURED_AGENT_SESSION_METHODS + }) +}) + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + await rm(root, { recursive: true, force: true }) +}) + +describe('a structured Claude session over agentSession.*', () => { + it('creates, sends, streams, approves, interrupts, and resumes from the chain head', async () => { + const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) + expect(claude.live().launch.args).toContain('--session-id') + expect(claude.live().launch.args).toContain(PROVIDER_SESSION) + const stream = await subscribe() + + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'List files' }] } + const sent = await ok<{ + submission: { dispatchState: string; providerItemId: string | null } + }>('agentSession.send', { + envelope: envelope('agentSession.send', { body }, created.fence), + body + }) + expect(sent.submission).toMatchObject({ + dispatchState: 'accepted', + providerItemId: `claude:${PROVIDER_SESSION}:user-1` + }) + + claude.live().handlers.onMessage?.({ + type: 'stream_event', + session_id: PROVIDER_SESSION, + uuid: 'assistant-leaf', + event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'Two files.' } } + }) + claude.live().handlers.onMessage?.({ + type: 'assistant', + session_id: PROVIDER_SESSION, + uuid: 'assistant-leaf', + parent_tool_use_id: null, + message: { role: 'assistant', content: [{ type: 'text', text: 'Two files.' }] } + }) + await getStructuredAgentSessionHost()?.flushStreamedEvents(SESSION) + expect(itemsOf(stream).find((item) => textOf(item) === 'Two files.')?.itemId).toBe( + `claude:${PROVIDER_SESSION}:assistant-leaf` + ) + + claude.live().handlers.onControlRequest?.({ + type: 'control_request', + request_id: 'permission-1', + request: { + subtype: 'can_use_tool', + tool_name: 'Bash', + tool_use_id: 'tool-1', + input: { command: 'ls' } + } + }) + await getStructuredAgentSessionHost()?.flushStreamedEvents(SESSION) + const approval = itemsOf(stream).find((item) => item.body?.kind === 'approval') + expect(approval?.body).toMatchObject({ title: 'Allow Bash?', detail: '{"command":"ls"}' }) + await ok('agentSession.respondToApproval', { + envelope: envelope( + 'agentSession.respondTo:approval', + { + itemId: approval?.itemId, + expectedRevision: approval?.revision, + optionId: 'allow' + }, + created.fence + ), + itemId: approval?.itemId, + expectedRevision: approval?.revision, + optionId: 'allow' + }) + expect(claude.live().replies.at(-1)).toMatchObject({ + requestId: 'permission-1', + response: { behavior: 'allow', toolUseID: 'tool-1' } + }) + + await expect( + ok('agentSession.cancel', { + envelope: envelope('agentSession.cancel', { turnId: 'user-1' }, created.fence), + turnId: 'user-1' + }) + ).resolves.toMatchObject({ turnId: 'user-1', cancelled: true }) + expect(claude.live().calls.at(-1)).toMatchObject({ subtype: 'interrupt' }) + + const old = claude.live() + const resumed = await ok<{ fence: number }>('agentSession.ensure', ensureParams(created.fence)) + expect(resumed.fence).toBe(created.fence + 1) + expect(old.closed).toBe(true) + expect(claude.live().launch.args.slice(-2)).toEqual(['--resume', PROVIDER_SESSION]) + const host = getStructuredAgentSessionHost() as unknown as { + deps: { store: { getRecord: (sessionId: string) => { providerHandleChain: unknown[] } } } + } + expect(host.deps.store.getRecord(SESSION).providerHandleChain.at(-1)).toMatchObject({ + handle: { + provider: 'claude', + sessionId: PROVIDER_SESSION, + leafUuid: 'assistant-leaf' + }, + origin: 'resumed' + }) + }) +}) diff --git a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts index 083c677e39f0..b59092f018d0 100644 --- a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts +++ b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts @@ -52,4 +52,56 @@ describe('structured agent-session create intent', () => { path: '/accounts/selected/home' }) }) + + it('pins the selected Claude config directory without changing its auth environment', async () => { + const runtime = new OrcaRuntimeService({ + getSettings: () => ({ + agentDefaultEnv: { + claude: { + CLAUDE_CONFIG_DIR: '/accounts/claude/home', + ANTHROPIC_AUTH_TOKEN: 'inherited-by-launch' + } + } + }) + } as never) + vi.spyOn(runtime, 'getStructuredAgentSessionCreateSupport').mockResolvedValue({ + supported: true + }) + const internal = runtime as unknown as { + resolveStructuredAgentSessionLocation: (selector: string) => Promise<{ + executionHostId: string + wslDistro: null + workspaceId: string + workspaceKind: 'folder' + }> + resolveRuntimeFileTarget: (selector: string) => Promise<{ + worktree: { path: string } + }> + } + internal.resolveStructuredAgentSessionLocation = vi.fn(async () => ({ + executionHostId: 'local', + wslDistro: null, + workspaceId: 'folder-1', + workspaceKind: 'folder' as const + })) + internal.resolveRuntimeFileTarget = vi.fn(async () => ({ + worktree: { path: '/folders/one' } + })) + + const intent = await runtime.resolveStructuredAgentSessionCreateIntent({ + envelope: { sessionId: 'session-claude', clientOperationId: 'operation-claude' }, + worktree: 'id:folder-1', + agent: 'claude' + }) + + expect(intent).toMatchObject({ + provider: 'claude', + agent: 'claude', + location: { workspaceKind: 'folder' }, + accountHome: { + variable: 'CLAUDE_CONFIG_DIR', + path: '/accounts/claude/home' + } + }) + }) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 246c4f33b204..219642e5ed81 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -9201,11 +9201,8 @@ export class OrcaRuntimeService { async getStructuredAgentSessionCreateSupport( worktreeSelector: string, - agent: 'codex' + agent: 'claude' | 'codex' ): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' }> { - if (agent !== 'codex') { - return { supported: false, reason: 'agent' } - } const location = await this.resolveStructuredAgentSessionLocation(worktreeSelector) await this.ensureStructuredAgentSessionHost() if (getStructuredAgentSessionHost()?.supportsCreate(location, agent)) { @@ -9246,18 +9243,24 @@ export class OrcaRuntimeService { async resolveStructuredAgentSessionCreateIntent(input: { envelope: { sessionId: string; clientOperationId: string } worktree: string - agent: 'codex' + agent: 'claude' | 'codex' }): Promise { const support = await this.getStructuredAgentSessionCreateSupport(input.worktree, input.agent) if (!support.supported) { throw new Error('structured_agent_session_unsupported') } const settings = this.requireStore().getSettings() - const launchEnv = resolveTuiAgentLaunchEnv('codex', settings.agentDefaultEnv) + const launchEnv = resolveTuiAgentLaunchEnv(input.agent, settings.agentDefaultEnv) const location = await this.resolveStructuredAgentSessionLocation(input.worktree) const workspacePath = (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path - const preparedHome = this.prepareCodexStructuredLaunchFn?.({ workspacePath, launchEnv }) - const configuredHome = launchEnv.CODEX_HOME + const preparedHome = + input.agent === 'codex' + ? this.prepareCodexStructuredLaunchFn?.({ workspacePath, launchEnv }) + : null + const configuredHome = + input.agent === 'codex' ? launchEnv.CODEX_HOME : launchEnv.CLAUDE_CONFIG_DIR + const variable = + input.agent === 'codex' ? ('CODEX_HOME' as const) : ('CLAUDE_CONFIG_DIR' as const) return { envelope: { sessionId: input.envelope.sessionId, @@ -9266,16 +9269,18 @@ export class OrcaRuntimeService { payloadFingerprint: '' }, location, - provider: 'codex', - agent: 'codex', + provider: input.agent, + agent: input.agent, accountHome: { - variable: 'CODEX_HOME', + variable, path: - preparedHome?.trim() || - (this.prepareCodexStructuredLaunchFn - ? getSystemCodexHomePath() - : configuredHome?.trim()) || - getSystemCodexHomePath() + input.agent === 'codex' + ? preparedHome?.trim() || + (this.prepareCodexStructuredLaunchFn + ? getSystemCodexHomePath() + : configuredHome?.trim()) || + getSystemCodexHomePath() + : configuredHome?.trim() || join(homedir(), '.claude') }, runtimeKind: 'native' } @@ -9293,7 +9298,7 @@ export class OrcaRuntimeService { publishStructuredAgentSessionTab(input: { workspaceId: string sessionId: string - agent: 'codex' + agent: 'claude' | 'codex' activate: boolean notify?: boolean }): void { @@ -9305,7 +9310,7 @@ export class OrcaRuntimeService { const tab: RuntimeMobileSessionAgentTab = { type: 'agent-session', id, - title: 'Codex Chat', + title: input.agent === 'claude' ? 'Claude Chat' : 'Codex Chat', sessionId: input.sessionId, agent: input.agent, isActive: input.activate diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index 4288c5253c63..b052242cc979 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -98,7 +98,7 @@ export const CreateIntentParams = z .object({ envelope: MutationEnvelope, worktree: Identifier('Invalid worktree selector'), - agent: z.literal('codex') + agent: z.enum(['claude', 'codex']) }) .strict() @@ -107,7 +107,7 @@ export const CreateParams = z.union([AttachParams, CreateIntentParams]) export const CreateSupportParams = z .object({ worktree: Identifier('Invalid worktree selector'), - agent: z.literal('codex') + agent: z.enum(['claude', 'codex']) }) .strict() diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 1f667db19530..2263b8af7b33 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -137,7 +137,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ ctx.runtime.publishStructuredAgentSessionTab({ workspaceId: resolved.location.workspaceId, sessionId: result.value.sessionId, - agent: 'codex', + agent: resolved.agent === 'claude' ? 'claude' : 'codex', activate: true }) } diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 66968c4a6fc5..9c708039ad22 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -10,12 +10,19 @@ import { join } from 'node:path' import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { createClaudeStructuredLaunchResolver } from '../claude/claude-structured-launch-resolution' +import { + ClaudeStructuredSessionAdapter, + type ClaudeStructuredSessionAdapterDeps +} from '../claude/claude-structured-session-adapter' +import { claudeProviderHandleLink } from '../claude/claude-structured-owner-identity' import { createCodexStructuredLaunchResolver } from '../codex/codex-structured-launch-resolution' import { CodexStructuredSessionAdapter, type CodexStructuredSessionAdapterDeps } from '../codex/codex-structured-session-adapter' import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { AgentSessionRecordStore } from './agent-session-record-store' import { probeAgentSessionProcessIdentity } from './agent-session-process-identity-probe' @@ -34,15 +41,17 @@ export type StructuredAgentSessionRuntimeDeps = { claimKeyId: string resolveWorkspacePath: (workspaceId: string) => Promise resolveCodexCommand?: () => string + resolveClaudeCommand?: () => string /** Transport for the Codex child. Overridden only to drive the whole runtime * against a scripted app-server; production spawns the real one. */ openCodexConnection?: CodexStructuredSessionAdapterDeps['openConnection'] + openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection'] onError?: (input: { scope: string; error: unknown }) => void } type InstalledRuntime = { host: StructuredAgentSessionHost - adapter: CodexStructuredSessionAdapter + adapter: StructuredAgentSessionAdapterRouter } let installing: Promise | null = null @@ -58,7 +67,7 @@ export function ensureStructuredAgentSessionHost( return installing.then((installed) => installed.host) } -/** Drops the host and reaps every Codex child under it. Runtime teardown and +/** Drops the host and reaps every structured child under it. Runtime teardown and * test isolation take the same path, so neither can leave a live app-server. */ export async function stopStructuredAgentSessionRuntime(): Promise { const pending = installing @@ -83,7 +92,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { + const record = store.getRecord(observed.sessionId) + if (!record || record.provider !== 'claude') { + return + } + const fence = record.lease.runtimeFence + await store.recordProviderHandle({ + sessionId: observed.sessionId, + fence, + link: claudeProviderHandleLink({ + sessionId: observed.providerSessionId, + leafUuid: observed.leafUuid, + resumed: record.providerHandleChain.length > 0, + fence, + observedAt: Date.now() + }), + now: Date.now() + }) + } + }) + const adapter = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => + Promise.all([claude.closeAll(), codex.closeAll()]).then(() => undefined) + ) const host = new StructuredAgentSessionHost({ store, adapter, diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index a94c7e36ac24..9e331046ac80 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -256,7 +256,7 @@ export type RuntimeMobileSessionAgentTab = { id: string title: string sessionId: string - agent: 'codex' + agent: 'claude' | 'codex' color?: string | null isPinned?: boolean isActive: boolean From 83d73bd0f4742e59a69126b879ede5ee18fabd56 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 03:45:00 -0700 Subject: [PATCH 2/5] fix: harden Claude structured runtime --- .../claude/claude-structured-dispatch.test.ts | 3 +- ...ude-structured-journal-translation.test.ts | 42 +++++++++++++++++++ .../claude-structured-journal-translation.ts | 6 ++- ...laude-structured-launch-resolution.test.ts | 21 +++++++++- .../claude-structured-launch-resolution.ts | 2 + .../claude/claude-structured-prompt-items.ts | 7 +++- ...ude-structured-session-integration.test.ts | 11 +++++ src/main/runtime/orca-runtime.ts | 6 ++- .../structured-agent-session-runtime.ts | 4 +- 9 files changed, 94 insertions(+), 8 deletions(-) diff --git a/src/main/claude/claude-structured-dispatch.test.ts b/src/main/claude/claude-structured-dispatch.test.ts index 69a107e5d2e7..ffdd0a5bbf80 100644 --- a/src/main/claude/claude-structured-dispatch.test.ts +++ b/src/main/claude/claude-structured-dispatch.test.ts @@ -16,7 +16,8 @@ function sessionFor(send = vi.fn().mockResolvedValue(undefined)): ClaudeSession dispatchWaiters: [], options: new Map(), reportedOptions: {}, - events: undefined + events: undefined, + translator: null } } diff --git a/src/main/claude/claude-structured-journal-translation.test.ts b/src/main/claude/claude-structured-journal-translation.test.ts index e29310432a26..d80c0a568895 100644 --- a/src/main/claude/claude-structured-journal-translation.test.ts +++ b/src/main/claude/claude-structured-journal-translation.test.ts @@ -5,6 +5,10 @@ import type { } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' import type { ClaudePendingPrompt } from './claude-structured-prompt-replies' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' @@ -139,6 +143,19 @@ describe('Claude structured journal translation', () => { }) }) + it('bounds persisted thinking text to the shared journal payload limit', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + const thinking = 'considering '.repeat(20_000) + + translator.handle(message('assistant', 'assistant-thinking', [{ type: 'thinking', thinking }])) + + expect(state.items.at(-1)?.body).toEqual({ + kind: 'status', + text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + }) + }) + it('creates addressable approval and multi-question cards and cancels them durably', () => { const state = sinkState() const bindings: unknown[][] = [] @@ -188,6 +205,31 @@ describe('Claude structured journal translation', () => { 'Ship?' ]) + const multiSelect = prompt({ + requestId: 'questions-multi', + promptKey: 'questions-multi', + toolUseId: 'tool-multi', + toolName: 'AskUserQuestion', + kind: 'question', + input: { + questions: [ + { + question: 'Libraries?', + multiSelect: true, + options: [{ label: 'Luxon' }, { label: 'Temporal' }] + } + ] + }, + questionIds: ['Libraries?'] + }) + translator.handle({ type: 'prompt', sessionId: 'orca-session', prompt: multiSelect }) + expect(state.items.at(-1)?.body).toMatchObject({ + kind: 'question', + question: 'Libraries?\n\nEnter one or more choices separated by commas.', + options: [], + freeTextQuestionId: 'Libraries?' + }) + translator.handle({ type: 'prompt-cancelled', sessionId: 'orca-session', diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index 5a7d71de94fb..f77fe2a87f43 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -5,6 +5,10 @@ import { type AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-session-wire/agent-session-delta-coalescer' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' import { claudeMessageBody, @@ -187,7 +191,7 @@ export function createClaudeJournalTranslator( if (thinking) { deps.sink.appendItem(claudeThinkingIdentity(envelope.sessionId, envelope.uuid), { kind: 'status', - text: thinking + text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text }) changed = true } diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index 4604e308752d..3f79ba1066ce 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -29,11 +29,12 @@ function record(overrides: Partial = {}): AgentSessionRecord } as AgentSessionRecord } -function resolverFor(value: AgentSessionRecord | null) { +function resolverFor(value: AgentSessionRecord | null, resolveEnv?: () => Record) { return createClaudeStructuredLaunchResolver({ store: { getRecord: () => value } as unknown as AgentSessionRecordStore, resolveWorkspacePath: async (id) => `/repos/${id}`, - resolveCommand: () => '/usr/local/bin/claude' + resolveCommand: () => '/usr/local/bin/claude', + ...(resolveEnv ? { resolveEnv } : {}) }) } @@ -83,6 +84,22 @@ describe('claude structured launch resolution', () => { expect(launch.args.slice(-2)).toEqual(['--resume', 'provider-current']) }) + it('resolves configured environment live without storing it in the session record', async () => { + let token = 'first-token' + const resolver = resolverFor(record(), () => ({ + ANTHROPIC_AUTH_TOKEN: token, + ANTHROPIC_BASE_URL: 'https://gateway.example.test' + })) + + expect((await resolver({ identity: IDENTITY })).env).toEqual({ + ANTHROPIC_AUTH_TOKEN: 'first-token', + ANTHROPIC_BASE_URL: 'https://gateway.example.test' + }) + token = 'rotated-token' + expect((await resolver({ identity: IDENTITY })).env?.ANTHROPIC_AUTH_TOKEN).toBe('rotated-token') + expect(JSON.stringify(record())).not.toContain('token') + }) + it('refuses other hosts, WSL, providers, and account-home variables', async () => { await expect( resolverFor(record({ location: { ...record().location, executionHostId: 'ssh:build' } }))({ diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 86a90830ce15..bd33dc7f60f2 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -38,6 +38,7 @@ export type ClaudeStructuredLaunchResolverDeps = { store: AgentSessionRecordStore resolveWorkspacePath: (workspaceId: string) => Promise resolveCommand?: () => string + resolveEnv?: () => Record } export function claudeSessionIdForOrcaSession(sessionId: string): string { @@ -85,6 +86,7 @@ export function createClaudeStructuredLaunchResolver( command: spawnCmd, args: spawnArgs, cwd: await deps.resolveWorkspacePath(record.location.workspaceId), + ...(deps.resolveEnv ? { env: deps.resolveEnv() } : {}), claudeConfigDir: record.accountHome.path, providerSessionId, resumeLeafUuid: head?.handle.provider === 'claude' ? head.handle.leafUuid : null, diff --git a/src/main/claude/claude-structured-prompt-items.ts b/src/main/claude/claude-structured-prompt-items.ts index e568c0d383f3..19a00f3901eb 100644 --- a/src/main/claude/claude-structured-prompt-items.ts +++ b/src/main/claude/claude-structured-prompt-items.ts @@ -85,6 +85,7 @@ export function claudeQuestionItems(input: { const question = claudeRecord(value) const questionId = input.prompt.questionIds[index] const text = claudeText(question?.question) ?? claudeText(question?.header) + const multiSelect = question?.multiSelect === true return question && questionId && text ? [ { @@ -96,8 +97,10 @@ export function claudeQuestionItems(input: { }), body: { kind: 'question', - question: text, - options: questionOptions(question, questionId), + question: multiSelect + ? `${text}\n\nEnter one or more choices separated by commas.` + : text, + options: multiSelect ? [] : questionOptions(question, questionId), freeTextQuestionId: questionId, resolution: { ...PENDING } } diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 646f55fbbe75..4bef47657b6f 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -13,6 +13,7 @@ import type { openClaudeStreamJsonConnection } from '../claude/claude-stream-json-connection' import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution' +import { CLAUDE_SPAWN_TOKEN_ENV } from '../claude/claude-structured-owner-identity' import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import type { OrcaRuntimeService } from './orca-runtime' @@ -244,6 +245,10 @@ beforeEach(async () => { resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, resolveCodexCommand: () => '/usr/local/bin/codex', resolveClaudeCommand: () => '/usr/local/bin/claude', + resolveClaudeLaunchEnv: () => ({ + ANTHROPIC_AUTH_TOKEN: 'configured-token', + ANTHROPIC_BASE_URL: 'https://gateway.example.test' + }), openClaudeConnection: claude.openConnection }).then(() => undefined), registerSubscriptionCleanup: (id: string, dispose: () => void) => cleanups.set(id, dispose), @@ -266,6 +271,12 @@ describe('a structured Claude session over agentSession.*', () => { const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) expect(claude.live().launch.args).toContain('--session-id') expect(claude.live().launch.args).toContain(PROVIDER_SESSION) + expect(claude.live().launch.env).toEqual({ + ANTHROPIC_AUTH_TOKEN: 'configured-token', + ANTHROPIC_BASE_URL: 'https://gateway.example.test', + CLAUDE_CONFIG_DIR: '/accounts/claude', + [CLAUDE_SPAWN_TOKEN_ENV]: expect.any(String) + }) const stream = await subscribe() const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'List files' }] } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 219642e5ed81..739b7f3d8171 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -9195,7 +9195,11 @@ export class OrcaRuntimeService { // Resolves folder workspaces as well as git worktrees, so a chat session // in a plain folder lands in the folder rather than failing to resolve. resolveWorkspacePath: async (workspaceId) => - (await this.resolveRuntimeFileTarget(`id:${workspaceId}`)).worktree.path + (await this.resolveRuntimeFileTarget(`id:${workspaceId}`)).worktree.path, + resolveClaudeLaunchEnv: () => { + const settings = this.requireStore().getSettings() + return resolveTuiAgentLaunchEnv('claude', settings.agentDefaultEnv) + } }) } diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 9c708039ad22..05c51ee8868b 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -42,6 +42,7 @@ export type StructuredAgentSessionRuntimeDeps = { resolveWorkspacePath: (workspaceId: string) => Promise resolveCodexCommand?: () => string resolveClaudeCommand?: () => string + resolveClaudeLaunchEnv?: () => Record /** Transport for the Codex child. Overridden only to drive the whole runtime * against a scripted app-server; production spawns the real one. */ openCodexConnection?: CodexStructuredSessionAdapterDeps['openConnection'] @@ -104,7 +105,8 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { From c30acb771bec97567cce08a9907b74a625a25959 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 04:04:11 -0700 Subject: [PATCH 3/5] fix: fence Claude structured compatibility --- ...ude-structured-journal-translation.test.ts | 21 +++++++++- .../claude-structured-journal-translation.ts | 2 +- .../claude-structured-prompt-items.test.ts | 36 +++++++++++++++++ .../claude/claude-structured-prompt-items.ts | 15 ++++--- .../claude-structured-prompt-replies.ts | 25 +++++++++++- ...ession-tab-agent-status-projection.test.ts | 39 +++++++++++++++++++ .../session-tab-agent-status-projection.ts | 23 +++++++++-- .../methods/structured-agent-session.test.ts | 2 + src/shared/protocol-version.ts | 4 ++ 9 files changed, 152 insertions(+), 15 deletions(-) create mode 100644 src/main/claude/claude-structured-prompt-items.test.ts diff --git a/src/main/claude/claude-structured-journal-translation.test.ts b/src/main/claude/claude-structured-journal-translation.test.ts index d80c0a568895..3546ec99b029 100644 --- a/src/main/claude/claude-structured-journal-translation.test.ts +++ b/src/main/claude/claude-structured-journal-translation.test.ts @@ -156,6 +156,23 @@ describe('Claude structured journal translation', () => { }) }) + it('starts a cancellable lifecycle for image-only root user replays', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle( + message('user', 'user-image', [ + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AA==' } } + ]) + ) + + expect(state.items.at(-1)?.body).toEqual({ + kind: 'status', + text: 'Claude is working…', + turnLifecycle: { turnId: 'user-image', state: 'running' } + }) + }) + it('creates addressable approval and multi-question cards and cancels them durably', () => { const state = sinkState() const bindings: unknown[][] = [] @@ -200,7 +217,7 @@ describe('Claude structured journal translation', () => { translator.handle({ type: 'prompt', sessionId: 'orca-session', prompt: questions }) expect(state.items.filter((item) => item.body.kind === 'question')).toHaveLength(2) expect(bindings.at(-1)).toEqual([ - 'orca:claude-prompt%3Aorca-session%3Aquestions-1%3AShip%3F', + 'orca:claude-prompt%3Aorca-session%3Aquestions-1%3Aq2', 'questions-1', 'Ship?' ]) @@ -227,7 +244,7 @@ describe('Claude structured journal translation', () => { kind: 'question', question: 'Libraries?\n\nEnter one or more choices separated by commas.', options: [], - freeTextQuestionId: 'Libraries?' + freeTextQuestionId: 'q1' }) translator.handle({ diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index f77fe2a87f43..fa97949e26a4 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -195,7 +195,7 @@ export function createClaudeJournalTranslator( }) changed = true } - if (envelope.role === 'user' && body && message.parent_tool_use_id === null) { + if (envelope.role === 'user' && message.parent_tool_use_id === null) { if (currentTurn) { publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false) } diff --git a/src/main/claude/claude-structured-prompt-items.test.ts b/src/main/claude/claude-structured-prompt-items.test.ts new file mode 100644 index 000000000000..5472ff9cf373 --- /dev/null +++ b/src/main/claude/claude-structured-prompt-items.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { claudeQuestionItems } from './claude-structured-prompt-items' +import { + applyClaudePromptAnswer, + type ClaudePendingPrompt +} from './claude-structured-prompt-replies' + +describe('Claude structured question addressing', () => { + it('keeps wire IDs bounded while returning the original question and choice', () => { + const questionId = 'Which option? '.repeat(100) + const label = 'A detailed choice '.repeat(100) + const prompt: ClaudePendingPrompt = { + requestId: 'question-1', + promptKey: 'question-1', + toolUseId: 'tool-1', + toolName: 'AskUserQuestion', + kind: 'question', + input: { questions: [{ question: questionId, options: [{ label }] }] }, + suggestions: [], + questionIds: [questionId], + answers: new Map(), + request: { subtype: 'can_use_tool' } + } + + const item = claudeQuestionItems({ sessionId: 'session-1', prompt })[0]! + expect(agentJournalItemKey(item.identity).length).toBeLessThan(512) + expect(item.body.options[0]!.id.length).toBeLessThan(512) + expect(item.body.freeTextQuestionId).toBe('q1') + expect( + applyClaudePromptAnswer({ prompt, questionId: item.questionId }, item.body.options[0]!.id) + ).toMatchObject({ + updatedInput: { answers: { [questionId]: label } } + }) + }) +}) diff --git a/src/main/claude/claude-structured-prompt-items.ts b/src/main/claude/claude-structured-prompt-items.ts index 19a00f3901eb..b669455aaf8e 100644 --- a/src/main/claude/claude-structured-prompt-items.ts +++ b/src/main/claude/claude-structured-prompt-items.ts @@ -64,15 +64,17 @@ export type ClaudeQuestionItem = { function questionOptions( question: Record, - questionId: string + questionAddress: string ): AgentJournalPromptOption[] { if (!Array.isArray(question.options)) { return [] } - return question.options.flatMap((value) => { + return question.options.flatMap((value, index) => { const option = claudeRecord(value) const label = claudeText(option?.label) - return label ? [{ id: encodeClaudeQuestionOptionId(questionId, label), label }] : [] + return label + ? [{ id: encodeClaudeQuestionOptionId(questionAddress, `choice-${index + 1}`), label }] + : [] }) } @@ -84,6 +86,7 @@ export function claudeQuestionItems(input: { return questions.flatMap((value, index) => { const question = claudeRecord(value) const questionId = input.prompt.questionIds[index] + const questionAddress = `q${index + 1}` const text = claudeText(question?.question) ?? claudeText(question?.header) const multiSelect = question?.multiSelect === true return question && questionId && text @@ -93,15 +96,15 @@ export function claudeQuestionItems(input: { identity: claudePromptIdentity({ sessionId: input.sessionId, promptKey: input.prompt.promptKey, - questionId + questionId: questionAddress }), body: { kind: 'question', question: multiSelect ? `${text}\n\nEnter one or more choices separated by commas.` : text, - options: multiSelect ? [] : questionOptions(question, questionId), - freeTextQuestionId: questionId, + options: multiSelect ? [] : questionOptions(question, questionAddress), + freeTextQuestionId: questionAddress, resolution: { ...PENDING } } } diff --git a/src/main/claude/claude-structured-prompt-replies.ts b/src/main/claude/claude-structured-prompt-replies.ts index 564e02a8f633..0346368eca68 100644 --- a/src/main/claude/claude-structured-prompt-replies.ts +++ b/src/main/claude/claude-structured-prompt-replies.ts @@ -33,6 +33,26 @@ function questionsFrom(input: Record): Record[ return Array.isArray(input.questions) ? input.questions.filter(isRecord) : [] } +function questionIdFromAddress(prompt: ClaudePendingPrompt, address: string): string | null { + const match = /^q([1-9]\d*)$/.exec(address) + const index = match ? Number(match[1]) - 1 : -1 + return index >= 0 ? (prompt.questionIds[index] ?? null) : null +} + +function questionAnswer(prompt: ClaudePendingPrompt, questionId: string, optionId: string): string { + const decoded = decodeClaudeQuestionOptionId(optionId) + if (!decoded) { + return optionId + } + const questionIndex = prompt.questionIds.indexOf(questionId) + const choice = /^choice-([1-9]\d*)$/.exec(decoded.answer) + const optionIndex = choice ? Number(choice[1]) - 1 : -1 + const question = questionsFrom(prompt.input)[questionIndex] + const option = Array.isArray(question?.options) ? question.options[optionIndex] : null + const label = isRecord(option) ? readString(option.label) : null + return decoded.questionId === `q${questionIndex + 1}` && label ? label : decoded.answer +} + function questionId(question: Record, index: number): string { return readString(question.question) ?? readString(question.header) ?? `question-${index + 1}` } @@ -161,13 +181,14 @@ function questionResponse( ): Record | null { const decoded = decodeClaudeQuestionOptionId(optionId) const selectedQuestionId = - decoded?.questionId ?? boundQuestionId ?? + (decoded ? questionIdFromAddress(prompt, decoded.questionId) : null) ?? + decoded?.questionId ?? (prompt.questionIds.length === 1 ? prompt.questionIds[0] : null) - const answer = decoded?.answer ?? optionId if (!selectedQuestionId || !prompt.questionIds.includes(selectedQuestionId)) { throw new Error(`${optionId} does not name a question on Claude prompt ${prompt.promptKey}`) } + const answer = questionAnswer(prompt, selectedQuestionId, optionId) prompt.answers.set(selectedQuestionId, answer) if (prompt.questionIds.some((id) => !prompt.answers.has(id))) { return null diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts index d3ece3a8a1d0..1176c082ef05 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { RuntimeMobileSessionTabsSnapshot } from '../../../../shared/runtime-types' @@ -103,6 +104,44 @@ describe('projectSessionTabAgentStatus', () => { expect(projected.tabs[0]).not.toHaveProperty('agentStatus') }) + it('withholds Claude tabs from the first Codex-only structured clients', () => { + const snapshot: RuntimeMobileSessionTabsSnapshot = { + ...makeSnapshot(false), + tabs: [ + { + type: 'agent-session', + id: 'agent-session:codex', + title: 'Codex Chat', + sessionId: 'codex', + agent: 'codex', + isActive: true + }, + { + type: 'agent-session', + id: 'agent-session:claude', + title: 'Claude Chat', + sessionId: 'claude', + agent: 'claude', + isActive: false + } + ], + activeTabId: 'agent-session:codex', + activeTabType: 'agent-session' + } + + expect( + projectSessionTabAgentStatus(snapshot, 'mobile', [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ]).tabs.map((tab) => tab.id) + ).toEqual(['agent-session:codex']) + expect( + projectSessionTabAgentStatus(snapshot, 'mobile', [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ]) + ).toBe(snapshot) + }) + it('publishes session boundaries to clients that negotiated them', () => { const snapshot = makeSnapshot(true) diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts index a47ff3966ba0..311000434a72 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts @@ -1,9 +1,11 @@ import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../../../shared/protocol-version' import type { + RuntimeMobileSessionAgentTab, RuntimeMobileSessionTabsResult, RuntimeMobileSessionTabsSnapshot } from '../../../../shared/runtime-types' @@ -19,7 +21,14 @@ export function projectSessionTabAgentStatus true) + if ( + structuredVisible && + clientKind !== undefined && + !clientCapabilities?.includes(CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + ) { + projected = projectAgentSessionTabsOut(projected, (tab) => tab.agent === 'claude') + } // Why: only paired runtimes have legacy `done` completion side effects; mobile must keep its row without changing the exact v2 auth shape. if ( clientKind !== 'runtime' || @@ -40,11 +49,17 @@ export function projectSessionTabAgentStatus( - payload: TPayload +function projectAgentSessionTabsOut( + payload: TPayload, + shouldHide: (tab: RuntimeMobileSessionAgentTab) => boolean ): TPayload { const hiddenIds = new Set( - payload.tabs.filter((tab) => tab.type === 'agent-session').map((tab) => tab.id) + payload.tabs + .filter( + (tab): tab is RuntimeMobileSessionAgentTab => + tab.type === 'agent-session' && shouldHide(tab) + ) + .map((tab) => tab.id) ) if (hiddenIds.size === 0) { return payload diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index 5d82b9c49692..c85eaa9c6231 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import { + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, RUNTIME_CAPABILITIES, RUNTIME_PROTOCOL_VERSION, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY @@ -164,6 +165,7 @@ afterEach(() => { describe('capability gating', () => { it('advertises the capability without bumping the protocol version', () => { expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + expect(RUNTIME_CAPABILITIES).toContain(CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) // Additive methods do not break an old client; bumping would strand every // paired device that has not updated. expect(RUNTIME_PROTOCOL_VERSION).toBe(3) diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 53aff48de441..eb723528e2c2 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -84,6 +84,9 @@ export const AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY = // can neither display nor drive. The host also refuses every agentSession.* // method from a connection that does not advertise this. export const STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = 'agent-session.structured.v1' as const +// Why: the first structured mobile client hardcoded Codex rendering, so hosts may publish Claude tabs only after this narrower capability is negotiated. +export const CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = + 'agent-session.structured.claude.v1' as const // Why: older runtimes strip mutation owner fields, so clients must fence writes before RPC. export const FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY = 'files.mutation-ownership.v1' as const export const FILE_MUTATION_OWNERSHIP_UPDATE_REQUIRED_MESSAGE = @@ -120,6 +123,7 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY, AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY, ACCOUNT_IMPORT_RUNTIME_CAPABILITY, CODEX_RESET_CREDIT_RUNTIME_CAPABILITY From abfb4ac7524109a84b3c3552070d11eda18a9e64 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 04:14:01 -0700 Subject: [PATCH 4/5] fix: preserve Claude free-text prompt answers --- .../claude-structured-prompt-items.test.ts | 42 +++++++++++++++++++ .../claude-structured-prompt-replies.ts | 21 ++++++++-- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/src/main/claude/claude-structured-prompt-items.test.ts b/src/main/claude/claude-structured-prompt-items.test.ts index 5472ff9cf373..0462c7da90fe 100644 --- a/src/main/claude/claude-structured-prompt-items.test.ts +++ b/src/main/claude/claude-structured-prompt-items.test.ts @@ -33,4 +33,46 @@ describe('Claude structured question addressing', () => { updatedInput: { answers: { [questionId]: label } } }) }) + + it('preserves colon-containing free-text answers', () => { + const questionId = 'Where should this run?' + const prompt: ClaudePendingPrompt = { + requestId: 'question-1', + promptKey: 'question-1', + toolUseId: 'tool-1', + toolName: 'AskUserQuestion', + kind: 'question', + input: { questions: [{ question: questionId }] }, + suggestions: [], + questionIds: [questionId], + answers: new Map(), + request: { subtype: 'can_use_tool' } + } + const answer = 'https://example.test:8443/path' + + expect(applyClaudePromptAnswer({ prompt }, answer)).toMatchObject({ + updatedInput: { answers: { [questionId]: answer } } + }) + }) + + it('preserves comma-separated multi-select answers', () => { + const questionId = 'Which targets?' + const prompt: ClaudePendingPrompt = { + requestId: 'question-1', + promptKey: 'question-1', + toolUseId: 'tool-1', + toolName: 'AskUserQuestion', + kind: 'question', + input: { questions: [{ question: questionId, multiSelect: true }] }, + suggestions: [], + questionIds: [questionId], + answers: new Map(), + request: { subtype: 'can_use_tool' } + } + const answer = 'frontend, backend' + + expect(applyClaudePromptAnswer({ prompt, questionId }, answer)).toMatchObject({ + updatedInput: { answers: { [questionId]: answer } } + }) + }) }) diff --git a/src/main/claude/claude-structured-prompt-replies.ts b/src/main/claude/claude-structured-prompt-replies.ts index 0346368eca68..31cbe5724bb1 100644 --- a/src/main/claude/claude-structured-prompt-replies.ts +++ b/src/main/claude/claude-structured-prompt-replies.ts @@ -45,12 +45,22 @@ function questionAnswer(prompt: ClaudePendingPrompt, questionId: string, optionI return optionId } const questionIndex = prompt.questionIds.indexOf(questionId) + if (questionIndex < 0) { + return optionId + } const choice = /^choice-([1-9]\d*)$/.exec(decoded.answer) const optionIndex = choice ? Number(choice[1]) - 1 : -1 const question = questionsFrom(prompt.input)[questionIndex] - const option = Array.isArray(question?.options) ? question.options[optionIndex] : null + const options = Array.isArray(question?.options) ? question.options : [] + const option = options[optionIndex] const label = isRecord(option) ? readString(option.label) : null - return decoded.questionId === `q${questionIndex + 1}` && label ? label : decoded.answer + if (decoded.questionId === `q${questionIndex + 1}` && label) { + return label + } + const legacyChoice = options.some( + (candidate) => isRecord(candidate) && readString(candidate.label) === decoded.answer + ) + return decoded.questionId === questionId && legacyChoice ? decoded.answer : optionId } function questionId(question: Record, index: number): string { @@ -180,10 +190,13 @@ function questionResponse( boundQuestionId?: string ): Record | null { const decoded = decodeClaudeQuestionOptionId(optionId) + const decodedQuestionId = decoded + ? (questionIdFromAddress(prompt, decoded.questionId) ?? + (prompt.questionIds.includes(decoded.questionId) ? decoded.questionId : null)) + : null const selectedQuestionId = boundQuestionId ?? - (decoded ? questionIdFromAddress(prompt, decoded.questionId) : null) ?? - decoded?.questionId ?? + decodedQuestionId ?? (prompt.questionIds.length === 1 ? prompt.questionIds[0] : null) if (!selectedQuestionId || !prompt.questionIds.includes(selectedQuestionId)) { throw new Error(`${optionId} does not name a question on Claude prompt ${prompt.promptKey}`) From 0ee46a3024fd996c43bb477c1c347d9f05a5684c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 04:25:20 -0700 Subject: [PATCH 5/5] fix: decode addressed Claude prompt text --- src/main/claude/claude-structured-prompt-items.test.ts | 9 +++++++-- src/main/claude/claude-structured-prompt-replies.ts | 7 ++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/main/claude/claude-structured-prompt-items.test.ts b/src/main/claude/claude-structured-prompt-items.test.ts index 0462c7da90fe..db7ba28a37c8 100644 --- a/src/main/claude/claude-structured-prompt-items.test.ts +++ b/src/main/claude/claude-structured-prompt-items.test.ts @@ -3,6 +3,7 @@ import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key import { claudeQuestionItems } from './claude-structured-prompt-items' import { applyClaudePromptAnswer, + encodeClaudeQuestionOptionId, type ClaudePendingPrompt } from './claude-structured-prompt-replies' @@ -50,7 +51,9 @@ describe('Claude structured question addressing', () => { } const answer = 'https://example.test:8443/path' - expect(applyClaudePromptAnswer({ prompt }, answer)).toMatchObject({ + expect( + applyClaudePromptAnswer({ prompt }, encodeClaudeQuestionOptionId('q1', answer)) + ).toMatchObject({ updatedInput: { answers: { [questionId]: answer } } }) }) @@ -71,7 +74,9 @@ describe('Claude structured question addressing', () => { } const answer = 'frontend, backend' - expect(applyClaudePromptAnswer({ prompt, questionId }, answer)).toMatchObject({ + expect( + applyClaudePromptAnswer({ prompt, questionId }, encodeClaudeQuestionOptionId('q1', answer)) + ).toMatchObject({ updatedInput: { answers: { [questionId]: answer } } }) }) diff --git a/src/main/claude/claude-structured-prompt-replies.ts b/src/main/claude/claude-structured-prompt-replies.ts index 31cbe5724bb1..60404251ebd7 100644 --- a/src/main/claude/claude-structured-prompt-replies.ts +++ b/src/main/claude/claude-structured-prompt-replies.ts @@ -57,10 +57,15 @@ function questionAnswer(prompt: ClaudePendingPrompt, questionId: string, optionI if (decoded.questionId === `q${questionIndex + 1}` && label) { return label } + if (decoded.questionId === `q${questionIndex + 1}`) { + return decoded.answer + } const legacyChoice = options.some( (candidate) => isRecord(candidate) && readString(candidate.label) === decoded.answer ) - return decoded.questionId === questionId && legacyChoice ? decoded.answer : optionId + return decoded.questionId === questionId && (legacyChoice || decoded.answer.trim().length > 0) + ? decoded.answer + : optionId } function questionId(question: Record, index: number): string {