From e14cf9bdcd1424e5a744c1e0a3993c336a0c70ff Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 25 Sep 2026 18:39:31 +0800 Subject: [PATCH 1/4] fix(ui): remove the duplicate settled-choice history row #5592 projected every settled form and question interaction into the transcript as a separate form_interaction message and rendered it after the owning Turn. For AskUserQuestion, the tool row already holds the full question, options and answer in its durable args and result, so every answered question appeared twice, outside the conversation column and below a still-running Turn, in all sessions rather than only WorkHub. The WorkHub target selector's choice is likewise already recorded in the tasks tool result. Keeping that second projection live also required closing every transcript subscription whenever a question settled (transcript_changed), a CLI recovery path for the resulting page race, a 512x wider transcript sequence stride, a per-Turn interaction query with a new index, and extra branches in search, backfill and shared transcripts. Remove all of it and bump the compatibility epoch to 188 because form_interaction messages and the transcript_changed close reason leave the wire. The WorkHub stop/status changes from #5592 are unchanged. Generated-by: Claude Code --- ...me-host-session-subscription-owner.test.ts | 4 +- ...runtime-host-session-subscription-owner.ts | 1 - docs/astryx-surface-file-inventory.md | 3 +- docs/astryx-surface-file-inventory.paths | 1 - .../runtime-host-prompt-transcript.test.ts | 37 ---- .../runtime-host-session-driver.test.ts | 62 +++--- .../cli/src/runtime-host-session-channel.ts | 13 +- packages/core/src/session.ts | 43 ---- packages/core/src/transcript-search.ts | 1 - .../session-continuity-coordinator.test.ts | 45 ---- .../session-transcript-pager.test.ts | 21 -- .../session-transcript-reader.test.ts | 197 ++---------------- .../src/client/session-subscription.ts | 7 - packages/runtime-host/src/protocol/index.ts | 3 +- .../src/protocol/session-continuity.ts | 5 +- .../server/session-continuity-coordinator.ts | 29 +-- .../src/server/session-transcript-reader.ts | 81 ++----- .../src/server/shared-session-transcript.ts | 3 - .../runtime/src/runtime-event-backfill.ts | 1 - .../storage/src/interaction-store-contract.ts | 1 - packages/storage/src/interaction-store.ts | 16 -- .../src/sqlite-core-execution-schema.ts | 3 - .../src/test-only/memory-execution-control.ts | 12 -- .../form-interaction-history.test.tsx | 69 ------ packages/ui/src/chat-view.tsx | 14 +- packages/ui/src/form-interaction-history.tsx | 80 ------- packages/ui/src/styles.css | 12 -- 27 files changed, 81 insertions(+), 683 deletions(-) delete mode 100644 packages/ui/src/__tests__/form-interaction-history.test.tsx delete mode 100644 packages/ui/src/form-interaction-history.tsx diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-subscription-owner.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-subscription-owner.test.ts index dc33f4a193..a8f671fee8 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-subscription-owner.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-subscription-owner.test.ts @@ -209,7 +209,7 @@ test('reseeds an evicted replica on the same live subscription', async () => { await owner.close(); }); -for (const reason of ['slow_consumer', 'transcript_changed'] as const) test(`a reseed superseded by subscription recovery does not displace the new replica (${reason})`, async () => { +test('a reseed superseded by subscription recovery does not displace the new replica', async () => { const firstEvents = new AsyncFrameQueue(); const secondEvents = new AsyncFrameQueue(); const reseedFetch = deferred(); @@ -270,7 +270,7 @@ for (const reason of ['slow_consumer', 'transcript_changed'] as const) test(`a r hostEpoch: 'host-1', subscriptionId: 'subscription-1', sequence: 1, - reason, + reason: 'slow_consumer', }); await pollFor(() => opens === 2); reseedFetch.resolve(undefined); diff --git a/apps/desktop/src/main/runtime-host-session-subscription-owner.ts b/apps/desktop/src/main/runtime-host-session-subscription-owner.ts index dca2a7ab4d..4cccd55eb8 100644 --- a/apps/desktop/src/main/runtime-host-session-subscription-owner.ts +++ b/apps/desktop/src/main/runtime-host-session-subscription-owner.ts @@ -443,7 +443,6 @@ function isRecoverableSubscriptionFailure(error: unknown): boolean { if (!(error instanceof RuntimeHostSubscriptionError)) return false; return ( error.reason === 'slow_consumer' || - error.reason === 'transcript_changed' || error.reason === 'sequence_gap' || error.reason === 'projection_revision_invalid' ); diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index e97630c219..455397fd2a 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.6.2` (195 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 299 files — blocker 0, reimplementation 0, polish 4, aligned 295. +**Totals:** 298 files — blocker 0, reimplementation 0, polish 4, aligned 294. ## Exclusions (explicit) @@ -274,7 +274,6 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `packages/ui/src/daily-review-panel.tsx` | module-hub | Banner, Button, Divider, EmptyState, HStack, Heading, List, ListItem, SegmentedControl, SegmentedControlItem, Skeleton, StackItem, Text, Toolbar, VStack | aligned — uses Astryx (Banner, Button, Divider, EmptyState, HStack, Heading, List, ListItem) | aligned | | `packages/ui/src/directory-reference-chip.tsx` | ui-composition | Token, Tooltip | aligned — uses Astryx (Token, Tooltip) | aligned | | `packages/ui/src/executor-model-picker.tsx` | ui-composition | Button, Popover | aligned — uses Astryx (Button, Popover) | aligned | -| `packages/ui/src/form-interaction-history.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/form-interaction-prompt.tsx` | ui-composition | Button, CheckboxInput, RadioList, RadioListItem, Selector, Text, TextInput | aligned — uses Astryx (Button, CheckboxInput, RadioList, RadioListItem, Selector, Text, TextInput) | aligned | | `packages/ui/src/icons.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/inline-reference.tsx` | ui-composition | ChatTokenizedText | aligned — uses Astryx (ChatTokenizedText) | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index 8889912e84..03477d1e05 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -244,7 +244,6 @@ packages/ui/src/composer.tsx packages/ui/src/daily-review-panel.tsx packages/ui/src/directory-reference-chip.tsx packages/ui/src/executor-model-picker.tsx -packages/ui/src/form-interaction-history.tsx packages/ui/src/form-interaction-prompt.tsx packages/ui/src/icons.tsx packages/ui/src/inline-reference.tsx diff --git a/packages/cli/src/__tests__/runtime-host-prompt-transcript.test.ts b/packages/cli/src/__tests__/runtime-host-prompt-transcript.test.ts index be4bad5b37..d1a38c2126 100644 --- a/packages/cli/src/__tests__/runtime-host-prompt-transcript.test.ts +++ b/packages/cli/src/__tests__/runtime-host-prompt-transcript.test.ts @@ -23,7 +23,6 @@ import { setImmediate } from 'node:timers/promises'; import { deferred } from '@maka/core/test-only/async-primitives'; import type { StoredMessage } from '@maka/core/session'; import { - RuntimeHostOperationError, RuntimeHostSubscriptionError, type DecodedSessionTranscriptPage, type RuntimeHostSessionSubscription, @@ -318,42 +317,6 @@ test('recovery across root turns rereads the old prompt below the new bootstrap await channel.close(); }); -test('prompt transcript recovers when a retired subscription page fails before its close frame', async () => { - const first = new TranscriptSubscription('first', 7); - const second = new TranscriptSubscription('second', 31); - let opens = 0; - const channel = await openChannel(async () => (++opens === 1 ? first : second)); - const transcript = channel.trackPromptTranscript('turn'); - try { - first.advance(31); - await setImmediate(); - // The Host has removed the subscription but its close frame has not arrived. - first.readPage = async () => { - throw new RuntimeHostOperationError( - 'session.transcript.page', - 'not_found', - 'Session subscription was not found', - ); - }; - const expected = [result('tool', 'Form completed'), terminal()]; - second.readPage = async (input) => - second.page(input, [ - { identity: 16, message: expected[0]! }, - { identity: 24, message: expected[1]! }, - ]); - const observed: StoredMessage[] = []; - await transcript.reconcile(async (messages) => { - observed.push(...messages); - }); - assert.equal(opens, 2); - assert.deepEqual(observed, expected); - assert.equal(second.pages[0]?.anchorSequence, 7); - } finally { - transcript.dispose(); - await channel.close(); - } -}); - test('prompt transcript rejects nonadvancing cursors and propagates consumer failures', async () => { for (const failure of ['cursor', 'consumer'] as const) { const subscription = new TranscriptSubscription('first', 7); diff --git a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts index 538179862b..937e4b66be 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -2461,41 +2461,37 @@ describe('Runtime Host Maka Session driver', () => { assert.equal(statuses.at(-1), undefined); }); - for (const reason of ['slow_consumer', 'transcript_changed'] as const) - test(`reopens a failed Session channel before starting the next turn (${reason})`, async () => { - const first = new FakeSubscription( - continuitySnapshot({ rootTurn: null }), - Promise.resolve([]), - ); - const second = new FakeSubscription( - continuitySnapshot({ rootTurn: null }), - Promise.resolve([]), - 'subscription-2', - ); - const connection = new FakeConnection([first, second]); - const driver = createRuntimeHostMakaSessionDriver({ - connection: connection.value, - cwd: '/tmp', - llmConnectionId: 'connection-1', - llmConnectionSlug: 'openai-main', - model: 'gpt-5', - newId: sequenceIds('turn-2'), - }); - await driver.switchSession('session-1'); - - first.push({ - kind: 'subscription.closed', - hostEpoch: 'host-1', - subscriptionId: 'subscription-1', - sequence: 1, - reason, - }); - await new Promise((resolve) => setImmediate(resolve)); + test('reopens a failed Session channel before starting the next turn', async () => { + const first = new FakeSubscription(continuitySnapshot({ rootTurn: null }), Promise.resolve([])); + const second = new FakeSubscription( + continuitySnapshot({ rootTurn: null }), + Promise.resolve([]), + 'subscription-2', + ); + const connection = new FakeConnection([first, second]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: sequenceIds('turn-2'), + }); + await driver.switchSession('session-1'); - const turn = await driver.preparePrompt('Continue'); - second.push(deltaFrame(1, 'turn-2', 0, 'Recovered', 'subscription-2', 'run-2')); - assert.equal((await nextEvent(turn.events)).text, 'Recovered'); + first.push({ + kind: 'subscription.closed', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + reason: 'slow_consumer', }); + await new Promise((resolve) => setImmediate(resolve)); + + const turn = await driver.preparePrompt('Continue'); + second.push(deltaFrame(1, 'turn-2', 0, 'Recovered', 'subscription-2', 'run-2')); + assert.equal((await nextEvent(turn.events)).text, 'Recovered'); + }); test('starts explicit Skills through the Host command and preserves its typed feedback', async () => { const subscription = new FakeSubscription( diff --git a/packages/cli/src/runtime-host-session-channel.ts b/packages/cli/src/runtime-host-session-channel.ts index 8736814dff..14576bfdce 100644 --- a/packages/cli/src/runtime-host-session-channel.ts +++ b/packages/cli/src/runtime-host-session-channel.ts @@ -32,7 +32,6 @@ import { } from '@maka/runtime-host/adapter'; import { isRuntimeHostReconnectingConnection, - RuntimeHostOperationError, RuntimeHostRequestInterruptedError, RuntimeHostSubscriptionError, type RuntimeHostConnection, @@ -801,11 +800,6 @@ export class RuntimeHostSessionChannel { #canRecover(error: unknown): boolean { if (!isRuntimeHostReconnectingConnection(this.#connection)) return false; - // The Host can retire a transcript subscription before its close frame is - // consumed. Recover the invalidated read through the same bounded policy. - if (error instanceof RuntimeHostOperationError) { - return error.operation === 'session.transcript.page' && error.code === 'not_found'; - } if (error instanceof RuntimeHostRequestInterruptedError) { return error.reason === 'connection_lost'; } @@ -814,7 +808,6 @@ export class RuntimeHostSessionChannel { (error.reason === 'connection_closed' || error.reason === 'sequence_gap' || error.reason === 'projection_revision_invalid' || - error.reason === 'transcript_changed' || error.reason === 'slow_consumer') ); } @@ -835,10 +828,10 @@ export class RuntimeHostSessionChannel { return; } if (frame.kind === 'subscription.closed') { - if (frame.reason === 'slow_consumer' || frame.reason === 'transcript_changed') { + if (frame.reason === 'slow_consumer') { throw new RuntimeHostSubscriptionError( - frame.reason, - 'Runtime Host Session transcript requires a fresh subscription', + 'slow_consumer', + 'Runtime Host Session subscription consumer fell behind', ); } this.#fail(new Error(`Runtime Host Session subscription closed: ${frame.reason}`)); diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 1f3999b208..9ae0ac39bd 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -20,14 +20,6 @@ import type { ExecutorConfiguration } from './executor-catalog.js'; import { isWorkHubActionReceipt, type WorkHubActionReceipt } from './workhub-action-result.js'; -import { - decodeInteractionRequest, - decodeInteractionCanonicalOutcome, - isInteractionCanonicalOutcomeValidForRequest, - type InteractionFormRequest, - type InteractionQuestionRequest, - type InteractionCanonicalOutcome, -} from './interaction.js'; import { isExecutorId } from './executor-id.js'; import { isThinkingLevel, type ThinkingLevel } from './model-thinking.js'; @@ -782,7 +774,6 @@ export type StoredMessage = | AssistantMessage | ToolCallMessage | ToolResultMessage - | FormInteractionMessage | PermissionDecisionMessage | TokenUsageMessage | TurnStateMessage @@ -927,19 +918,6 @@ export interface ToolResultMessage { parentOperationId?: string; } -/** Read projection of a canonical answered or closed form or question, never model-authored text. */ -export interface FormInteractionMessage { - type: 'form_interaction'; - id: string; - turnId: string; - ts: number; - request: InteractionFormRequest | InteractionQuestionRequest; - outcome: Extract< - InteractionCanonicalOutcome, - { kind: 'form_answer' | 'question_answer' | 'closure' } - >; -} - export interface PermissionDecisionMessage { type: 'permission_decision'; /** Equals PermissionRequestEvent.requestId for audit correlation. */ @@ -1369,10 +1347,6 @@ const TOOL_RESULT_MESSAGE_SHAPE = defineObjectShape()( 'parentOperationId', ], ); -const FORM_INTERACTION_MESSAGE_SHAPE = defineObjectShape()( - ['type', 'id', 'turnId', 'ts', 'request', 'outcome'], - [], -); const PERMISSION_DECISION_MESSAGE_SHAPE = defineObjectShape()( ['type', 'id', 'turnId', 'ts', 'toolUseId', 'toolName', 'decision'], ['rememberForTurn', 'reviewer', 'rationale', 'riskLevel', 'hint'], @@ -1673,23 +1647,6 @@ function decodeMessage( ) return message as unknown as ToolResultMessage; break; - case 'form_interaction': - if ( - hasMessageEnvelope(message, true) && - hasExactShape(message, FORM_INTERACTION_MESSAGE_SHAPE) - ) { - const request = decodeInteractionRequest(message.request); - const outcome = decodeInteractionCanonicalOutcome(message.outcome); - if ( - (request.kind === 'form' || request.kind === 'question') && - (outcome.kind === 'form_answer' || - outcome.kind === 'question_answer' || - outcome.kind === 'closure') && - isInteractionCanonicalOutcomeValidForRequest(request, outcome) - ) - return { ...message, request, outcome } as unknown as FormInteractionMessage; - } - break; case 'permission_decision': if ( hasExactShape(message, PERMISSION_DECISION_MESSAGE_SHAPE) && diff --git a/packages/core/src/transcript-search.ts b/packages/core/src/transcript-search.ts index ad6e79a3c8..f98cd0fe52 100644 --- a/packages/core/src/transcript-search.ts +++ b/packages/core/src/transcript-search.ts @@ -58,7 +58,6 @@ export function threadSearchMatchKind(message: StoredMessage): ThreadSearchMatch return 'tool_intent'; case 'tool_result': return 'tool_result'; - case 'form_interaction': case 'permission_decision': case 'token_usage': case 'turn_state': diff --git a/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts index fecccc1549..51a3e0ab4b 100644 --- a/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts @@ -2483,48 +2483,3 @@ async function waitFor(predicate: () => boolean): Promise { message: 'Timed out waiting for continuity state', }); } - -test('settled choice invalidates consumed transcript even without a new RuntimeEvent', async () => { - let projection = canonical({ interactions: { pending: [pendingInteraction()] } }); - const durable: StoredMessage[] = [ - { type: 'assistant', id: 'old', turnId: 'turn-1', ts: 1, text: 'Done', modelId: 'test' }, - ]; - const coordinator = new SessionContinuityCoordinator( - HOST_EPOCH, - async () => projection, - new SessionAdmissionGate(), - undefined, - { ...transcriptReader(durable), readDurableHighWater: async () => 1 }, - ); - const sink = new RecordingSink(); - const connection = attachTestConnection(coordinator, 'late-choice', sink); - const opened = await open(coordinator, 'late-choice', { kind: 'tail', maxBytes: 1 << 20 }); - connection.activate(opened.subscriptionId); - const history: StoredMessage = { - type: 'form_interaction', - id: 'interaction-1', - turnId: 'turn-1', - ts: 2, - request: pendingInteraction().request, - outcome: { kind: 'question_answer', answers: ['Yes'], committedAt: 2 }, - }; - durable.push(history); - projection = canonical(); - await coordinator.refreshCanonical(SESSION_ID); - await delayImmediate(); - const closed = sink.frames.find((frame) => frame.kind === 'subscription.closed'); - assert.equal( - closed?.kind === 'subscription.closed' ? closed.reason : undefined, - 'transcript_changed', - ); - const reopened = await open(coordinator, 'late-choice', { kind: 'tail', maxBytes: 1 << 20 }); - const client = clientSubscription( - reopened, - async () => undefined, - async () => { - throw new Error('unexpected pagination'); - }, - ); - assert.deepEqual(await client.loadTranscript((value) => value), durable); - coordinator.close(); -}); diff --git a/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts b/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts index c36231c02a..b46a0f03eb 100644 --- a/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts +++ b/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts @@ -672,24 +672,3 @@ test('shared transcript preserves admitted action identity alongside its physica message, ); }); - -test('shared transcript omits private choice prompts and answers', () => { - assert.equal( - projectSharedSessionTranscriptMessage( - { - type: 'form_interaction', - id: 'choice', - turnId: 'turn', - ts: 2, - request: { - kind: 'question', - toolUseId: 'tool', - questions: [{ question: 'Private?', options: [{ label: 'Yes' }, { label: 'No' }] }], - }, - outcome: { kind: 'question_answer', answers: ['Yes'], committedAt: 2 }, - }, - 'session', - ), - null, - ); -}); diff --git a/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts b/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts index cdbd27d91a..e01ee49126 100644 --- a/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts +++ b/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts @@ -777,16 +777,16 @@ test('serves every row of a Turn nested inside another', async () => { await read.end('outer', 'outer-end'); const throughSequence = (await read.readDurableHighWater(sessionId))!; - assert.equal(throughSequence, 10 * 4096 + 4095); + assert.equal(throughSequence, 10 * 8 + 7); await assertTranscriptRows(read, sessionId, throughSequence, [ - [2 * 4096, 'outer-before'], - [4 * 4096, 'inner-0'], - [5 * 4096, 'inner-1'], - [6 * 4096, 'inner-2'], - [7 * 4096, 'inner-3'], - [8 * 4096, 'inner-end'], - [9 * 4096, 'outer-after'], - [10 * 4096, 'outer-end'], + [2 * 8, 'outer-before'], + [4 * 8, 'inner-0'], + [5 * 8, 'inner-1'], + [6 * 8, 'inner-2'], + [7 * 8, 'inner-3'], + [8 * 8, 'inner-end'], + [9 * 8, 'outer-after'], + [10 * 8, 'outer-end'], ]); }); }); @@ -816,16 +816,16 @@ test('serves a running Turn that encloses two separated Turns', async () => { // A watermark inside the outer Turn: it is still running as of this read, // so it has no ending to be reached through. - const throughSequence = 10 * 4096 + 4095; - assert.equal(await read.readDurableHighWater(sessionId), 11 * 4096 + 4095); + const throughSequence = 10 * 8 + 7; + assert.equal(await read.readDurableHighWater(sessionId), 11 * 8 + 7); await assertTranscriptRows(read, sessionId, throughSequence, [ - [2 * 4096, 'outer-a'], - [4 * 4096, 'first-a'], - [5 * 4096, 'first-end'], - [6 * 4096, 'outer-b'], - [8 * 4096, 'second-a'], - [9 * 4096, 'second-end'], - [10 * 4096, 'outer-c'], + [2 * 8, 'outer-a'], + [4 * 8, 'first-a'], + [5 * 8, 'first-end'], + [6 * 8, 'outer-b'], + [8 * 8, 'second-a'], + [9 * 8, 'second-end'], + [10 * 8, 'outer-c'], ]); }); }); @@ -926,7 +926,7 @@ test('does not end a page where a handoff resumes the same Turn', async () => { const lookup = (turnId: string) => read.readDurableTurnLandmarks(sessionId, { maxLandmarks: 1, turnId }); assert.deepEqual((await lookup('turn-first')).landmarks, [ - { turnId: 'turn-first', sequence: 1 * 4096, lastSequence: 6 * 4096 + 4095, label: '' }, + { turnId: 'turn-first', sequence: 1 * 8, lastSequence: 6 * 8 + 7, label: '' }, ]); assert.deepEqual((await lookup('turn-missing')).landmarks, []); }); @@ -1264,162 +1264,3 @@ function assertLargeBashResult( assert.equal(result.content.output.stdoutTruncated, true); assert.equal(result.content.output.stderrTruncated, true); } - -test('replays Host form choices and cancellations without model-authored form events', async () => { - const base = await mkdtemp(join(tmpdir(), 'maka-form-history-')); - const capability = await resolveStorageRoot({ path: join(base, 'root'), kind: 'interactive' }); - const owner = await tryAcquireInteractiveRootOwner(capability); - assert.ok(owner); - try { - const stores = await openInteractiveExecutionStoresForWrite(owner.lease); - const session = await stores.sessionStore.create({ - cwd: base, - llmConnectionSlug: 'fake', - model: 'fake', - permissionMode: 'ask', - }); - await seedInvocation(stores.runtimeEventStore, { - sessionId: session.id, - runId: 'run-1', - turnId: 'turn-1', - openedAt: 1, - }); - await stores.runtimeEventStore.appendRuntimeEvent( - session.id, - 'run-1', - runtimeEvent(session.id, { - id: 'user-1', - ts: 2, - role: 'user', - author: 'user', - content: { kind: 'text', text: 'Continue login' }, - }), - ); - const request = { - kind: 'form' as const, - toolUseId: 'tool-1', - message: 'Which login work?', - requester: { name: 'WorkHub' }, - fields: [ - { - kind: 'single_select' as const, - name: 'target', - label: 'Work', - required: true, - options: [ - { value: 'ui', label: 'Login UI' }, - { value: 'api', label: 'Login API' }, - ], - }, - ], - }; - for (const requestId of [ - 'selected', - 'cancelled', - ...Array.from({ length: 8 }, (_, i) => `closed-${i}`), - ]) { - await stores.interactionStore.establishRequest({ - sessionId: session.id, - turnId: 'turn-1', - runId: 'run-1', - requestId, - createdAt: 3, - request, - }); - await stores.interactionStore.commitOutcome( - requestId, - requestId === 'selected' - ? { kind: 'form_answer', action: 'accept', values: { target: 'api' }, committedAt: 4 } - : requestId === 'cancelled' - ? { kind: 'form_answer', action: 'cancel', committedAt: 5 } - : { kind: 'closure', reason: 'turn_terminal', committedAt: 7 }, - ); - } - await stores.interactionStore.establishRequest({ - sessionId: session.id, - turnId: 'turn-1', - runId: 'run-1', - requestId: 'question', - createdAt: 5, - request: { - kind: 'question', - toolUseId: 'question-tool', - questions: [ - { - question: 'Which work should stop?', - options: [{ label: 'Both' }, { label: 'Only UI' }], - }, - ], - }, - }); - await stores.interactionStore.commitOutcome('question', { - kind: 'question_answer', - answers: ['Only UI'], - committedAt: 6, - }); - await stores.runtimeEventStore.appendRuntimeEvent( - session.id, - 'run-1', - runtimeEvent(session.id, { - id: 'answer-1', - ts: 6, - role: 'model', - author: 'agent', - content: { kind: 'text', text: 'Done.' }, - }), - ); - await seedInvocation(stores.runtimeEventStore, { - sessionId: session.id, - runId: 'run-2', - turnId: 'turn-1', - openedAt: 8, - }); - await stores.runtimeEventStore.appendRuntimeEvent(session.id, 'run-2', { - ...runtimeEvent(session.id, { - id: 'continued', - ts: 9, - role: 'model', - author: 'agent', - content: { kind: 'text', text: 'Continued.' }, - }), - runId: 'run-2', - invocationId: 'run-2', - turnId: 'turn-1', - }); - const read = createSessionTranscriptReader({ - stores, - canonicalPermissionOutcomes: { readPermissionOutcome: async () => undefined }, - }); - const page = await read.readDurableRecords(session.id, { - direction: 'newer', - position: 0, - maxStoredBytes: TRANSCRIPT_TURN_MAX_BYTES, - maxMessages: 64, - }); - const forms = page.records - .map(({ message }) => message) - .filter((message) => message.type === 'form_interaction'); - assert.equal(forms.length, 11); - assert.deepEqual(forms.find((form) => form.id === 'question')?.outcome, { - kind: 'question_answer', - answers: ['Only UI'], - committedAt: 6, - }); - assert.deepEqual(forms[0]?.request, request); - assert.deepEqual( - forms - .filter((form) => form.outcome.kind === 'form_answer') - .map((form) => form.outcome.kind === 'form_answer' && form.outcome.action), - ['accept', 'cancel'], - ); - assert.deepEqual(forms.find((form) => form.id === 'selected')?.outcome, { - kind: 'form_answer', - action: 'accept', - values: { target: 'api' }, - committedAt: 4, - }); - } finally { - await owner.close(); - await rm(base, { recursive: true, force: true }); - } -}); diff --git a/packages/runtime-host/src/client/session-subscription.ts b/packages/runtime-host/src/client/session-subscription.ts index 226a227e1d..c20f0d04c9 100644 --- a/packages/runtime-host/src/client/session-subscription.ts +++ b/packages/runtime-host/src/client/session-subscription.ts @@ -43,7 +43,6 @@ export type RuntimeHostSubscriptionFailureReason = | 'correlation_changed' | 'projection_revision_invalid' | 'slow_consumer' - | 'transcript_changed' | 'connection_closed'; export class RuntimeHostSubscriptionError extends Error { @@ -62,12 +61,6 @@ export class SessionRemovedSubscriptionError extends Error { } export function subscriptionClosedError(reason: SubscriptionClosedFrame['reason']): Error { - if (reason === 'transcript_changed') { - return new RuntimeHostSubscriptionError( - 'transcript_changed', - 'Settled choice history requires a fresh transcript', - ); - } if (reason === 'session_removed') { return new SessionRemovedSubscriptionError( 'Runtime Host Session was removed while it was observed', diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index b1b4f8c020..ec84c8a69c 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -103,7 +103,8 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 187 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 188 as const; +// 188: Remove form_interaction transcript messages and the transcript_changed close reason. // 187: transcript_changed closes a subscription for automatic bounded-tail reseeding. // 186: Settled form/question history adds form_interaction transcript messages. // 185: Add the bounded next-prompt suggestion operation. diff --git a/packages/runtime-host/src/protocol/session-continuity.ts b/packages/runtime-host/src/protocol/session-continuity.ts index 98b5473290..f376c04519 100644 --- a/packages/runtime-host/src/protocol/session-continuity.ts +++ b/packages/runtime-host/src/protocol/session-continuity.ts @@ -291,7 +291,7 @@ export interface AgentGraphChangedFrame extends SubscriptionEnvelope { export interface SubscriptionClosedFrame extends SubscriptionEnvelope { kind: 'subscription.closed'; - reason: 'slow_consumer' | 'session_removed' | 'access_revoked' | 'transcript_changed'; + reason: 'slow_consumer' | 'session_removed' | 'access_revoked'; } export type SubscriptionFrame = @@ -533,8 +533,7 @@ export function decodeSubscriptionFrame(value: unknown): SubscriptionFrame { if ( record.reason !== 'slow_consumer' && record.reason !== 'session_removed' && - record.reason !== 'access_revoked' && - record.reason !== 'transcript_changed' + record.reason !== 'access_revoked' ) { throw invalidProtocolFrame('Invalid subscription close reason'); } diff --git a/packages/runtime-host/src/server/session-continuity-coordinator.ts b/packages/runtime-host/src/server/session-continuity-coordinator.ts index aba4f76ab4..7f93e18227 100644 --- a/packages/runtime-host/src/server/session-continuity-coordinator.ts +++ b/packages/runtime-host/src/server/session-continuity-coordinator.ts @@ -665,7 +665,6 @@ export class SessionContinuityCoordinator implements SessionContinuityService { const nextRevision = state.revision + 1; const snapshot = createSessionContinuitySnapshot(canonical, nextRevision); - this.#invalidateSettledChoiceTranscript(state, canonical); state.canonical = canonical; state.revision = nextRevision; delete state.terminalPublicationFence; @@ -1309,10 +1308,7 @@ export class SessionContinuityCoordinator implements SessionContinuityService { ); } - #closeSubscriber( - subscriber: Subscriber, - reason: 'slow_consumer' | 'access_revoked' | 'transcript_changed', - ): void { + #closeSubscriber(subscriber: Subscriber, reason: 'slow_consumer' | 'access_revoked'): void { if (subscriber.phase !== 'open') return; subscriber.phase = 'closing'; const inFlight = subscriber.pumping ? subscriber.queue[0] : undefined; @@ -1702,7 +1698,6 @@ export class SessionContinuityCoordinator implements SessionContinuityService { } const nextRevision = state.revision + 1; const value = createSessionContinuitySnapshot(canonical, nextRevision); - this.#invalidateSettledChoiceTranscript(state, canonical); state.canonical = canonical; state.revision = nextRevision; return { changed, state, value }; @@ -1714,32 +1709,10 @@ export class SessionContinuityCoordinator implements SessionContinuityService { }; } - #invalidateSettledChoiceTranscript( - state: SessionProjectionState, - next: CanonicalSessionProjection, - ): void { - const pending = new Set(next.interactions.pending.map((item) => item.interactionId)); - if ( - !state.canonical.interactions.pending.some( - (item) => - (item.request.kind === 'form' || item.request.kind === 'question') && - !pending.has(item.interactionId), - ) - ) - return; - // Outcomes can settle after their owner's last event. Their historical rows - // then lie behind an already-consumed cursor: advancing the event high water - // cannot deliver them. Close explicitly so observers rebuild their bounded tail. - for (const subscriber of state.subscribers.values()) { - if (subscriber.transcript) this.#closeSubscriber(subscriber, 'transcript_changed'); - } - } - #publishCanonical(state: SessionProjectionState, canonical: CanonicalSessionProjection): void { if (isDeepStrictEqual(state.canonical, canonical)) return; const nextRevision = state.revision + 1; const snapshot = createSessionContinuitySnapshot(canonical, nextRevision); - this.#invalidateSettledChoiceTranscript(state, canonical); state.canonical = immutableClone(canonical); state.revision = nextRevision; this.#broadcastProjection(state, snapshot); diff --git a/packages/runtime-host/src/server/session-transcript-reader.ts b/packages/runtime-host/src/server/session-transcript-reader.ts index dcff483b38..f625f99b72 100644 --- a/packages/runtime-host/src/server/session-transcript-reader.ts +++ b/packages/runtime-host/src/server/session-transcript-reader.ts @@ -47,8 +47,7 @@ import type { SessionTurnLandmark } from '../protocol/index.js'; const PERMISSION_OUTCOME_READ_CONCURRENCY = 8; /** One event can emit content, a permission, usage, and terminal/notice rows. */ -// A single event can anchor a batch of Host interaction outcomes. -const EVENT_SEQUENCE_STRIDE = 4_096; +const EVENT_SEQUENCE_STRIDE = 8; const TRANSCRIPT_TURN_MAX_MESSAGES = 4_096; export const TRANSCRIPT_TURN_MAX_BYTES = 16 * 1024 * 1024; const TRANSCRIPT_SOURCE_MAX_EVENTS = TRANSCRIPT_TURN_MAX_MESSAGES * 2; @@ -176,43 +175,6 @@ function createDurableLedgerTranscriptReader(input: { if (projected.diagnostics.some(isHardRuntimeEventReadModelDiagnostic)) { throw new Error('Durable RuntimeEvent transcript projection is incomplete'); } - const interactions = await input.stores.interactionStore.listTurnInteractions( - turn.invocation.sessionId, - turn.invocation.turnId, - ); - interactions.sort( - (a, b) => - (a.outcome?.outcome.committedAt ?? a.request.createdAt) - - (b.outcome?.outcome.committedAt ?? b.request.createdAt), - ); - for (const record of interactions) { - const { request, outcome } = record; - if ( - request.runId !== turn.invocation.runId || - (request.request.kind !== 'form' && request.request.kind !== 'question') || - !outcome || - (outcome.outcome.kind !== 'form_answer' && - outcome.outcome.kind !== 'question_answer' && - outcome.outcome.kind !== 'closure') - ) - continue; - // Anchor after acceptance, so an incremental reader that already saw the - // pending request receives the settled history row as well. - const source = - turn.projection.anchors.find((event) => event.ts >= outcome.outcome.committedAt) ?? - turn.projection.anchors.at(-1); - if (!source) continue; - projected.messages.push({ - type: 'form_interaction', - id: request.requestId, - turnId: request.turnId, - ts: outcome.outcome.committedAt, - request: request.request, - outcome: outcome.outcome, - }); - projected.sourceEventIds.push(source.id); - } - assertTurnPresentationBounded(projected.messages); const admission = turn.invocation.sessionId === WORKHUB_COORDINATION_SESSION_ID ? await input.stores.agentRunStore.readRootTurnAdmission( @@ -226,26 +188,24 @@ function createDurableLedgerTranscriptReader(input: { : undefined; const ordinals = turn.ordinals; const emitted = new Map(); - return projected.messages - .map((message, index) => { - const ordinal = ordinals.get(projected.sourceEventIds[index]!); - if (ordinal === undefined) { - throw new Error('Durable transcript message has no source RuntimeEvent'); - } - const offset = emitted.get(ordinal) ?? 0; - if (offset >= EVENT_SEQUENCE_STRIDE) { - throw new Error('RuntimeEvent exceeds its transcript sequence stride'); - } - emitted.set(ordinal, offset + 1); - return { - sequence: ordinal * EVENT_SEQUENCE_STRIDE + offset, - message: - message.type === 'user' && actionId - ? { ...message, coordinationActionId: actionId } - : message, - }; - }) - .sort((a, b) => a.sequence - b.sequence); + return projected.messages.map((message, index) => { + const ordinal = ordinals.get(projected.sourceEventIds[index]!); + if (ordinal === undefined) { + throw new Error('Durable transcript message has no source RuntimeEvent'); + } + const offset = emitted.get(ordinal) ?? 0; + if (offset >= EVENT_SEQUENCE_STRIDE) { + throw new Error('RuntimeEvent exceeds its transcript sequence stride'); + } + emitted.set(ordinal, offset + 1); + return { + sequence: ordinal * EVENT_SEQUENCE_STRIDE + offset, + message: + message.type === 'user' && actionId + ? { ...message, coordinationActionId: actionId } + : message, + }; + }); }; const readRun = async ( @@ -627,7 +587,6 @@ interface PendingTranscriptRun extends RuntimeTranscriptRun { /** Keep only presentation state while the storage snapshot visits complete facts. */ function createTranscriptProjection(invocations: readonly RuntimeInvocationRecord[]) { const canonicalPermissionOutcomes = new Map(); - const anchors: { id: string; ts: number }[] = []; let messageCount = 0; let messageBytes = 0; let eventCount = 0; @@ -644,9 +603,7 @@ function createTranscriptProjection(invocations: readonly RuntimeInvocationRecor }, }); return { - anchors, push(event: RuntimeEvent) { - anchors.push({ id: event.id, ts: event.ts }); eventCount += 1; const content = event.content; // The durable model projection is never a transcript input. Large Bash diff --git a/packages/runtime-host/src/server/shared-session-transcript.ts b/packages/runtime-host/src/server/shared-session-transcript.ts index e316137cab..243512859f 100644 --- a/packages/runtime-host/src/server/shared-session-transcript.ts +++ b/packages/runtime-host/src/server/shared-session-transcript.ts @@ -66,9 +66,6 @@ export function projectSharedSessionTranscriptMessage( : { coordinationActionId: message.coordinationActionId }), }; } - case 'form_interaction': - // Choice requests and answers are private decision evidence. - return null; case 'assistant': return { type: message.type, diff --git a/packages/runtime/src/runtime-event-backfill.ts b/packages/runtime/src/runtime-event-backfill.ts index 0631797500..be32b644d6 100644 --- a/packages/runtime/src/runtime-event-backfill.ts +++ b/packages/runtime/src/runtime-event-backfill.ts @@ -362,7 +362,6 @@ export function backfillRuntimeEventsFromStoredMessages( // Both are already accounted for elsewhere: the turn's ending becomes the // terminal RuntimeEvent below, and a coordination record is the WorkHub's // own durable proof, which no run ledger owns a copy of. - case 'form_interaction': // InteractionStore owns this read projection. case 'turn_state': case 'workhub_coordination': break; diff --git a/packages/storage/src/interaction-store-contract.ts b/packages/storage/src/interaction-store-contract.ts index f78a242935..18308d3158 100644 --- a/packages/storage/src/interaction-store-contract.ts +++ b/packages/storage/src/interaction-store-contract.ts @@ -113,7 +113,6 @@ export type CommitInteractionOutcomeResult = | InteractionMutationFailureResult; export interface InteractionStoreReader { - listTurnInteractions(sessionId: string, turnId: string): Promise; readInteraction(requestId: string): Promise; listSessionPending(sessionId: string): Promise; listPending(filter?: PendingInteractionFilter): Promise; diff --git a/packages/storage/src/interaction-store.ts b/packages/storage/src/interaction-store.ts index 6127262424..288dd5b1e5 100644 --- a/packages/storage/src/interaction-store.ts +++ b/packages/storage/src/interaction-store.ts @@ -122,8 +122,6 @@ export async function openSqliteInteractiveInteractionStoreForRead( access: 'read' as const, readInteraction: (requestId: string) => run(() => store.readInteraction(requestId)), listSessionPending: (sessionId: string) => run(() => store.listSessionPending(sessionId)), - listTurnInteractions: (sessionId: string, turnId: string) => - run(() => store.listTurnInteractions(sessionId, turnId)), listPending: (filter?: PendingInteractionFilter) => run(() => store.listPending(filter)), readClientCapabilitySessionGrant: (key: ClientCapabilitySessionGrantKey) => run(() => store.readClientCapabilitySessionGrant(key)), @@ -203,8 +201,6 @@ export async function openSqliteInteractiveInteractionStoreForWrite( access: 'write' as const, readInteraction: (requestId: string) => run(() => store.readInteraction(requestId)), listSessionPending: (sessionId: string) => run(() => store.listSessionPending(sessionId)), - listTurnInteractions: (sessionId: string, turnId: string) => - run(() => store.listTurnInteractions(sessionId, turnId)), listPending: (filter?: PendingInteractionFilter) => run(() => store.listPending(filter)), readClientCapabilitySessionGrant: (key: ClientCapabilitySessionGrantKey) => run(() => store.readClientCapabilitySessionGrant(key)), @@ -443,18 +439,6 @@ class SqliteInteractionStore implements InteractionStoreWriter { return readSqliteInteraction(this.#lease, requestId); } - async listTurnInteractions(sessionId: string, turnId: string): Promise { - assertId(sessionId); - assertId(turnId); - const rows = this.#lease.database - .prepare(` - SELECT request_id FROM core_interaction_requests - WHERE session_id = ? AND turn_id = ? ORDER BY created_at, request_id - `) - .all(sessionId, turnId) as { request_id: string }[]; - return rows.map(({ request_id }) => readSqliteInteraction(this.#lease, request_id)!); - } - async listSessionPending(sessionId: string): Promise { return this.listPending({ sessionId }); } diff --git a/packages/storage/src/sqlite-core-execution-schema.ts b/packages/storage/src/sqlite-core-execution-schema.ts index c2e69c5dc5..efc3252f13 100644 --- a/packages/storage/src/sqlite-core-execution-schema.ts +++ b/packages/storage/src/sqlite-core-execution-schema.ts @@ -110,9 +110,6 @@ export function migrateSqliteCoreExecutionDatabase(db: DatabaseSync): void { CREATE INDEX IF NOT EXISTS core_interaction_pending ON core_interaction_requests(session_id, created_at, request_id); - CREATE INDEX IF NOT EXISTS core_interaction_requests_by_turn - ON core_interaction_requests(session_id, turn_id, created_at, request_id); - CREATE TABLE IF NOT EXISTS core_interaction_outcomes ( request_id TEXT PRIMARY KEY, record_json TEXT NOT NULL, diff --git a/packages/storage/src/test-only/memory-execution-control.ts b/packages/storage/src/test-only/memory-execution-control.ts index f0726f6b53..4d961e74a3 100644 --- a/packages/storage/src/test-only/memory-execution-control.ts +++ b/packages/storage/src/test-only/memory-execution-control.ts @@ -122,18 +122,6 @@ export function createMemoryInteractionStore( assertId(requestId); return interactions(s).get(requestId); }), - listTurnInteractions: async (sessionId, turnId) => - a.read((s) => { - assertId(sessionId); - assertId(turnId); - return [...interactions(s).values()] - .filter((r) => r.request.sessionId === sessionId && r.request.turnId === turnId) - .sort( - (a, b) => - a.request.createdAt - b.request.createdAt || - a.request.requestId.localeCompare(b.request.requestId), - ); - }), listSessionPending: async (sessionId) => store.listPending({ sessionId }), listPending: async (filter = {}) => a.read((s) => { diff --git a/packages/ui/src/__tests__/form-interaction-history.test.tsx b/packages/ui/src/__tests__/form-interaction-history.test.tsx deleted file mode 100644 index 136478fc1e..0000000000 --- a/packages/ui/src/__tests__/form-interaction-history.test.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { parseHTML } from 'linkedom'; -import type { FormInteractionMessage } from '@maka/core/session'; -import { FormInteractionHistory } from '../form-interaction-history.js'; -import { LocaleProvider } from '../locale-context.js'; - -const message: FormInteractionMessage = { - type: 'form_interaction', id: 'choice', turnId: 'turn', ts: 1, - request: { kind: 'form', toolUseId: 'tool', message: 'Which login work should continue?', requester: { name: 'WorkHub' }, - fields: [{ kind: 'single_select', name: 'target', label: 'Work', required: true, - options: [{ value: 'ui', label: 'Login UI', description: 'Page work' }, { value: 'api', label: 'Login API', description: 'Server work' }] }] }, - outcome: { kind: 'form_answer', action: 'accept', values: { target: 'api' }, committedAt: 2 }, -}; - -test('choice history uses a collapsed native disclosure retaining the complete question and every option', () => { - const { document } = parseHTML(renderToStaticMarkup()); - const details = document.querySelector('details')!; - assert.ok(details); - assert.equal(details.hasAttribute('open'), false); - assert.equal(details.querySelector('summary')?.textContent, 'You selected: Login API'); - assert.match(details.textContent!, /Which login work should continue/); - assert.equal(details.querySelectorAll('li').length, 2); - assert.match(details.querySelector('li[data-selected="true"]')!.textContent!, /Login API/); - assert.match(details.textContent!, /Page work/); - assert.match(details.textContent!, /Server work/); -}); - -test('cancelled and closed forms never imply that an option was selected', () => { - for (const outcome of [{ kind: 'form_answer', action: 'cancel', committedAt: 2 }, { kind: 'closure', reason: 'turn_terminal', committedAt: 2 }] as const) { - const html = renderToStaticMarkup(); - assert.doesNotMatch(html, /data-selected="true"/); - assert.match(html, /Login UI/); - assert.match(html, /Login API/); - } -}); - - -test('model question choices preserve the full question, all options and free-text answers', () => { - const request = { kind: 'question', toolUseId: 'tool', questions: [{ question: 'Which work should stop?', options: [{ label: 'Both', description: 'Stop both tasks' }, { label: 'Only UI' }] }] } as const; - for (const answer of ['Only UI', 'Neither yet']) { - const { document } = parseHTML(renderToStaticMarkup()); - assert.equal(document.querySelector('details')?.hasAttribute('open'), false); - assert.match(document.querySelector('details')!.textContent!, /Which work should stop/); - assert.equal(document.querySelectorAll('li').length, 2); - assert.equal(document.querySelector('summary')?.textContent, `You selected: ${answer}`); - assert.equal(document.querySelectorAll('li[data-selected="true"]').length, answer === 'Only UI' ? 1 : 0); - } -}); diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index 7337c379d3..42d10d0b87 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -34,7 +34,6 @@ import { ICON_SIZE, AlertTriangle, } from './icons.js'; -import { FormInteractionHistory } from './form-interaction-history.js'; import { EmptyChatHero } from './chat-empty-hero.js'; import type { ChatModelChoice } from './chat-model-helpers.js'; import { @@ -504,21 +503,14 @@ export function ChatView(props: { (sessionId: string) => onOpenLinkedSessionRef.current?.(sessionId), [], ); - const conversationItems = useMemo(() => [ - ...(props.conversationItems ?? []), - ...props.messages.flatMap((message) => message.type === 'form_interaction' ? [{ - id: `interaction:${message.id}`, afterTurnId: message.turnId, renderWhenAnchorMissing: false, - content: , - }] : []), - ], [props.conversationItems, props.messages]); const conversationItemPlacement = useMemo(() => placeChatConversationItems( - conversationItems.map((item) => ({ + (props.conversationItems ?? []).map((item) => ({ afterTurnId: item.afterTurnId, renderWhenAnchorMissing: item.renderWhenAnchorMissing, value: { id: item.id, content: item.content }, })), turnIds, - ), [conversationItems, turnIds]); + ), [props.conversationItems, turnIds]); const chatLayout = useChatLayoutContext(); if (!chatLayout) { throw new Error('ChatView must be rendered inside ChatSurfaceLayout'); @@ -621,7 +613,7 @@ export function ChatView(props: { ); if (!props.activeSession) { - + const conversationItems = props.conversationItems ?? []; // A side conversation forks lazily: its first send arms the optimistic // bubble (and, after the rising-edge delay, the running-status line) BEFORE // the fork commits, so there is no session yet. Render that optimistic diff --git a/packages/ui/src/form-interaction-history.tsx b/packages/ui/src/form-interaction-history.tsx deleted file mode 100644 index fe8a1b47e2..0000000000 --- a/packages/ui/src/form-interaction-history.tsx +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import type { FormInteractionMessage } from '@maka/core/session'; -import type { UiCatalog } from '@maka/core/ui-locale'; -import { useUiLocale } from './locale-context.js'; - -const historyCopy: UiCatalog<{ selected: string; cancelled: string; declined: string; closed: string; chosen: string; empty: string }> = { - 'zh-CN': { selected: '你选择了', cancelled: '你取消了选择', declined: '你拒绝了请求', closed: '选择已关闭', chosen: '已选', empty: '未填写' }, - 'zh-TW': { selected: '你選擇了', cancelled: '你取消了選擇', declined: '你拒絕了請求', closed: '選擇已關閉', chosen: '已選', empty: '未填寫' }, - en: { selected: 'You selected', cancelled: 'You cancelled the selection', declined: 'You declined the request', closed: 'Selection closed', chosen: 'Selected', empty: 'Not provided' }, -}; - -export function FormInteractionHistory({ message }: { message: FormInteractionMessage }) { - const copy = historyCopy[useUiLocale()]; - const { request, outcome } = message; - if (request.kind === 'question') { - const answers = outcome.kind === 'question_answer' ? outcome.answers : []; - const summary = outcome.kind === 'closure' ? copy.closed : `${copy.selected}: ${answers.filter(Boolean).join(' · ') || copy.empty}`; - return
- {summary} -
- {request.questions.map((question, index) =>
-

{question.question}

-
    {question.options.map((option) =>
  • - {option.label}{answers[index] === option.label && — {copy.chosen}} - {option.description &&

    {option.description}

    } -
  • )}
- {answers[index] && !question.options.some((option) => option.label === answers[index]) &&

{copy.selected}: {answers[index]}

} -
)} -
-
; - } - if (outcome.kind === 'question_answer') return null; - const values = outcome.kind === 'form_answer' && outcome.action === 'accept' ? outcome.values : undefined; - const labels = request.fields.flatMap((field) => { - const value = values?.[field.name]; - if (value === undefined) return []; - if (field.kind === 'single_select' || field.kind === 'multi_select') - return field.options.filter((option) => Array.isArray(value) ? value.includes(option.value) : value === option.value).map((option) => option.label); - return [String(value)]; - }); - const summary = outcome.kind === 'closure' ? copy.closed : outcome.action === 'cancel' ? copy.cancelled : outcome.action === 'decline' ? copy.declined : `${copy.selected}: ${labels.join(' · ')}`; - return
- {summary} -
-

