diff --git a/src/main/claude/claude-structured-dispatch.test.ts b/src/main/claude/claude-structured-dispatch.test.ts index 69a107e5d2e..ffdd0a5bbf8 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 e29310432a2..d80c0a56889 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 5a7d71de94f..f77fe2a87f4 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 4604e308752..3f79ba1066c 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 86a90830ce1..bd33dc7f60f 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 e568c0d383f..19a00f3901e 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 646f55fbbe7..4bef47657b6 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 219642e5ed8..739b7f3d817 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 9c708039ad2..05c51ee8868 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 {