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/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/cli/src/runtime-host-session-channel.ts b/packages/cli/src/runtime-host-session-channel.ts index d6f8f76904..4b7ed07112 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, @@ -852,11 +851,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'; } @@ -865,7 +859,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') ); } @@ -886,10 +879,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/__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/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/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/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 76069f6ba2..0893d00db7 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 = 188 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 189 as const; +// 189: Remove form_interaction transcript messages and the transcript_changed close reason. // 188: Session capability replacement can require an atomic idle root check; // complete MCP configuration identities fence conflicting providers across ACP clients. // 187: transcript_changed closes a subscription for automatic bounded-tail reseeding. 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/__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/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..332b0ef6aa 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, @@ -193,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; 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/__tests__/tool-activity-presentation.test.ts b/packages/ui/src/__tests__/tool-activity-presentation.test.ts index 531ccab1bb..0cf38b4245 100644 --- a/packages/ui/src/__tests__/tool-activity-presentation.test.ts +++ b/packages/ui/src/__tests__/tool-activity-presentation.test.ts @@ -132,6 +132,25 @@ describe('tool activity presentation', () => { ); }); + it('shows AskUserQuestion answers against the offered options', () => { + 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' }] } }, + }, + })) + .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', () => { const item: ToolActivityItem = { toolUseId: 'computer-observe', diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index 3c5fe2d6b6..b41180ff3f 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 { @@ -485,21 +484,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'); @@ -602,7 +594,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 f688fd663b..47677bcb2c 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -1137,15 +1137,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; } diff --git a/packages/ui/src/tool-activity.tsx b/packages/ui/src/tool-activity.tsx index 87b79fbdd3..ce5016b79e 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 { @@ -286,6 +287,13 @@ function describeToolCall( }; } + const userQuestionAnswers = !ownsPanel && item.toolName === 'AskUserQuestion' && displayResult?.kind === 'json' + ? formatUserQuestionResult(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