{request.message}

- {request.fields.map((field) =>
-

{field.label}

- {field.description &&

{field.description}

} - {field.kind === 'single_select' || field.kind === 'multi_select' ?
    - {field.options.map((option) => { - const value = values?.[field.name]; - const selected = Array.isArray(value) ? value.includes(option.value) : value === option.value; - return
  • - {option.label}{selected && — {copy.chosen}} - {option.description &&

    {option.description}

    } -
  • ; - })} -
:

{values?.[field.name] === undefined ? copy.empty : String(values[field.name])}

} -
)} -
-
; -} diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index 36015f19d4..633f8957f4 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -1146,15 +1146,3 @@ .maka-choice-panel:focus-visible { outline: var(--focus-ring-width) solid var(--focus-ring); outline-offset: var(--focus-ring-offset); } .maka-choice-hint { margin: 8px 0; } - -.maka-form-history { margin: 8px 0; color: var(--foreground); font-size: 13px; } -.maka-form-history > summary { cursor: pointer; padding: 8px 10px; border-radius: 6px; overflow-wrap: anywhere; } -.maka-form-history > summary:hover { background: var(--muted); } -.maka-form-history > summary:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; } -.maka-form-history-content { margin: 4px 10px 12px; padding-left: 16px; border-left: 2px solid var(--border); overflow-wrap: anywhere; } -.maka-form-history-question { white-space: pre-wrap; font-weight: 500; } -.maka-form-history-field { margin-top: 12px; } -.maka-form-history-field p { white-space: pre-wrap; margin: 4px 0; } -.maka-form-history-field ul { padding-left: 20px; margin: 6px 0; } -.maka-form-history-field li { padding: 4px 0; } -.maka-form-history-field li[data-selected='true'] { font-weight: 500; } From 4e1599b60283a2aef3b8725f5b777da5ba376af2 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 25 Sep 2026 18:39:37 +0800 Subject: [PATCH 2/4] feat(ui): show AskUserQuestion answers against their options The tool row is the single record of an AskUserQuestion choice, but its expanded result rendered the raw result shape: English keys, a repeated question, and a literal null for an unanswered question. Render each question with its offered options, mark the chosen one, and label an unanswered question. Live rows only have the question-text args preview until the durable transcript supplies full args, so they show the question and the chosen answer. Generated-by: Claude Code --- .../tool-activity-presentation.test.ts | 41 +++++++++++++++++++ packages/ui/src/tool-activity.tsx | 29 +++++++++++++ packages/ui/src/tool-activity/copy.ts | 4 ++ 3 files changed, 74 insertions(+) diff --git a/packages/ui/src/__tests__/tool-activity-presentation.test.ts b/packages/ui/src/__tests__/tool-activity-presentation.test.ts index 531ccab1bb..10e0e96667 100644 --- a/packages/ui/src/__tests__/tool-activity-presentation.test.ts +++ b/packages/ui/src/__tests__/tool-activity-presentation.test.ts @@ -132,6 +132,47 @@ describe('tool activity presentation', () => { ); }); + it('shows AskUserQuestion answers against the offered options', () => { + const detailText = (item: ToolActivityItem, locale?: UiLocale) => + renderToStaticMarkup(createElement(ToolCallDetail, { item }), locale) + .split(/<[^>]+>/) + .filter((text) => text.trim().replace('​', '') !== '') + .join('\n'); + const result = { + kind: 'json' as const, + value: { + answers: [ + { question: 'Which client?', answer: 'maka' }, + { question: 'Which scope?', answer: null }, + ], + }, + }; + const settled = detailText({ + toolUseId: 'question', + toolName: 'AskUserQuestion', + status: 'completed', + args: { + questions: [ + { question: 'Which client?', options: [{ label: 'claude' }, { label: 'maka' }] }, + { question: 'Which scope?', options: [{ label: 'user' }, { label: 'project' }] }, + ], + }, + result, + }); + assert.ok(settled.includes('Which client?\n claude\n✓ maka\nWhich scope?\n user\n project\n 未回答'), settled); + assert.doesNotMatch(settled, /answers:|null/); + + const live = detailText({ + toolUseId: 'question', + toolName: 'AskUserQuestion', + status: 'completed', + args: undefined, + argsPreview: { questions: [{ question: 'Which client?' }, { question: 'Which scope?' }] }, + result, + }, 'en'); + assert.ok(live.includes('Which client?\n✓ maka\nWhich scope?\n Not answered'), live); + }); + it('describes Computer Use proxy calls by action instead of the generic tool name', () => { const item: ToolActivityItem = { toolUseId: 'computer-observe', diff --git a/packages/ui/src/tool-activity.tsx b/packages/ui/src/tool-activity.tsx index bb61560067..712160a7a5 100644 --- a/packages/ui/src/tool-activity.tsx +++ b/packages/ui/src/tool-activity.tsx @@ -204,6 +204,28 @@ type DetailDecision = { | { kind: 'none' }; }; +// Live frames carry only a question-text args preview, so options may be absent. +function formatUserQuestionAnswers(args: unknown, value: unknown, locale: UiLocale): string | undefined { + const answers = (value as { answers?: unknown } | null)?.answers; + if (!Array.isArray(answers) || answers.length === 0) return undefined; + const questions = (args as { questions?: unknown } | null)?.questions; + const unanswered = getToolActivityCopy(locale).result.unanswered; + const blocks: string[] = []; + for (const [index, entry] of answers.entries()) { + const { question, answer } = (entry ?? {}) as { question?: unknown; answer?: unknown }; + if (typeof question !== 'string' || (answer !== null && typeof answer !== 'string')) return undefined; + const options = Array.isArray(questions) ? (questions[index] as { options?: unknown } | undefined)?.options : undefined; + const labels = Array.isArray(options) + ? options.flatMap((option) => typeof option?.label === 'string' ? [option.label as string] : []) + : []; + const lines = [question, ...labels.map((label) => `${label === answer ? '✓' : ' '} ${label}`)]; + if (answer === null) lines.push(` ${unanswered}`); + else if (!labels.includes(answer)) lines.push(`✓ ${answer}`); + blocks.push(lines.join('\n')); + } + return redactSecrets(blocks.join('\n\n')); +} + function describeToolCall( item: ToolActivityItem, locale: UiLocale, @@ -286,6 +308,13 @@ function describeToolCall( }; } + const userQuestionAnswers = !ownsPanel && item.toolName === 'AskUserQuestion' && displayResult?.kind === 'json' + ? formatUserQuestionAnswers(item.args ?? item.argsPreview, displayResult.value, locale) + : undefined; + if (userQuestionAnswers) { + return { decorations, body: { kind: 'quietText', body: userQuestionAnswers } }; + } + if (!ownsPanel && displayResult?.kind === 'json') { const quiet = formatQuietJsonValue(displayResult.value, locale); // Keep structured quiet JSON raw: generic permission-text replacement diff --git a/packages/ui/src/tool-activity/copy.ts b/packages/ui/src/tool-activity/copy.ts index 8b042f8a71..4b476ed295 100644 --- a/packages/ui/src/tool-activity/copy.ts +++ b/packages/ui/src/tool-activity/copy.ts @@ -165,6 +165,7 @@ export interface ToolActivityCopy { workflowCompleted: string; workflowFailed: string; fileWritten: (bytes: number, path: string) => string; + unanswered: string; webNoResults: string; webResults: (count: number) => string; credentialSource: Record; @@ -284,6 +285,7 @@ const TOOL_ACTIVITY_COPY = { workflowCompleted: 'Rive 工作流已完成', workflowFailed: 'Rive 工作流执行失败', fileWritten: (bytes, path) => `已向 ${path} 写入 ${bytes} 字节`, + unanswered: '未回答', }, agent: { subagentStatus: { completed: '已完成', failed: '失败', cancelled: '已取消', running: '运行中', waiting_for_user: '等待用户输入' }, @@ -392,6 +394,7 @@ const TOOL_ACTIVITY_COPY = { workflowCompleted: 'Rive 工作流已完成', workflowFailed: 'Rive 工作流執行失敗', fileWritten: (bytes, path) => `已向 ${path} 寫入 ${bytes} 位元組`, + unanswered: '未回答', }, agent: { subagentStatus: { completed: '已完成', failed: '失敗', cancelled: '已取消', running: '執行中', waiting_for_user: '等待使用者輸入' }, @@ -497,6 +500,7 @@ const TOOL_ACTIVITY_COPY = { workflowCompleted: 'Rive workflow completed', workflowFailed: 'Rive workflow failed', fileWritten: (bytes, path) => `Wrote ${bytes} bytes to ${path}`, + unanswered: 'Not answered', }, agent: { subagentStatus: { completed: 'Completed', failed: 'Failed', cancelled: 'Cancelled', running: 'Running', waiting_for_user: 'Waiting for user input' }, From bf57800b41d4162451aa2200a90ce13a869fc301 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 25 Sep 2026 18:47:30 +0800 Subject: [PATCH 3/4] refactor(core): share the AskUserQuestion answer formatter with the CLI Quiet tool-result formatting lives in @maka/core so Desktop and the CLI/TUI render the same text, and the CLI transcript still printed the raw answers/question/answer shape. Move the formatter next to the existing AskUserQuestion args preview, keep its "not answered" string in core's locale table, and use it from both the Desktop tool row and the CLI transcript. Generated-by: Claude Code --- packages/cli/src/pi-transcript-tools.ts | 15 ++++-- .../src/__tests__/tool-quiet-preview.test.ts | 31 +++++++++++ packages/core/src/tool-quiet-preview.ts | 33 ++++++++++++ .../tool-activity-presentation.test.ts | 52 ++++++------------- packages/ui/src/tool-activity.tsx | 25 +-------- packages/ui/src/tool-activity/copy.ts | 4 -- 6 files changed, 93 insertions(+), 67 deletions(-) diff --git a/packages/cli/src/pi-transcript-tools.ts b/packages/cli/src/pi-transcript-tools.ts index 040a2dbaa4..e283a09758 100644 --- a/packages/cli/src/pi-transcript-tools.ts +++ b/packages/cli/src/pi-transcript-tools.ts @@ -18,7 +18,11 @@ */ import type { ToolOutputStream, ToolResultContent } from '@maka/core/events'; -import { formatQuietJsonValue, formatToolInvocationLine } from '@maka/core/tool-quiet-preview'; +import { + formatQuietJsonValue, + formatToolInvocationLine, + formatUserQuestionResult, +} from '@maka/core/tool-quiet-preview'; import { redactSecrets } from '@maka/core/display-redaction'; import { isActiveShellRunStatus, @@ -600,6 +604,11 @@ function plainResultText(entry: MakaPiToolEntry): string { if (result?.kind === 'text') return typeof result.text === 'string' ? result.text : ''; if (result?.kind === 'json') { const value = result.value; + const answers = + entry.toolName === 'AskUserQuestion' + ? formatUserQuestionResult(entry.input, value, 'en') + : undefined; + if (answers) return answers; if (value !== null && typeof value === 'object') { const content = (value as { content?: unknown }).content; if (typeof content === 'string') return content; @@ -610,8 +619,8 @@ function plainResultText(entry: MakaPiToolEntry): string { // Generic json fallback: use the shared quiet-value formatter instead of // dumping a single-line JSON blob. It extracts headline + body from // known shapes (lists, text payloads, Write/Edit results, key-value) and - // never produces escaped JSON braces (#1065). AskUserQuestion, GoalSet, - // ScheduledTask, and any future tool without a custom case render + // never produces escaped JSON braces (#1065). GoalSet, ScheduledTask, + // and any future tool without a custom case render // human-readable text here. const preview = formatQuietJsonValue(value, 'en'); return preview.headline ? `${preview.headline}\n${preview.body}` : preview.body; diff --git a/packages/core/src/__tests__/tool-quiet-preview.test.ts b/packages/core/src/__tests__/tool-quiet-preview.test.ts index 847fc19a5d..077ea1a19e 100644 --- a/packages/core/src/__tests__/tool-quiet-preview.test.ts +++ b/packages/core/src/__tests__/tool-quiet-preview.test.ts @@ -23,6 +23,7 @@ import { formatAsKeyValueLines, formatQuietJsonValue, formatToolInvocationLine, + formatUserQuestionResult, projectToolArgsPreview, } from '../tool-quiet-preview.js'; import { projectToolActivityArgs } from '../tool-activity-args.js'; @@ -36,6 +37,36 @@ describe('tool quiet preview', () => { assert.doesNotMatch(key, /secret/); assert.match(key, /redacted/i); }); + + it('lists AskUserQuestion answers against the offered options', () => { + const args = { + questions: [ + { question: 'Which client?', options: [{ label: 'claude' }, { label: 'maka' }] }, + { question: 'Which scope?', options: [{ label: 'user' }, { label: 'project' }] }, + { question: 'Anything else?', options: [{ label: 'no' }, { label: 'yes' }] }, + ], + }; + const value = { + answers: [ + { question: 'Which client?', answer: 'maka' }, + { question: 'Which scope?', answer: null }, + { question: 'Anything else?', answer: 'typed reply' }, + ], + }; + assert.equal( + formatUserQuestionResult(projectToolActivityArgs('AskUserQuestion', args), value, 'zh-CN'), + 'Which client?\n claude\n✓ maka\n\nWhich scope?\n user\n project\n 未回答\n\nAnything else?\n no\n yes\n✓ typed reply', + ); + // Live rows carry only the question-text args preview. + assert.equal( + formatUserQuestionResult(projectToolArgsPreview('AskUserQuestion', args), value, 'en'), + 'Which client?\n✓ maka\n\nWhich scope?\n Not answered\n\nAnything else?\n✓ typed reply', + ); + assert.equal( + formatUserQuestionResult(args, { answers: [{ question: 'Which client?' }] }, 'en'), + undefined, + ); + }); }); describe('formatToolInvocationLine', () => { diff --git a/packages/core/src/tool-quiet-preview.ts b/packages/core/src/tool-quiet-preview.ts index 697006f21a..b82b8bab84 100644 --- a/packages/core/src/tool-quiet-preview.ts +++ b/packages/core/src/tool-quiet-preview.ts @@ -49,6 +49,7 @@ interface QuietPreviewStrings { bytes: (n: number) => string; /** Suffix for a question list previewed by its first entry, e.g. `等 2 问` / `+1 more`. */ moreQuestions: (total: number) => string; + unanswered: string; } const STRINGS_BY_LOCALE: Record = { @@ -61,6 +62,7 @@ const STRINGS_BY_LOCALE: Record = { written: '已写入', bytes: (n) => `共 ${n} 字节`, moreQuestions: (total) => (total > 1 ? ` 等 ${total} 问` : ''), + unanswered: '未回答', }, 'zh-TW': { backgroundTerminal: '後臺終端互動', @@ -71,6 +73,7 @@ const STRINGS_BY_LOCALE: Record = { written: '已寫入', bytes: (n) => `共 ${n} 位元組`, moreQuestions: (total) => (total > 1 ? ` 等 ${total} 問` : ''), + unanswered: '未回答', }, en: { backgroundTerminal: 'Background terminal interaction', @@ -81,6 +84,7 @@ const STRINGS_BY_LOCALE: Record = { written: 'written', bytes: (n) => `${n} bytes`, moreQuestions: (total) => (total > 1 ? ` +${total - 1} more` : ''), + unanswered: 'Not answered', }, }; @@ -503,6 +507,35 @@ export interface QuietPreview { body: string; } +/** + * An AskUserQuestion result listed against its offered options. Live args + * previews carry question text only, so options may be absent. + */ +export function formatUserQuestionResult( + args: unknown, + value: unknown, + locale: UiLocale, +): string | undefined { + const answers = asRecord(value)?.answers; + if (!Array.isArray(answers) || answers.length === 0) return undefined; + const questions = asRecord(args)?.questions; + const blocks: string[] = []; + for (const [index, entry] of answers.entries()) { + const question = stringField(asRecord(entry), 'question'); + const answer = asRecord(entry)?.answer; + if (question === undefined || (answer !== null && typeof answer !== 'string')) return undefined; + const options = Array.isArray(questions) ? asRecord(questions[index])?.options : undefined; + const labels = Array.isArray(options) + ? options.flatMap((option) => stringField(asRecord(option), 'label') ?? []) + : []; + const lines = [question, ...labels.map((label) => `${label === answer ? '✓' : ' '} ${label}`)]; + if (answer === null) lines.push(` ${strings(locale).unanswered}`); + else if (!labels.includes(answer)) lines.push(`✓ ${answer}`); + blocks.push(lines.join('\n')); + } + return redactSecrets(blocks.join('\n\n')); +} + /** * Format any tool JSON/result payload for the quiet panel. * Always returns a body — never `undefined` for object values so callers diff --git a/packages/ui/src/__tests__/tool-activity-presentation.test.ts b/packages/ui/src/__tests__/tool-activity-presentation.test.ts index 10e0e96667..0cf38b4245 100644 --- a/packages/ui/src/__tests__/tool-activity-presentation.test.ts +++ b/packages/ui/src/__tests__/tool-activity-presentation.test.ts @@ -133,44 +133,22 @@ describe('tool activity presentation', () => { }); it('shows AskUserQuestion answers against the offered options', () => { - const detailText = (item: ToolActivityItem, locale?: UiLocale) => - renderToStaticMarkup(createElement(ToolCallDetail, { item }), locale) - .split(/<[^>]+>/) - .filter((text) => text.trim().replace('​', '') !== '') - .join('\n'); - const result = { - kind: 'json' as const, - value: { - answers: [ - { question: 'Which client?', answer: 'maka' }, - { question: 'Which scope?', answer: null }, - ], - }, - }; - const settled = detailText({ - toolUseId: 'question', - toolName: 'AskUserQuestion', - status: 'completed', - args: { - questions: [ - { question: 'Which client?', options: [{ label: 'claude' }, { label: 'maka' }] }, - { question: 'Which scope?', options: [{ label: 'user' }, { label: 'project' }] }, - ], + const settled = renderToStaticMarkup(createElement(ToolCallDetail, { + item: { + toolUseId: 'question', + toolName: 'AskUserQuestion', + status: 'completed', + args: { + questions: [{ question: 'Which client?', options: [{ label: 'claude' }, { label: 'maka' }] }], + }, + result: { kind: 'json', value: { answers: [{ question: 'Which client?', answer: 'maka' }] } }, }, - result, - }); - assert.ok(settled.includes('Which client?\n claude\n✓ maka\nWhich scope?\n user\n project\n 未回答'), settled); - assert.doesNotMatch(settled, /answers:|null/); - - const live = detailText({ - toolUseId: 'question', - toolName: 'AskUserQuestion', - status: 'completed', - args: undefined, - argsPreview: { questions: [{ question: 'Which client?' }, { question: 'Which scope?' }] }, - result, - }, 'en'); - assert.ok(live.includes('Which client?\n✓ maka\nWhich scope?\n Not answered'), live); + })) + .split(/<[^>]+>/) + .filter((text) => text.trim() !== '') + .join('\n'); + assert.ok(settled.includes('Which client?\n claude\n✓ maka'), settled); + assert.doesNotMatch(settled, /answers:/); }); it('describes Computer Use proxy calls by action instead of the generic tool name', () => { diff --git a/packages/ui/src/tool-activity.tsx b/packages/ui/src/tool-activity.tsx index 712160a7a5..f47c69f6f2 100644 --- a/packages/ui/src/tool-activity.tsx +++ b/packages/ui/src/tool-activity.tsx @@ -20,6 +20,7 @@ import { useEffect, useRef, useState } from 'react'; import { countDiffLineStats } from '@maka/core/unified-diff'; import { isInFlightToolStatus } from '@maka/core/tool-result-status'; +import { formatUserQuestionResult } from '@maka/core/tool-quiet-preview'; import { type ToolResultContent } from '@maka/core/events'; import { type UiLocale } from '@maka/core/ui-locale'; import { @@ -204,28 +205,6 @@ type DetailDecision = { | { kind: 'none' }; }; -// Live frames carry only a question-text args preview, so options may be absent. -function formatUserQuestionAnswers(args: unknown, value: unknown, locale: UiLocale): string | undefined { - const answers = (value as { answers?: unknown } | null)?.answers; - if (!Array.isArray(answers) || answers.length === 0) return undefined; - const questions = (args as { questions?: unknown } | null)?.questions; - const unanswered = getToolActivityCopy(locale).result.unanswered; - const blocks: string[] = []; - for (const [index, entry] of answers.entries()) { - const { question, answer } = (entry ?? {}) as { question?: unknown; answer?: unknown }; - if (typeof question !== 'string' || (answer !== null && typeof answer !== 'string')) return undefined; - const options = Array.isArray(questions) ? (questions[index] as { options?: unknown } | undefined)?.options : undefined; - const labels = Array.isArray(options) - ? options.flatMap((option) => typeof option?.label === 'string' ? [option.label as string] : []) - : []; - const lines = [question, ...labels.map((label) => `${label === answer ? '✓' : ' '} ${label}`)]; - if (answer === null) lines.push(` ${unanswered}`); - else if (!labels.includes(answer)) lines.push(`✓ ${answer}`); - blocks.push(lines.join('\n')); - } - return redactSecrets(blocks.join('\n\n')); -} - function describeToolCall( item: ToolActivityItem, locale: UiLocale, @@ -309,7 +288,7 @@ function describeToolCall( } const userQuestionAnswers = !ownsPanel && item.toolName === 'AskUserQuestion' && displayResult?.kind === 'json' - ? formatUserQuestionAnswers(item.args ?? item.argsPreview, displayResult.value, locale) + ? formatUserQuestionResult(item.args ?? item.argsPreview, displayResult.value, locale) : undefined; if (userQuestionAnswers) { return { decorations, body: { kind: 'quietText', body: userQuestionAnswers } }; diff --git a/packages/ui/src/tool-activity/copy.ts b/packages/ui/src/tool-activity/copy.ts index 4b476ed295..8b042f8a71 100644 --- a/packages/ui/src/tool-activity/copy.ts +++ b/packages/ui/src/tool-activity/copy.ts @@ -165,7 +165,6 @@ export interface ToolActivityCopy { workflowCompleted: string; workflowFailed: string; fileWritten: (bytes: number, path: string) => string; - unanswered: string; webNoResults: string; webResults: (count: number) => string; credentialSource: Record; @@ -285,7 +284,6 @@ const TOOL_ACTIVITY_COPY = { workflowCompleted: 'Rive 工作流已完成', workflowFailed: 'Rive 工作流执行失败', fileWritten: (bytes, path) => `已向 ${path} 写入 ${bytes} 字节`, - unanswered: '未回答', }, agent: { subagentStatus: { completed: '已完成', failed: '失败', cancelled: '已取消', running: '运行中', waiting_for_user: '等待用户输入' }, @@ -394,7 +392,6 @@ const TOOL_ACTIVITY_COPY = { workflowCompleted: 'Rive 工作流已完成', workflowFailed: 'Rive 工作流執行失敗', fileWritten: (bytes, path) => `已向 ${path} 寫入 ${bytes} 位元組`, - unanswered: '未回答', }, agent: { subagentStatus: { completed: '已完成', failed: '失敗', cancelled: '已取消', running: '執行中', waiting_for_user: '等待使用者輸入' }, @@ -500,7 +497,6 @@ const TOOL_ACTIVITY_COPY = { workflowCompleted: 'Rive workflow completed', workflowFailed: 'Rive workflow failed', fileWritten: (bytes, path) => `Wrote ${bytes} bytes to ${path}`, - unanswered: 'Not answered', }, agent: { subagentStatus: { completed: 'Completed', failed: 'Failed', cancelled: 'Cancelled', running: 'Running', waiting_for_user: 'Waiting for user input' }, From 61e2366143378564254e7c8e0830c0d1a430ed78 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 25 Sep 2026 19:01:55 +0800 Subject: [PATCH 4/4] fix(storage): drop the per-Turn interaction index on upgrade Databases opened by a build that includes #5592 (including the published baseline) carry core_interaction_requests_by_turn. The target-schema check rejects it as an unexpected object, so the migration rolled back and the Runtime Host could not open the workspace. Drop it explicitly, like the other retired execution indexes, and cover the upgrade in the existing test. Generated-by: Claude Code --- .../src/__tests__/sqlite-core-execution-store.test.ts | 6 ++++-- packages/storage/src/sqlite-core-execution-schema.ts | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/storage/src/__tests__/sqlite-core-execution-store.test.ts b/packages/storage/src/__tests__/sqlite-core-execution-store.test.ts index 558b08f7b4..d537b427d6 100644 --- a/packages/storage/src/__tests__/sqlite-core-execution-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-core-execution-store.test.ts @@ -403,7 +403,7 @@ describe('SQLite core execution stores', () => { }); }); - test('drops the obsolete AgentRun identity index on upgrade', async () => { + test('drops obsolete execution indexes on upgrade', async () => { await withRoot(async (root) => { createSqliteAgentRunStore(root).close?.(); const path = join(root, 'runtime.sqlite'); @@ -411,6 +411,8 @@ describe('SQLite core execution stores', () => { legacy.exec(` CREATE INDEX IF NOT EXISTS core_agent_runs_identity ON core_agent_runs(run_id, session_id); + CREATE INDEX IF NOT EXISTS core_interaction_requests_by_turn + ON core_interaction_requests(session_id, turn_id, created_at, request_id); UPDATE operational_schema_migrations SET version = 5 WHERE scope = 'core_execution'; `); legacy.close(); @@ -421,7 +423,7 @@ describe('SQLite core execution stores', () => { assert.deepEqual( migrated .prepare( - "SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'core_agent_runs_identity'", + "SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('core_agent_runs_identity', 'core_interaction_requests_by_turn')", ) .all(), [], diff --git a/packages/storage/src/sqlite-core-execution-schema.ts b/packages/storage/src/sqlite-core-execution-schema.ts index efc3252f13..332b0ef6aa 100644 --- a/packages/storage/src/sqlite-core-execution-schema.ts +++ b/packages/storage/src/sqlite-core-execution-schema.ts @@ -190,6 +190,7 @@ export function migrateSqliteCoreExecutionDatabase(db: DatabaseSync): void { WHERE json_extract(record_json, '$.execution.kind') = 'safe_boundary_continuation'; DROP INDEX IF EXISTS core_agent_runs_identity; + DROP INDEX IF EXISTS core_interaction_requests_by_turn; DROP TABLE IF EXISTS core_message_receipts; DROP TABLE IF EXISTS core_message_host_epochs;