diff --git a/apps/desktop/src/main/__tests__/browser-tools.test.ts b/apps/desktop/src/main/__tests__/browser-tools.test.ts index 98edf823c8..d5e6736ea7 100644 --- a/apps/desktop/src/main/__tests__/browser-tools.test.ts +++ b/apps/desktop/src/main/__tests__/browser-tools.test.ts @@ -287,6 +287,7 @@ describe('browser tool execution', () => { { signal: new AbortController().signal, accept: async () => undefined, + requestInteraction: async () => assert.fail('Unexpected provider interaction'), }, ); assert.equal(resolved, 2); diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts index da5c76d48b..65ee0b6da9 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts @@ -1214,6 +1214,7 @@ function connectionHarness( return provider.call(frame, { signal: new AbortController().signal, accept: async () => undefined, + requestInteraction: async () => assert.fail('Unexpected provider interaction'), }); }, disconnect: () => resolveClosed?.(), diff --git a/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts b/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts index 98d97e5ba9..89658d460a 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts @@ -602,6 +602,7 @@ test('forwards Host cancellation to an admitted Desktop invocation', async () => const inFlight = provider.call(capabilityFrame(), { signal: controller.signal, accept: async () => undefined, + requestInteraction: async () => assert.fail('Unexpected provider interaction'), }); await started; @@ -723,5 +724,6 @@ async function call( return provider.call(frame, { signal: new AbortController().signal, accept: async (evidence) => accept(evidence), + requestInteraction: async () => assert.fail('Unexpected provider interaction'), }); } diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx index b2ab639c27..1a70a0e022 100644 --- a/apps/desktop/src/renderer/chat-composer-region.tsx +++ b/apps/desktop/src/renderer/chat-composer-region.tsx @@ -23,6 +23,7 @@ import { Button, ClientCapabilityPrompt, Composer, + type ComposerInteraction, ComposerGoalProjectionConsumer, SandboxBoundaryPrompt, UserQuestionPrompt, @@ -61,11 +62,6 @@ interface BoundaryUnreadableNotice { onRetry(): void; } -type ComposerInteraction = - | ComponentProps['request'] - | ComponentProps['request'] - | ComponentProps['request']; - /** * The composer region of the chat surface (issue #1043): the composer * interaction slot (permission / user-question prompts) plus the always-mounted diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 39ffbfe26b..6a8e205ed5 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -2090,7 +2090,7 @@ describe('Maka Pi TUI transcript', () => { assert.ok(visibleLines.every((line) => !line.includes(' a '))); }); - test('queues sandbox boundary and user-question requests in arrival order', () => { + test('queues sandbox boundary, question, and form requests in arrival order', () => { const state = createMakaPiTranscriptState(); applyMakaSessionEventToTranscript( state, @@ -2106,6 +2106,17 @@ describe('Maka Pi TUI transcript', () => { }, }), ); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'form_request', + requestId: 'form-1', + toolUseId: 'tool-3', + message: 'Configure deployment', + requester: { name: 'deploy', source: 'Acme MCP' }, + fields: [{ kind: 'boolean', name: 'notify', label: 'Notify', required: false }], + }), + ); applyMakaSessionEventToTranscript( state, event({ @@ -2119,7 +2130,7 @@ describe('Maka Pi TUI transcript', () => { assert.equal(state.pendingInteraction?.requestId, 'boundary-1'); assert.deepEqual( state.queuedInteractions.map((item) => item.requestId), - ['question-1'], + ['form-1', 'question-1'], ); applyMakaSessionEventToTranscript( @@ -2133,7 +2144,25 @@ describe('Maka Pi TUI transcript', () => { revision: 1, }), ); + assert.equal(state.pendingInteraction?.requestId, 'form-1'); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'form_answer_ack', + requestId: 'form-1', + toolUseId: 'tool-3', + }), + ); assert.equal(state.pendingInteraction?.requestId, 'question-1'); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'user_question_answer_ack', + requestId: 'question-1', + toolUseId: 'tool-2', + }), + ); + assert.equal(state.pendingInteraction, undefined); assert.deepEqual(state.queuedInteractions, []); }); diff --git a/packages/cli/src/__tests__/pi-tui-form-interaction.test.ts b/packages/cli/src/__tests__/pi-tui-form-interaction.test.ts new file mode 100644 index 0000000000..bdae0f633f --- /dev/null +++ b/packages/cli/src/__tests__/pi-tui-form-interaction.test.ts @@ -0,0 +1,279 @@ +/* + * 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 type { TUI } from '@earendil-works/pi-tui'; +import type { FormRequestEvent } from '@maka/core/events'; +import type { InteractionFormResponse } from '@maka/core/interaction'; +import { + buildTuiFormResponse, + createTuiFormDrafts, + FormInteractionOverlay, +} from '../pi-tui-form-interaction.js'; +import { stripAnsi } from '../tui-ansi.js'; + +const REQUEST: FormRequestEvent = { + type: 'form_request', + id: 'event-form', + ts: 1, + turnId: 'turn-1', + requestId: 'form-1', + toolUseId: 'tool-1', + message: 'Configure deployment', + requester: { name: 'deploy', source: 'Acme MCP' }, + fields: [ + { kind: 'string', name: 'version', label: 'Version', required: true, minLength: 2 }, + { kind: 'number', name: 'ratio', label: 'Ratio', required: true, default: 1.5 }, + { kind: 'integer', name: 'replicas', label: 'Replicas', required: true, default: 3 }, + { kind: 'boolean', name: 'notify', label: 'Notify', required: false }, + { + kind: 'single_select', + name: 'channel', + label: 'Channel', + required: true, + default: 'stable', + options: [ + { value: 'stable', label: 'Stable' }, + { value: 'canary', label: 'Canary' }, + ], + }, + { + kind: 'multi_select', + name: 'owners', + label: 'Owners', + required: false, + default: ['a'], + options: [ + { value: 'a', label: 'A' }, + { value: 'b', label: 'B' }, + ], + }, + ], +}; + +test('TUI drafts cover every primitive and preserve optional omission', () => { + assert.deepEqual(createTuiFormDrafts(REQUEST.fields), [ + { included: true, value: '' }, + { included: true, value: '1.5' }, + { included: true, value: '3' }, + { included: false, value: false }, + { included: true, value: 'stable' }, + { included: true, value: ['a'] }, + ]); +}); + +test('TUI acceptance parses numbers without inventing omitted values', () => { + const drafts = createTuiFormDrafts(REQUEST.fields); + drafts[0] = { included: true, value: 'v2' }; + assert.deepEqual(buildTuiFormResponse(REQUEST, drafts), { + requestId: 'form-1', + action: 'accept', + values: { + version: 'v2', + ratio: 1.5, + replicas: 3, + channel: 'stable', + owners: ['a'], + }, + }); + drafts[2] = { included: true, value: '3.5' }; + assert.equal(buildTuiFormResponse(REQUEST, drafts), null); +}); + +test('protocol field names remain own data properties', () => { + const request = { + ...REQUEST, + fields: [{ kind: 'string', name: '__proto__', label: 'Prototype', required: true }], + } satisfies FormRequestEvent; + const response = buildTuiFormResponse(request, [{ included: true, value: 'data' }]); + assert.equal(response?.action, 'accept'); + if (response?.action !== 'accept') assert.fail('expected an accepted response'); + assert.equal(Object.hasOwn(response.values, '__proto__'), true); + assert.equal(response.values.__proto__, 'data'); +}); + +test('overlay retains invalid drafts, then submits the corrected value', () => { + const responses: InteractionFormResponse[] = []; + const request = { ...REQUEST, fields: [REQUEST.fields[0]!] }; + const overlay = new FormInteractionOverlay(fakeTui(), { + locale: 'en', + request, + onRespond: (response) => responses.push(response), + }); + + overlay.handleInput('s'); + assert.equal(responses.length, 0); + assert.match(rendered(overlay), /Value does not meet this field's constraints/u); + + overlay.handleInput('\r'); + overlay.handleInput('v'); + overlay.handleInput('2'); + overlay.handleInput('\r'); + overlay.handleInput('s'); + assert.deepEqual(responses, [ + { requestId: 'form-1', action: 'accept', values: { version: 'v2' } }, + ]); +}); + +test('overlay restores a same-request draft and explains active constraints', () => { + const request = { + ...REQUEST, + fields: [ + { + kind: 'string', + name: 'version', + label: 'Version', + required: true, + minLength: 2, + maxLength: 12, + format: 'date-time', + }, + ], + } satisfies FormRequestEvent; + const first = new FormInteractionOverlay(fakeTui(), { + locale: 'en', + request, + onRespond: () => undefined, + }); + assert.match(rendered(first), /2–12 characters · Format: date-time/u); + first.handleInput('\r'); + first.handleInput('2'); + first.handleInput('\r'); + + const restored = new FormInteractionOverlay(fakeTui(), { + locale: 'en', + request: { ...request, fields: request.fields.map((field) => ({ ...field })) }, + initialDrafts: first.snapshotDrafts(), + onRespond: () => undefined, + }); + assert.match(rendered(restored), /Version \(required\): 2/u); +}); + +test('overlay distinguishes optional false, decline, and cancel', () => { + const request = { ...REQUEST, fields: [REQUEST.fields[3]!] }; + const accepted: InteractionFormResponse[] = []; + const overlay = new FormInteractionOverlay(fakeTui(), { + locale: 'en', + request, + onRespond: (response) => accepted.push(response), + }); + assert.match(rendered(overlay), /omitted/u); + overlay.handleInput(' '); + overlay.handleInput('s'); + assert.deepEqual(accepted, [ + { requestId: 'form-1', action: 'accept', values: { notify: false } }, + ]); + + const declined: InteractionFormResponse[] = []; + new FormInteractionOverlay(fakeTui(), { + locale: 'en', + request, + onRespond: (response) => declined.push(response), + }).handleInput('d'); + assert.deepEqual(declined, [{ requestId: 'form-1', action: 'decline' }]); + + const cancelled: InteractionFormResponse[] = []; + new FormInteractionOverlay(fakeTui(), { + locale: 'en', + request, + onRespond: (response) => cancelled.push(response), + }).handleInput('\u001b'); + assert.deepEqual(cancelled, [{ requestId: 'form-1', action: 'cancel' }]); +}); + +test('overlay renders provenance and neutralizes terminal control text', () => { + const overlay = new FormInteractionOverlay(fakeTui(), { + locale: 'en', + request: { + ...REQUEST, + message: '\u001b[31mDeploy\nnow', + requester: { name: '\u202edeploy', source: '\u001b]0;owned\u0007MCP' }, + fields: [ + { + kind: 'string', + name: 'name', + label: '\u001b[2JName', + required: false, + default: '\u001b[31mvalue', + }, + ], + }, + onRespond: () => undefined, + }); + const output = rendered(overlay); + assert.match(output, /Deploy now/u); + assert.match(output, /Requested by deploy · MCP/u); + assert.match(output, /Do not enter passwords, API keys, access tokens, or payment details/u); + assert.doesNotMatch(output, /\u001b\[31m|\u001b\]0/u); +}); + +test('overlay keeps bounded field and option windows around the active row', () => { + const fields = Array.from({ length: 12 }, (_, index) => ({ + kind: 'boolean' as const, + name: `field-${index}`, + label: `Field ${index}`, + required: true, + })); + const overlay = new FormInteractionOverlay(fakeTui(), { + locale: 'en', + request: { ...REQUEST, fields }, + onRespond: () => undefined, + }); + for (let index = 0; index < 10; index += 1) overlay.handleInput('\u001b[B'); + const fieldWindow = rendered(overlay); + assert.match(fieldWindow, /Field 10/u); + assert.match(fieldWindow, /… ↑/u); + assert.doesNotMatch(fieldWindow, /Field 0 /u); + + const options = Array.from({ length: 14 }, (_, index) => ({ + value: `option-${index}`, + label: `Option ${index}`, + })); + const optionOverlay = new FormInteractionOverlay(fakeTui(), { + locale: 'en', + request: { + ...REQUEST, + fields: [ + { + kind: 'single_select', + name: 'choice', + label: 'Choice', + required: true, + options, + }, + ], + }, + onRespond: () => undefined, + }); + optionOverlay.handleInput('\r'); + for (let index = 0; index < 11; index += 1) optionOverlay.handleInput('\u001b[B'); + const optionWindow = rendered(optionOverlay); + assert.match(optionWindow, /Option 11/u); + assert.match(optionWindow, /… ↑/u); + assert.doesNotMatch(optionWindow, /Option 0/u); +}); + +function fakeTui(): TUI { + return { requestRender: () => undefined } as unknown as TUI; +} + +function rendered(overlay: FormInteractionOverlay): string { + return overlay.render(100).map(stripAnsi).join('\n'); +} diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 58540fd5ee..2c633b4642 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -38,6 +38,7 @@ import { type SessionSummary, type StoredMessage } from '@maka/core/session'; import { type ThinkingLevel } from '@maka/core/model-thinking'; import type { RuntimeHostConnectionCatalogSnapshot as ConnectionCatalogSnapshot } from '@maka/runtime-host/client'; import { type UserQuestionResponse } from '@maka/core/user-question'; +import type { InteractionFormResponse } from '@maka/core/interaction'; import type { SkillInvocationResult } from '@maka/core/skill-invocation'; import type { AgentGraphClientSnapshot, @@ -2201,6 +2202,169 @@ describe('Maka Pi TUI runner', () => { await run; }); + test('reviews and answers a Host-owned form without losing typed or optional values', async () => { + const terminal = new FakeTerminal(100, 30); + const driver = new FormPromptDriver(); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('deploy'); + terminal.input('\r'); + await waitFor(() => + plainTerminalOutput(terminal.screenOutput()).includes('Configure deployment'), + ); + const firstScreen = plainTerminalOutput(terminal.screenOutput()); + assert.match(firstScreen, /Requested by deploy · Acme MCP/u); + assert.match(firstScreen, /Do not enter passwords, API keys, access tokens/u); + assert.match(firstScreen, /Version \(required\): empty/u); + assert.match(firstScreen, /Notify \(optional\): omitted/u); + + terminal.input('\r'); + terminal.input('v2'); + terminal.input('\r'); + terminal.input('\x1b[B'); + terminal.input(' '); + terminal.input('\r'); + terminal.input('\x1b[A'); + terminal.input('\r'); + terminal.input('s'); + + await waitFor(() => driver.responses.length === 1); + assert.deepEqual(driver.responses, [ + { + requestId: 'form-1', + action: 'accept', + values: { version: 'v2', notify: true }, + }, + ]); + + exitMaka(terminal); + await run; + }); + + test('keeps form cancel distinct from global Turn stop', async () => { + const cancelTerminal = new FakeTerminal(100, 30); + const cancelDriver = new FormPromptDriver(); + const cancelRun = runMakaPiTui({ + title: 'Maka', + driver: cancelDriver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal: cancelTerminal, + }); + cancelTerminal.input('deploy'); + cancelTerminal.input('\r'); + await waitFor(() => + plainTerminalOutput(cancelTerminal.screenOutput()).includes('Configure deployment'), + ); + cancelTerminal.input('\u001b'); + await waitFor(() => cancelDriver.responses.length === 1); + assert.deepEqual(cancelDriver.responses, [{ requestId: 'form-1', action: 'cancel' }]); + assert.equal(cancelDriver.stopCalls, 0); + exitMaka(cancelTerminal); + await cancelRun; + + const stopTerminal = new FakeTerminal(100, 30); + const stopDriver = new FormPromptDriver(); + const stopRun = runMakaPiTui({ + title: 'Maka', + driver: stopDriver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal: stopTerminal, + }); + stopTerminal.input('deploy'); + stopTerminal.input('\r'); + await waitFor(() => + plainTerminalOutput(stopTerminal.screenOutput()).includes('Configure deployment'), + ); + stopTerminal.input('\u0003'); + await waitFor(() => stopDriver.stopCalls === 1); + assert.deepEqual(stopDriver.responses, []); + exitMaka(stopTerminal); + await stopRun; + }); + + test('closes a stale form when reconnect replaces the authoritative transcript', async () => { + const terminal = new FakeTerminal(100, 30); + const driver = new FormPromptDriver(); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('deploy'); + terminal.input('\r'); + await waitFor(() => + plainTerminalOutput(terminal.screenOutput()).includes('Configure deployment'), + ); + + driver.publishReconnect(); + await waitFor( + () => !plainTerminalOutput(terminal.screenOutput()).includes('Configure deployment'), + ); + assert.deepEqual(driver.responses, []); + + exitMaka(terminal); + await run; + }); + + test('restores a still-pending form draft after reconnect replay', async () => { + const terminal = new FakeTerminal(100, 30); + const driver = new FormPromptDriver(); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('deploy'); + terminal.input('\r'); + await waitFor(() => + plainTerminalOutput(terminal.screenOutput()).includes('Configure deployment'), + ); + terminal.input('\r'); + terminal.input('v2'); + terminal.input('\r'); + await waitFor(() => + plainTerminalOutput(terminal.screenOutput()).includes('Version (required): v2'), + ); + + driver.publishReconnect(); + await waitFor( + () => !plainTerminalOutput(terminal.screenOutput()).includes('Configure deployment'), + ); + driver.replayForm(); + await waitFor(() => + plainTerminalOutput(terminal.screenOutput()).includes('Version (required): v2'), + ); + + terminal.input('\u001b'); + await waitFor(() => driver.responses.length === 1); + exitMaka(terminal); + await run; + }); + test('off-screen shell-run settle never clears scrollback (#1135)', async () => { const terminal = new FakeTerminal(); const driver = new OffscreenSettleDriver(); @@ -9002,6 +9166,76 @@ class LongOptionsQuestionDriver extends FakeSessionDriver { } } +class FormPromptDriver extends FakeSessionDriver { + readonly responses: InteractionFormResponse[] = []; + stopCalls = 0; + private wake: ((action: 'replay' | 'release') => void) | undefined; + readonly #transcriptListeners = new Set< + ( + sessionId: string, + turnId: string, + messages: StoredMessage[], + reason: MakaTranscriptReplacementReason, + ) => void + >(); + + preparePrompt(prompt: string): Promise { + return prepareTestPrompt(this, prompt); + } + async *promptEvents(_prompt: string): AsyncIterable { + const formRequest = { + type: 'form_request', + id: 'event-form', + turnId: 'turn-1', + ts: 1, + requestId: 'form-1', + toolUseId: 'tool-1', + message: 'Configure deployment', + requester: { name: 'deploy', source: 'Acme MCP' }, + fields: [ + { kind: 'string', name: 'version', label: 'Version', required: true, minLength: 2 }, + { kind: 'boolean', name: 'notify', label: 'Notify', required: false }, + ], + } satisfies SessionEvent; + yield formRequest; + while (true) { + const action = await new Promise<'replay' | 'release'>((resolve) => { + this.wake = resolve; + }); + if (action === 'release') break; + yield { ...formRequest, id: `event-form-replay-${Date.now()}` }; + } + yield { type: 'complete', id: 'complete-1', turnId: 'turn-1', ts: 2, stopReason: 'end_turn' }; + } + async respondToUserForm(response: InteractionFormResponse): Promise { + this.responses.push(response); + this.wake?.('release'); + } + async stop(): Promise { + this.stopCalls += 1; + this.wake?.('release'); + } + subscribeTranscriptReplacements( + listener: ( + sessionId: string, + turnId: string, + messages: StoredMessage[], + reason: MakaTranscriptReplacementReason, + ) => void, + ): () => void { + this.#transcriptListeners.add(listener); + return () => this.#transcriptListeners.delete(listener); + } + publishReconnect(): void { + for (const listener of this.#transcriptListeners) { + listener('session-1', 'turn-1', [], 'reconnect'); + } + } + replayForm(): void { + this.wake?.('replay'); + } +} + class InterruptibleTurnDriver extends FakeSessionDriver { stopCalls = 0; readonly prompts: string[] = []; diff --git a/packages/cli/src/__tests__/runtime-host-capability-provider-command.test.ts b/packages/cli/src/__tests__/runtime-host-capability-provider-command.test.ts index 6df9f8bd49..e5cf430c3e 100644 --- a/packages/cli/src/__tests__/runtime-host-capability-provider-command.test.ts +++ b/packages/cli/src/__tests__/runtime-host-capability-provider-command.test.ts @@ -149,6 +149,7 @@ test('MCP capability publication freezes an accepted callable tool snapshot', as accept: async () => { accepted = true; }, + requestInteraction: async () => assert.fail('Unexpected provider interaction'), }, ); assert.deepEqual(result, { content: [{ type: 'text', text: '{"path":"README.md"}' }] }); diff --git a/packages/cli/src/__tests__/runtime-host-run-command.test.ts b/packages/cli/src/__tests__/runtime-host-run-command.test.ts index 49d76680c8..c0173f2f6e 100644 --- a/packages/cli/src/__tests__/runtime-host-run-command.test.ts +++ b/packages/cli/src/__tests__/runtime-host-run-command.test.ts @@ -748,6 +748,33 @@ describe('Runtime Host maka run adapter', () => { ]); }); + test('fails and stops instead of dropping an interactive form', async () => { + const fixture = runFixture({ + turnEvents: formEvents('turn-1'), + pendingInteractions: [pendingForm('turn-1')], + pendingAfterTurnStarts: true, + }); + const session = await fixture.context.runtime.createSession({ + cwd: '/workspace', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + permissionMode: 'ask', + }); + + await assert.rejects( + collect( + fixture.context.runtime.sendMessage(session.id, { + turnId: 'turn-1', + text: 'configure deployment', + }), + ), + new Error('interactive user forms are unavailable in non-interactive mode'), + ); + assert.deepEqual(fixture.exactTurnStops, [ + { sessionId: session.id, turnId: 'turn-1', runId: 'run-1' }, + ]); + }); + test('stops Graph Mode when a successor waits for an interactive question', async () => { const fixture = runFixture({ graph: true, @@ -1268,6 +1295,20 @@ async function* questionEvents(turnId: string): AsyncIterable { ], }; } + +async function* formEvents(turnId: string): AsyncIterable { + yield { + type: 'form_request', + id: `${turnId}-form`, + turnId, + ts: 1, + requestId: 'form-1', + toolUseId: 'tool-1', + message: 'Configure deployment', + requester: { name: 'deploy', source: 'Acme MCP' }, + fields: [{ kind: 'string', name: 'version', label: 'Version', required: true }], + }; +} function pendingQuestion(turnId: string): InteractionPendingSnapshot { return { schemaVersion: 1, @@ -1286,6 +1327,26 @@ function pendingQuestion(turnId: string): InteractionPendingSnapshot { }; } +function pendingForm(turnId: string): InteractionPendingSnapshot { + return { + schemaVersion: 1, + interactionId: 'form-1', + sessionId: 'session-created', + turnId, + runId: turnId === 'turn-1' ? 'run-1' : 'run-2', + revision: 1, + status: 'pending', + outcome: null, + request: { + kind: 'form', + toolUseId: 'tool-form', + message: 'Configure deployment', + requester: { name: 'deploy', source: 'Acme MCP' }, + fields: [{ kind: 'string', name: 'version', label: 'Version', required: true }], + }, + }; +} + function pendingPermission(turnId: string): InteractionPendingSnapshot { return { schemaVersion: 1, 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 1acaa8841b..230b1ec72e 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -1917,6 +1917,48 @@ describe('Runtime Host Maka Session driver', () => { }); }); + test('answers and releases a Host-owned form through the generic Interaction operation', async () => { + const subscription = new FakeSubscription( + continuitySnapshot({ interactions: { pending: [pendingForm()] } }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + now: () => 76, + }); + const switched = await driver.switchSession('session-1'); + assert.ok(switched.activeTurn); + assert.equal((await nextEvent(switched.activeTurn.events)).type, 'form_request'); + + await driver.respondToUserForm!({ + requestId: 'form-1', + action: 'accept', + values: { version: 'v2' }, + }); + + assert.deepEqual(connection.requests.at(-1), { + operation: 'interaction.answer', + input: { + sessionId: 'session-1', + interactionId: 'form-1', + answer: { kind: 'form', action: 'accept', values: { version: 'v2' } }, + }, + }); + assert.deepEqual(await nextEvent(switched.activeTurn.events), { + type: 'form_answer_ack', + id: 'host-interaction:form-1:2', + turnId: 'turn-1', + ts: 76, + requestId: 'form-1', + toolUseId: 'tool-form', + }); + }); + test('publishes a pending permission that has no transcript event', async () => { const permission = pendingPermission(); const subscription = new FakeSubscription( @@ -2807,12 +2849,24 @@ class FakeConnection { ], } : operation === 'interaction.answer' - ? { - ...pendingQuestion(), - revision: 2, - status: 'answered', - outcome: { kind: 'question_answer', answers: ['Yes'], committedAt: 75 }, - } + ? (input as OperationInput<'interaction.answer'>).answer.kind === 'form' + ? { + ...pendingForm(), + revision: 2, + status: 'answered', + outcome: { + kind: 'form_answer', + action: 'accept', + values: { version: 'v2' }, + committedAt: 76, + }, + } + : { + ...pendingQuestion(), + revision: 2, + status: 'answered', + outcome: { kind: 'question_answer', answers: ['Yes'], committedAt: 75 }, + } : operation === 'interaction.query' ? this.interactionQuery : operation === 'turn.start' @@ -3142,6 +3196,26 @@ function pendingQuestion() { }; } +function pendingForm() { + return { + schemaVersion: 1 as const, + interactionId: 'form-1', + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + revision: 1 as const, + status: 'pending' as const, + outcome: null, + request: { + kind: 'form' as const, + toolUseId: 'tool-form', + message: 'Configure deployment', + requester: { name: 'deploy', source: 'Acme MCP' }, + fields: [{ kind: 'string' as const, name: 'version', label: 'Version', required: true }], + }, + }; +} + function pendingPermission() { return { schemaVersion: 1 as const, diff --git a/packages/cli/src/__tests__/tui-copy-catalog.test.ts b/packages/cli/src/__tests__/tui-copy-catalog.test.ts index 0e50a3ec3d..289fbb16f2 100644 --- a/packages/cli/src/__tests__/tui-copy-catalog.test.ts +++ b/packages/cli/src/__tests__/tui-copy-catalog.test.ts @@ -35,6 +35,9 @@ const MESSAGE_VALUES = { request: 'nope', reason: 'not found', limit: 3, + minimum: 1, + maximum: 3, + format: 'email', notice: 'The original account was deleted.', recovery: 'Add or enable a connection first.', } as const; diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index f3bb911159..c0b6d0a236 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -21,6 +21,7 @@ import { Markdown, visibleWidth } from '@earendil-works/pi-tui'; import type { ProviderRetryEvent, ProviderRetryScheduledEvent, + FormRequestEvent, SandboxBoundaryRequestEvent, UserQuestionRequestEvent, SessionEvent, @@ -122,7 +123,10 @@ export interface MakaPiTranscriptState { providerRetry?: ProviderRetryCountdown; } -export type MakaPiPendingInteraction = SandboxBoundaryRequestEvent | UserQuestionRequestEvent; +export type MakaPiPendingInteraction = + | SandboxBoundaryRequestEvent + | UserQuestionRequestEvent + | FormRequestEvent; /** * A provider retry event plus the CLIENT-local time it was applied. Counting @@ -936,6 +940,9 @@ export function applyMakaSessionEventToTranscript( case 'user_question_request': enqueuePendingInteraction(state, event); break; + case 'form_request': + enqueuePendingInteraction(state, event); + break; case 'sandbox_boundary_decision_ack': { @@ -955,6 +962,10 @@ export function applyMakaSessionEventToTranscript( completePendingInteraction(state, event.requestId); break; + case 'form_answer_ack': + completePendingInteraction(state, event.requestId); + break; + case 'plan_submitted': state.entries.push({ kind: 'notice', @@ -1483,6 +1494,10 @@ export function activeUserQuestionRequest( : undefined; } +export function activeFormRequest(state: MakaPiTranscriptState): FormRequestEvent | undefined { + return state.pendingInteraction?.type === 'form_request' ? state.pendingInteraction : undefined; +} + function enqueuePendingInteraction( state: MakaPiTranscriptState, request: MakaPiPendingInteraction, diff --git a/packages/cli/src/pi-tui-form-interaction.ts b/packages/cli/src/pi-tui-form-interaction.ts new file mode 100644 index 0000000000..1b5051cecd --- /dev/null +++ b/packages/cli/src/pi-tui-form-interaction.ts @@ -0,0 +1,653 @@ +/* + * 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 { + Editor, + Key, + isKeyRepeat, + matchesKey, + truncateToWidth, + visibleWidth, + type Component, + type TUI, +} from '@earendil-works/pi-tui'; +import type { FormRequestEvent } from '@maka/core/events'; +import { + isInteractionFormFieldValueValid, + type InteractionFormField, + type InteractionFormResponse, + type InteractionFormValue, +} from '@maka/core/interaction'; +import { sanitizeUnicodeText } from '@maka/core/text-sanitize'; +import { + defineUiMessageCatalog, + formatUiMessage, + resolveUiMessageCatalog, + type UiLocale, +} from '@maka/core/ui-locale'; +import { ansi, editorTheme, stripAnsi } from './tui-ansi.js'; +import { TUI_COPY_RESOURCES } from './tui-copy-catalog.js'; + +export interface TuiFormDraft { + readonly included: boolean; + readonly value: string | boolean | readonly string[]; +} + +interface TuiFormCopy { + readonly requestedBy: string; + readonly sensitiveWarning: string; + readonly required: string; + readonly optional: string; + readonly omitted: string; + readonly empty: string; + readonly invalid: string; + readonly minimumLength: string; + readonly maximumLength: string; + readonly lengthRange: string; + readonly minimumValue: string; + readonly maximumValue: string; + readonly valueRange: string; + readonly minimumItems: string; + readonly maximumItems: string; + readonly itemRange: string; + readonly format: string; + readonly reviewHint: string; + readonly textHint: string; + readonly choiceHint: string; + readonly multiHint: string; + readonly trueValue: string; + readonly falseValue: string; + readonly selectedCount: string; +} + +const FORM_COPY = resolveUiMessageCatalog( + defineUiMessageCatalog()(TUI_COPY_RESOURCES['form-interaction']), +); + +const FORM_REVIEW_MAX_VISIBLE_FIELDS = 8; +const FORM_CHOICE_MAX_VISIBLE_OPTIONS = 10; + +type EditMode = + | { readonly kind: 'review' } + | { readonly kind: 'text'; readonly index: number } + | { readonly kind: 'choice'; readonly index: number; optionIndex: number } + | { readonly kind: 'multi'; readonly index: number; optionIndex: number }; + +export function createTuiFormDrafts(fields: readonly InteractionFormField[]): TuiFormDraft[] { + return fields.map((field) => { + const included = field.required || field.default !== undefined; + if (field.kind === 'boolean') return { included, value: field.default ?? false }; + if (field.kind === 'multi_select') return { included, value: [...(field.default ?? [])] }; + if (field.kind === 'number' || field.kind === 'integer') { + return { included, value: field.default === undefined ? '' : String(field.default) }; + } + return { included, value: field.default ?? '' }; + }); +} + +export function buildTuiFormResponse( + request: FormRequestEvent, + drafts: readonly TuiFormDraft[], +): InteractionFormResponse | null { + if (drafts.length !== request.fields.length) return null; + const entries: Array<[string, InteractionFormValue]> = []; + for (const [index, field] of request.fields.entries()) { + const draft = drafts[index]; + if (!draft) return null; + if (!draft.included) { + if (field.required) return null; + continue; + } + const value = draftValue(field, draft); + if (!isInteractionFormFieldValueValid(field, value)) return null; + entries.push([field.name, value]); + } + return { + requestId: request.requestId, + action: 'accept', + values: Object.fromEntries(entries), + }; +} + +export class FormInteractionOverlay implements Component { + readonly #copy: TuiFormCopy; + readonly #editor: Editor; + #drafts: TuiFormDraft[]; + #activeIndex = 0; + #mode: EditMode = { kind: 'review' }; + #invalid = new Set(); + #submitting = false; + + constructor( + tui: TUI, + private readonly input: { + readonly locale: UiLocale; + readonly request: FormRequestEvent; + readonly initialDrafts?: readonly TuiFormDraft[]; + readonly onRespond: (response: InteractionFormResponse) => void; + }, + ) { + this.#copy = FORM_COPY[input.locale]; + this.#drafts = + input.initialDrafts?.length === input.request.fields.length + ? cloneTuiFormDrafts(input.initialDrafts) + : createTuiFormDrafts(input.request.fields); + this.#editor = new Editor(tui, editorTheme(), { paddingX: 0 }); + this.#editor.onChange = (value) => { + if (this.#mode.kind !== 'text') return; + this.#replaceDraft(this.#mode.index, { value }); + }; + } + + invalidate(): void { + this.#editor.invalidate(); + } + + setSubmissionFailed(): void { + this.#submitting = false; + } + + snapshotDrafts(): readonly TuiFormDraft[] { + return cloneTuiFormDrafts(this.#drafts); + } + + handleInput(data: string): void { + if (this.#submitting) return; + if (this.#mode.kind === 'review') { + this.#handleReviewInput(data); + } else if (this.#mode.kind === 'text') { + if (matchesKey(data, Key.escape)) { + this.#mode = { kind: 'review' }; + } else if ( + (matchesKey(data, Key.enter) || matchesKey(data, Key.return)) && + !isKeyRepeat(data) + ) { + this.#replaceDraft(this.#mode.index, { value: this.#editor.getText() }); + this.#mode = { kind: 'review' }; + } else { + this.#editor.handleInput(data); + } + } else if (this.#mode.kind === 'choice') { + this.#handleChoiceInput(data, this.#mode); + } else { + this.#handleMultiInput(data, this.#mode); + } + } + + render(width: number): string[] { + const safeWidth = Math.max(1, width); + const lines = this.#renderHeader(safeWidth); + if (this.#mode.kind === 'review') lines.push(...this.#renderReview(safeWidth)); + else if (this.#mode.kind === 'text') + lines.push(...this.#renderTextEditor(this.#mode.index, safeWidth)); + else if (this.#mode.kind === 'choice') lines.push(...this.#renderChoice(this.#mode, safeWidth)); + else lines.push(...this.#renderMulti(this.#mode, safeWidth)); + lines.push(padLine(ansi.accent('-'.repeat(safeWidth)), safeWidth)); + return lines; + } + + #handleReviewInput(data: string): void { + const fields = this.input.request.fields; + if (matchesKey(data, Key.escape)) { + this.#respond({ requestId: this.input.request.requestId, action: 'cancel' }); + return; + } + if (data === 'd' || data === 'D') { + this.#respond({ requestId: this.input.request.requestId, action: 'decline' }); + return; + } + if (data === 's' || data === 'S') { + this.#submit(); + return; + } + if (fields.length === 0) return; + if (matchesKey(data, Key.up)) { + this.#activeIndex = this.#activeIndex === 0 ? fields.length - 1 : this.#activeIndex - 1; + return; + } + if (matchesKey(data, Key.down)) { + this.#activeIndex = this.#activeIndex === fields.length - 1 ? 0 : this.#activeIndex + 1; + return; + } + const field = fields[this.#activeIndex]; + const draft = this.#drafts[this.#activeIndex]; + if (!field || !draft) return; + if (matchesKey(data, Key.space) && !field.required) { + this.#replaceDraft(this.#activeIndex, { included: !draft.included }); + return; + } + if ((matchesKey(data, Key.enter) || matchesKey(data, Key.return)) && !isKeyRepeat(data)) { + if (!draft.included) this.#replaceDraft(this.#activeIndex, { included: true }); + this.#openEditor(field, this.#activeIndex); + } + } + + #handleChoiceInput(data: string, mode: Extract): void { + if (matchesKey(data, Key.escape)) { + this.#mode = { kind: 'review' }; + return; + } + const field = this.input.request.fields[mode.index]; + if (!field || (field.kind !== 'boolean' && field.kind !== 'single_select')) return; + const count = field.kind === 'boolean' ? 2 : field.options.length; + if (count === 0) return; + if (matchesKey(data, Key.up)) + mode.optionIndex = mode.optionIndex === 0 ? count - 1 : mode.optionIndex - 1; + else if (matchesKey(data, Key.down)) + mode.optionIndex = mode.optionIndex === count - 1 ? 0 : mode.optionIndex + 1; + else if ((matchesKey(data, Key.enter) || matchesKey(data, Key.return)) && !isKeyRepeat(data)) { + const value = + field.kind === 'boolean' ? mode.optionIndex === 0 : field.options[mode.optionIndex]?.value; + if (value !== undefined) this.#replaceDraft(mode.index, { included: true, value }); + this.#mode = { kind: 'review' }; + } + } + + #handleMultiInput(data: string, mode: Extract): void { + if ( + matchesKey(data, Key.escape) || + matchesKey(data, Key.enter) || + matchesKey(data, Key.return) + ) { + if (!isKeyRepeat(data)) this.#mode = { kind: 'review' }; + return; + } + const field = this.input.request.fields[mode.index]; + const draft = this.#drafts[mode.index]; + if (!field || field.kind !== 'multi_select' || !draft || !Array.isArray(draft.value)) return; + if (field.options.length === 0) return; + if (matchesKey(data, Key.up)) + mode.optionIndex = mode.optionIndex === 0 ? field.options.length - 1 : mode.optionIndex - 1; + else if (matchesKey(data, Key.down)) + mode.optionIndex = mode.optionIndex === field.options.length - 1 ? 0 : mode.optionIndex + 1; + else if (matchesKey(data, Key.space)) { + const value = field.options[mode.optionIndex]?.value; + if (value === undefined) return; + const selected = draft.value.includes(value) + ? draft.value.filter((candidate) => candidate !== value) + : [...draft.value, value]; + this.#replaceDraft(mode.index, { included: true, value: selected }); + } + } + + #openEditor(field: InteractionFormField, index: number): void { + const draft = this.#drafts[index]; + if (!draft) return; + if (field.kind === 'boolean') { + this.#mode = { kind: 'choice', index, optionIndex: draft.value === true ? 0 : 1 }; + } else if (field.kind === 'single_select') { + this.#mode = { + kind: 'choice', + index, + optionIndex: Math.max( + 0, + field.options.findIndex((option) => option.value === draft.value), + ), + }; + } else if (field.kind === 'multi_select') { + this.#mode = { kind: 'multi', index, optionIndex: 0 }; + } else { + this.#editor.setText(typeof draft.value === 'string' ? draft.value : ''); + this.#mode = { kind: 'text', index }; + } + } + + #submit(): void { + const response = buildTuiFormResponse(this.input.request, this.#drafts); + if (response) { + this.#respond(response); + return; + } + this.#invalid = new Set( + this.input.request.fields.flatMap((field, index) => { + const draft = this.#drafts[index]; + if (!draft || (!draft.included && field.required)) return [index]; + if (!draft.included) return []; + return isInteractionFormFieldValueValid(field, draftValue(field, draft)) ? [] : [index]; + }), + ); + const first = this.#invalid.values().next().value; + if (typeof first === 'number') this.#activeIndex = first; + } + + #respond(response: InteractionFormResponse): void { + this.#submitting = true; + this.input.onRespond(response); + } + + #replaceDraft(index: number, patch: Partial): void { + const current = this.#drafts[index]; + if (!current) return; + this.#drafts = this.#drafts.map((draft, candidate) => + candidate === index ? { ...current, ...patch } : draft, + ); + this.#invalid.delete(index); + } + + #renderHeader(width: number): string[] { + const requester = this.input.request.requester; + const detail = requester.source + ? `${safeDisplay(requester.name)} · ${safeDisplay(requester.source)}` + : safeDisplay(requester.name); + const provenance = formatUiMessage(this.#copy.requestedBy, { detail }, this.input.locale); + return [ + padLine(ansi.bold(safeDisplay(this.input.request.message)), width), + padLine(ansi.dim(provenance), width), + padLine(ansi.red(this.#copy.sensitiveWarning), width), + padLine('', width), + ]; + } + + #renderReview(width: number): string[] { + const lines: string[] = []; + const window = visibleWindow( + this.input.request.fields.length, + this.#activeIndex, + FORM_REVIEW_MAX_VISIBLE_FIELDS, + ); + if (window.start > 0) lines.push(padLine(ansi.dim(` … ↑ ${window.start}`), width)); + this.input.request.fields.slice(window.start, window.end).forEach((field, offset) => { + const index = window.start + offset; + const draft = this.#drafts[index]; + if (!draft) return; + const requirement = field.required ? this.#copy.required : this.#copy.optional; + const prefix = index === this.#activeIndex ? '→ ' : ' '; + const summary = this.#summary(field, draft); + const invalid = this.#invalid.has(index); + const row = `${prefix}${safeDisplay(field.label)} (${requirement}): ${summary}${invalid ? ` · ${this.#copy.invalid}` : ''}`; + lines.push( + formatReviewRow(invalid ? ansi.red(row) : row, index === this.#activeIndex, width), + ); + if (index === this.#activeIndex && field.description) { + lines.push(padLine(` ${ansi.dim(safeDisplay(field.description))}`, width)); + } + if (index === this.#activeIndex) { + const constraint = this.#constraint(field); + if (constraint) lines.push(padLine(` ${ansi.dim(constraint)}`, width)); + } + }); + if (window.end < this.input.request.fields.length) { + lines.push( + padLine(ansi.dim(` … ↓ ${this.input.request.fields.length - window.end}`), width), + ); + } + if (this.input.request.fields.length === 0) lines.push(padLine(ansi.dim('(no fields)'), width)); + lines.push(padLine('', width)); + lines.push(padLine(ansi.dim(this.#copy.reviewHint), width)); + return lines; + } + + #renderTextEditor(index: number, width: number): string[] { + const field = this.input.request.fields[index]; + if (!field) return []; + this.#editor.focused = true; + return [ + padLine( + `${safeDisplay(field.label)} (${field.required ? this.#copy.required : this.#copy.optional})`, + width, + ), + ...this.#fieldDetails(field, width), + ...this.#editor.render(width), + padLine(ansi.dim(this.#copy.textHint), width), + ]; + } + + #renderChoice(mode: Extract, width: number): string[] { + const field = this.input.request.fields[mode.index]; + if (!field || (field.kind !== 'boolean' && field.kind !== 'single_select')) return []; + const options = + field.kind === 'boolean' + ? [this.#copy.trueValue, this.#copy.falseValue] + : field.options.map((option) => safeDisplay(option.label)); + const window = visibleWindow(options.length, mode.optionIndex, FORM_CHOICE_MAX_VISIBLE_OPTIONS); + return [ + padLine(safeDisplay(field.label), width), + ...this.#fieldDetails(field, width), + ...(window.start > 0 ? [padLine(ansi.dim(` … ↑ ${window.start}`), width)] : []), + ...options.slice(window.start, window.end).map((label, offset) => { + const index = window.start + offset; + return formatReviewRow( + `${index === mode.optionIndex ? '→ ' : ' '}${label}`, + index === mode.optionIndex, + width, + ); + }), + ...(window.end < options.length + ? [padLine(ansi.dim(` … ↓ ${options.length - window.end}`), width)] + : []), + padLine(ansi.dim(this.#copy.choiceHint), width), + ]; + } + + #renderMulti(mode: Extract, width: number): string[] { + const field = this.input.request.fields[mode.index]; + const draft = this.#drafts[mode.index]; + if (!field || field.kind !== 'multi_select' || !draft || !Array.isArray(draft.value)) return []; + const selected = draft.value; + const window = visibleWindow( + field.options.length, + mode.optionIndex, + FORM_CHOICE_MAX_VISIBLE_OPTIONS, + ); + return [ + padLine(safeDisplay(field.label), width), + ...this.#fieldDetails(field, width), + ...(window.start > 0 ? [padLine(ansi.dim(` … ↑ ${window.start}`), width)] : []), + ...field.options.slice(window.start, window.end).map((option, offset) => { + const index = window.start + offset; + const row = `${index === mode.optionIndex ? '→ ' : ' '}[${selected.includes(option.value) ? 'x' : ' '}] ${safeDisplay(option.label)}`; + return formatReviewRow(row, index === mode.optionIndex, width); + }), + ...(window.end < field.options.length + ? [padLine(ansi.dim(` … ↓ ${field.options.length - window.end}`), width)] + : []), + padLine(ansi.dim(this.#copy.multiHint), width), + ]; + } + + #summary(field: InteractionFormField, draft: TuiFormDraft): string { + if (!draft.included) return ansi.dim(this.#copy.omitted); + if (field.kind === 'boolean') + return draft.value === true ? this.#copy.trueValue : this.#copy.falseValue; + if (field.kind === 'single_select') { + const option = field.options.find((candidate) => candidate.value === draft.value); + return option ? safeDisplay(option.label) : ansi.dim(this.#copy.empty); + } + if (field.kind === 'multi_select' && Array.isArray(draft.value)) { + return formatUiMessage( + this.#copy.selectedCount, + { count: draft.value.length }, + this.input.locale, + ); + } + return typeof draft.value === 'string' && draft.value.length > 0 + ? safeDisplay(draft.value) + : ansi.dim(this.#copy.empty); + } + + #fieldDetails(field: InteractionFormField, width: number): string[] { + const details = field.description ? [safeDisplay(field.description)] : []; + const constraint = this.#constraint(field); + if (constraint) details.push(constraint); + return details.map((detail) => padLine(ansi.dim(detail), width)); + } + + #constraint(field: InteractionFormField): string | undefined { + const constraints: string[] = []; + if (field.kind === 'string') { + if (field.minLength !== undefined && field.maxLength !== undefined) { + constraints.push( + formatUiMessage( + this.#copy.lengthRange, + { + minimum: field.minLength, + maximum: field.maxLength, + }, + this.input.locale, + ), + ); + } else if (field.minLength !== undefined) { + constraints.push( + formatUiMessage( + this.#copy.minimumLength, + { + minimum: field.minLength, + }, + this.input.locale, + ), + ); + } else if (field.maxLength !== undefined) { + constraints.push( + formatUiMessage( + this.#copy.maximumLength, + { + maximum: field.maxLength, + }, + this.input.locale, + ), + ); + } + if (field.format !== undefined) { + constraints.push( + formatUiMessage( + this.#copy.format, + { + format: field.format, + }, + this.input.locale, + ), + ); + } + } else if (field.kind === 'number' || field.kind === 'integer') { + if (field.minimum !== undefined && field.maximum !== undefined) { + constraints.push( + formatUiMessage( + this.#copy.valueRange, + { + minimum: field.minimum, + maximum: field.maximum, + }, + this.input.locale, + ), + ); + } else if (field.minimum !== undefined) { + constraints.push( + formatUiMessage( + this.#copy.minimumValue, + { + minimum: field.minimum, + }, + this.input.locale, + ), + ); + } else if (field.maximum !== undefined) { + constraints.push( + formatUiMessage( + this.#copy.maximumValue, + { + maximum: field.maximum, + }, + this.input.locale, + ), + ); + } + } else if (field.kind === 'multi_select') { + if (field.minItems !== undefined && field.maxItems !== undefined) { + constraints.push( + formatUiMessage( + this.#copy.itemRange, + { + minimum: field.minItems, + maximum: field.maxItems, + }, + this.input.locale, + ), + ); + } else if (field.minItems !== undefined) { + constraints.push( + formatUiMessage( + this.#copy.minimumItems, + { + minimum: field.minItems, + }, + this.input.locale, + ), + ); + } else if (field.maxItems !== undefined) { + constraints.push( + formatUiMessage( + this.#copy.maximumItems, + { + maximum: field.maxItems, + }, + this.input.locale, + ), + ); + } + } + return constraints.length === 0 ? undefined : constraints.join(' · '); + } +} + +function cloneTuiFormDrafts(drafts: readonly TuiFormDraft[]): TuiFormDraft[] { + return drafts.map((draft) => ({ + included: draft.included, + value: Array.isArray(draft.value) ? [...draft.value] : draft.value, + })); +} + +function draftValue(field: InteractionFormField, draft: TuiFormDraft): InteractionFormValue { + if (field.kind === 'number' || field.kind === 'integer') { + return typeof draft.value === 'string' && draft.value.trim() !== '' + ? Number(draft.value) + : Number.NaN; + } + return draft.value; +} + +function safeDisplay(value: string): string { + return sanitizeUnicodeText(stripAnsi(value), { maxCodePoints: 1_024 }); +} + +function formatReviewRow(text: string, active: boolean, width: number): string { + const padded = padLine(text, width); + return active ? ansi.reverse(padded) : padded; +} + +function padLine(text: string, width: number): string { + const safeWidth = Math.max(1, width); + const trimmed = visibleWidth(text) > safeWidth ? truncateToWidth(text, safeWidth, '') : text; + return `${trimmed}${' '.repeat(Math.max(0, safeWidth - visibleWidth(trimmed)))}`; +} + +function visibleWindow( + length: number, + activeIndex: number, + limit: number, +): { + readonly start: number; + readonly end: number; +} { + if (length <= limit) return { start: 0, end: length }; + const start = Math.min( + Math.max(0, activeIndex - Math.floor(limit / 2)), + Math.max(0, length - limit), + ); + return { start, end: Math.min(length, start + limit) }; +} diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 28934db7bf..b673dec3e9 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -112,6 +112,7 @@ import { createMakaPiTranscriptState, hasRunningUserCommand, activeSandboxBoundaryRequest, + activeFormRequest, activeUserQuestionRequest, applyExpansionDefaultToAll, completePendingInteraction, @@ -128,6 +129,8 @@ import { type ExpansionEntryKind, type MakaPiTranscriptMetadata, } from './pi-transcript.js'; +import { FormInteractionOverlay, type TuiFormDraft } from './pi-tui-form-interaction.js'; +import type { InteractionFormResponse } from '@maka/core/interaction'; import { runMakaPiTuiTurn, type MakaPiTuiTurnRequest } from './pi-tui-turn.js'; import { editorTheme, selectListTheme } from './tui-ansi.js'; import { MakaAutocompleteAboveEditorComponent } from './tui-autocomplete-layout.js'; @@ -516,6 +519,20 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { answers: Array; } | undefined; + let formResponseInFlightRequestId: string | undefined; + let formOverlay: OverlayHandle | undefined; + let formOverlayComponent: FormInteractionOverlay | undefined; + let formOverlayRequestId: string | undefined; + let formOverlaySessionId: string | undefined; + let formOverlaySchema: string | undefined; + let retainedFormDraft: + | { + readonly sessionId: string; + readonly requestId: string; + readonly schema: string; + readonly drafts: readonly TuiFormDraft[]; + } + | undefined; let turnRunning = false; // Monotonic generation for visible agent turns. A mid-turn `/session` // switch-away (#3380) bumps it to orphan the in-flight drain: every callback @@ -726,7 +743,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { return; } permissionResponseInFlightRequestId = null; - syncUserQuestionOverlay(); + syncInteractionOverlays(); requestRender(); }) ?? (() => {}); const unsubscribeTranscriptReplacements = @@ -734,6 +751,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { if (closed || input.driver.getSessionId() !== sessionId) return; if (reason === 'reconnect') { replaceTranscript(messages, { preserveClientLocalEntries: true }); + syncInteractionOverlays(); shellRunElapsedTicker.sync(); requestRender(); const messageIds = state.entries.flatMap((entry) => @@ -1491,6 +1509,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { replaceTranscript(authoritativeAttachedTurn.messages, { preserveClientLocalEntries: true, }); + syncInteractionOverlays(); shellRunHydration.reset(); if (input.listShellRunUpdates) { await shellRunHydration.hydrate(authoritativeAttachedTurn.sessionId); @@ -1513,7 +1532,9 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // not reach the adopted Session's transcript or overlays. if (superseded()) return; if ( - (event.type === 'sandbox_boundary_request' || event.type === 'user_question_request') && + (event.type === 'sandbox_boundary_request' || + event.type === 'user_question_request' || + event.type === 'form_request') && resolvedInteractionIds.delete(event.requestId) ) { return; @@ -1539,7 +1560,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { permissionAlerted = false; } shellRunElapsedTicker.sync(); - syncUserQuestionOverlay(); + syncInteractionOverlays(); requestRender(); }, // A turn failing is worth pulling the user back, regardless of how long it @@ -1553,7 +1574,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { appendTurnFailureToTranscript(state, error); attention.attentionNeeded(); shellRunElapsedTicker.sync(); - syncUserQuestionOverlay(); + syncInteractionOverlays(); requestRender(); }, }).then( @@ -1717,6 +1738,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { }: MakaSessionSwitchResult): Promise => { adoptSessionMetadata(summary, false); replaceTranscript(messages); + syncInteractionOverlays(); if (connectionIdentityNotice) { state.entries.push({ kind: 'notice', level: 'error', text: connectionIdentityNotice }); } @@ -2108,13 +2130,13 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { completePendingInteraction(state, requestId); } userQuestionProgress = undefined; - syncUserQuestionOverlay(); + syncInteractionOverlays(); requestRender(); }) .catch((error) => { userQuestionInFlight = false; reportError(error); - syncUserQuestionOverlay(); + syncInteractionOverlays(); }); }; @@ -2168,6 +2190,136 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { } }; + const closeFormOverlay = (retainDraft = true): void => { + if ( + retainDraft && + formOverlayComponent && + formOverlaySessionId && + formOverlayRequestId && + formOverlaySchema + ) { + retainedFormDraft = { + sessionId: formOverlaySessionId, + requestId: formOverlayRequestId, + schema: formOverlaySchema, + drafts: formOverlayComponent.snapshotDrafts(), + }; + } + formOverlay?.hide(); + formOverlay = undefined; + formOverlayComponent = undefined; + formOverlayRequestId = undefined; + formOverlaySessionId = undefined; + formOverlaySchema = undefined; + }; + + const finishUserForm = (response: InteractionFormResponse): void => { + if (formResponseInFlightRequestId) return; + const respond = input.driver.respondToUserForm; + if (!respond) { + formOverlayComponent?.setSubmissionFailed(); + reportError(new Error('User forms are unavailable on this driver.')); + return; + } + const responseSessionId = formOverlaySessionId ?? input.driver.getSessionId(); + formResponseInFlightRequestId = response.requestId; + void respond + .call(input.driver, response) + .then(() => { + if (formResponseInFlightRequestId === response.requestId) { + formResponseInFlightRequestId = undefined; + } + if ( + input.driver.getSessionId() === responseSessionId && + activeFormRequest(state)?.requestId === response.requestId + ) { + completePendingInteraction(state, response.requestId); + } + if ( + retainedFormDraft?.sessionId === responseSessionId && + retainedFormDraft.requestId === response.requestId + ) { + retainedFormDraft = undefined; + } + if ( + formOverlaySessionId === responseSessionId && + formOverlayRequestId === response.requestId + ) { + closeFormOverlay(false); + } + syncInteractionOverlays(); + requestRender(); + }) + .catch((error) => { + if (formResponseInFlightRequestId === response.requestId) { + formResponseInFlightRequestId = undefined; + } + if ( + activeFormRequest(state)?.requestId === response.requestId && + formOverlaySessionId === responseSessionId && + formOverlayRequestId === response.requestId + ) { + formOverlayComponent?.setSubmissionFailed(); + } else { + closeFormOverlay(); + syncInteractionOverlays(); + } + reportError(error); + requestRender(); + }); + }; + + const syncFormOverlay = (): void => { + const request = activeFormRequest(state); + if (!request) { + closeFormOverlay(); + return; + } + const sessionId = input.driver.getSessionId(); + if (!sessionId) { + closeFormOverlay(); + return; + } + if ( + retainedFormDraft?.sessionId === sessionId && + retainedFormDraft.requestId !== request.requestId + ) { + retainedFormDraft = undefined; + } + if (formOverlaySessionId !== sessionId || formOverlayRequestId !== request.requestId) + closeFormOverlay(); + if (formResponseInFlightRequestId || formOverlayComponent) return; + const schema = JSON.stringify(request.fields); + const restoredDrafts = + retainedFormDraft?.sessionId === sessionId && + retainedFormDraft.requestId === request.requestId && + retainedFormDraft.schema === schema + ? retainedFormDraft.drafts + : undefined; + if ( + retainedFormDraft?.sessionId === sessionId && + retainedFormDraft.requestId === request.requestId && + retainedFormDraft.schema !== schema + ) { + retainedFormDraft = undefined; + } + formOverlayComponent = new FormInteractionOverlay(tui, { + locale, + request, + initialDrafts: restoredDrafts, + onRespond: finishUserForm, + }); + formOverlaySessionId = sessionId; + formOverlaySchema = schema; + formOverlayRequestId = request.requestId; + formOverlay = showBottomPicker(formOverlayComponent); + }; + + const syncInteractionOverlays = (): void => { + syncUserQuestionOverlay(); + syncFormOverlay(); + }; + const showSelectPicker = ( title: string, rightLabel: string, @@ -2563,7 +2715,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { applyMakaSessionEventToTranscript(state, event); ctxRefresher?.observe(event); shellRunElapsedTicker.sync(); - syncUserQuestionOverlay(); + syncInteractionOverlays(); requestRender(); } } catch (error) { @@ -2789,6 +2941,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // session, send a prompt to begin" cue. A notice here would make entries // non-empty and suppress it. replaceTranscript([]); + syncInteractionOverlays(); shellRunElapsedTicker.sync(); await discardCurrentSidePair(); requestRender(); @@ -3904,7 +4057,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { return { consume: true }; } if ( - activeUserQuestionRequest(state) && + (activeUserQuestionRequest(state) || activeFormRequest(state)) && turnRunning && matchesKey(data, Key.ctrl('c')) && !isKeyRepeat(data) diff --git a/packages/cli/src/runtime-host-run-command.ts b/packages/cli/src/runtime-host-run-command.ts index 92b84cc285..daa99e57b4 100644 --- a/packages/cli/src/runtime-host-run-command.ts +++ b/packages/cli/src/runtime-host-run-command.ts @@ -453,7 +453,11 @@ class RuntimeHostRunRuntime implements MakaRunRuntime { const next = await this.#interactions.race(events.next()); if (next.done) break; const event = next.value; - if (event.type === 'user_question_request' || event.type === 'sandbox_boundary_request') { + if ( + event.type === 'user_question_request' || + event.type === 'form_request' || + event.type === 'sandbox_boundary_request' + ) { continue; } active.outcome.accept(observationFromSessionEvent(event)); @@ -892,7 +896,9 @@ class NonInteractiveInteractionController { throw new Error( pending.request.kind === 'question' ? 'interactive user questions are unavailable in non-interactive mode' - : 'interactive permission requests are unavailable in non-interactive mode', + : pending.request.kind === 'form' + ? 'interactive user forms are unavailable in non-interactive mode' + : 'interactive permission requests are unavailable in non-interactive mode', ); } diff --git a/packages/cli/src/runtime-host-session-channel.ts b/packages/cli/src/runtime-host-session-channel.ts index 3937b371ad..a75ff5d63b 100644 --- a/packages/cli/src/runtime-host-session-channel.ts +++ b/packages/cli/src/runtime-host-session-channel.ts @@ -288,6 +288,8 @@ export class RuntimeHostSessionChannel { }; if (answered.outcome.kind === 'question_answer') { this.#emit({ type: 'user_question_answer_ack', ...base }); + } else if (answered.outcome.kind === 'form_answer') { + this.#emit({ type: 'form_answer_ack', ...base }); } else if (answered.outcome.kind === 'sandbox_boundary_decision') { this.#emit({ type: 'sandbox_boundary_decision_ack', diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index 8a79e6f0b6..bcbce2672f 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -52,6 +52,7 @@ import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { SkillInvocationResult } from '@maka/core/skill-invocation'; import type { UserQuestionResponse } from '@maka/core/user-question'; +import type { InteractionFormResponse } from '@maka/core/interaction'; import type { ContextDiagnostics } from '@maka/runtime/context-diagnostics'; import { isRuntimeHostTerminalTurn as isTerminalTurn } from '@maka/runtime-host/adapter'; import type { DirectRequestOperationKey, RuntimeHostConnection } from '@maka/runtime-host/client'; @@ -592,6 +593,23 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { if (pending) this.#channel?.publishInteractionAnswer(answered, pending); } + async respondToUserForm(response: InteractionFormResponse): Promise { + const sessionId = this.#requireSession('respond to a user form'); + const pending = this.#channel?.pendingInteraction(response.requestId); + if (pending && pending.request.kind !== 'form') { + throw new Error('Interaction is not a form request'); + } + const answered = await this.#request('interaction.answer', { + sessionId, + interactionId: response.requestId, + answer: + response.action === 'accept' + ? { kind: 'form', action: 'accept', values: response.values } + : { kind: 'form', action: response.action }, + }); + if (pending) this.#channel?.publishInteractionAnswer(answered, pending); + } + setModel(model: string, connectionSlug?: string, connectionId?: string): Promise { return this.#admit(() => this.#setModel(model, connectionSlug, connectionId)); } diff --git a/packages/cli/src/session-driver.ts b/packages/cli/src/session-driver.ts index 4dac719577..aef111ab4f 100644 --- a/packages/cli/src/session-driver.ts +++ b/packages/cli/src/session-driver.ts @@ -26,6 +26,7 @@ import type { SessionSummary, StoredMessage } from '@maka/core/session'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { CreateSessionInput, TurnOrchestration } from '@maka/core/runtime-inputs'; import type { UserQuestionResponse } from '@maka/core/user-question'; +import type { InteractionFormResponse } from '@maka/core/interaction'; import type { ContextDiagnostics } from '@maka/runtime/context-diagnostics'; import type { SkillInvocationResult } from '@maka/core/skill-invocation'; import type { @@ -165,6 +166,7 @@ export interface MakaSessionDriver { retractQueued?(): Promise; respondToSandboxBoundary(response: SandboxBoundaryResponse): Promise; respondToUserQuestion?(response: UserQuestionResponse): Promise; + respondToUserForm?(response: InteractionFormResponse): Promise; setModel(model: string, connectionSlug?: string, connectionId?: string): Promise; setThinkingLevel(level: ThinkingLevel | undefined): Promise; setPermissionMode(mode: PermissionMode): Promise; diff --git a/packages/cli/src/tui-copy-catalog.ts b/packages/cli/src/tui-copy-catalog.ts index 94ab6d6584..3cfdd9cf26 100644 --- a/packages/cli/src/tui-copy-catalog.ts +++ b/packages/cli/src/tui-copy-catalog.ts @@ -253,6 +253,60 @@ export const TUI_COPY_RESOURCES = { }, }, }, + 'form-interaction': { + en: { + requestedBy: 'Requested by {detail}', + sensitiveWarning: 'Do not enter passwords, API keys, access tokens, or payment details.', + required: 'required', + optional: 'optional', + omitted: 'omitted', + empty: 'empty', + invalid: "Value does not meet this field's constraints", + minimumLength: 'At least {minimum} characters', + maximumLength: 'At most {maximum} characters', + lengthRange: '{minimum}–{maximum} characters', + minimumValue: 'Minimum {minimum}', + maximumValue: 'Maximum {maximum}', + valueRange: 'Range {minimum}–{maximum}', + minimumItems: 'Select at least {minimum}', + maximumItems: 'Select at most {maximum}', + itemRange: 'Select {minimum}–{maximum}', + format: 'Format: {format}', + reviewHint: '↑↓ field · Enter edit · Space include/omit · s submit · d decline · Esc cancel', + textHint: 'Type a value · Enter review · Esc review', + choiceHint: '↑↓ select · Enter review · Esc review', + multiHint: '↑↓ move · Space toggle · Enter review · Esc review', + trueValue: 'true', + falseValue: 'false', + selectedCount: '{count} selected', + }, + zh: { + requestedBy: '由 {detail} 请求', + sensitiveWarning: '请勿输入密码、API 密钥、访问令牌或支付信息。', + required: '必填', + optional: '选填', + omitted: '未提供', + empty: '空', + invalid: '该值不符合字段约束', + minimumLength: '至少 {minimum} 个字符', + maximumLength: '最多 {maximum} 个字符', + lengthRange: '长度 {minimum}–{maximum} 个字符', + minimumValue: '最小值 {minimum}', + maximumValue: '最大值 {maximum}', + valueRange: '范围 {minimum}–{maximum}', + minimumItems: '至少选择 {minimum} 项', + maximumItems: '最多选择 {maximum} 项', + itemRange: '选择 {minimum}–{maximum} 项', + format: '格式:{format}', + reviewHint: '↑↓ 选择字段 · Enter 编辑 · Space 提供/省略 · s 提交 · d 拒绝 · Esc 取消', + textHint: '输入值 · Enter 返回检查 · Esc 返回检查', + choiceHint: '↑↓ 选择 · Enter 返回检查 · Esc 返回检查', + multiHint: '↑↓ 移动 · Space 切换 · Enter 返回检查 · Esc 返回检查', + trueValue: '是', + falseValue: '否', + selectedCount: '已选择 {count} 项', + }, + }, pickers: { en: { modelPickerTitle: 'Select Model', diff --git a/packages/core/package.json b/packages/core/package.json index 11e29d6f3b..194034d62b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -142,6 +142,7 @@ "./task-submission-readiness": "./dist/task-submission-readiness.js", "./terminal-input": "./dist/terminal-input.js", "./terminal-mouse-input": "./dist/terminal-mouse-input.js", + "./text-sanitize": "./dist/text-sanitize.js", "./tool-activity-args": "./dist/tool-activity-args.js", "./tool-quiet-preview": "./dist/tool-quiet-preview.js", "./tool-result-record-schema": "./dist/tool-result-record-schema.js", diff --git a/packages/core/src/__tests__/interaction.test.ts b/packages/core/src/__tests__/interaction.test.ts index 9b820797e9..c824cda0c8 100644 --- a/packages/core/src/__tests__/interaction.test.ts +++ b/packages/core/src/__tests__/interaction.test.ts @@ -29,12 +29,15 @@ import { InteractionPermissionProjectionError, decodeInteractionAnswer, decodeInteractionCanonicalOutcome, + decodeInteractionFormResponse, decodeInteractionRequest, interactionCanonicalOutcomesEquivalent, isInteractionAnswerValidForRequest, isInteractionCanonicalOutcomeValidForRequest, + isInteractionFormFieldValueValid, projectInteractionClientCapabilityRequest, projectInteractionPermissionRequest, + projectInteractionFormRequest, projectInteractionQuestionRequest, projectInteractionSandboxBoundaryRequest, } from '../interaction.js'; @@ -1059,4 +1062,561 @@ describe('Interaction decoding and validity', () => { }), ); }); + + test('decodes the complete bounded primitive form contract', () => { + const request = projectInteractionFormRequest({ + toolUseId: 'tool-form', + message: 'Choose deployment settings', + requester: { name: 'deploy', source: 'Example MCP server' }, + fields: [ + { + kind: 'string', + name: 'owner', + label: 'Owner email', + required: true, + format: 'email', + minLength: 3, + maxLength: 100, + }, + { + kind: 'number', + name: 'ratio', + label: 'Traffic ratio', + required: false, + minimum: 0, + maximum: 1, + default: 0.5, + }, + { + kind: 'integer', + name: 'replicas', + label: 'Replicas', + required: true, + minimum: 1, + maximum: 10, + }, + { + kind: 'boolean', + name: 'confirm', + label: 'Confirm deployment', + required: true, + default: false, + }, + { + kind: 'single_select', + name: 'environment', + label: 'Environment', + required: true, + options: [ + { value: 'staging', label: 'Staging' }, + { value: 'production', label: 'Production' }, + ], + default: 'staging', + }, + { + kind: 'multi_select', + name: 'regions', + label: 'Regions', + required: false, + options: [ + { value: 'us', label: 'US' }, + { value: 'eu', label: 'EU' }, + ], + minItems: 1, + maxItems: 2, + default: ['us'], + }, + ], + }); + + const accepted = decodeInteractionAnswer({ + kind: 'form', + action: 'accept', + values: { + owner: 'owner@example.test', + ratio: 0.25, + replicas: 3, + confirm: true, + environment: 'production', + regions: ['us', 'eu'], + }, + }); + assert.equal(isInteractionAnswerValidForRequest(request, accepted), true); + assert.equal( + isInteractionAnswerValidForRequest( + request, + decodeInteractionAnswer({ kind: 'form', action: 'decline' }), + ), + true, + ); + assert.equal( + isInteractionAnswerValidForRequest( + request, + decodeInteractionAnswer({ kind: 'form', action: 'cancel' }), + ), + true, + ); + }); + + test('projects every human-facing form label through the safe review boundary', () => { + const projected = projectInteractionFormRequest({ + toolUseId: 'tool-form', + message: '\u202e password=message-secret', + requester: { + name: 'token=requester-secret', + source: '\napi_key=source-secret', + }, + fields: [ + { + kind: 'single_select', + name: 'environment', + label: '\u0007 password=field-secret', + description: '\nclient_secret=description-secret', + required: true, + options: [ + { value: 'production', label: '\u202e token=option-secret' }, + { value: 'staging', label: 'Staging' }, + ], + }, + ], + }); + + assert.equal(projected.message, '\\u{202E} password=[redacted]'); + assert.deepEqual(projected.requester, { + name: 'token=[redacted]', + source: '\\u{A}api_key=[redacted]', + }); + assert.deepEqual(projected.fields[0], { + kind: 'single_select', + name: 'environment', + label: '\\u{7} password=[redacted]', + description: '\\u{A}client_secret=[redacted]', + required: true, + options: [ + { value: 'production', label: '\\u{202E} token=[redacted]' }, + { value: 'staging', label: 'Staging' }, + ], + }); + assert.doesNotMatch(JSON.stringify(projected), /message-secret|requester-secret|source-secret/); + }); + + test('drops string defaults that cannot be represented without changing canonical semantics', () => { + const bidiDefault = '\u202e'; + const secretDefault = 'sk-live-secret'; + const projected = projectInteractionFormRequest({ + toolUseId: 'tool-form', + message: 'Review defaults', + requester: { name: 'deploy' }, + fields: [ + { + kind: 'string', + name: 'direction', + label: 'Direction', + required: false, + default: bidiDefault, + minLength: 1, + maxLength: 1, + }, + { + kind: 'string', + name: 'token', + label: 'Token', + required: false, + default: secretDefault, + minLength: secretDefault.length, + maxLength: secretDefault.length, + }, + { + kind: 'string', + name: 'contact', + label: 'Contact', + required: false, + default: 'password=secret@example.test', + format: 'email', + maxLength: 512, + }, + { + kind: 'string', + name: 'safe-contact', + label: 'Safe contact', + required: false, + default: 'owner@example.test', + format: 'email', + maxLength: 512, + }, + ], + }); + + assert.equal(projected.fields[0]?.default, undefined); + assert.equal(projected.fields[1]?.default, undefined); + assert.equal(projected.fields[2]?.default, undefined); + assert.equal(projected.fields[3]?.default, 'owner@example.test'); + }); + + test('rejects form option labels that collide after safe projection', () => { + assert.throws(() => + projectInteractionFormRequest({ + toolUseId: 'tool-form', + message: 'Choose settings', + requester: { name: 'deploy' }, + fields: [ + { + kind: 'single_select', + name: 'environment', + label: 'Environment', + required: true, + options: [ + { value: 'first', label: 'password=first-secret' }, + { value: 'second', label: 'password=second-secret' }, + ], + }, + ], + }), + ); + }); + + test('reserves canonical outcome overhead before admitting an accepted form answer', () => { + const values = { + a: 'x'.repeat(2_048), + b: 'x'.repeat(2_048), + c: 'x'.repeat(2_048), + d: 'x'.repeat(1_950), + }; + assert.ok( + Buffer.byteLength(JSON.stringify({ kind: 'form', action: 'accept', values })) < 8 * 1_024, + ); + assert.throws( + () => decodeInteractionAnswer({ kind: 'form', action: 'accept', values }), + /Interaction form outcome exceeds serialized byte limit/, + ); + }); + + test('validates date-time calendar and clock fields without Date.parse normalization', () => { + const field = { + kind: 'string' as const, + name: 'when', + label: 'When', + required: true, + format: 'date-time' as const, + }; + assert.equal(isInteractionFormFieldValueValid(field, '2024-02-29T23:59:59Z'), true); + assert.equal(isInteractionFormFieldValueValid(field, '2023-02-29T00:00:00Z'), false); + assert.equal(isInteractionFormFieldValueValid(field, '2023-02-30T00:00:00Z'), false); + assert.equal(isInteractionFormFieldValueValid(field, '2024-01-01T24:00:00Z'), false); + assert.equal(isInteractionFormFieldValueValid(field, '2024-01-01T00:00:00+24:00'), false); + }); + + test('rejects malformed form schemas and invalid accepted values', () => { + const request = projectInteractionFormRequest({ + toolUseId: 'tool-form', + message: 'Choose settings', + requester: { name: 'deploy' }, + fields: [ + { + kind: 'integer', + name: 'replicas', + label: 'Replicas', + required: true, + minimum: 1, + maximum: 10, + }, + { + kind: 'multi_select', + name: 'regions', + label: 'Regions', + required: false, + options: [ + { value: 'us', label: 'US' }, + { value: 'eu', label: 'EU' }, + ], + minItems: 1, + }, + ], + }); + + for (const values of [ + {}, + { replicas: 1.5 }, + { replicas: 11 }, + { replicas: 2, unknown: true }, + { replicas: 2, regions: [] }, + { replicas: 2, regions: ['elsewhere'] }, + ]) { + const answer = decodeInteractionAnswer({ kind: 'form', action: 'accept', values }); + assert.equal(isInteractionAnswerValidForRequest(request, answer), false); + } + + assert.throws(() => + decodeInteractionAnswer({ + kind: 'form', + action: 'accept', + values: { replicas: 2, regions: ['us', 'us'] }, + }), + ); + + assert.throws(() => + projectInteractionFormRequest({ + toolUseId: 'tool-form', + message: 'Choose settings', + requester: { name: 'deploy' }, + fields: [ + { kind: 'boolean', name: 'same', label: 'First', required: false }, + { kind: 'boolean', name: 'same', label: 'Second', required: false }, + ], + }), + ); + assert.throws(() => + projectInteractionFormRequest({ + toolUseId: 'tool-form', + message: 'Choose settings', + requester: { name: 'deploy' }, + fields: [ + { + kind: 'single_select', + name: 'environment', + label: 'Environment', + required: true, + options: [{ value: 'staging', label: 'Staging' }], + default: 'production', + }, + ], + }), + ); + }); + + test('rejects forms that cannot produce a bounded accepted answer', () => { + assert.throws( + () => + projectInteractionFormRequest({ + toolUseId: 'tool-form', + message: 'Enter required values', + requester: { name: 'deploy' }, + fields: Array.from({ length: 5 }, (_, index) => ({ + kind: 'string' as const, + name: `value-${index}`, + label: `Value ${index}`, + required: true, + minLength: 2_048, + maxLength: 2_048, + })), + }), + /Interaction form answer exceeds serialized byte limit/, + ); + + assert.doesNotThrow(() => + projectInteractionFormRequest({ + toolUseId: 'tool-form', + message: 'Enter a timestamp', + requester: { name: 'deploy' }, + fields: [ + { + kind: 'string', + name: 'when', + label: 'When', + required: true, + format: 'date-time', + minLength: 22, + maxLength: 22, + }, + ], + }), + ); + + assert.throws( + () => + projectInteractionFormRequest({ + toolUseId: 'tool-form', + message: 'Optional values still consume the persisted answer envelope', + requester: { name: 'deploy' }, + fields: Array.from({ length: 4 }, (_, index) => ({ + kind: 'string' as const, + name: `optional-${index}`, + label: `Optional ${index}`, + required: false, + maxLength: 2_048, + })), + }), + /Interaction form (answer|outcome) exceeds serialized byte limit/, + ); + }); + + test('reserves the worst-case escaped answer envelope at admission', () => { + // String constraints keep their schema semantics — maxLength in code + // points, raw UTF-8 bytes bounded per value — so admission must prove that + // even a fully JSON-escaped legal answer still fits the canonical + // envelope. A form whose limits allow an undeliverable answer is rejected + // instead of stranding the pending interaction after the user submits. + const stringField = (name: string, maxLength?: number) => ({ + kind: 'string' as const, + name, + label: name, + required: true, + ...(maxLength === undefined ? {} : { maxLength }), + }); + for (const fields of [ + [stringField('a', 2_048), stringField('b', 2_048)], + [stringField('loose')], + ]) { + assert.throws( + () => + projectInteractionFormRequest({ + toolUseId: 'tool-form', + message: 'Enter required values', + requester: { name: 'deploy' }, + fields, + }), + /Interaction form (answer|outcome) exceeds serialized byte limit/, + ); + } + + // A form whose escaped worst case fits stays admissible, and its + // escape-heavy legal answers — backslashes, newlines, control characters — + // decode and deliver. + const request = projectInteractionFormRequest({ + toolUseId: 'tool-form', + message: 'Enter a value', + requester: { name: 'deploy' }, + fields: [stringField('value', 1_024)], + }); + for (const heavy of ['\\'.repeat(1_024), '\n'.repeat(1_024), '\u0001'.repeat(1_024)]) { + const answer = { + kind: 'form' as const, + action: 'accept' as const, + values: { value: heavy }, + }; + assert.equal(isInteractionAnswerValidForRequest(request, answer), true); + decodeInteractionAnswer(answer); + } + + // The displayed character constraint keeps its meaning: 2,048 plain + // characters or 1,024 backslashes satisfy a maxLength: 2_048 field. + const wideField = stringField('wide', 2_048); + assert.equal(isInteractionFormFieldValueValid(wideField, 'a'.repeat(2_048)), true); + assert.equal(isInteractionFormFieldValueValid(wideField, '\\'.repeat(1_024)), true); + + // Select envelopes are measured in the serialized domain too: the + // raw-longest option is not always the serialized-longest one. + const options = [ + { value: 'a'.repeat(1_200), label: 'Plain' }, + { value: '\\'.repeat(1_000), label: 'Escaped' }, + ]; + const selectFields = (count: number) => + Array.from({ length: count }, (_, index) => ({ + kind: 'single_select' as const, + name: `select-${index}`, + label: `Select ${index}`, + required: true, + options, + })); + assert.throws(() => + projectInteractionFormRequest({ + toolUseId: 'tool-form', + message: 'Pick five values', + requester: { name: 'deploy' }, + fields: selectFields(5), + }), + ); + assert.doesNotThrow(() => + projectInteractionFormRequest({ + toolUseId: 'tool-form', + message: 'Pick three values', + requester: { name: 'deploy' }, + fields: selectFields(3), + }), + ); + }); + + test('admits date and date-time strings without an explicit maxLength', () => { + // The admission envelope reserves the worst value inside each format's + // legal language: date is fixed-length over [0-9-], and date-time adds + // only characters that never JSON-escape. Neither may inflate to the + // unconstrained control-character worst case. + const request = projectInteractionFormRequest({ + toolUseId: 'tool-form', + message: 'Schedule the deploy', + requester: { name: 'deploy' }, + fields: [ + { kind: 'string', name: 'day', label: 'Day', required: true, format: 'date' }, + { kind: 'string', name: 'at', label: 'At', required: true, format: 'date-time' }, + ], + }); + const answer = { + kind: 'form' as const, + action: 'accept' as const, + values: { day: '2024-02-29', at: '2024-02-29T23:59:59Z' }, + }; + assert.equal(isInteractionAnswerValidForRequest(request, answer), true); + decodeInteractionAnswer(answer); + + // Fractional seconds leave the date-time length unbounded up to the field + // caps, and the envelope reserves exactly that plain-character worst case. + const fractional = projectInteractionFormRequest({ + toolUseId: 'tool-form', + message: 'Schedule the deploy', + requester: { name: 'deploy' }, + fields: [{ kind: 'string', name: 'at', label: 'At', required: true, format: 'date-time' }], + }); + const longFraction = { + kind: 'form' as const, + action: 'accept' as const, + values: { at: `2024-02-29T23:59:59.${'0'.repeat(2_020)}Z` }, + }; + assert.equal(isInteractionAnswerValidForRequest(fractional, longFraction), true); + decodeInteractionAnswer(longFraction); + }); + + test('compares canonical accepted form values structurally', () => { + const first = decodeInteractionCanonicalOutcome({ + kind: 'form_answer', + action: 'accept', + values: { regions: ['us', 'eu'], replicas: 2 }, + committedAt: 1, + }); + const retry = decodeInteractionCanonicalOutcome({ + kind: 'form_answer', + action: 'accept', + values: { replicas: 2, regions: ['us', 'eu'] }, + committedAt: 2, + }); + assert.equal(interactionCanonicalOutcomesEquivalent(first, retry), true); + assert.equal( + interactionCanonicalOutcomesEquivalent( + first, + decodeInteractionCanonicalOutcome({ + kind: 'form_answer', + action: 'accept', + values: { replicas: 2, regions: ['eu', 'us'] }, + committedAt: 3, + }), + ), + false, + ); + }); + + test('decodes form responses as a closed renderer-to-runtime shape', () => { + assert.deepEqual( + decodeInteractionFormResponse({ + requestId: 'form-1', + action: 'accept', + values: { replicas: 3, regions: ['us', 'eu'] }, + }), + { + requestId: 'form-1', + action: 'accept', + values: { replicas: 3, regions: ['us', 'eu'] }, + }, + ); + assert.deepEqual(decodeInteractionFormResponse({ requestId: 'form-1', action: 'decline' }), { + requestId: 'form-1', + action: 'decline', + }); + for (const invalid of [ + { requestId: 'form-1', action: 'accept' }, + { requestId: 'form-1', action: 'cancel', values: {} }, + { requestId: 'form-1', action: 'accept', values: {}, extra: true }, + { requestId: '', action: 'decline' }, + ]) { + assert.throws(() => decodeInteractionFormResponse(invalid)); + } + }); }); diff --git a/packages/core/src/__tests__/runtime-event.test.ts b/packages/core/src/__tests__/runtime-event.test.ts index 2e6725bfd3..18c4dde645 100644 --- a/packages/core/src/__tests__/runtime-event.test.ts +++ b/packages/core/src/__tests__/runtime-event.test.ts @@ -623,7 +623,7 @@ describe('RuntimeEvent actions', () => { } }); - test('permission and user-question interactions are first-class actions', () => { + test('permission, question, and form interactions are first-class actions', () => { const actions: RuntimeEventActions = { permissionRequest: { kind: 'tool_permission', @@ -638,6 +638,14 @@ describe('RuntimeEvent actions', () => { permissionDecision: { requestId: 'pr-1', decision: 'deny' }, permissionAnswerAccepted: { requestId: 'hosted-pr-1' }, userQuestionAnswerAccepted: { requestId: 'question-1' }, + formRequest: { + requestId: 'form-1', + toolUseId: 'tc-1', + message: 'Choose settings', + requester: { name: 'deploy' }, + fields: [{ kind: 'boolean', name: 'confirm', label: 'Confirm', required: true }], + }, + formAnswerAccepted: { requestId: 'form-1' }, }; assert.strictEqual(actions.permissionRequest?.category, 'shell_unsafe'); assert.strictEqual(actions.permissionDecision?.decision, 'deny'); @@ -649,6 +657,7 @@ describe('RuntimeEvent actions', () => { for (const [accepted, requestId] of [ [decodedActions?.permissionAnswerAccepted, 'hosted-pr-1'], [decodedActions?.userQuestionAnswerAccepted, 'question-1'], + [decodedActions?.formAnswerAccepted, 'form-1'], ] as const) { assert.deepEqual(accepted, { requestId }); assert.ok(accepted); @@ -659,8 +668,17 @@ describe('RuntimeEvent actions', () => { for (const invalidAcceptedAction of [ { permissionAnswerAccepted: { requestId: 'pr-1', extra: true } }, { userQuestionAnswerAccepted: { requestId: 'question-1', extra: true } }, + { formAnswerAccepted: { requestId: 'form-1', extra: true } }, { permissionAnswerAccepted: Object.create({ requestId: 'inherited-pr-1' }) }, { userQuestionAnswerAccepted: { requestId: 'x'.repeat(257) } }, + { + formRequest: { + ...actions.formRequest, + fields: [ + { kind: 'boolean', name: 'confirm', label: 'Confirm', required: true, extra: true }, + ], + }, + }, ]) { assert.throws(() => decodeRuntimeEvent({ diff --git a/packages/core/src/backend-types.ts b/packages/core/src/backend-types.ts index 9f326ca20f..7b9d45854f 100644 --- a/packages/core/src/backend-types.ts +++ b/packages/core/src/backend-types.ts @@ -35,9 +35,10 @@ import type { QuoteRef, SessionEvent, SandboxBoundaryRequestEvent, + FormRequestEvent, UserQuestionRequestEvent, } from './events.js'; -import type { InteractionClosureReason } from './interaction.js'; +import type { InteractionClosureReason, InteractionFormResult } from './interaction.js'; import type { RuntimeEvent } from './runtime-event.js'; import type { SandboxBoundaryResponse, SandboxBoundarySettlement } from './sandbox-boundary.js'; import type { StoredMessage, PersistedBackendKind } from './session.js'; @@ -132,6 +133,11 @@ export interface HostedUserQuestionSettlement { applyClosure(reason: Exclude): Promise; } +export interface HostedFormSettlement { + applyAnswer(answer: InteractionFormResult): Promise; + applyClosure(reason: Exclude): Promise; +} + export interface HostedSandboxBoundarySettlement { applyDecision(settlement: SandboxBoundarySettlement): Promise; applyClosure(reason: Exclude): Promise; @@ -150,6 +156,12 @@ export interface HostedInteractionBridge { request: UserQuestionRequestEvent; settlement: HostedUserQuestionSettlement; }): Promise; + admitFormRequest(input: { + request: FormRequestEvent; + settlement: HostedFormSettlement; + }): Promise; + /** Withdraw one exact producer-owned form without closing the surrounding Run. */ + withdrawFormRequest(requestId: string): Promise; admitSandboxBoundaryRequest(input: { request: SandboxBoundaryRequestEvent; settlement: HostedSandboxBoundarySettlement; diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 52af0d0727..a30d3999d4 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -36,6 +36,7 @@ import type { SandboxEscalationRequest, } from './permission.js'; import type { SandboxBoundaryExpansion, SandboxBoundaryRequestStatus } from './sandbox-boundary.js'; +import type { InteractionFormField, InteractionRequesterProjection } from './interaction.js'; import type { UserQuestionRequest } from './user-question.js'; import type { ClientCapabilityGrantCapability, @@ -568,6 +569,8 @@ export type SessionEvent = | PermissionDecisionAckEvent | UserQuestionRequestEvent | UserQuestionAnswerAckEvent + | FormRequestEvent + | FormAnswerAckEvent | PlanSubmittedEvent | TokenUsageEvent | SteeringMessageEvent @@ -1014,6 +1017,15 @@ export interface UserQuestionRequestEvent extends BaseEvent, UserQuestionRequest type: 'user_question_request'; } +export interface FormRequestEvent extends BaseEvent { + type: 'form_request'; + requestId: string; + toolUseId: string; + message: string; + requester: InteractionRequesterProjection; + fields: readonly InteractionFormField[]; +} + export interface SandboxBoundaryRequestEvent extends BaseEvent { type: 'sandbox_boundary_request'; requestId: string; @@ -1038,6 +1050,7 @@ export interface ClientCapabilityRequestEvent extends BaseEvent { export type ActiveInteractionRequestEvent = | SandboxBoundaryRequestEvent | UserQuestionRequestEvent + | FormRequestEvent | ClientCapabilityRequestEvent; export interface SandboxBoundaryDecisionAckEvent extends BaseEvent { @@ -1066,6 +1079,13 @@ export interface UserQuestionAnswerAckEvent extends BaseEvent { toolUseId: string; } +/** Echo that the hosted runtime accepted a form answer. */ +export interface FormAnswerAckEvent extends BaseEvent { + type: 'form_answer_ack'; + requestId: string; + toolUseId: string; +} + /** * Echo that the hosted runtime accepted a permission answer. * The canonical decision remains owned by the Interaction outcome. diff --git a/packages/core/src/interaction.ts b/packages/core/src/interaction.ts index c064906aff..a05a4cde46 100644 --- a/packages/core/src/interaction.ts +++ b/packages/core/src/interaction.ts @@ -59,10 +59,20 @@ export const INTERACTION_SANDBOX_BOUNDARY_JUSTIFICATION_MAX_CHARS = 2_000; export const INTERACTION_ANSWER_SERIALIZED_MAX_BYTES = 8 * 1024; export const INTERACTION_OUTCOME_SERIALIZED_MAX_BYTES = 8 * 1024; export const INTERACTION_AUTO_REVIEW_RATIONALE_MAX_CHARS = 1_000; +export const INTERACTION_FORM_MAX_FIELDS = 32; +export const INTERACTION_FORM_MAX_OPTIONS = 64; +export const INTERACTION_FORM_MESSAGE_MAX_BYTES = 2_048; +export const INTERACTION_FORM_REQUESTER_NAME_MAX_BYTES = 256; +export const INTERACTION_FORM_REQUESTER_SOURCE_MAX_BYTES = 512; +export const INTERACTION_FORM_FIELD_NAME_MAX_BYTES = 256; +export const INTERACTION_FORM_FIELD_LABEL_MAX_BYTES = 256; +export const INTERACTION_FORM_FIELD_DESCRIPTION_MAX_BYTES = 512; +export const INTERACTION_FORM_VALUE_MAX_BYTES = 2_048; export const INTERACTION_CLOSURE_REASONS = [ 'turn_stopped', 'turn_terminal', + 'producer_cancelled', 'timed_out', 'host_restarted', 'provider_disconnected', @@ -94,6 +104,72 @@ export interface InteractionQuestionRequest { readonly questions: readonly InteractionQuestion[]; } +export interface InteractionRequesterProjection { + /** Human-readable name only. Runtime identity remains the exact Tool invocation. */ + readonly name: string; + /** Optional display provenance such as a provider or server name. */ + readonly source?: string; +} + +export interface InteractionFormOption { + readonly value: string; + readonly label: string; +} + +interface InteractionFormFieldBase { + readonly name: string; + readonly label: string; + readonly required: boolean; + readonly description?: string; +} + +export type InteractionFormField = + | (InteractionFormFieldBase & { + readonly kind: 'string'; + readonly default?: string; + readonly minLength?: number; + readonly maxLength?: number; + readonly format?: 'email' | 'uri' | 'date' | 'date-time'; + }) + | (InteractionFormFieldBase & { + readonly kind: 'number'; + readonly default?: number; + readonly minimum?: number; + readonly maximum?: number; + }) + | (InteractionFormFieldBase & { + readonly kind: 'integer'; + readonly default?: number; + readonly minimum?: number; + readonly maximum?: number; + }) + | (InteractionFormFieldBase & { + readonly kind: 'boolean'; + readonly default?: boolean; + }) + | (InteractionFormFieldBase & { + readonly kind: 'single_select'; + readonly options: readonly InteractionFormOption[]; + readonly default?: string; + }) + | (InteractionFormFieldBase & { + readonly kind: 'multi_select'; + readonly options: readonly InteractionFormOption[]; + readonly default?: readonly string[]; + readonly minItems?: number; + readonly maxItems?: number; + }); + +export type InteractionFormValue = string | number | boolean | readonly string[]; + +export interface InteractionFormRequest { + readonly kind: 'form'; + readonly toolUseId: string; + readonly message: string; + readonly requester: InteractionRequesterProjection; + readonly fields: readonly InteractionFormField[]; +} + export interface InteractionSandboxBoundaryRequest { readonly kind: 'sandbox_boundary'; readonly expansion: SandboxBoundaryExpansion; @@ -109,6 +185,7 @@ export interface InteractionClientCapabilityRequest { export type InteractionRequest = | InteractionPermissionRequest | InteractionQuestionRequest + | InteractionFormRequest | InteractionSandboxBoundaryRequest | InteractionClientCapabilityRequest; @@ -125,6 +202,19 @@ export interface InteractionQuestionAnswer { readonly answers: readonly (string | null)[]; } +export type InteractionFormResult = + | { + readonly action: 'accept'; + readonly values: Readonly>; + } + | { + readonly action: 'decline' | 'cancel'; + }; + +export type InteractionFormAnswer = { readonly kind: 'form' } & InteractionFormResult; + +export type InteractionFormResponse = { readonly requestId: string } & InteractionFormResult; + export interface InteractionSandboxBoundaryAnswer { readonly kind: 'sandbox_boundary'; readonly decision: 'allow' | 'deny'; @@ -138,6 +228,7 @@ export interface InteractionClientCapabilityAnswer { export type InteractionAnswer = | InteractionPermissionAnswer | InteractionQuestionAnswer + | InteractionFormAnswer | InteractionSandboxBoundaryAnswer | InteractionClientCapabilityAnswer; @@ -155,6 +246,19 @@ export interface InteractionCanonicalQuestionOutcome { readonly committedAt: number; } +export type InteractionCanonicalFormOutcome = + | { + readonly kind: 'form_answer'; + readonly action: 'accept'; + readonly values: Readonly>; + readonly committedAt: number; + } + | { + readonly kind: 'form_answer'; + readonly action: 'decline' | 'cancel'; + readonly committedAt: number; + }; + export interface InteractionCanonicalSandboxBoundaryOutcome { readonly kind: 'sandbox_boundary_decision'; readonly decision: 'allow' | 'deny'; @@ -177,6 +281,7 @@ export interface InteractionCanonicalClosureOutcome { export type InteractionCanonicalOutcome = | InteractionCanonicalPermissionOutcome | InteractionCanonicalQuestionOutcome + | InteractionCanonicalFormOutcome | InteractionCanonicalSandboxBoundaryOutcome | InteractionCanonicalClientCapabilityOutcome | InteractionCanonicalClosureOutcome; @@ -186,6 +291,12 @@ export type InteractionQuestionProjectionInput = Pick< 'toolUseId' | 'questions' >; +export type InteractionFormInput = Pick; + +export type InteractionFormProjectionInput = InteractionFormInput & { + readonly toolUseId: string; +}; + const PERMISSION_REQUEST_SHAPE = defineObjectShape()( ['kind', 'toolUseId', 'prompt'], [], @@ -194,6 +305,10 @@ const QUESTION_REQUEST_SHAPE = defineObjectShape()( ['kind', 'toolUseId', 'questions'], [], ); +const FORM_REQUEST_SHAPE = defineObjectShape()( + ['kind', 'toolUseId', 'message', 'requester', 'fields'], + [], +); const SANDBOX_BOUNDARY_REQUEST_SHAPE = defineObjectShape()( ['kind', 'expansion', 'justification'], [], @@ -210,6 +325,18 @@ const QUESTION_ANSWER_SHAPE = defineObjectShape()( ['kind', 'answers'], [], ); +const FORM_ACCEPT_ANSWER_SHAPE = defineObjectShape< + Extract +>()(['kind', 'action', 'values'], []); +const FORM_EMPTY_ANSWER_SHAPE = defineObjectShape< + Extract +>()(['kind', 'action'], []); +const FORM_ACCEPT_RESPONSE_SHAPE = defineObjectShape< + Extract +>()(['requestId', 'action', 'values'], []); +const FORM_EMPTY_RESPONSE_SHAPE = defineObjectShape< + Extract +>()(['requestId', 'action'], []); const SANDBOX_BOUNDARY_ANSWER_SHAPE = defineObjectShape()( ['kind', 'decision'], [], @@ -226,6 +353,12 @@ const QUESTION_OUTCOME_SHAPE = defineObjectShape +>()(['kind', 'action', 'values', 'committedAt'], []); +const FORM_EMPTY_OUTCOME_SHAPE = defineObjectShape< + Extract +>()(['kind', 'action', 'committedAt'], []); const SANDBOX_BOUNDARY_OUTCOME_SHAPE = defineObjectShape()( ['kind', 'decision', 'status', 'committedAt'], @@ -242,6 +375,32 @@ const CLOSURE_OUTCOME_SHAPE = defineObjectShape()(['question', 'options'], []); const OPTION_SHAPE = defineObjectShape()(['label'], ['description']); +const FORM_REQUESTER_SHAPE = defineObjectShape()( + ['name'], + ['source'], +); +const FORM_OPTION_SHAPE = defineObjectShape()(['value', 'label'], []); +const FORM_STRING_FIELD_SHAPE = defineObjectShape< + Extract +>()( + ['kind', 'name', 'label', 'required'], + ['description', 'default', 'minLength', 'maxLength', 'format'], +); +const FORM_NUMBER_FIELD_SHAPE = defineObjectShape< + Extract +>()(['kind', 'name', 'label', 'required'], ['description', 'default', 'minimum', 'maximum']); +const FORM_BOOLEAN_FIELD_SHAPE = defineObjectShape< + Extract +>()(['kind', 'name', 'label', 'required'], ['description', 'default']); +const FORM_SINGLE_SELECT_FIELD_SHAPE = defineObjectShape< + Extract +>()(['kind', 'name', 'label', 'required', 'options'], ['description', 'default']); +const FORM_MULTI_SELECT_FIELD_SHAPE = defineObjectShape< + Extract +>()( + ['kind', 'name', 'label', 'required', 'options'], + ['description', 'default', 'minItems', 'maxItems'], +); export function decodeInteractionRequest(value: unknown): InteractionRequest { const record = plainRecord(value, 'Interaction request'); @@ -265,6 +424,21 @@ export function decodeInteractionRequest(value: unknown): InteractionRequest { INTERACTION_MAX_QUESTIONS, ).map(decodeQuestion), }; + } else if (record.kind === 'form') { + exact(record, FORM_REQUEST_SHAPE, 'form request'); + const fields = plainArray(record.fields, 'form fields', 0, INTERACTION_FORM_MAX_FIELDS).map( + decodeFormField, + ); + if (new Set(fields.map((field) => field.name)).size !== fields.length) { + throw new Error('Duplicate form field name'); + } + request = { + kind: 'form', + toolUseId: boundedString(record.toolUseId, 'toolUseId', INTERACTION_ID_MAX_BYTES), + message: boundedString(record.message, 'form message', INTERACTION_FORM_MESSAGE_MAX_BYTES), + requester: decodeFormRequester(record.requester), + fields, + }; } else if (record.kind === 'sandbox_boundary') { exact(record, SANDBOX_BOUNDARY_REQUEST_SHAPE, 'sandbox boundary request'); const expansion = validateSandboxBoundaryExpansion(record.expansion); @@ -288,6 +462,7 @@ export function decodeInteractionRequest(value: unknown): InteractionRequest { } else { throw new Error('Invalid Interaction request kind'); } + if (request.kind === 'form') assertFormHasAcceptedAnswer(request); if (request.kind !== 'sandbox_boundary') { serializedLimit(request, INTERACTION_REQUEST_MAX_BYTES, 'Interaction request'); } @@ -310,6 +485,15 @@ export function decodeInteractionAnswer(value: unknown): InteractionAnswer { } else if (record.kind === 'question') { exact(record, QUESTION_ANSWER_SHAPE, 'question answer'); answer = { kind: 'question', answers: decodeAnswers(record.answers) }; + } else if (record.kind === 'form') { + const action = oneOf(record.action, ['accept', 'decline', 'cancel'] as const, 'form action'); + if (action === 'accept') { + exact(record, FORM_ACCEPT_ANSWER_SHAPE, 'accepted form answer'); + answer = { kind: 'form', action, values: decodeFormValues(record.values) }; + } else { + exact(record, FORM_EMPTY_ANSWER_SHAPE, 'empty form answer'); + answer = { kind: 'form', action }; + } } else if (record.kind === 'sandbox_boundary') { exact(record, SANDBOX_BOUNDARY_ANSWER_SHAPE, 'sandbox boundary answer'); answer = { @@ -326,9 +510,32 @@ export function decodeInteractionAnswer(value: unknown): InteractionAnswer { throw new Error('Invalid Interaction answer kind'); } serializedLimit(answer, INTERACTION_ANSWER_SERIALIZED_MAX_BYTES, 'Interaction answer'); + if (answer.kind === 'form' && answer.action === 'accept') { + assertAcceptedFormAnswerFitsCanonicalOutcome(answer); + } return deepFreeze(answer); } +export function decodeInteractionFormResponse(value: unknown): InteractionFormResponse { + const record = plainRecord(value, 'Interaction form response'); + const requestId = boundedString(record.requestId, 'requestId', INTERACTION_ID_MAX_BYTES); + const action = oneOf(record.action, ['accept', 'decline', 'cancel'] as const, 'form action'); + if (action === 'accept') { + exact(record, FORM_ACCEPT_RESPONSE_SHAPE, 'accepted form response'); + const answer = decodeInteractionAnswer({ + kind: 'form', + action, + values: record.values, + }); + if (answer.kind !== 'form' || answer.action !== 'accept') { + throw new Error('Invalid accepted form response'); + } + return deepFreeze({ requestId, action, values: answer.values }); + } + exact(record, FORM_EMPTY_RESPONSE_SHAPE, 'empty form response'); + return deepFreeze({ requestId, action }); +} + export function decodeInteractionCanonicalOutcome(value: unknown): InteractionCanonicalOutcome { const record = plainRecord(value, 'Interaction canonical outcome'); let outcome: InteractionCanonicalOutcome; @@ -372,6 +579,21 @@ export function decodeInteractionCanonicalOutcome(value: unknown): InteractionCa answers: decodeAnswers(record.answers), committedAt: safeInteger(record.committedAt, 'committedAt', false), }; + } else if (record.kind === 'form_answer') { + const action = oneOf(record.action, ['accept', 'decline', 'cancel'] as const, 'form action'); + const committedAt = safeInteger(record.committedAt, 'committedAt', false); + if (action === 'accept') { + exact(record, FORM_ACCEPT_OUTCOME_SHAPE, 'accepted form outcome'); + outcome = { + kind: 'form_answer', + action, + values: decodeFormValues(record.values), + committedAt, + }; + } else { + exact(record, FORM_EMPTY_OUTCOME_SHAPE, 'empty form outcome'); + outcome = { kind: 'form_answer', action, committedAt }; + } } else if (record.kind === 'sandbox_boundary_decision') { exact(record, SANDBOX_BOUNDARY_OUTCOME_SHAPE, 'sandbox boundary outcome'); const status = oneOf( @@ -470,6 +692,75 @@ export function projectInteractionQuestionRequest( return decodeInteractionRequest(projected) as InteractionQuestionRequest; } +export function projectInteractionFormRequest( + input: InteractionFormProjectionInput, +): InteractionFormRequest { + const decoded = decodeInteractionRequest({ kind: 'form', ...input }) as InteractionFormRequest; + return decodeInteractionRequest({ + ...decoded, + message: projectInteractionReviewText(decoded.message, INTERACTION_FORM_MESSAGE_MAX_BYTES), + requester: { + name: projectInteractionReviewText( + decoded.requester.name, + INTERACTION_FORM_REQUESTER_NAME_MAX_BYTES, + ), + ...(decoded.requester.source === undefined + ? {} + : { + source: projectInteractionReviewText( + decoded.requester.source, + INTERACTION_FORM_REQUESTER_SOURCE_MAX_BYTES, + true, + ), + }), + }, + fields: decoded.fields.map(projectInteractionFormField), + }) as InteractionFormRequest; +} + +function projectInteractionFormField(field: InteractionFormField): InteractionFormField { + const display = { + label: projectInteractionReviewText(field.label, INTERACTION_FORM_FIELD_LABEL_MAX_BYTES), + ...(field.description === undefined + ? {} + : { + description: projectInteractionReviewText( + field.description, + INTERACTION_FORM_FIELD_DESCRIPTION_MAX_BYTES, + true, + ), + }), + }; + if (field.kind !== 'single_select' && field.kind !== 'multi_select') { + // `name` is a protocol identity returned to the tool. A string default is + // both display text and a canonical answer value, so a safety rewrite must + // not silently change its semantics under the original constraints. + if (field.kind === 'string' && field.default !== undefined) { + const { default: canonicalDefault, ...fieldWithoutDefault } = field; + const projectedDefault = projectInteractionReviewText( + canonicalDefault, + INTERACTION_FORM_VALUE_MAX_BYTES, + true, + ); + return { + ...fieldWithoutDefault, + ...display, + ...(projectedDefault === canonicalDefault ? { default: canonicalDefault } : {}), + }; + } + return { ...field, ...display }; + } + // Select values are protocol identities; labels are their display text. + const options = field.options.map((option) => ({ + ...option, + label: projectInteractionReviewText(option.label, INTERACTION_FORM_FIELD_LABEL_MAX_BYTES), + })); + if (new Set(options.map((option) => option.label)).size !== options.length) { + throw new Error('Form option labels collide after safe projection'); + } + return { ...field, ...display, options }; +} + export function projectInteractionSandboxBoundaryRequest(input: { readonly expansion: SandboxBoundaryExpansion; readonly justification: string; @@ -509,9 +800,11 @@ export function interactionOutcomeMatchesRequestKind( ? outcome.kind === 'permission_answer' : request.kind === 'question' ? outcome.kind === 'question_answer' - : request.kind === 'sandbox_boundary' - ? outcome.kind === 'sandbox_boundary_decision' - : outcome.kind === 'client_capability_decision') + : request.kind === 'form' + ? outcome.kind === 'form_answer' + : request.kind === 'sandbox_boundary' + ? outcome.kind === 'sandbox_boundary_decision' + : outcome.kind === 'client_capability_decision') ); } @@ -546,6 +839,9 @@ export function isInteractionAnswerValidForRequest( interactionQuestionAnswerCountMatchesRequest(request, answer.answers) ); } + if (answer.kind === 'form') { + return request.kind === 'form' && interactionFormAnswerMatchesRequest(request, answer); + } if (answer.kind === 'sandbox_boundary') return request.kind === 'sandbox_boundary'; if (answer.kind === 'client_capability') return request.kind === 'client_capability'; return interactionRememberForTurnIsEligible(request, answer); @@ -571,6 +867,17 @@ export function isInteractionCanonicalOutcomeValidForRequest( interactionQuestionAnswerCountMatchesRequest(request, outcome.answers) ); } + if (outcome.kind === 'form_answer') { + return ( + request.kind === 'form' && + interactionFormAnswerMatchesRequest( + request, + outcome.action === 'accept' + ? { kind: 'form', action: 'accept', values: outcome.values } + : { kind: 'form', action: outcome.action }, + ) + ); + } if (outcome.kind === 'client_capability_decision') { return request.kind === 'client_capability'; } @@ -586,6 +893,13 @@ export function interactionCanonicalOutcomesEquivalent( return left.decision === right.decision && left.rememberForTurn === right.rememberForTurn; if (left.kind === 'question_answer' && right.kind === 'question_answer') return equalAnswers(left.answers, right.answers); + if (left.kind === 'form_answer' && right.kind === 'form_answer') { + return ( + left.action === right.action && + (left.action !== 'accept' || + (right.action === 'accept' && equalFormValues(left.values, right.values))) + ); + } if (left.kind === 'sandbox_boundary_decision' && right.kind === 'sandbox_boundary_decision') { return left.decision === right.decision && left.status === right.status; } @@ -629,6 +943,447 @@ function decodeOption(value: unknown): InteractionQuestionOption { }); } +function decodeFormRequester(value: unknown): InteractionRequesterProjection { + const record = plainRecord(value, 'Interaction form requester'); + exact(record, FORM_REQUESTER_SHAPE, 'form requester'); + return Object.freeze({ + name: boundedString( + record.name, + 'form requester name', + INTERACTION_FORM_REQUESTER_NAME_MAX_BYTES, + ), + ...(record.source === undefined + ? {} + : { + source: boundedText( + record.source, + 'form requester source', + INTERACTION_FORM_REQUESTER_SOURCE_MAX_BYTES, + ), + }), + }); +} + +function decodeFormField(value: unknown): InteractionFormField { + const record = plainRecord(value, 'Interaction form field'); + const common = decodeFormFieldBase(record); + if (record.kind === 'string') { + exact(record, FORM_STRING_FIELD_SHAPE, 'string form field'); + const minLength = optionalBoundedCount(record.minLength, 'minLength'); + const maxLength = optionalBoundedCount(record.maxLength, 'maxLength'); + if (minLength !== undefined && maxLength !== undefined && minLength > maxLength) { + throw new Error('Invalid form string length range'); + } + const field: Extract = { + ...common, + kind: 'string', + ...(record.default === undefined + ? {} + : { + default: boundedText( + record.default, + 'form string default', + INTERACTION_FORM_VALUE_MAX_BYTES, + ), + }), + ...(minLength === undefined ? {} : { minLength }), + ...(maxLength === undefined ? {} : { maxLength }), + ...(record.format === undefined + ? {} + : { + format: oneOf( + record.format, + ['email', 'uri', 'date', 'date-time'] as const, + 'form string format', + ), + }), + }; + if (field.default !== undefined && !isInteractionFormFieldValueValid(field, field.default)) { + throw new Error('Invalid form string default'); + } + return deepFreeze(field); + } + if (record.kind === 'number' || record.kind === 'integer') { + exact(record, FORM_NUMBER_FIELD_SHAPE, 'number form field'); + const minimum = optionalFiniteNumber(record.minimum, 'minimum'); + const maximum = optionalFiniteNumber(record.maximum, 'maximum'); + if (minimum !== undefined && maximum !== undefined && minimum > maximum) { + throw new Error('Invalid form number range'); + } + const field: Extract = { + ...common, + kind: record.kind, + ...(record.default === undefined + ? {} + : { default: finiteNumber(record.default, 'form number default') }), + ...(minimum === undefined ? {} : { minimum }), + ...(maximum === undefined ? {} : { maximum }), + }; + if (field.default !== undefined && !isInteractionFormFieldValueValid(field, field.default)) { + throw new Error('Invalid form number default'); + } + return deepFreeze(field); + } + if (record.kind === 'boolean') { + exact(record, FORM_BOOLEAN_FIELD_SHAPE, 'boolean form field'); + return Object.freeze({ + ...common, + kind: 'boolean', + ...(record.default === undefined + ? {} + : { default: boolean(record.default, 'form boolean default') }), + }); + } + if (record.kind === 'single_select') { + exact(record, FORM_SINGLE_SELECT_FIELD_SHAPE, 'single-select form field'); + const options = decodeFormOptions(record.options); + const field: Extract = { + ...common, + kind: 'single_select', + options, + ...(record.default === undefined + ? {} + : { + default: boundedText( + record.default, + 'single-select default', + INTERACTION_FORM_VALUE_MAX_BYTES, + ), + }), + }; + if (field.default !== undefined && !isInteractionFormFieldValueValid(field, field.default)) { + throw new Error('Invalid single-select default'); + } + return deepFreeze(field); + } + if (record.kind === 'multi_select') { + exact(record, FORM_MULTI_SELECT_FIELD_SHAPE, 'multi-select form field'); + const options = decodeFormOptions(record.options); + const minItems = optionalBoundedCount(record.minItems, 'minItems', options.length); + const maxItems = optionalBoundedCount(record.maxItems, 'maxItems', options.length); + if (minItems !== undefined && maxItems !== undefined && minItems > maxItems) { + throw new Error('Invalid multi-select item range'); + } + const field: Extract = { + ...common, + kind: 'multi_select', + options, + ...(record.default === undefined + ? {} + : { + default: decodeFormStringArray(record.default, 'multi-select default'), + }), + ...(minItems === undefined ? {} : { minItems }), + ...(maxItems === undefined ? {} : { maxItems }), + }; + if (field.default !== undefined && !isInteractionFormFieldValueValid(field, field.default)) { + throw new Error('Invalid multi-select default'); + } + return deepFreeze(field); + } + throw new Error('Invalid form field kind'); +} + +function decodeFormFieldBase(record: Record): InteractionFormFieldBase { + return { + name: boundedString(record.name, 'form field name', INTERACTION_FORM_FIELD_NAME_MAX_BYTES), + label: boundedString(record.label, 'form field label', INTERACTION_FORM_FIELD_LABEL_MAX_BYTES), + required: boolean(record.required, 'form field required'), + ...(record.description === undefined + ? {} + : { + description: boundedText( + record.description, + 'form field description', + INTERACTION_FORM_FIELD_DESCRIPTION_MAX_BYTES, + ), + }), + }; +} + +function decodeFormOptions(value: unknown): readonly InteractionFormOption[] { + const options = plainArray(value, 'form options', 1, INTERACTION_FORM_MAX_OPTIONS).map( + (candidate) => { + const record = plainRecord(candidate, 'Interaction form option'); + exact(record, FORM_OPTION_SHAPE, 'form option'); + return Object.freeze({ + value: boundedText(record.value, 'form option value', INTERACTION_FORM_VALUE_MAX_BYTES), + label: boundedString( + record.label, + 'form option label', + INTERACTION_FORM_FIELD_LABEL_MAX_BYTES, + ), + }); + }, + ); + if (new Set(options.map((option) => option.value)).size !== options.length) { + throw new Error('Duplicate form option value'); + } + if (new Set(options.map((option) => option.label)).size !== options.length) { + throw new Error('Duplicate form option label'); + } + return Object.freeze(options); +} + +function decodeFormValues(value: unknown): Readonly> { + const record = plainRecord(value, 'Interaction form values'); + const entries = Object.entries(record); + if (entries.length > INTERACTION_FORM_MAX_FIELDS) throw new Error('Too many form values'); + return deepFreeze( + Object.fromEntries( + entries.map(([name, candidate]) => [ + boundedString(name, 'form value name', INTERACTION_FORM_FIELD_NAME_MAX_BYTES), + decodeFormValue(candidate), + ]), + ), + ); +} + +function decodeFormValue(value: unknown): InteractionFormValue { + if (typeof value === 'string') { + return boundedText(value, 'form string value', INTERACTION_FORM_VALUE_MAX_BYTES); + } + if (typeof value === 'number') return finiteNumber(value, 'form number value'); + if (typeof value === 'boolean') return value; + return decodeFormStringArray(value, 'form multi-select value'); +} + +function decodeFormStringArray(value: unknown, label: string): readonly string[] { + const values = plainArray(value, label, 0, INTERACTION_FORM_MAX_OPTIONS).map((candidate) => + boundedText(candidate, label, INTERACTION_FORM_VALUE_MAX_BYTES), + ); + if (new Set(values).size !== values.length) throw new Error(`Duplicate ${label}`); + return Object.freeze(values); +} + +export function interactionFormAnswerMatchesRequest( + request: InteractionFormRequest, + answer: InteractionFormAnswer, +): boolean { + if (answer.action !== 'accept') return true; + const fields = new Map(request.fields.map((field) => [field.name, field])); + const names = Object.keys(answer.values); + if (names.some((name) => !fields.has(name))) return false; + for (const field of request.fields) { + if (!Object.hasOwn(answer.values, field.name)) { + if (field.required) return false; + continue; + } + if (!isInteractionFormFieldValueValid(field, answer.values[field.name])) return false; + } + return true; +} + +export function isInteractionFormFieldValueValid( + field: InteractionFormField, + value: InteractionFormValue | undefined, +): boolean { + if (value === undefined) return false; + if (field.kind === 'string') { + if (typeof value !== 'string') return false; + const length = [...value].length; + return ( + (field.minLength === undefined || length >= field.minLength) && + (field.maxLength === undefined || length <= field.maxLength) && + matchesStringFormat(value, field.format) + ); + } + if (field.kind === 'number' || field.kind === 'integer') { + return ( + typeof value === 'number' && + Number.isFinite(value) && + (field.kind !== 'integer' || Number.isSafeInteger(value)) && + (field.minimum === undefined || value >= field.minimum) && + (field.maximum === undefined || value <= field.maximum) + ); + } + if (field.kind === 'boolean') return typeof value === 'boolean'; + if (field.kind === 'single_select') { + return typeof value === 'string' && field.options.some((option) => option.value === value); + } + if (!Array.isArray(value) || value.some((candidate) => typeof candidate !== 'string')) { + return false; + } + return ( + new Set(value).size === value.length && + (field.minItems === undefined || value.length >= field.minItems) && + (field.maxItems === undefined || value.length <= field.maxItems) && + value.every((candidate) => field.options.some((option) => option.value === candidate)) + ); +} + +function matchesStringFormat( + value: string, + format: Extract['format'], +): boolean { + if (format === undefined) return true; + if (format === 'email') return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); + if (format === 'uri') { + try { + return new URL(value).protocol.length > 1; + } catch { + return false; + } + } + if (format === 'date') { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!match) return false; + return isValidCalendarDate(Number(match[1]), Number(match[2]), Number(match[3])); + } + const match = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2}))$/.exec( + value, + ); + if (!match) return false; + return ( + isValidCalendarDate(Number(match[1]), Number(match[2]), Number(match[3])) && + Number(match[4]) <= 23 && + Number(match[5]) <= 59 && + Number(match[6]) <= 59 && + (match[7] === undefined || Number(match[7]) <= 23) && + (match[8] === undefined || Number(match[8]) <= 59) && + Number.isFinite(Date.parse(value)) + ); +} + +function isValidCalendarDate(year: number, month: number, day: number): boolean { + if (month < 1 || month > 12 || day < 1) return false; + const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + return day <= days[month - 1]!; +} + +/** Reject a form that can be admitted but can never produce a bounded accepted answer. */ +function assertFormHasAcceptedAnswer(request: InteractionFormRequest): void { + const values: Record = {}; + for (const field of request.fields) { + if (!field.required) continue; + values[field.name] = formFieldWitness(field); + } + const answer = { kind: 'form' as const, action: 'accept' as const, values }; + if (!interactionFormAnswerMatchesRequest(request, answer)) { + throw new Error('Interaction form has no valid accepted answer'); + } + serializedLimit(answer, INTERACTION_ANSWER_SERIALIZED_MAX_BYTES, 'Interaction form answer'); + assertAcceptedFormAnswerFitsCanonicalOutcome(answer); + assertEveryFormAnswerFitsCanonicalOutcome(request); +} + +/** + * Admission must reserve the whole legal answer envelope, not only a smallest + * witness. This intentionally over-approximates format-constrained strings: + * rejecting an over-large form is safe, whereas accepting one that can later + * reject a valid user answer strands the interaction. + */ +function assertEveryFormAnswerFitsCanonicalOutcome(request: InteractionFormRequest): void { + const values = Object.fromEntries( + request.fields.map((field) => [field.name, formFieldMaximumEnvelope(field)]), + ); + const answer = { kind: 'form' as const, action: 'accept' as const, values }; + serializedLimit(answer, INTERACTION_ANSWER_SERIALIZED_MAX_BYTES, 'Interaction form answer'); + assertAcceptedFormAnswerFitsCanonicalOutcome(answer); +} + +function formFieldMaximumEnvelope(field: InteractionFormField): InteractionFormValue { + if (field.kind === 'string') { + // Admission must prove every legal answer serializes, so reserve the worst + // value inside the format's legal language, measured post-serialization. + // - date is a fixed-length language over [0-9-]; nothing JSON-escapes. + // - date-time adds only digits and `.:TZ+-`; nothing JSON-escapes, and the + // fractional seconds leave the length unbounded up to the field caps. + // - every other string may legally hold control characters, which + // JSON-escape to six bytes per code point (one raw byte each). + if (field.format === 'date') return '0000-01-01'; + const maximumCodePoints = Math.min( + field.maxLength ?? INTERACTION_FORM_VALUE_MAX_BYTES, + INTERACTION_FORM_VALUE_MAX_BYTES, + ); + if (field.format === 'date-time') return '0'.repeat(maximumCodePoints); + return '\u0001'.repeat(maximumCodePoints); + } + if (field.kind === 'number' || field.kind === 'integer') return -1.7976931348623157e308; + if (field.kind === 'boolean') return false; + if (field.kind === 'single_select') { + return field.options.reduce( + (longest, option) => + serializedByteLength(option.value) > serializedByteLength(longest) ? option.value : longest, + field.options[0]!.value, + ); + } + return [...field.options] + .sort((left, right) => serializedByteLength(right.value) - serializedByteLength(left.value)) + .slice(0, field.maxItems ?? field.options.length) + .map((option) => option.value); +} + +function serializedByteLength(value: string): number { + return UTF8.encode(JSON.stringify(value)).byteLength; +} + +function assertAcceptedFormAnswerFitsCanonicalOutcome( + answer: Extract, +): void { + serializedLimit( + { + kind: 'form_answer', + action: 'accept', + values: answer.values, + committedAt: Number.MAX_SAFE_INTEGER, + } satisfies Extract, + INTERACTION_OUTCOME_SERIALIZED_MAX_BYTES, + 'Interaction form outcome', + ); +} + +function formFieldWitness(field: InteractionFormField): InteractionFormValue { + if (field.default !== undefined) return field.default; + if (field.kind === 'string') { + const minLength = field.minLength ?? 0; + let value: string; + if (field.format === 'email') { + value = `${'a'.repeat(Math.max(1, minLength - 5))}@b.co`; + } else if (field.format === 'uri') { + const base = 'https://a.co/'; + value = `${base}${'a'.repeat(Math.max(0, minLength - base.length))}`; + } else if (field.format === 'date') { + value = '2000-01-01'; + } else if (field.format === 'date-time') { + const base = '2000-01-01T00:00:00'; + const fractionalLength = minLength <= 20 ? 0 : Math.max(1, minLength - 21); + value = fractionalLength === 0 ? `${base}Z` : `${base}.${'0'.repeat(fractionalLength)}Z`; + } else { + value = 'a'.repeat(minLength); + } + if (!isInteractionFormFieldValueValid(field, value)) { + throw new Error(`Interaction form field ${field.name} has no valid bounded value`); + } + return value; + } + if (field.kind === 'number' || field.kind === 'integer') { + const lower = field.minimum ?? Number.NEGATIVE_INFINITY; + const upper = field.maximum ?? Number.POSITIVE_INFINITY; + const value = + field.kind === 'integer' + ? lower > 0 + ? Math.ceil(lower) + : upper < 0 + ? Math.floor(upper) + : 0 + : lower > 0 + ? lower + : upper < 0 + ? upper + : 0; + if (!isInteractionFormFieldValueValid(field, value)) { + throw new Error(`Interaction form field ${field.name} has no valid bounded value`); + } + return value; + } + if (field.kind === 'boolean') return false; + if (field.kind === 'single_select') return field.options[0].value; + return field.options.slice(0, field.minItems ?? 0).map((option) => option.value); +} + function decodeAnswers(value: unknown): readonly (string | null)[] { return Object.freeze( plainArray(value, 'answers', 1, INTERACTION_MAX_QUESTIONS).map((answer) => @@ -687,6 +1442,13 @@ function boundedString(value: unknown, label: string, maxBytes: number): string return value; } +function boundedText(value: unknown, label: string, maxBytes: number): string { + if (typeof value !== 'string' || UTF8.encode(value).byteLength > maxBytes) { + throw new Error(`Invalid ${label}`); + } + return value; +} + function boundedCharacterString(value: unknown, label: string, maxChars: number): string { if (typeof value !== 'string' || value.length === 0 || value.length > maxChars) throw new Error(`Invalid ${label}`); @@ -704,6 +1466,26 @@ function safeInteger(value: unknown, label: string, positive: boolean): number { return value; } +function optionalBoundedCount( + value: unknown, + label: string, + max = INTERACTION_FORM_VALUE_MAX_BYTES, +): number | undefined { + if (value === undefined) return undefined; + const count = safeInteger(value, label, false); + if (count > max) throw new Error(`Invalid ${label}`); + return count; +} + +function finiteNumber(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error(`Invalid ${label}`); + return Object.is(value, -0) ? 0 : value; +} + +function optionalFiniteNumber(value: unknown, label: string): number | undefined { + return value === undefined ? undefined : finiteNumber(value, label); +} + function oneOf( value: unknown, values: T, @@ -731,6 +1513,26 @@ function equalAnswers( return left.length === right.length && left.every((answer, index) => answer === right[index]); } +function equalFormValues( + left: Readonly>, + right: Readonly>, +): boolean { + const leftKeys = Object.keys(left).sort(); + const rightKeys = Object.keys(right).sort(); + return ( + leftKeys.length === rightKeys.length && + leftKeys.every((key, index) => { + if (key !== rightKeys[index]) return false; + const leftValue = left[key]; + const rightValue = right[key]; + return Array.isArray(leftValue) && Array.isArray(rightValue) + ? leftValue.length === rightValue.length && + leftValue.every((candidate, valueIndex) => candidate === rightValue[valueIndex]) + : leftValue === rightValue; + }) + ); +} + function deepFreeze(value: T): T { if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) { for (const nested of Object.values(value as Record)) deepFreeze(nested); diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index 09738b35ad..4365735f99 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -39,7 +39,12 @@ import { type MessageContent, type PermissionClosureReason, } from './events.js'; -import { INTERACTION_ID_MAX_BYTES, INTERACTION_TOOL_NAME_MAX_BYTES } from './interaction.js'; +import { + INTERACTION_ID_MAX_BYTES, + INTERACTION_TOOL_NAME_MAX_BYTES, + decodeInteractionRequest, + type InteractionFormInput, +} from './interaction.js'; import type { PermissionRequestPayload, PermissionResponse } from './permission.js'; import { decodeTurnOrigin, type TurnOrigin } from './turn-origin.js'; import type { UserQuestionRequest } from './user-question.js'; @@ -347,6 +352,13 @@ interface RuntimeEventAnswerAcceptedIdentity { export interface RuntimeEventUserQuestionAnswerAccepted extends RuntimeEventAnswerAcceptedIdentity {} +export interface RuntimeEventFormRequest extends InteractionFormInput { + requestId: string; + toolUseId: string; +} + +export interface RuntimeEventFormAnswerAccepted extends RuntimeEventAnswerAcceptedIdentity {} + export interface RuntimeEventPermissionAnswerAccepted extends RuntimeEventAnswerAcceptedIdentity {} export interface RuntimeEventPermissionClosureAccepted { @@ -376,6 +388,10 @@ export interface RuntimeEventActions { userQuestionRequest?: UserQuestionRequest; /** Audit fact only; the canonical answer remains in InteractionStore. */ userQuestionAnswerAccepted?: RuntimeEventUserQuestionAnswerAccepted; + /** A provider-neutral structured form raised by a tool call. */ + formRequest?: RuntimeEventFormRequest; + /** Audit fact only; the canonical form result remains in InteractionStore. */ + formAnswerAccepted?: RuntimeEventFormAnswerAccepted; /** Hand off the invocation to another agent (multi-agent transfer). */ transferToAgent?: string; /** Marks the event that closes the invocation. */ @@ -574,6 +590,8 @@ const RUNTIME_ACTIONS_SHAPE = defineObjectShape()( 'permissionClosureAccepted', 'userQuestionRequest', 'userQuestionAnswerAccepted', + 'formRequest', + 'formAnswerAccepted', 'transferToAgent', 'endInvocation', 'tokenUsage', @@ -861,6 +879,9 @@ function isRuntimeEventActions(value: unknown): value is RuntimeEventActions { (value.userQuestionRequest === undefined || isUserQuestionRequest(value.userQuestionRequest)) && (value.userQuestionAnswerAccepted === undefined || isRuntimeEventAnswerAcceptedIdentity(value.userQuestionAnswerAccepted)) && + (value.formRequest === undefined || isRuntimeEventFormRequest(value.formRequest)) && + (value.formAnswerAccepted === undefined || + isRuntimeEventAnswerAcceptedIdentity(value.formAnswerAccepted)) && isOptionalString(value.transferToAgent) && (value.endInvocation === undefined || typeof value.endInvocation === 'boolean') && (value.tokenUsage === undefined || isRuntimeTokenUsage(value.tokenUsage)) && @@ -876,6 +897,24 @@ function isRuntimeEventActions(value: unknown): value is RuntimeEventActions { ); } +function isRuntimeEventFormRequest(value: unknown): value is RuntimeEventFormRequest { + if (!isRecord(value)) return false; + const { requestId, ...request } = value; + if ( + typeof requestId !== 'string' || + requestId.length === 0 || + UTF8.encode(requestId).byteLength > INTERACTION_ID_MAX_BYTES + ) { + return false; + } + try { + decodeInteractionRequest({ kind: 'form', ...request }); + return true; + } catch { + return false; + } +} + function isRuntimeManagedMutationTerminal( value: unknown, ): value is RuntimeEventManagedMutationTerminalV1 { diff --git a/packages/runtime-host/src/__tests__/agent-graph-protocol.test.ts b/packages/runtime-host/src/__tests__/agent-graph-protocol.test.ts index a8fc42cd59..a66a4bbfbf 100644 --- a/packages/runtime-host/src/__tests__/agent-graph-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/agent-graph-protocol.test.ts @@ -94,6 +94,16 @@ describe('Agent Graph Client protocol', () => { const inspection = operatorInspection(snapshot); assert.deepEqual(decodeAgentGraphClientSnapshot(snapshot), snapshot); assert.deepEqual(decodeAgentGraphOperatorInspection(inspection), inspection); + const formActivity = { + ...activity(), + facets: ['form_request'] as const, + signals: [{ kind: 'attention' as const, reason: 'form_request' as const }], + }; + assert.deepEqual( + decodeAgentGraphClientSnapshot({ ...snapshot, recentActivity: [formActivity] }) + .recentActivity, + [formActivity], + ); AGENT_GRAPH_OPERATION_SPECS['agent.graph.query'].assertOutputForInput?.( { rootSessionId: 'root-1' }, snapshot, diff --git a/packages/runtime-host/src/__tests__/client-capability-channel.test.ts b/packages/runtime-host/src/__tests__/client-capability-channel.test.ts index fd48cffabb..337c27e998 100644 --- a/packages/runtime-host/src/__tests__/client-capability-channel.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-channel.test.ts @@ -301,3 +301,180 @@ test('Client Capability channel forwards admitted tool progress before the resul ]); channel.close(new Error('test complete')); }); + +test('Client Capability channel correlates one admitted nested form before the final result', async () => { + let registrationId = ''; + const written: unknown[] = []; + let channel!: ClientCapabilityChannel; + const provider: ClientCapabilityProvider = { + offers: () => [ + { + offerId: 'fixture', + version: '0', + affinity: 'call', + hostPathAccess: 'none', + label: 'Fixture', + tools: [{ serverId: 'fixture', name: 'deploy', inputSchema: { type: 'object' } }], + }, + ], + call: async (_frame, options) => { + await options.accept({ kind: 'none' }); + const answer = await options.requestInteraction({ + message: 'Choose a target', + requester: { name: 'deploy', source: 'Fixture' }, + fields: [ + { + kind: 'single_select', + name: 'target', + label: 'Target', + required: true, + options: [ + { value: 'staging', label: 'Staging' }, + { value: 'production', label: 'Production' }, + ], + }, + ], + }); + assert.deepEqual(answer, { action: 'accept', values: { target: 'staging' } }); + return { content: [{ type: 'text', text: 'deployed' }] }; + }, + }; + channel = new ClientCapabilityChannel({ + write: async (frame) => { + written.push(frame); + if (frame.kind === 'client.capability.accepted') { + queueMicrotask(() => + channel.accept({ + kind: 'client.capability.admitted', + invocationId: frame.invocationId, + }), + ); + } else if (frame.kind === 'client.capability.interaction_request') { + queueMicrotask(() => + channel.accept({ + kind: 'client.capability.interaction_result', + invocationId: frame.invocationId, + interactionId: frame.interactionId, + result: { action: 'accept', values: { target: 'staging' } }, + }), + ); + } + }, + replace: async (input) => { + registrationId = input.registrationId; + return { registrationId, revision: 1 }; + }, + unregister: async (input) => ({ registrationId: input.registrationId, revision: 2 }), + onFailure: (error) => { + throw error; + }, + }); + await channel.replace(provider, 1_000); + channel.accept({ + kind: 'client.capability.call', + invocationId: 'nested-form', + registrationId, + offerId: 'fixture', + serverId: 'fixture', + toolName: 'deploy', + arguments: {}, + sessionId: 'session', + turnId: 'turn', + toolCallId: 'tool-call', + }); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + + const interaction = written.find( + (frame) => + typeof frame === 'object' && + frame !== null && + 'kind' in frame && + frame.kind === 'client.capability.interaction_request', + ); + assert.ok(interaction); + assert.deepEqual(written.at(-1), { + kind: 'client.capability.result', + invocationId: 'nested-form', + result: { content: [{ type: 'text', text: 'deployed' }] }, + }); + channel.accept({ kind: 'client.capability.release', invocationId: 'nested-form' }); + channel.close(new Error('test complete')); +}); + +test('Client Capability release rejects a pending nested form', async () => { + let registrationId = ''; + let interactionStarted!: () => void; + const started = new Promise((resolve) => { + interactionStarted = resolve; + }); + let observedError: unknown; + let channel!: ClientCapabilityChannel; + const provider: ClientCapabilityProvider = { + offers: () => [ + { + offerId: 'fixture', + version: '0', + affinity: 'call', + hostPathAccess: 'none', + label: 'Fixture', + tools: [{ serverId: 'fixture', name: 'deploy', inputSchema: { type: 'object' } }], + }, + ], + call: async (_frame, options) => { + await options.accept({ kind: 'none' }); + try { + await options.requestInteraction({ + message: 'Choose a target', + requester: { name: 'deploy' }, + fields: [{ kind: 'boolean', name: 'confirm', label: 'Confirm', required: true }], + }); + return { content: [] }; + } catch (error) { + observedError = error; + throw error; + } + }, + }; + channel = new ClientCapabilityChannel({ + write: async (frame) => { + if (frame.kind === 'client.capability.accepted') { + queueMicrotask(() => + channel.accept({ + kind: 'client.capability.admitted', + invocationId: frame.invocationId, + }), + ); + } else if (frame.kind === 'client.capability.interaction_request') { + interactionStarted(); + } + }, + replace: async (input) => { + registrationId = input.registrationId; + return { registrationId, revision: 1 }; + }, + unregister: async (input) => ({ registrationId: input.registrationId, revision: 2 }), + onFailure: (error) => { + throw error; + }, + }); + await channel.replace(provider, 1_000); + channel.accept({ + kind: 'client.capability.call', + invocationId: 'released-form', + registrationId, + offerId: 'fixture', + serverId: 'fixture', + toolName: 'deploy', + arguments: {}, + sessionId: 'session', + turnId: 'turn', + toolCallId: 'tool-call', + }); + await started; + + channel.accept({ kind: 'client.capability.release', invocationId: 'released-form' }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(observedError instanceof Error && observedError.name, 'AbortError'); + channel.close(new Error('test complete')); +}); diff --git a/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts index c34e4611f8..adb437d2eb 100644 --- a/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts @@ -1900,6 +1900,88 @@ test('service-only registration lifecycle does not invalidate model backends', a assert.equal(modelToolChanges, 2); }); +test('close waits for nested Client Capability interaction cleanup', async () => { + const coordinator = createCoordinator(); + let connection!: ClientCapabilityConnection; + let interactionStarted!: () => void; + const started = new Promise((resolve) => { + interactionStarted = resolve; + }); + let finishCleanup!: () => void; + const cleanup = new Promise((resolve) => { + finishCleanup = resolve; + }); + connection = coordinator.attachConnection(clientCapabilityConnectionIdentity('connection-a'), { + send: async (frame) => { + if (frame.kind === 'client.capability.call') { + connection.accept({ + kind: 'client.capability.accepted', + invocationId: frame.invocationId, + admissionEvidence: { kind: 'none' }, + }); + } else if (frame.kind === 'client.capability.admitted') { + connection.accept({ + kind: 'client.capability.interaction_request', + invocationId: frame.invocationId, + interactionId: 'interaction-a', + request: { + message: 'Choose a target', + requester: { name: 'deploy' }, + fields: [ + { kind: 'string', name: 'target', label: 'Target', required: true, maxLength: 256 }, + ], + }, + }); + } + }, + }); + await replace(coordinator, 'connection-a', 'registration-a', 'deploy'); + assert.deepEqual(await coordinator.bindSession('session-a', 'connection-a'), { ok: true }); + const snapshot = coordinator.snapshotForSession('session-a'); + assert.ok(snapshot); + const call = Promise.resolve( + snapshot.tools[0]!.impl( + {}, + { + sessionId: 'session-a', + turnId: 'turn-a', + cwd: '/tmp', + toolCallId: 'tool-call-a', + abortSignal: new AbortController().signal, + emitOutput: () => undefined, + requestUserForm: async (_form, options) => { + interactionStarted(); + const signal = options?.cancellationSignal; + assert.ok(signal); + if (!signal.aborted) { + await new Promise((resolve) => + signal.addEventListener('abort', () => resolve(), { once: true }), + ); + } + await cleanup; + throw signal.reason; + }, + }, + ), + ); + void call.catch(() => undefined); + await started; + snapshot.release(); + + const connectionClosing = connection.close(); + await new Promise((resolve) => setImmediate(resolve)); + + let closed = false; + const closing = coordinator.close().then(() => { + closed = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(closed, false); + finishCleanup(); + await Promise.all([connectionClosing, closing]); + await assert.rejects(call, ToolOutcomeUnknownError); +}); + async function invoke(tool: NonNullable>): Promise { return tool.impl( {}, diff --git a/packages/runtime-host/src/__tests__/client-capability-interaction-broker.test.ts b/packages/runtime-host/src/__tests__/client-capability-interaction-broker.test.ts new file mode 100644 index 0000000000..31e23f4d73 --- /dev/null +++ b/packages/runtime-host/src/__tests__/client-capability-interaction-broker.test.ts @@ -0,0 +1,397 @@ +/* + * 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 { ToolOutcomeUnknownError } from '@maka/core/events'; +import type { ClientCapabilityHostFrame } from '../protocol/index.js'; +import { + ClientCapabilityInvocationBroker, + ClientCapabilityInvocationError, + type ClientCapabilityInvocationBinding, + type ClientCapabilityInvocationRegistration, +} from '../server/client-capability-invocation-broker.js'; + +const REGISTRATION: ClientCapabilityInvocationRegistration = { + connectionId: 'connection-a', + registrationId: 'registration-a', +}; + +const BINDING: ClientCapabilityInvocationBinding = { + offerId: 'offer-a', + hostPathAccess: 'none', + descriptor: { + serverId: 'fixture', + name: 'deploy', + inputSchema: { type: 'object' }, + }, +}; + +const CONTEXT = { + sessionId: 'session-a', + turnId: 'turn-a', + toolCallId: 'tool-call-a', + cwd: '/tmp', +}; + +test('Client Capability nested interaction pauses and rearms the execution timeout', async () => { + const sent: ClientCapabilityHostFrame[] = []; + const timers = createTimerHarness(); + let answer!: (value: { action: 'accept'; values: { target: string } }) => void; + const broker = new ClientCapabilityInvocationBroker({ + senderFor: () => ({ send: async (frame) => void sent.push(frame) }), + onRegistrationIdle: () => {}, + scheduleTimeout: timers.schedule, + }); + const result = broker.invoke( + REGISTRATION, + BINDING, + {}, + CONTEXT, + undefined, + 1_000, + undefined, + async (_form, options) => { + assert.equal(options?.cancellationSignal?.aborted, false); + return new Promise((resolve) => { + answer = resolve; + }); + }, + ); + await flush(); + const invocationId = callInvocationId(sent); + assert.equal(timers.activeCount(), 1); + + broker.accept('connection-a', { + kind: 'client.capability.accepted', + invocationId, + admissionEvidence: { kind: 'none' }, + }); + await flush(); + broker.accept('connection-a', { + kind: 'client.capability.interaction_request', + invocationId, + interactionId: 'interaction-a', + request: { + message: 'Choose a target', + requester: { name: 'deploy' }, + fields: [{ kind: 'string', name: 'target', label: 'Target', required: true, maxLength: 256 }], + }, + }); + assert.equal(timers.activeCount(), 0); + + answer({ action: 'accept', values: { target: 'staging' } }); + await flush(); + assert.deepEqual(sent.at(-1), { + kind: 'client.capability.interaction_result', + invocationId, + interactionId: 'interaction-a', + result: { action: 'accept', values: { target: 'staging' } }, + }); + assert.equal(timers.activeCount(), 1); + + timers.fireActive(); + await assert.rejects(result, (error: unknown) => error instanceof ToolOutcomeUnknownError); + assert.equal(timers.activeCount(), 0); + broker.close(); +}); + +test('Client Capability accepts a final result while interaction delivery is still flushing', async () => { + const sent: ClientCapabilityHostFrame[] = []; + let broker!: ClientCapabilityInvocationBroker; + const brokerOptions = { + senderFor: () => ({ + send: async (frame: ClientCapabilityHostFrame) => { + sent.push(frame); + if (frame.kind !== 'client.capability.interaction_result') return; + broker.accept('connection-a', { + kind: 'client.capability.result', + invocationId: frame.invocationId, + result: { content: [{ type: 'text', text: 'deployed' }] }, + }); + await flush(); + }, + }), + onRegistrationIdle: () => {}, + }; + broker = new ClientCapabilityInvocationBroker(brokerOptions); + const result = broker.invoke( + REGISTRATION, + BINDING, + {}, + CONTEXT, + undefined, + 1_000, + undefined, + async () => ({ action: 'accept', values: { target: 'staging' } }), + ); + await flush(); + const invocationId = callInvocationId(sent); + broker.accept('connection-a', { + kind: 'client.capability.accepted', + invocationId, + admissionEvidence: { kind: 'none' }, + }); + await flush(); + broker.accept('connection-a', { + kind: 'client.capability.interaction_request', + invocationId, + interactionId: 'interaction-a', + request: { + message: 'Choose a target', + requester: { name: 'deploy' }, + fields: [{ kind: 'string', name: 'target', label: 'Target', required: true, maxLength: 256 }], + }, + }); + + assert.deepEqual(await result, { content: [{ type: 'text', text: 'deployed' }] }); + broker.close(); +}); + +test('Client Capability provider failure waits for pending interaction withdrawal', async () => { + const sent: ClientCapabilityHostFrame[] = []; + let finishWithdrawal!: () => void; + const withdrawal = new Promise((resolve) => { + finishWithdrawal = resolve; + }); + let producerCancelled = false; + const broker = new ClientCapabilityInvocationBroker({ + senderFor: () => ({ send: async (frame) => void sent.push(frame) }), + onRegistrationIdle: () => {}, + }); + const result = broker.invoke( + REGISTRATION, + BINDING, + {}, + CONTEXT, + undefined, + 1_000, + undefined, + async (_form, options) => { + const signal = options?.cancellationSignal; + assert.ok(signal); + await new Promise((resolve) => + signal.addEventListener( + 'abort', + () => { + producerCancelled = true; + resolve(); + }, + { once: true }, + ), + ); + await withdrawal; + throw signal.reason; + }, + ); + await flush(); + const invocationId = callInvocationId(sent); + broker.accept('connection-a', { + kind: 'client.capability.accepted', + invocationId, + admissionEvidence: { kind: 'none' }, + }); + await flush(); + broker.accept('connection-a', { + kind: 'client.capability.interaction_request', + invocationId, + interactionId: 'interaction-a', + request: { + message: 'Confirm', + requester: { name: 'deploy' }, + fields: [{ kind: 'boolean', name: 'confirm', label: 'Confirm', required: true }], + }, + }); + broker.accept('connection-a', { + kind: 'client.capability.failed', + invocationId, + message: 'provider stopped', + }); + await flush(); + assert.equal(producerCancelled, true); + assert.equal( + sent.some((frame) => frame.kind === 'client.capability.release'), + false, + ); + + finishWithdrawal(); + await assert.rejects( + result, + (error: unknown) => + error instanceof ClientCapabilityInvocationError && + error.code === 'provider_failed' && + error.message === 'provider stopped', + ); + assert.equal(sent.at(-1)?.kind, 'client.capability.release'); + broker.close(); +}); + +test('Client Capability connection release waits for pending interaction withdrawal', async () => { + const sent: ClientCapabilityHostFrame[] = []; + let finishWithdrawal!: () => void; + const withdrawal = new Promise((resolve) => { + finishWithdrawal = resolve; + }); + const broker = new ClientCapabilityInvocationBroker({ + senderFor: () => ({ send: async (frame) => void sent.push(frame) }), + onRegistrationIdle: () => {}, + }); + const result = broker.invoke( + REGISTRATION, + BINDING, + {}, + CONTEXT, + undefined, + 1_000, + undefined, + async (_form, options) => { + const signal = options?.cancellationSignal; + assert.ok(signal); + await new Promise((resolve) => + signal.addEventListener('abort', () => resolve(), { once: true }), + ); + await withdrawal; + throw signal.reason; + }, + ); + await flush(); + const invocationId = callInvocationId(sent); + broker.accept('connection-a', { + kind: 'client.capability.accepted', + invocationId, + admissionEvidence: { kind: 'none' }, + }); + await flush(); + broker.accept('connection-a', { + kind: 'client.capability.interaction_request', + invocationId, + interactionId: 'interaction-a', + request: { + message: 'Confirm', + requester: { name: 'deploy' }, + fields: [{ kind: 'boolean', name: 'confirm', label: 'Confirm', required: true }], + }, + }); + let released = false; + const release = broker.releaseConnection('connection-a').then(() => { + released = true; + }); + await flush(); + assert.equal(released, false); + + finishWithdrawal(); + await release; + await assert.rejects(result, /disconnected after accepting/); + broker.close(); +}); + +test('Client Capability cancellation settles only after the nested interaction closes', async () => { + const sent: ClientCapabilityHostFrame[] = []; + const invocationController = new AbortController(); + let finishWithdrawal!: () => void; + const withdrawal = new Promise((resolve) => { + finishWithdrawal = resolve; + }); + const broker = new ClientCapabilityInvocationBroker({ + senderFor: () => ({ send: async (frame) => void sent.push(frame) }), + onRegistrationIdle: () => {}, + }); + const result = broker.invoke( + REGISTRATION, + BINDING, + {}, + CONTEXT, + invocationController.signal, + 1_000, + undefined, + async (_form, options) => { + const signal = options?.cancellationSignal; + assert.ok(signal); + await new Promise((resolve) => + signal.addEventListener('abort', () => resolve(), { once: true }), + ); + await withdrawal; + throw signal.reason; + }, + ); + await flush(); + const invocationId = callInvocationId(sent); + broker.accept('connection-a', { + kind: 'client.capability.accepted', + invocationId, + admissionEvidence: { kind: 'none' }, + }); + await flush(); + broker.accept('connection-a', { + kind: 'client.capability.interaction_request', + invocationId, + interactionId: 'interaction-a', + request: { + message: 'Confirm', + requester: { name: 'deploy' }, + fields: [{ kind: 'boolean', name: 'confirm', label: 'Confirm', required: true }], + }, + }); + invocationController.abort(new Error('stop')); + await flush(); + assert.equal( + sent.some((frame) => frame.kind === 'client.capability.cancel'), + true, + ); + assert.equal( + sent.some((frame) => frame.kind === 'client.capability.release'), + false, + ); + + finishWithdrawal(); + await assert.rejects(result, (error: unknown) => error instanceof ToolOutcomeUnknownError); + assert.equal(sent.at(-1)?.kind, 'client.capability.release'); + broker.close(); +}); + +function callInvocationId(frames: readonly ClientCapabilityHostFrame[]): string { + const call = frames.find((frame) => frame.kind === 'client.capability.call'); + assert.ok(call && call.kind === 'client.capability.call'); + return call.invocationId; +} + +function createTimerHarness(): { + readonly schedule: (callback: () => void) => () => void; + activeCount(): number; + fireActive(): void; +} { + const active = new Set<() => void>(); + return { + schedule: (callback) => { + active.add(callback); + return () => active.delete(callback); + }, + activeCount: () => active.size, + fireActive: () => { + const callback = active.values().next().value; + assert.ok(callback); + active.delete(callback); + callback(); + }, + }; +} + +async function flush(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} diff --git a/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts b/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts index 1f79a4b7a3..b5cfcdf54a 100644 --- a/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts @@ -136,6 +136,94 @@ describe('Client Capability protocol', () => { invocationId: 'invocation', }, ); + assert.deepEqual( + decodeClientFrame({ + kind: 'client.capability.interaction_request', + invocationId: 'invocation', + interactionId: 'provider-form-1', + request: { + message: 'Choose a target', + requester: { name: 'deploy', source: 'Fixture' }, + fields: [ + { + kind: 'single_select', + name: 'target', + label: 'Target', + required: true, + options: [ + { value: 'staging', label: 'Staging' }, + { value: 'production', label: 'Production' }, + ], + }, + ], + }, + }), + { + kind: 'client.capability.interaction_request', + invocationId: 'invocation', + interactionId: 'provider-form-1', + request: { + message: 'Choose a target', + requester: { name: 'deploy', source: 'Fixture' }, + fields: [ + { + kind: 'single_select', + name: 'target', + label: 'Target', + required: true, + options: [ + { value: 'staging', label: 'Staging' }, + { value: 'production', label: 'Production' }, + ], + }, + ], + }, + }, + ); + assert.deepEqual( + decodeHostFrame({ + kind: 'client.capability.interaction_result', + invocationId: 'invocation', + interactionId: 'provider-form-1', + result: { action: 'accept', values: { target: 'staging' } }, + }), + { + kind: 'client.capability.interaction_result', + invocationId: 'invocation', + interactionId: 'provider-form-1', + result: { action: 'accept', values: { target: 'staging' } }, + }, + ); + }); + + test('rejects malformed nested Client Capability interactions at the codec', () => { + assert.throws( + () => + decodeClientFrame({ + kind: 'client.capability.interaction_request', + invocationId: 'invocation', + interactionId: 'provider-form-1', + request: { + message: 'Invalid duplicate fields', + requester: { name: 'fixture' }, + fields: [ + { kind: 'boolean', name: 'same', label: 'First', required: true }, + { kind: 'boolean', name: 'same', label: 'Second', required: true }, + ], + }, + }), + (error: unknown) => error instanceof RuntimeHostProtocolError, + ); + assert.throws( + () => + decodeHostFrame({ + kind: 'client.capability.interaction_result', + invocationId: 'invocation', + interactionId: 'provider-form-1', + result: { action: 'cancel', values: {} }, + }), + (error: unknown) => error instanceof RuntimeHostProtocolError, + ); }); test('keeps Host services open-world and outside model tool offers', () => { diff --git a/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts b/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts index bae9e4b7fc..52ebb394de 100644 --- a/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts @@ -24,12 +24,17 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, test } from 'node:test'; import type { SandboxBoundaryRequest } from '@maka/core/sandbox-boundary'; -import type { SandboxBoundaryRequestEvent, UserQuestionRequestEvent } from '@maka/core/events'; +import type { + FormRequestEvent, + SandboxBoundaryRequestEvent, + UserQuestionRequestEvent, +} from '@maka/core/events'; import { bindRuntimeInteractionRun, RuntimeInteractionAdmissionRejectedError, RuntimeInteractionFailStopError, type RuntimeInteractionRunIdentity, + type RuntimeFormContinuation, type RuntimeSandboxBoundaryContinuation, type RuntimeUserQuestionContinuation, } from '@maka/runtime/interaction-authority'; @@ -163,6 +168,94 @@ describe('HostInteractionCoordinator', () => { }); }); + test('validates and commits one canonical form answer before resuming its exact continuation', async () => { + await withStore(async ({ store }) => { + const order: string[] = []; + const coordinator = createCoordinator(store, { + refreshCanonicalContinuity: async () => { + const record = await store.readInteraction('form_1'); + order.push(record?.outcome ? 'refresh:answered' : 'refresh:pending'); + }, + }); + const owner = coordinator.bindRun(RUN); + assert.ok(owner.acceptFormRequest); + await owner.acceptFormRequest({ + request: formEvent('form_1', 10), + continuation: formContinuation('form_1', { + answer: (answer) => order.push(`apply:${answer.action}`), + }), + }); + assert.deepEqual(order, ['refresh:pending']); + + const invalid = await coordinator.handlers['interaction.answer']( + { + sessionId: RUN.sessionId, + interactionId: 'form_1', + answer: { kind: 'form', action: 'accept', values: { replicas: 0 } }, + }, + connection(), + ); + assert.equal(invalid.ok, false); + if (!invalid.ok) assert.equal(invalid.error.code, 'operation_conflict'); + assert.deepEqual(order, ['refresh:pending']); + + const answer = { + sessionId: RUN.sessionId, + interactionId: 'form_1', + answer: { + kind: 'form', + action: 'accept', + values: { replicas: 3, regions: ['us', 'eu'] }, + }, + } as const; + const [first, second] = await Promise.all([ + coordinator.handlers['interaction.answer'](answer, connection()), + coordinator.handlers['interaction.answer'](answer, connection()), + ]); + assert.equal(first.ok, true); + assert.equal(second.ok, true); + assert.deepEqual(order, ['refresh:pending', 'refresh:answered', 'apply:accept']); + const record = await store.readInteraction('form_1'); + assert.deepEqual(record?.outcome?.outcome, { + kind: 'form_answer', + action: 'accept', + values: { replicas: 3, regions: ['us', 'eu'] }, + committedAt: 101, + }); + + const closures: string[] = []; + await owner.acceptFormRequest({ + request: formEvent('form_2', 11), + continuation: formContinuation('form_2', { + closure: (reason) => closures.push(reason), + }), + }); + await owner.withdrawFormRequest('form_2'); + assert.deepEqual(closures, ['producer_cancelled']); + assert.deepEqual((await store.readInteraction('form_2'))?.outcome?.outcome, { + kind: 'closure', + reason: 'producer_cancelled', + committedAt: 102, + }); + + await owner.acceptFormRequest({ + request: formEvent('form_3', 12), + continuation: formContinuation('form_3', { + closure: (reason) => closures.push(reason), + }), + }); + await owner.close('turn_terminal'); + assert.deepEqual(closures, ['producer_cancelled', 'turn_terminal']); + assert.deepEqual((await store.readInteraction('form_3'))?.outcome?.outcome, { + kind: 'closure', + reason: 'turn_terminal', + committedAt: 103, + }); + owner.release(); + await coordinator.close(); + }); + }); + test('settles a sandbox boundary once through the canonical boundary Store and wakes its graph', async () => { await withStore(async ({ owner, store, stores }) => { const workspace = join(owner.capability.canonicalPath, 'workspace'); @@ -774,6 +867,59 @@ function questionEvent(requestId: string, ts: number): UserQuestionRequestEvent }; } +function formEvent(requestId: string, ts: number): FormRequestEvent { + return { + id: `event_${requestId}`, + type: 'form_request', + turnId: RUN.turnId, + ts, + requestId, + toolUseId: `tool_${requestId}`, + message: 'Choose deployment settings', + requester: { name: 'deploy', source: 'Synthetic provider' }, + fields: [ + { + kind: 'integer', + name: 'replicas', + label: 'Replicas', + required: true, + minimum: 1, + maximum: 10, + }, + { + kind: 'multi_select', + name: 'regions', + label: 'Regions', + required: false, + options: [ + { value: 'us', label: 'US' }, + { value: 'eu', label: 'EU' }, + ], + }, + ], + }; +} + +function formContinuation( + requestId: string, + callbacks: { + answer?: (answer: Parameters[0]) => unknown; + closure?: (reason: Parameters[0]) => unknown; + } = {}, +): RuntimeFormContinuation { + return { + ...RUN, + requestId, + waitForPublication: async () => {}, + applyAnswer: async (answer) => { + await callbacks.answer?.(answer); + }, + applyClosure: async (reason) => { + await callbacks.closure?.(reason); + }, + }; +} + function questionContinuation( requestId: string, callbacks: { diff --git a/packages/runtime-host/src/__tests__/interaction-protocol.test.ts b/packages/runtime-host/src/__tests__/interaction-protocol.test.ts index ba0e1ee51a..ee60f66272 100644 --- a/packages/runtime-host/src/__tests__/interaction-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/interaction-protocol.test.ts @@ -169,6 +169,56 @@ describe('Runtime Host Interaction protocol', () => { ); }); + test('decodes a form snapshot and exact form answer without widening the wire', () => { + const snapshot = { + schemaVersion: 1, + interactionId: 'form-1', + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + revision: 1, + status: 'pending', + outcome: null, + request: { + kind: 'form', + toolUseId: 'tool-1', + message: 'Choose settings', + requester: { name: 'deploy', source: 'Example server' }, + fields: [ + { + kind: 'boolean', + name: 'confirm', + label: 'Confirm', + required: true, + }, + ], + }, + } as const; + assert.deepEqual(decodeInteractionSnapshot(snapshot), snapshot); + + const frame = { + requestId: 'answer-form', + operation: 'interaction.answer', + input: { + sessionId: 'session-1', + interactionId: 'form-1', + answer: { kind: 'form', action: 'accept', values: { confirm: true } }, + }, + } as const; + assert.deepEqual(decodeClientFrame(frame), frame); + assert.throws( + () => + decodeClientFrame({ + ...frame, + input: { + ...frame.input, + answer: { ...frame.input.answer, requestState: 'must-not-cross-host-wire' }, + }, + }), + isInvalidFrame, + ); + }); + test('returns the canonical winner only for an equivalent normalized answer retry', () => { const request = storedRequest('interaction-1', 10); const answered: InteractionRecord & { outcome: NonNullable } = { diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index baf7270a1b..6f8210429e 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -211,6 +211,10 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 38); }); + test('publishes a new compatibility epoch for nested Client Capability interactions', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 81); + }); + test('publishes a new compatibility epoch for onboarding endpoint overrides', () => { // Epoch 44 peers reject the required `baseUrl` and `connectionId` on // onboarding inputs, and the `base_url_not_configured` / diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts index 5e85869232..3687f6d389 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -567,6 +567,17 @@ export function projectRuntimeHostInteractionRequest( }, ]; } + if (interaction.request.kind === 'form') { + return [ + { + type: 'form_request', + ...base, + message: interaction.request.message, + requester: structuredClone(interaction.request.requester), + fields: structuredClone(interaction.request.fields), + }, + ]; + } if (interaction.request.kind === 'sandbox_boundary') { return [ { diff --git a/packages/runtime-host/src/client/client-capability-channel.ts b/packages/runtime-host/src/client/client-capability-channel.ts index 5ed354567e..4467f961ae 100644 --- a/packages/runtime-host/src/client/client-capability-channel.ts +++ b/packages/runtime-host/src/client/client-capability-channel.ts @@ -18,9 +18,11 @@ */ import { randomUUID } from 'node:crypto'; +import type { InteractionFormInput, InteractionFormResult } from '@maka/core/interaction'; import { CLIENT_CAPABILITY_MAX_RESULT_BYTES, CLIENT_CAPABILITY_RESULT_CHUNK_MAX_BYTES, + decodeClientCapabilityClientFrame, decodeClientCapabilityReplaceInput, decodeClientCapabilityResult, type ClientCapabilityAdmissionEvidence, @@ -45,6 +47,7 @@ interface ClientCapabilityRegistration { interface ClientCapabilityInvocation { readonly controller: AbortController; admission?: ClientCapabilityAdmission; + interaction?: ClientCapabilityPendingInteraction; released: boolean; } @@ -54,6 +57,20 @@ interface ClientCapabilityAdmission { reject(error: unknown): boolean; } +interface ClientCapabilityPendingInteraction { + readonly interactionId: string; + readonly promise: Promise< + Extract['result'] + >; + resolve( + result: Extract< + ClientCapabilityHostFrame, + { kind: 'client.capability.interaction_result' } + >['result'], + ): boolean; + reject(error: unknown): boolean; +} + export interface ClientCapabilityChannelOptions { readonly write: (frame: ClientCapabilityClientFrame) => Promise; readonly replace: ( @@ -163,6 +180,7 @@ export class ClientCapabilityChannel { new DOMException('Client Capability invocation was cancelled', 'AbortError'), ); invocation.admission?.reject(capabilityInvocationAbortReason(invocation)); + invocation.interaction?.reject(capabilityInvocationAbortReason(invocation)); return; } case 'client.capability.release': { @@ -173,6 +191,7 @@ export class ClientCapabilityChannel { new DOMException('Client Capability invocation was released', 'AbortError'), ); invocation.admission?.reject(capabilityInvocationAbortReason(invocation)); + invocation.interaction?.reject(capabilityInvocationAbortReason(invocation)); this.#invocations.delete(frame.invocationId); return; } @@ -187,6 +206,18 @@ export class ClientCapabilityChannel { } return; } + case 'client.capability.interaction_result': { + const invocation = this.#invocations.get(frame.invocationId); + const interaction = invocation?.interaction; + if ( + !interaction || + interaction.interactionId !== frame.interactionId || + !interaction.resolve(frame.result) + ) { + throw new Error('Runtime Host returned an unmatched capability interaction result'); + } + return; + } } } @@ -197,6 +228,7 @@ export class ClientCapabilityChannel { invocation.released = true; invocation.controller.abort(error); invocation.admission?.reject(error); + invocation.interaction?.reject(error); } this.#invocations.clear(); const providers = new Set( @@ -285,6 +317,7 @@ export class ClientCapabilityChannel { readonly signal: AbortSignal; accept(evidence: ClientCapabilityAdmissionEvidence): Promise; progress(current: number, total: number): void; + requestInteraction(form: InteractionFormInput): Promise; }) => Promise>, ): Promise { let accepted = false; @@ -329,14 +362,52 @@ export class ClientCapabilityChannel { }) .catch((error: unknown) => this.#options.onFailure(asError(error))); }; + const requestInteraction = async ( + request: InteractionFormInput, + ): Promise => { + if (!accepted) { + throw new Error('Client Capability interaction requires an admitted invocation'); + } + if (invocation.released) throw capabilityInvocationAbortReason(invocation); + if (invocation.interaction) { + throw new Error('Client Capability invocation already has a pending interaction'); + } + const interactionId = randomUUID(); + const frame = decodeClientCapabilityClientFrame({ + kind: 'client.capability.interaction_request', + invocationId, + interactionId, + request, + }); + if (frame.kind !== 'client.capability.interaction_request') { + throw new Error('Client Capability interaction request was not canonical'); + } + const interaction = createClientCapabilityPendingInteraction(interactionId); + invocation.interaction = interaction; + try { + try { + await this.#options.write(frame); + } catch (error) { + interaction.reject(error); + throw error; + } + return await interaction.promise; + } finally { + if (invocation.interaction === interaction) invocation.interaction = undefined; + } + }; const result = decodeClientCapabilityResult( await execute({ signal: invocation.controller.signal, accept, progress, + requestInteraction, }), ); if (invocation.released) return; + if (invocation.interaction) { + throw new Error('Client Capability provider returned with a pending interaction'); + } await accept({ kind: 'none' }); await this.#sendResult(invocationId, result, invocation); } catch (error) { @@ -448,6 +519,37 @@ function createClientCapabilityAdmission(): ClientCapabilityAdmission { }; } +function createClientCapabilityPendingInteraction( + interactionId: string, +): ClientCapabilityPendingInteraction { + let state: 'pending' | 'resolved' | 'rejected' = 'pending'; + let resolvePromise!: (result: InteractionFormResult) => void; + let rejectPromise!: (error: unknown) => void; + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + // A buggy provider may start but not await the callback. The invocation still + // fails closed, while channel teardown must not create an unhandled rejection. + void promise.catch(() => undefined); + return { + interactionId, + promise, + resolve: (result) => { + if (state !== 'pending') return false; + state = 'resolved'; + resolvePromise(result); + return true; + }, + reject: (error) => { + if (state !== 'pending') return false; + state = 'rejected'; + rejectPromise(error); + return true; + }, + }; +} + function capabilityInvocationAbortReason(invocation: ClientCapabilityInvocation): unknown { return ( invocation.controller.signal.reason ?? diff --git a/packages/runtime-host/src/client/client-capability.ts b/packages/runtime-host/src/client/client-capability.ts index e91ff2cf58..f0a4946333 100644 --- a/packages/runtime-host/src/client/client-capability.ts +++ b/packages/runtime-host/src/client/client-capability.ts @@ -17,6 +17,7 @@ * under the License. */ +import type { InteractionFormInput, InteractionFormResult } from '@maka/core/interaction'; import type { ClientCapabilityCallFrame, ClientCapabilityCallResult, @@ -38,6 +39,8 @@ export interface ClientCapabilityProvider { accept(evidence: ClientCapabilityAdmissionEvidence): Promise; /** Publish bounded live progress after admission. */ progress?(current: number, total: number): void; + /** Request one Host-owned form after the invocation is admitted. */ + requestInteraction(form: InteractionFormInput): Promise; }, ): Promise; callService?( diff --git a/packages/runtime-host/src/protocol/agent-graph.ts b/packages/runtime-host/src/protocol/agent-graph.ts index faa0c7d246..02c9842c00 100644 --- a/packages/runtime-host/src/protocol/agent-graph.ts +++ b/packages/runtime-host/src/protocol/agent-graph.ts @@ -112,6 +112,7 @@ export type AgentGraphRecordFacet = | 'permission_request' | 'permission_decision' | 'user_question_request' + | 'form_request' | 'transfer' | 'usage' | 'completed' @@ -121,7 +122,10 @@ export type AgentGraphRecordFacet = | 'runtime_fact'; export type AgentGraphSupervisorSignal = - | { readonly kind: 'attention'; readonly reason: 'permission_request' | 'user_question_request' } + | { + readonly kind: 'attention'; + readonly reason: 'permission_request' | 'user_question_request' | 'form_request'; + } | { readonly kind: 'terminal'; readonly status: 'completed' | 'failed' | 'aborted' | 'cancelled'; @@ -1194,7 +1198,11 @@ function decodeSignal(value: unknown): AgentGraphSupervisorSignal { const record = requireShapedRecord(value, 'agent graph signal', ['kind'], ['reason', 'status']); if (record.kind === 'attention') { requireExactRecord(record, 'agent graph attention signal', ['kind', 'reason']); - if (record.reason !== 'permission_request' && record.reason !== 'user_question_request') { + if ( + record.reason !== 'permission_request' && + record.reason !== 'user_question_request' && + record.reason !== 'form_request' + ) { throw invalidProtocolFrame('Invalid agent graph attention reason'); } return { kind: record.kind, reason: record.reason }; @@ -1429,6 +1437,7 @@ function requireFacet(value: unknown): AgentGraphRecordFacet { value === 'permission_request' || value === 'permission_decision' || value === 'user_question_request' || + value === 'form_request' || value === 'transfer' || value === 'usage' || value === 'completed' || diff --git a/packages/runtime-host/src/protocol/client-capability.ts b/packages/runtime-host/src/protocol/client-capability.ts index ce51734621..404f6eac43 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -18,6 +18,12 @@ */ import { TOOL_ACTIVITY_KINDS, type ToolActivityKind } from '@maka/core/events'; +import { + decodeInteractionAnswer, + projectInteractionFormRequest, + type InteractionFormInput, + type InteractionFormResult, +} from '@maka/core/interaction'; import { assertExactKeys, requireCount, @@ -177,13 +183,21 @@ export interface ClientCapabilityAdmittedFrame { readonly invocationId: string; } +export interface ClientCapabilityInteractionResultFrame { + readonly kind: 'client.capability.interaction_result'; + readonly invocationId: string; + readonly interactionId: string; + readonly result: InteractionFormResult; +} + export type ClientCapabilityHostFrame = | ClientCapabilityCallFrame | ClientCapabilityServiceCallFrame | ClientCapabilityCancelFrame | ClientCapabilityReleaseFrame | ClientCapabilityRegistrationReleaseFrame - | ClientCapabilityAdmittedFrame; + | ClientCapabilityAdmittedFrame + | ClientCapabilityInteractionResultFrame; export interface ClientCapabilityAcceptedFrame { readonly kind: 'client.capability.accepted'; @@ -234,6 +248,13 @@ export interface ClientCapabilityResultChunkFrame { readonly data: string; } +export interface ClientCapabilityInteractionRequestFrame { + readonly kind: 'client.capability.interaction_request'; + readonly invocationId: string; + readonly interactionId: string; + readonly request: InteractionFormInput; +} + export type ClientCapabilityClientFrame = | ClientCapabilityAcceptedFrame | ClientCapabilityRejectedFrame @@ -241,7 +262,8 @@ export type ClientCapabilityClientFrame = | ClientCapabilityProgressFrame | ClientCapabilityResultFrame | ClientCapabilityResultStartFrame - | ClientCapabilityResultChunkFrame; + | ClientCapabilityResultChunkFrame + | ClientCapabilityInteractionRequestFrame; export const CLIENT_CAPABILITY_OPERATION_SPECS = { 'client.capability.replace': defineHostPathOperation< @@ -485,6 +507,19 @@ export function decodeClientCapabilityClientFrame(value: unknown): ClientCapabil data, }; } + case 'client.capability.interaction_request': + assertExactKeys(frame, 'Client Capability interaction request frame', [ + 'kind', + 'invocationId', + 'interactionId', + 'request', + ]); + return { + kind: frame.kind, + invocationId: requireEntityId(frame.invocationId, 'invocationId'), + interactionId: requireEntityId(frame.interactionId, 'interactionId'), + request: decodeClientCapabilityFormRequest(frame.request), + }; default: throw invalidProtocolFrame('Invalid Client Capability client frame kind'); } @@ -583,6 +618,19 @@ export function decodeClientCapabilityHostFrame(value: unknown): ClientCapabilit kind: frame.kind, invocationId: requireEntityId(frame.invocationId, 'invocationId'), }; + case 'client.capability.interaction_result': + assertExactKeys(frame, 'Client Capability interaction result frame', [ + 'kind', + 'invocationId', + 'interactionId', + 'result', + ]); + return { + kind: frame.kind, + invocationId: requireEntityId(frame.invocationId, 'invocationId'), + interactionId: requireEntityId(frame.interactionId, 'interactionId'), + result: decodeClientCapabilityFormResult(frame.result), + }; case 'client.capability.registration_release': assertExactKeys(frame, 'Client Capability registration release frame', [ 'kind', @@ -614,6 +662,44 @@ export function decodeClientCapabilityResult(value: unknown): ClientCapabilityCa }; } +function decodeClientCapabilityFormRequest(value: unknown): InteractionFormInput { + const record = requireExactRecord(value, 'Client Capability form request', [ + 'message', + 'requester', + 'fields', + ]); + let request: ReturnType; + try { + request = projectInteractionFormRequest({ + toolUseId: 'client-capability-interaction', + message: record.message as string, + requester: record.requester as InteractionFormInput['requester'], + fields: record.fields as InteractionFormInput['fields'], + }); + } catch { + throw invalidProtocolFrame('Invalid Client Capability form request'); + } + return { + message: request.message, + requester: request.requester, + fields: request.fields, + }; +} + +function decodeClientCapabilityFormResult(value: unknown): InteractionFormResult { + const record = requireRecord(value, 'Client Capability form result'); + let answer: ReturnType; + try { + answer = decodeInteractionAnswer({ kind: 'form', ...record }); + } catch { + throw invalidProtocolFrame('Invalid Client Capability form result'); + } + if (answer.kind !== 'form') throw invalidProtocolFrame('Invalid Client Capability form result'); + return answer.action === 'accept' + ? { action: 'accept', values: answer.values } + : { action: answer.action }; +} + function decodeClientCapabilityOffer(value: unknown): ClientCapabilityOffer { const record = requireRecord(value, 'Client Capability offer'); assertOptionalExactKeys( @@ -1131,6 +1217,7 @@ const CLIENT_CAPABILITY_CLIENT_FRAME_KINDS = new Set([ @@ -1140,4 +1227,5 @@ const CLIENT_CAPABILITY_HOST_FRAME_KINDS = new Set { - this.#invocations.releaseConnection(connectionId); + const invocationCleanup = this.#invocations.releaseConnection(connectionId); const connection = this.#connections.get(connectionId); - if (!connection) return Promise.resolve(); + if (!connection) return invocationCleanup; let task!: Promise; - task = this.#activation - .runMutation(() => this.#releaseConnectionState(connection)) + task = Promise.all([ + invocationCleanup, + this.#activation.runMutation(() => this.#releaseConnectionState(connection)), + ]) + .then(() => undefined) .finally(() => this.#pendingConnectionReleases.delete(task)); this.#pendingConnectionReleases.add(task); void task.catch(() => undefined); @@ -827,9 +830,10 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService async close(): Promise { this.beginDrain(); - for (const connectionId of [...this.#connections.keys()]) { - this.releaseConnection(connectionId); - } + const releases = [...this.#connections.keys()].map((connectionId) => + this.releaseConnection(connectionId), + ); + await Promise.allSettled(releases); await Promise.allSettled([...this.#pendingConnectionReleases]); this.#invocations.close(); this.#sessions.clear(); @@ -1069,7 +1073,8 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService ); if (!target) { return { - execute: ({ emitProgress } = {}) => prepared.admit(emitProgress), + execute: ({ emitProgress, requestInteraction } = {}) => + prepared.admit(emitProgress, requestInteraction), cancel: () => prepared.cancel(), }; } @@ -1090,7 +1095,8 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService } } return { - execute: ({ emitProgress } = {}) => prepared.admit(emitProgress), + execute: ({ emitProgress, requestInteraction } = {}) => + prepared.admit(emitProgress, requestInteraction), cancel: () => prepared.cancel(), }; } catch (error) { @@ -1108,6 +1114,7 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService options.signal, options.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS, options.emitProgress, + options.requestInteraction, ); }, }; diff --git a/packages/runtime-host/src/server/client-capability-invocation-broker.ts b/packages/runtime-host/src/server/client-capability-invocation-broker.ts index a4c7db15b3..b8a2669241 100644 --- a/packages/runtime-host/src/server/client-capability-invocation-broker.ts +++ b/packages/runtime-host/src/server/client-capability-invocation-broker.ts @@ -19,6 +19,7 @@ import { randomUUID } from 'node:crypto'; import { ToolOutcomeUnknownError } from '@maka/core/events'; +import type { InteractionFormInput, InteractionFormResult } from '@maka/core/interaction'; import { CLIENT_CAPABILITY_MAX_RESULT_BYTES, CLIENT_CAPABILITY_RESULT_CHUNK_MAX_BYTES, @@ -81,11 +82,19 @@ interface InvocationState void; onProgress?: (current: number, total: number) => void; + requestInteraction?: ClientCapabilityInteractionHandler; readonly timeoutMs: number; readonly providerAvailability: AbortController; - timer: NodeJS.Timeout | undefined; + cancelTimer?: () => void; + interaction?: InvocationInteraction; acceptedSettled: boolean; - phase: 'dispatched' | 'accepted' | 'admitted' | 'chunks'; + phase: + | 'dispatched' + | 'accepted' + | 'admitted' + | 'awaiting_interaction' + | 'delivering_interaction_result' + | 'chunks'; progress?: { current: number; total: number }; chunks?: { readonly byteLength: number; @@ -95,12 +104,28 @@ interface InvocationState; + readonly resolveDone: () => void; + terminal?: { readonly error: Error; readonly releaseRemote: boolean }; +} + +type ClientCapabilityInteractionHandler = ( + form: InteractionFormInput, + options?: { readonly cancellationSignal?: AbortSignal }, +) => Promise; + export interface PreparedClientCapabilityInvocation { readonly invocationId: string; /** Resolves once the provider has parsed the call and is waiting at its admission cut. */ waitUntilAccepted(): Promise; /** Crosses the admission cut and returns the provider result. */ - admit(onProgress?: (current: number, total: number) => void): Promise; + admit( + onProgress?: (current: number, total: number) => void, + requestInteraction?: ClientCapabilityInteractionHandler, + ): Promise; /** Cancels an accepted call that will not cross the admission cut. */ cancel(): void; /** Aborts when the provider connection disappears before this call is admitted. */ @@ -112,6 +137,7 @@ export interface ClientCapabilityInvocationBrokerOptions< > { readonly senderFor: (connectionId: string) => ClientCapabilityConnectionSender | undefined; readonly onRegistrationIdle: (registration: Registration) => void; + readonly scheduleTimeout?: (callback: () => void, timeoutMs: number) => () => void; } export class ClientCapabilityInvocationBroker< @@ -119,12 +145,21 @@ export class ClientCapabilityInvocationBroker< > { readonly #senderFor: ClientCapabilityInvocationBrokerOptions['senderFor']; readonly #onRegistrationIdle: ClientCapabilityInvocationBrokerOptions['onRegistrationIdle']; + readonly #scheduleTimeout: NonNullable< + ClientCapabilityInvocationBrokerOptions['scheduleTimeout'] + >; readonly #invocations = new Map>(); readonly #retiredInvocationIds = new Set(); constructor(options: ClientCapabilityInvocationBrokerOptions) { this.#senderFor = options.senderFor; this.#onRegistrationIdle = options.onRegistrationIdle; + this.#scheduleTimeout = + options.scheduleTimeout ?? + ((callback, timeoutMs) => { + const timer = setTimeout(callback, timeoutMs); + return () => clearTimeout(timer); + }); } async invoke( @@ -135,6 +170,7 @@ export class ClientCapabilityInvocationBroker< signal: AbortSignal | undefined, timeoutMs: number, onProgress?: (current: number, total: number) => void, + requestInteraction?: ClientCapabilityInteractionHandler, ): Promise { const prepared = this.prepare( registration, @@ -144,6 +180,7 @@ export class ClientCapabilityInvocationBroker< signal, timeoutMs, onProgress, + requestInteraction, ); await prepared.waitUntilAccepted(); return prepared.admit(); @@ -157,20 +194,28 @@ export class ClientCapabilityInvocationBroker< signal: AbortSignal | undefined, timeoutMs: number, onProgress?: (current: number, total: number) => void, + requestInteraction?: ClientCapabilityInteractionHandler, ): PreparedClientCapabilityInvocation { - return this.#prepare(registration, signal, timeoutMs, onProgress, (invocationId) => ({ - kind: 'client.capability.call', - invocationId, - registrationId: registration.registrationId, - offerId: binding.offerId, - serverId: binding.descriptor.serverId, - toolName: binding.descriptor.name, - arguments: args, - sessionId: context.sessionId, - turnId: context.turnId, - toolCallId: context.toolCallId, - ...(binding.hostPathAccess === 'cwd' ? { cwd: context.cwd } : {}), - })); + return this.#prepare( + registration, + signal, + timeoutMs, + onProgress, + requestInteraction, + (invocationId) => ({ + kind: 'client.capability.call', + invocationId, + registrationId: registration.registrationId, + offerId: binding.offerId, + serverId: binding.descriptor.serverId, + toolName: binding.descriptor.name, + arguments: args, + sessionId: context.sessionId, + turnId: context.turnId, + toolCallId: context.toolCallId, + ...(binding.hostPathAccess === 'cwd' ? { cwd: context.cwd } : {}), + }), + ); } async invokeService( @@ -204,7 +249,7 @@ export class ClientCapabilityInvocationBroker< signal: AbortSignal | undefined, timeoutMs: number, ): PreparedClientCapabilityInvocation { - return this.#prepare(registration, signal, timeoutMs, undefined, (invocationId) => ({ + return this.#prepare(registration, signal, timeoutMs, undefined, undefined, (invocationId) => ({ kind: 'client.capability.service_call', invocationId, registrationId: registration.registrationId, @@ -220,6 +265,7 @@ export class ClientCapabilityInvocationBroker< signal: AbortSignal | undefined, timeoutMs: number, onProgress: ((current: number, total: number) => void) | undefined, + requestInteraction: ClientCapabilityInteractionHandler | undefined, frameFor: (invocationId: string) => ClientCapabilityHostFrame, ): PreparedClientCapabilityInvocation { const sender = this.#senderFor(registration.connectionId); @@ -253,16 +299,17 @@ export class ClientCapabilityInvocationBroker< const invocation = this.#invocations.get(invocationId); if (!invocation) return; void sender.send({ kind: 'client.capability.cancel', invocationId }).catch(() => {}); - this.#settle( - invocation, - undefined, + const error = invocation.phase === 'dispatched' || invocation.phase === 'accepted' ? asError(abortReason(signal)) : new ToolOutcomeUnknownError( 'Client Capability invocation was cancelled after admission', - ), - true, - ); + ); + if (invocation.interaction) { + this.#terminateInteraction(invocation, error, true, true); + } else { + this.#settle(invocation, undefined, error, true); + } } : undefined; const invocation: InvocationState = { @@ -275,14 +322,14 @@ export class ClientCapabilityInvocationBroker< signal, onAbort, onProgress, + requestInteraction, timeoutMs, providerAvailability: new AbortController(), - timer: undefined, acceptedSettled: false, phase: 'dispatched', }; this.#invocations.set(invocationId, invocation); - this.#startTimeout(invocation); + this.#armTimer(invocation); if (onAbort) signal?.addEventListener('abort', onAbort, { once: true }); void sender.send(frameFor(invocationId)).catch(() => { const current = this.#invocations.get(invocationId); @@ -307,14 +354,15 @@ export class ClientCapabilityInvocationBroker< providerSignal: this.#invocations.get(invocationId)?.providerAvailability.signal ?? AbortSignal.abort(), waitUntilAccepted: () => accepted, - admit: async (onProgress) => { + admit: async (onProgress, requestInteraction) => { await accepted; const invocation = this.#invocations.get(invocationId); if (!invocation) return result; if (invocation.phase === 'accepted') { invocation.onProgress = onProgress ?? invocation.onProgress; + invocation.requestInteraction = requestInteraction ?? invocation.requestInteraction; invocation.phase = 'admitted'; - this.#startTimeout(invocation); + this.#armTimer(invocation); const currentSender = this.#senderFor(invocation.registration.connectionId); if (!currentSender) { this.#settle( @@ -380,8 +428,7 @@ export class ClientCapabilityInvocationBroker< throw new Error('Client Capability invocation was accepted more than once'); } invocation.phase = 'accepted'; - if (invocation.timer) clearTimeout(invocation.timer); - invocation.timer = undefined; + this.#clearTimer(invocation); invocation.acceptedSettled = true; invocation.resolveAccepted(frame.admissionEvidence); return; @@ -398,18 +445,31 @@ export class ClientCapabilityInvocationBroker< ); return; case 'client.capability.failed': - if (invocation.phase !== 'admitted' && invocation.phase !== 'chunks') { + if (invocation.phase === 'dispatched' || invocation.phase === 'accepted') { throw new Error('Client Capability failure arrived before admission'); } - this.#settle( - invocation, - undefined, - new ClientCapabilityInvocationError('provider_failed', frame.message), - true, - ); + if (invocation.interaction) { + this.#terminateInteraction( + invocation, + new ClientCapabilityInvocationError('provider_failed', frame.message), + true, + true, + ); + } else { + this.#settle( + invocation, + undefined, + new ClientCapabilityInvocationError('provider_failed', frame.message), + true, + ); + } return; case 'client.capability.progress': - if (invocation.phase !== 'admitted' && invocation.phase !== 'chunks') { + if ( + invocation.phase !== 'admitted' && + invocation.phase !== 'delivering_interaction_result' && + invocation.phase !== 'chunks' + ) { throw new Error('Client Capability progress arrived before admission'); } if ( @@ -422,14 +482,47 @@ export class ClientCapabilityInvocationBroker< invocation.progress = { current: frame.current, total: frame.total }; invocation.onProgress?.(frame.current, frame.total); return; + case 'client.capability.interaction_request': + this.#acceptInteraction(invocation, frame.interactionId, frame.request); + return; case 'client.capability.result': - if (invocation.phase !== 'admitted') { + if (invocation.phase === 'awaiting_interaction') { + this.#terminateInteraction( + invocation, + new ClientCapabilityInvocationError( + 'provider_failed', + 'Client Capability provider returned before its interaction completed', + ), + true, + true, + ); + return; + } + if ( + invocation.phase !== 'admitted' && + invocation.phase !== 'delivering_interaction_result' + ) { throw new Error('Client Capability result arrived outside the admitted phase'); } this.#settle(invocation, frame.result, undefined, true); return; case 'client.capability.result_start': - if (invocation.phase !== 'admitted') { + if (invocation.phase === 'awaiting_interaction') { + this.#terminateInteraction( + invocation, + new ClientCapabilityInvocationError( + 'provider_failed', + 'Client Capability provider returned before its interaction completed', + ), + true, + true, + ); + return; + } + if ( + invocation.phase !== 'admitted' && + invocation.phase !== 'delivering_interaction_result' + ) { throw new Error('Client Capability result chunks started outside the admitted phase'); } invocation.phase = 'chunks'; @@ -445,7 +538,8 @@ export class ClientCapabilityInvocationBroker< } } - releaseConnection(connectionId: string): void { + async releaseConnection(connectionId: string): Promise { + const interactions: Promise[] = []; for (const invocation of [...this.#invocations.values()]) { if (invocation.registration.connectionId !== connectionId) continue; if (invocation.phase === 'dispatched' || invocation.phase === 'accepted') { @@ -456,9 +550,7 @@ export class ClientCapabilityInvocationBroker< ), ); } - this.#settle( - invocation, - undefined, + const error = invocation.phase === 'dispatched' || invocation.phase === 'accepted' ? new ClientCapabilityInvocationError( 'capability_lost', @@ -466,10 +558,15 @@ export class ClientCapabilityInvocationBroker< ) : new ToolOutcomeUnknownError( 'Client Capability provider disconnected after accepting the call', - ), - false, - ); + ); + if (invocation.interaction) { + interactions.push(invocation.interaction.done); + this.#terminateInteraction(invocation, error, false, true); + } else { + this.#settle(invocation, undefined, error, false); + } } + await Promise.all(interactions); } holdsRegistration(registration: Registration): boolean { @@ -514,11 +611,156 @@ export class ClientCapabilityInvocationBroker< this.#settle(invocation, decodeClientCapabilityResult(decoded), undefined, true); } - #startTimeout(invocation: InvocationState): void { - if (invocation.timer) clearTimeout(invocation.timer); - invocation.timer = setTimeout(() => { + #acceptInteraction( + invocation: InvocationState, + interactionId: string, + request: InteractionFormInput, + ): void { + if ( + (invocation.phase !== 'admitted' && invocation.phase !== 'delivering_interaction_result') || + invocation.interaction + ) { + if (invocation.interaction) { + this.#terminateInteraction( + invocation, + new ClientCapabilityInvocationError( + 'provider_failed', + 'Client Capability provider requested overlapping interactions', + ), + true, + true, + ); + return; + } + throw new Error('Client Capability interaction arrived outside the admitted phase'); + } + if (!invocation.requestInteraction) { + this.#settle( + invocation, + undefined, + new ClientCapabilityInvocationError( + 'provider_failed', + 'Client Capability interaction is unavailable for this invocation', + ), + true, + ); + return; + } + this.#clearTimer(invocation); + invocation.phase = 'awaiting_interaction'; + let resolveDone!: () => void; + const interaction: InvocationInteraction = { + interactionId, + controller: new AbortController(), + done: new Promise((resolve) => { + resolveDone = resolve; + }), + resolveDone: () => resolveDone(), + }; + invocation.interaction = interaction; + void this.#runInteraction(invocation, interaction, request); + } + + async #runInteraction( + invocation: InvocationState, + interaction: InvocationInteraction, + request: InteractionFormInput, + ): Promise { + try { + const signal = invocation.signal + ? AbortSignal.any([invocation.signal, interaction.controller.signal]) + : interaction.controller.signal; + const result = await invocation.requestInteraction!(request, { + cancellationSignal: signal, + }); + if (!this.#isCurrentInteraction(invocation, interaction)) return; + const terminalBeforeSend = interaction.terminal; + if (terminalBeforeSend) { + this.#settle( + invocation, + undefined, + terminalBeforeSend.error, + terminalBeforeSend.releaseRemote, + ); + return; + } + const sender = this.#senderFor(invocation.registration.connectionId); + if (!sender) { + this.#settle( + invocation, + undefined, + new ToolOutcomeUnknownError( + 'Client Capability provider disappeared before receiving an interaction result', + ), + false, + ); + return; + } + invocation.interaction = undefined; + invocation.phase = 'delivering_interaction_result'; + this.#armTimer(invocation); + await sender.send({ + kind: 'client.capability.interaction_result', + invocationId: invocation.invocationId, + interactionId: interaction.interactionId, + result, + }); + if (this.#invocations.get(invocation.invocationId) !== invocation) return; + if (invocation.phase !== 'delivering_interaction_result') return; + invocation.phase = 'admitted'; + this.#armTimer(invocation); + } catch (error) { + if (this.#invocations.get(invocation.invocationId) !== invocation) return; + if (invocation.interaction !== interaction) { + if (invocation.phase !== 'delivering_interaction_result') return; + this.#settle( + invocation, + undefined, + new ToolOutcomeUnknownError( + `Client Capability interaction result could not be delivered: ${asError(error).message}`, + ), + false, + ); + return; + } + this.#settle( + invocation, + undefined, + interaction.terminal?.error ?? asError(error), + interaction.terminal?.releaseRemote ?? true, + ); + } finally { + interaction.resolveDone(); + } + } + + #terminateInteraction( + invocation: InvocationState, + error: Error, + releaseRemote: boolean, + cancelProducer: boolean, + ): void { + const interaction = invocation.interaction; + if (!interaction || interaction.terminal) return; + interaction.terminal = { error, releaseRemote }; + if (cancelProducer) interaction.controller.abort(error); + } + + #isCurrentInteraction( + invocation: InvocationState, + interaction: InvocationInteraction, + ): boolean { + return ( + this.#invocations.get(invocation.invocationId) === invocation && + invocation.interaction === interaction + ); + } + + #armTimer(invocation: InvocationState): void { + this.#clearTimer(invocation); + invocation.cancelTimer = this.#scheduleTimeout(() => { const current = this.#invocations.get(invocation.invocationId); - if (!current) return; + if (current !== invocation) return; const sender = this.#senderFor(current.registration.connectionId); void sender ?.send({ kind: 'client.capability.cancel', invocationId: current.invocationId }) @@ -537,6 +779,11 @@ export class ClientCapabilityInvocationBroker< }, invocation.timeoutMs); } + #clearTimer(invocation: InvocationState): void { + invocation.cancelTimer?.(); + invocation.cancelTimer = undefined; + } + #settle( invocation: InvocationState, result: ClientCapabilityCallResult | undefined, @@ -545,7 +792,7 @@ export class ClientCapabilityInvocationBroker< ): void { if (this.#invocations.get(invocation.invocationId) !== invocation) return; this.#invocations.delete(invocation.invocationId); - if (invocation.timer) clearTimeout(invocation.timer); + this.#clearTimer(invocation); if (invocation.onAbort && invocation.signal) { invocation.signal.removeEventListener('abort', invocation.onAbort); } diff --git a/packages/runtime-host/src/server/interaction-coordinator.ts b/packages/runtime-host/src/server/interaction-coordinator.ts index 68b36e0dc5..be899c983f 100644 --- a/packages/runtime-host/src/server/interaction-coordinator.ts +++ b/packages/runtime-host/src/server/interaction-coordinator.ts @@ -23,11 +23,16 @@ import type { ClientCapabilityGrantTarget, ClientCapabilitySessionGrant, } from '@maka/core/client-capability-grant'; -import type { SandboxBoundaryRequestEvent, UserQuestionRequestEvent } from '@maka/core/events'; +import type { + FormRequestEvent, + SandboxBoundaryRequestEvent, + UserQuestionRequestEvent, +} from '@maka/core/events'; import { isInteractionAnswerValidForRequest, projectInteractionClientCapabilityRequest, projectInteractionSandboxBoundaryRequest, + projectInteractionFormRequest, projectInteractionQuestionRequest, type InteractionCanonicalOutcome, type InteractionClosureReason, @@ -45,6 +50,7 @@ import { type RuntimeInteractionRunClosureReason, type RuntimeInteractionRunIdentity, type RuntimeInteractionRunOwner, + type RuntimeFormContinuation, type RuntimeSandboxBoundaryContinuation, type RuntimeUserQuestionContinuation, } from '@maka/runtime/interaction-authority'; @@ -70,8 +76,10 @@ import { projectInteractionRecord, projectSandboxBoundaryInteraction, projectSessionInteractions, + formCanonicalOutcome, questionCanonicalOutcome, runtimeQuestionOutcome, + runtimeFormOutcome, } from './interaction-projection.js'; import type { InteractionOperationHandlerMap } from './operation-dispatcher.js'; import { type SessionAdmissionLease, SessionAdmissionGate } from './session-admission-gate.js'; @@ -128,6 +136,12 @@ interface LiveQuestionEntry extends LiveEntryBase { readonly continuation: RuntimeUserQuestionContinuation; } +interface LiveFormEntry extends LiveEntryBase { + readonly kind: 'form'; + readonly request: StoredInteractionRequest; + readonly continuation: RuntimeFormContinuation; +} + interface LiveSandboxBoundaryEntry extends LiveEntryBase { readonly kind: 'sandbox_boundary'; readonly boundaryRequest: SandboxBoundaryRequest; @@ -142,10 +156,11 @@ interface LiveClientCapabilityEntry extends LiveEntryBase { readonly reject: (error: unknown) => void; } -type LiveStoredEntry = LiveQuestionEntry | LiveClientCapabilityEntry; +type LiveStoredEntry = LiveQuestionEntry | LiveFormEntry | LiveClientCapabilityEntry; type LiveEntry = LiveStoredEntry | LiveSandboxBoundaryEntry; type LiveStoredCandidate = | Omit + | Omit | Omit; interface CommittedEntry { @@ -237,6 +252,9 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { acceptUserQuestionRequest: ( input: Parameters[0], ) => this.#acceptUserQuestionRequest(run, input), + acceptFormRequest: (input: Parameters[0]) => + this.#acceptFormRequest(run, input), + withdrawFormRequest: (requestId: string) => this.#withdrawFormRequest(run, requestId), acceptSandboxBoundaryRequest: ( input: Parameters[0], ) => this.#acceptSandboxBoundaryRequest(run, input), @@ -481,6 +499,45 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { } } + #acceptFormRequest( + run: BoundRun, + input: Parameters[0], + ): Promise { + try { + this.#assertAcceptable(run, input.request, input.continuation); + let request: ReturnType; + try { + request = projectInteractionFormRequest({ + toolUseId: input.request.toolUseId, + message: input.request.message, + requester: input.request.requester, + fields: input.request.fields, + }); + } catch { + return rejected( + new RuntimeInteractionAdmissionRejectedError( + input.continuation.requestId, + 'invalid_request', + ), + ); + } + return observed( + this.#accept(run, { + kind: 'form', + request: { + ...runIdentity(run), + requestId: input.continuation.requestId, + createdAt: input.request.ts, + request, + }, + continuation: input.continuation, + }).then(() => undefined), + ); + } catch (error) { + return rejected(error); + } + } + #acceptSandboxBoundaryRequest( run: BoundRun, input: Parameters[0], @@ -645,9 +702,12 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { ), ); } - const questions = await this.#readPending({ sessionId: run.sessionId }); + const storedInteractions = await this.#readPending({ sessionId: run.sessionId }); const sandboxBoundaries = await this.#readPendingSandboxBoundaries(run.sessionId); - if (questions.length + sandboxBoundaries.length >= INTERACTION_MAX_PENDING_PER_SESSION) { + if ( + storedInteractions.length + sandboxBoundaries.length >= + INTERACTION_MAX_PENDING_PER_SESSION + ) { throw new RuntimeInteractionAdmissionRejectedError( input.request.requestId, 'capacity_exceeded', @@ -664,7 +724,10 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { turnId: run.turnId, runId: run.runId, }; - const projection = projectSessionInteractions(questions, [...sandboxBoundaries, candidate]); + const projection = projectSessionInteractions(storedInteractions, [ + ...sandboxBoundaries, + candidate, + ]); if (!(await this.#preflightSessionSnapshot(run.sessionId, projection, admission))) { throw new RuntimeInteractionAdmissionRejectedError( input.request.requestId, @@ -743,7 +806,7 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { if (record.request.sessionId !== input.sessionId) return interactionNotFound(); return record.request.request.kind === 'client_capability' ? this.#answerClientCapability(record, input.answer, admission) - : this.#answerQuestion(record, input.answer, admission); + : this.#answerStoredInteraction(record, input.answer, admission); } const sandboxBoundary = await this.#readSandboxBoundary( input.sessionId, @@ -760,7 +823,7 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { ); } - async #answerQuestion( + async #answerStoredInteraction( record: InteractionRecord, answer: InteractionAnswerInput['answer'], admission: SessionAdmissionLease, @@ -771,18 +834,26 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { : operationConflict('Sandbox boundary authority is not stored in InteractionStore'); } if (record.outcome) return answerOutcome(recordWithOutcome(record), answer); - if (record.request.request.kind !== 'question' || answer.kind !== 'question') { + if ( + (record.request.request.kind !== 'question' && record.request.request.kind !== 'form') || + record.request.request.kind !== answer.kind + ) { return operationConflict('Interaction answer does not match the pending request'); } if (!isInteractionAnswerValidForRequest(record.request.request, answer)) { return operationConflict('Interaction answer does not match the pending request'); } - const entry = this.#requireLiveQuestion(record.request); - const outcome = await this.#commitAnswer( - entry, - questionCanonicalOutcome(answer, this.#now()), - admission, - ); + const entry = this.#requireLiveStored(record.request); + const candidate = + answer.kind === 'question' + ? questionCanonicalOutcome(answer, this.#now()) + : answer.kind === 'form' + ? formCanonicalOutcome(answer, this.#now()) + : undefined; + if (!candidate) { + return operationConflict('Interaction answer does not match the pending request'); + } + const outcome = await this.#commitAnswer(entry, candidate, admission); return answerOutcome({ request: record.request, outcome }, answer); } @@ -861,8 +932,8 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { } async #commitAnswer( - entry: LiveQuestionEntry, - candidate: Extract, + entry: LiveStoredEntry, + candidate: Extract, admission: SessionAdmissionLease, ): Promise { const target = await this.#commitOutcome(entry.request, candidate); @@ -899,6 +970,60 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { } } + #withdrawFormRequest(run: BoundRun, requestId: string): Promise { + try { + this.#assertOwnedRun(run); + this.#throwIfPoisoned(); + if (run.released) { + throw this.#poison( + new RuntimeInteractionInvariantError( + `Released Interaction Run ${run.runId} cannot withdraw a form`, + ), + ); + } + return observed( + this.#sessionAdmission.run(run.sessionId, async (admission) => { + this.#throwIfPoisoned(); + // A whole-Run stop/terminal closure that already claimed ownership + // remains the reason for every still-pending Interaction in that Run. + if (run.closure) return; + const record = await this.#readInteraction(requestId); + // Cancellation may win while admission is still proving publication. + // The producer will also observe its abort and must not publish afterward. + if (!record) return; + if (!sameRun(record.request, run) || record.request.request.kind !== 'form') { + throw this.#poison( + new RuntimeInteractionInvariantError( + `Interaction Run ${run.runId} cannot withdraw form ${requestId}`, + ), + ); + } + // A canonical user answer or Run closure that won the Session admission + // race stays authoritative. + if (record.outcome) return; + const entry = this.#requireLiveStored(record.request); + if (entry.kind !== 'form' || entry.run !== run) { + throw this.#poison( + new RuntimeInteractionInvariantError( + `Form ${requestId} is not owned by Interaction Run ${run.runId}`, + ), + ); + } + const outcome = await this.#commitOutcome(record.request, { + kind: 'closure', + reason: 'producer_cancelled', + committedAt: this.#now(), + }); + await this.#refreshCanonicalContinuity(run.sessionId, admission); + this.#throwIfPoisoned(); + await this.#applyAndDelete(entry, outcome); + }), + ); + } catch (error) { + return rejected(error); + } + } + #claimRunClosure(run: BoundRun, reason: RuntimeInteractionRunClosureReason): RunClosure { if (run.closure) return run.closure; @@ -950,10 +1075,7 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { const pending = await this.#readPending(runIdentity(run)); const committed: CommittedEntry[] = []; for (const request of pending.sort(compareStoredInteractionRequests)) { - const entry = - request.request.kind === 'client_capability' - ? this.#requireLiveClientCapability(request) - : this.#requireLiveQuestion(request); + const entry = this.#requireLiveStored(request); const closureOutcome = { kind: 'closure' as const, reason: closure.reason, @@ -1057,25 +1179,35 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { const sessions = new Map< string, { - questions: StoredInteractionRequest[]; + storedInteractions: StoredInteractionRequest[]; sandboxBoundaries: SandboxBoundaryRequest[]; } >(); - const pendingQuestions = await this.#readPending(); + const pendingInteractions = await this.#readPending(); const pendingSandboxBoundaries = await this.#readAllPendingSandboxBoundaries(); - for (const request of pendingQuestions) { + for (const request of pendingInteractions) { const requests = sessions.get(request.sessionId); - if (requests) requests.questions.push(request); - else sessions.set(request.sessionId, { questions: [request], sandboxBoundaries: [] }); + if (requests) requests.storedInteractions.push(request); + else { + sessions.set(request.sessionId, { + storedInteractions: [request], + sandboxBoundaries: [], + }); + } } for (const request of pendingSandboxBoundaries) { const requests = sessions.get(request.sessionId); if (requests) requests.sandboxBoundaries.push(request); - else sessions.set(request.sessionId, { questions: [], sandboxBoundaries: [request] }); + else { + sessions.set(request.sessionId, { + storedInteractions: [], + sandboxBoundaries: [request], + }); + } } for (const [sessionId, requests] of sessions) { if ( - requests.questions.length + requests.sandboxBoundaries.length > + requests.storedInteractions.length + requests.sandboxBoundaries.length > INTERACTION_MAX_PENDING_PER_SESSION ) { throw new RuntimeInteractionInvariantError( @@ -1083,7 +1215,9 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { ); } await this.#sessionAdmission.run(sessionId, async (admission) => { - for (const request of requests.questions.sort(compareStoredInteractionRequests)) { + for (const request of requests.storedInteractions.sort( + compareStoredInteractionRequests, + )) { const closure = { kind: 'closure' as const, reason: 'host_restarted' as const, @@ -1288,11 +1422,20 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { return; } try { - const projected = runtimeQuestionOutcome(outcome.outcome); + const projected = + entry.kind === 'question' + ? runtimeQuestionOutcome(outcome.outcome) + : runtimeFormOutcome(outcome.outcome); if (projected.kind === 'closure') { await entry.continuation.applyClosure(projected.reason); - } else { + } else if (entry.kind === 'question' && projected.kind === 'question_answer') { + await entry.continuation.applyAnswer(projected.answer); + } else if (entry.kind === 'form' && projected.kind === 'form_answer') { await entry.continuation.applyAnswer(projected.answer); + } else { + throw new RuntimeInteractionInvariantError( + `Stored Interaction ${entry.request.requestId} projected the wrong answer kind`, + ); } } catch (error) { throw this.#poison(error); @@ -1352,17 +1495,18 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { } } - #requireLiveQuestion(request: StoredInteractionRequest): LiveQuestionEntry { + #requireLiveStored(request: StoredInteractionRequest): LiveStoredEntry { const entry = this.#live.get(request.requestId); if ( !entry || - entry.kind !== 'question' || + entry.kind === 'sandbox_boundary' || + entry.kind !== request.request.kind || entry.phase !== 'live' || !isDeepStrictEqual(entry.request, request) ) { throw this.#poison( new RuntimeInteractionInvariantError( - `Pending question Interaction ${request.requestId} has no exact live continuation`, + `Pending stored Interaction ${request.requestId} has no exact live continuation`, ), ); } diff --git a/packages/runtime-host/src/server/interaction-projection.ts b/packages/runtime-host/src/server/interaction-projection.ts index fe4e60a4ab..98e614e510 100644 --- a/packages/runtime-host/src/server/interaction-projection.ts +++ b/packages/runtime-host/src/server/interaction-projection.ts @@ -27,6 +27,7 @@ import { import type { SandboxBoundaryRequest } from '@maka/core/sandbox-boundary'; import { RuntimeInteractionInvariantError, + type RuntimeFormOutcome, type RuntimeUserQuestionOutcome, } from '@maka/runtime/interaction-authority'; import type { @@ -176,6 +177,20 @@ export function clientCapabilityCanonicalOutcome( return { kind: 'client_capability_decision', decision: answer.decision, committedAt }; } +export function formCanonicalOutcome( + answer: Extract, + committedAt: number, +): Extract { + return answer.action === 'accept' + ? { + kind: 'form_answer', + action: 'accept', + values: structuredClone(answer.values), + committedAt, + } + : { kind: 'form_answer', action: answer.action, committedAt }; +} + export function runtimeQuestionOutcome( outcome: InteractionCanonicalOutcome, ): RuntimeUserQuestionOutcome { @@ -186,13 +201,30 @@ export function runtimeQuestionOutcome( return { kind: 'closure', reason: outcome.reason }; } if (outcome.kind !== 'question_answer') { - throw new RuntimeInteractionInvariantError( - 'Question Interaction resolved with a permission answer', - ); + throw new RuntimeInteractionInvariantError('Question Interaction resolved with another answer'); } return { kind: 'question_answer', answer: { answers: [...outcome.answers] } }; } +export function runtimeFormOutcome(outcome: InteractionCanonicalOutcome): RuntimeFormOutcome { + if (outcome.kind === 'closure') { + if (outcome.reason === 'timed_out') { + throw new RuntimeInteractionInvariantError('Form Interaction resolved with a timeout'); + } + return { kind: 'closure', reason: outcome.reason }; + } + if (outcome.kind !== 'form_answer') { + throw new RuntimeInteractionInvariantError('Form Interaction resolved with another answer'); + } + return { + kind: 'form_answer', + answer: + outcome.action === 'accept' + ? { action: 'accept', values: structuredClone(outcome.values) } + : { action: outcome.action }, + }; +} + export function answerOutcome( record: InteractionRecord & { outcome: StoredInteractionOutcome }, candidate: InteractionAnswer, @@ -234,6 +266,7 @@ function canonicalOutcomeForHistoricalAnswer( if (answer.kind === 'question') { return questionCanonicalOutcome(answer, committedAt); } + if (answer.kind === 'form') return formCanonicalOutcome(answer, committedAt); if (answer.kind === 'sandbox_boundary') { throw new RuntimeInteractionInvariantError( 'Sandbox boundary answers require their canonical boundary settlement', diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index f7442e572e..f9c5f1278f 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -2412,7 +2412,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { await this.continuity.acceptRuntimeEvent(input.sessionId, active.runId, event); } else if (isInteractionAnswerAck(event)) { await this.continuity.refreshCanonical(input.sessionId); - } else if (event.type === 'user_question_request') { + } else if (event.type === 'user_question_request' || event.type === 'form_request') { this.continuity.enqueueCanonicalRefresh(input.sessionId); } } @@ -3128,7 +3128,7 @@ function isRuntimeSessionForwardedEvent( } function isInteractionAnswerAck(event: SessionEvent): boolean { - return event.type === 'user_question_answer_ack'; + return event.type === 'user_question_answer_ack' || event.type === 'form_answer_ack'; } function completedStart(outcome: RootMessageStartOutcome): TurnStartDisposition { diff --git a/packages/runtime/src/__tests__/fake-backend.test.ts b/packages/runtime/src/__tests__/fake-backend.test.ts index b7ed06ceca..c61f3447e5 100644 --- a/packages/runtime/src/__tests__/fake-backend.test.ts +++ b/packages/runtime/src/__tests__/fake-backend.test.ts @@ -44,6 +44,8 @@ test('Fake question publication waits for exact hosted admission', async () => { admissionStarted.resolve(); await allowAdmission.promise; }, + acceptFormRequest: async () => {}, + withdrawFormRequest: async () => {}, close: async () => {}, release: () => {}, }), diff --git a/packages/runtime/src/__tests__/interaction-authority.test.ts b/packages/runtime/src/__tests__/interaction-authority.test.ts index c0417c4cc3..ebca8c44c3 100644 --- a/packages/runtime/src/__tests__/interaction-authority.test.ts +++ b/packages/runtime/src/__tests__/interaction-authority.test.ts @@ -36,6 +36,7 @@ import { bindRuntimeInteractionRun, type RuntimeInteractionAuthority, type RuntimeInteractionRunOwner, + type RuntimeFormContinuation, type RuntimeSandboxBoundaryContinuation, type RuntimeUserQuestionContinuation, } from '../interaction-authority.js'; @@ -89,6 +90,8 @@ describe('Runtime Interaction authority seam', () => { runId: 'wrong-run', acceptSandboxBoundaryRequest: async () => {}, acceptUserQuestionRequest: async () => {}, + acceptFormRequest: async () => {}, + withdrawFormRequest: async () => {}, close: async (reason) => { log.push(`close:${reason}`); }, @@ -204,6 +207,72 @@ describe('Runtime Interaction authority seam', () => { binding.release(); }); + test('publishes a hosted form and resumes only after its exact canonical continuation settles', async () => { + let form: RuntimeFormContinuation | undefined; + const events: SessionEvent[] = []; + const binding = await bindRuntimeInteractionRun( + authority({ + acceptFormRequest: async ({ continuation }) => { + form = continuation; + }, + }), + RUN, + ); + const runtime = toolRuntime(events, binding); + const tool: MakaTool> = { + name: 'SyntheticForm', + description: 'Exercise the form Interaction seam.', + parameters: {}, + nesting: 'direct_only', + impl: (_input, context) => + context.requestUserForm!({ + message: 'Choose settings', + requester: { name: 'deploy' }, + fields: [ + { + kind: 'integer', + name: 'replicas', + label: 'Replicas', + required: true, + minimum: 1, + }, + ], + }), + }; + const pending = settleTool( + runtime, + tool, + RUN.turnId, + durableEventSink(events), + )({}, { toolCallId: 'tool-form', abortSignal: new AbortController().signal }); + + await waitFor(() => events.some((event) => event.type === 'form_request')); + assert.ok(form); + assert.throws( + () => + runtime.respondToUserForm({ + requestId: form!.requestId, + action: 'accept', + values: { replicas: 2 }, + }), + RuntimeInteractionInvariantError, + ); + await form!.applyAnswer({ action: 'accept', values: { replicas: 2 } }); + assert.deepEqual(await pending, { action: 'accept', values: { replicas: 2 } }); + await waitFor(() => events.some((event) => event.type === 'form_answer_ack')); + assert.equal( + await binding.canResumeAfterSettlementAck( + events.find((event) => event.type === 'form_answer_ack')!, + ), + true, + ); + + await runtime.endTurn(); + await binding.close('turn_terminal'); + await binding.settleLocalClosures(); + binding.release(); + }); + test('matches a hosted sandbox boundary acknowledgement to its exact durable settlement', async () => { let continuation: RuntimeSandboxBoundaryContinuation | undefined; let applied = false; @@ -534,6 +603,8 @@ function authority( close: async () => {}, release: () => {}, ...overrides, + acceptFormRequest: overrides.acceptFormRequest ?? (async () => {}), + withdrawFormRequest: overrides.withdrawFormRequest ?? (async () => {}), }), }; } diff --git a/packages/runtime/src/__tests__/mcp-tools.test.ts b/packages/runtime/src/__tests__/mcp-tools.test.ts index b3ffdeb2b3..878154d13f 100644 --- a/packages/runtime/src/__tests__/mcp-tools.test.ts +++ b/packages/runtime/src/__tests__/mcp-tools.test.ts @@ -97,6 +97,94 @@ test('buildMcpTools projects discovery, abort, and rich model output', async () assert.match(model?.value[2]?.type === 'text' ? model.value[2].text : '', /structuredContent/u); }); +test('buildMcpTools carries the Runtime-owned form callback to the provider', async () => { + const cancellation = new AbortController(); + const provider = fakeProvider( + [boundTool(descriptor('client', 'deploy'), binding('nested-form-binding'))], + async (_binding, _args, options) => { + assert.ok(options.requestInteraction); + const answer = await options.requestInteraction( + { + message: 'Choose a target', + requester: { name: 'deploy' }, + fields: [ + { kind: 'string', name: 'target', label: 'Target', required: true, maxLength: 256 }, + ], + }, + { cancellationSignal: cancellation.signal }, + ); + assert.deepEqual(answer, { action: 'accept', values: { target: 'staging' } }); + return { content: [] }; + }, + ); + const [tool] = buildMcpTools(provider); + + await tool?.impl( + {}, + { + sessionId: 'session', + turnId: 'turn', + cwd: '/workspace', + toolCallId: 'tool-call', + abortSignal: new AbortController().signal, + emitOutput() {}, + requestUserForm: async (form, options) => { + assert.equal(form.message, 'Choose a target'); + assert.equal(options?.cancellationSignal, cancellation.signal); + return { action: 'accept', values: { target: 'staging' } }; + }, + }, + ); +}); + +test('prepared MCP execution receives the Runtime-owned form callback after admission', async () => { + const toolBinding = binding('prepared-form-binding'); + const provider: McpToolProvider = { + toolSnapshot: () => ({ + revision: 1, + tools: [boundTool(descriptor('client', 'deploy'), toolBinding)], + }), + prepareTool: async () => ({ + execute: async (options) => { + assert.ok(options?.requestInteraction); + const answer = await options.requestInteraction({ + message: 'Choose a target', + requester: { name: 'deploy' }, + fields: [ + { kind: 'string', name: 'target', label: 'Target', required: true, maxLength: 256 }, + ], + }); + assert.deepEqual(answer, { action: 'accept', values: { target: 'staging' } }); + return { content: [] }; + }, + cancel: () => undefined, + }), + callTool: async () => assert.fail('Prepared provider must not use direct callTool'), + }; + const [tool] = buildMcpTools(provider); + assert.ok(tool?.prepareExecution); + const controller = new AbortController(); + const prepared = await tool.prepareExecution( + {}, + { + sessionId: 'session', + turnId: 'turn', + cwd: '/workspace', + toolCallId: 'tool-call', + abortSignal: controller.signal, + }, + ); + await prepared.execute({ + sessionId: 'session', + turnId: 'turn', + cwd: '/workspace', + toolCallId: 'tool-call', + abortSignal: controller.signal, + emitOutput: () => undefined, + requestUserForm: async () => ({ action: 'accept', values: { target: 'staging' } }), + }); +}); + test('Direct-mode MCP calls request managed network expansion before provider dispatch', async () => { const sequence: string[] = []; const boundary = createManagedExecutionBoundary(createWorkspaceWritePermissionProfile(), 0); diff --git a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts index 9ffea826b8..c914c32745 100644 --- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts @@ -1894,6 +1894,20 @@ const ACTION_COVERAGE_SAMPLES: ActionCoverageSamples = { action: { requestId: 'coverage-question' }, event: { author: 'user', refs: { toolCallId: 'coverage-question-tool' } }, }, + formRequest: { + action: { + requestId: 'coverage-form', + toolUseId: 'coverage-form-tool', + message: 'Choose settings', + requester: { name: 'deploy' }, + fields: [{ kind: 'boolean', name: 'confirm', label: 'Confirm', required: true }], + }, + event: { refs: { toolCallId: 'coverage-form-tool' } }, + }, + formAnswerAccepted: { + action: { requestId: 'coverage-form' }, + event: { author: 'user', refs: { toolCallId: 'coverage-form-tool' } }, + }, transferToAgent: { action: 'agent-b' }, // The terminal fact is one of the actions that does own a row. endInvocation: { action: true }, diff --git a/packages/runtime/src/__tests__/runtime-kernel-interaction.test.ts b/packages/runtime/src/__tests__/runtime-kernel-interaction.test.ts index f15caa6568..2216a52160 100644 --- a/packages/runtime/src/__tests__/runtime-kernel-interaction.test.ts +++ b/packages/runtime/src/__tests__/runtime-kernel-interaction.test.ts @@ -100,6 +100,8 @@ describe('RuntimeKernel Interaction close cleanup', () => { ...identity, acceptSandboxBoundaryRequest: async () => {}, acceptUserQuestionRequest: async () => {}, + acceptFormRequest: async () => {}, + withdrawFormRequest: async () => {}, close: async () => { closeCalls += 1; closeStarted.resolve(); @@ -483,6 +485,8 @@ function runtimeFixture(options: RuntimeFixtureOptions = {}): { ...identity, acceptSandboxBoundaryRequest: async () => {}, acceptUserQuestionRequest: async () => {}, + acceptFormRequest: async () => {}, + withdrawFormRequest: async () => {}, close: async () => { markCloseStarted(); if (options.deferredClose) await closeReleased; diff --git a/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts b/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts index 99d8ebd938..e46de3ae4a 100644 --- a/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts +++ b/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts @@ -323,6 +323,46 @@ describe('mapSessionEventToRuntimeEvent (pure)', () => { assert.equal(mapped.refs?.toolCallId, 'tool-1'); }); + test('form_request maps to one system-authored runtime action', () => { + const mapped = mapSessionEventToRuntimeEvent( + ev({ + type: 'form_request', + requestId: 'form-1', + toolUseId: 'tool-1', + message: 'Choose settings', + requester: { name: 'deploy' }, + fields: [{ kind: 'boolean', name: 'confirm', label: 'Confirm', required: true }], + }), + ctx, + ); + + assert.equal(mapped.role, 'system'); + assert.equal(mapped.author, 'system'); + assert.deepEqual(mapped.actions?.formRequest, { + requestId: 'form-1', + toolUseId: 'tool-1', + message: 'Choose settings', + requester: { name: 'deploy' }, + fields: [{ kind: 'boolean', name: 'confirm', label: 'Confirm', required: true }], + }); + }); + + test('form_answer_ack maps without duplicating the canonical result', () => { + const mapped = mapSessionEventToRuntimeEvent( + ev({ + type: 'form_answer_ack', + requestId: 'form-1', + toolUseId: 'tool-1', + }), + ctx, + ); + + assert.equal(mapped.role, 'system'); + assert.equal(mapped.author, 'user'); + assert.deepEqual(mapped.actions?.formAnswerAccepted, { requestId: 'form-1' }); + assert.equal(mapped.refs?.toolCallId, 'tool-1'); + }); + test('tool_result without a prior tool_start still maps (name falls back to empty)', () => { const a = mapSessionEventToRuntimeEvent( ev({ @@ -529,6 +569,29 @@ const PROJECTION_SAMPLES: ProjectionSamples = { toolUseId: 'tool-1', }, }, + form_request: { + subject: { + type: 'form_request', + id: 'e', + turnId: 'turn-1', + ts: 1, + requestId: 'form-1', + toolUseId: 'tool-1', + message: 'Choose settings', + requester: { name: 'deploy' }, + fields: [{ kind: 'boolean', name: 'confirm', label: 'Confirm', required: true }], + }, + }, + form_answer_ack: { + subject: { + type: 'form_answer_ack', + id: 'e', + turnId: 'turn-1', + ts: 1, + requestId: 'form-1', + toolUseId: 'tool-1', + }, + }, plan_submitted: { subject: { type: 'plan_submitted', diff --git a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts index f1c59932fb..9c343b10b4 100644 --- a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts +++ b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts @@ -2925,6 +2925,8 @@ function hostedInteractionAuthority(): RuntimeInteractionAuthority { ...identity, acceptSandboxBoundaryRequest: async () => {}, acceptUserQuestionRequest: async () => {}, + acceptFormRequest: async () => {}, + withdrawFormRequest: async () => {}, close: async () => {}, release: () => {}, }), diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 9abce3a7ae..a0babc9ecb 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -14762,6 +14762,8 @@ function testInteractionAuthority(): RuntimeInteractionAuthority { ...identity, acceptSandboxBoundaryRequest: async () => {}, acceptUserQuestionRequest: async () => {}, + acceptFormRequest: async () => {}, + withdrawFormRequest: async () => {}, close: async () => {}, release: () => {}, }), diff --git a/packages/runtime/src/__tests__/session-projection-helpers.test.ts b/packages/runtime/src/__tests__/session-projection-helpers.test.ts index bacf2c9971..cda0243994 100644 --- a/packages/runtime/src/__tests__/session-projection-helpers.test.ts +++ b/packages/runtime/src/__tests__/session-projection-helpers.test.ts @@ -151,12 +151,18 @@ describe('session projection helpers', () => { assert.deepStrictEqual(statusFromEvent({ type: 'user_question_request', ts: 1 } as never), { status: 'waiting_for_user', }); + assert.deepStrictEqual(statusFromEvent({ type: 'form_request', ts: 1 } as never), { + status: 'waiting_for_user', + }); assert.deepStrictEqual( statusFromEvent({ type: 'sandbox_boundary_decision_ack', ts: 1 } as never), { status: 'running', }, ); + assert.deepStrictEqual(statusFromEvent({ type: 'form_answer_ack', ts: 1 } as never), { + status: 'running', + }); assert.strictEqual( statusFromEvent({ type: 'sandbox_boundary_decision_ack', ts: 1 } as never, { allowInteractionResume: false, diff --git a/packages/runtime/src/__tests__/stream-graph-projection.test.ts b/packages/runtime/src/__tests__/stream-graph-projection.test.ts index 102de1fd19..81a3d10369 100644 --- a/packages/runtime/src/__tests__/stream-graph-projection.test.ts +++ b/packages/runtime/src/__tests__/stream-graph-projection.test.ts @@ -566,6 +566,19 @@ describe('committed stream graph projection', () => { userQuestionAnswerAccepted: { requestId: 'question-1' }, }, }), + runtimeEvent(run, { + id: 'form-request', + ts: baseTs + 4, + actions: { + formRequest: { + requestId: 'form-1', + toolUseId: 'tool-3', + message: 'Choose settings', + requester: { name: 'fixture' }, + fields: [{ kind: 'boolean', name: 'confirm', label: 'Confirm', required: true }], + }, + }, + }), ], }, ], @@ -577,9 +590,11 @@ describe('committed stream graph projection', () => { [{ kind: 'attention', reason: 'permission_request' }], [{ kind: 'attention', reason: 'user_question_request' }], [], + [{ kind: 'attention', reason: 'form_request' }], ], ); assert.deepEqual(records[2]?.facets, ['runtime_fact']); + assert.deepEqual(records[3]?.facets, ['form_request']); assert.equal(replayAgentGraphRecords(records).operators.research?.status, 'running'); }); diff --git a/packages/runtime/src/__tests__/tool-runtime-form-interaction.test.ts b/packages/runtime/src/__tests__/tool-runtime-form-interaction.test.ts new file mode 100644 index 0000000000..8959f36e04 --- /dev/null +++ b/packages/runtime/src/__tests__/tool-runtime-form-interaction.test.ts @@ -0,0 +1,319 @@ +/* + * 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 { describe, test } from 'node:test'; +import type { HostedFormSettlement } from '@maka/core/backend-types'; +import type { SessionEvent } from '@maka/core/events'; +import type { SessionHeader } from '@maka/core/session'; +import { z } from 'zod'; + +import { createTestToolRuntime } from './execution-boundary-test-helpers.js'; +import type { MakaTool } from '../tool-runtime.js'; + +function header(): SessionHeader { + return { + id: 'session-1', + workspaceRoot: '/tmp/maka', + cwd: '/tmp/maka', + createdAt: 1, + name: 'Test', + titleIsManual: true, + isFlagged: false, + labels: [], + isArchived: false, + status: 'active', + statusUpdatedAt: 1, + hasUnread: false, + backend: 'ai-sdk', + llmConnectionSlug: 'c', + connectionLocked: true, + model: 'm', + permissionMode: 'ask', + schemaVersion: 1, + }; +} + +function formTool(cancellationSignal?: AbortSignal): MakaTool> { + return { + name: 'SyntheticForm', + description: 'Exercise the provider-neutral form seam.', + parameters: z.object({}), + nesting: 'direct_only', + impl: (_input, context) => { + if (!context.requestUserForm) throw new Error('Form Interaction is unavailable'); + return context.requestUserForm( + { + message: 'Choose deployment settings', + requester: { name: 'deploy', source: 'Synthetic provider' }, + fields: [ + { + kind: 'integer', + name: 'replicas', + label: 'Replicas', + required: true, + minimum: 1, + maximum: 10, + }, + { + kind: 'multi_select', + name: 'regions', + label: 'Regions', + required: false, + options: [ + { value: 'us', label: 'US' }, + { value: 'eu', label: 'EU' }, + ], + }, + ], + }, + cancellationSignal ? { cancellationSignal } : undefined, + ); + }, + }; +} + +function runtime(events: SessionEvent[]) { + let id = 0; + return createTestToolRuntime({ + sessionId: 'session-1', + header: header(), + connection: { providerType: 'openai', slug: 'c' } as never, + modelId: 'm', + appendMessage: async () => {}, + newId: () => `id-${++id}`, + now: () => 1, + getPermissionPauseTarget: () => null, + }); +} + +function sink(events: SessionEvent[]) { + return { + push: (event: SessionEvent) => events.push(event), + pushAndWaitUntilConsumed: async (event: SessionEvent) => { + events.push(event); + }, + }; +} + +describe('ToolRuntime form Interaction', () => { + test('parks one Tool call and resumes only after a schema-valid answer', async () => { + const events: SessionEvent[] = []; + const toolRuntime = runtime(events); + const pending = toolRuntime + .settleToolCall({ + tool: formTool(), + turnId: 'turn-1', + toolCallId: 'tool-1', + input: {}, + abortSignal: new AbortController().signal, + eventSink: sink(events), + }) + .then((settlement) => settlement.result); + + await new Promise((resolve) => setImmediate(resolve)); + const request = events.find((event) => event.type === 'form_request'); + assert.ok(request); + assert.equal(toolRuntime.pendingUserFormCount(), 1); + assert.throws(() => + toolRuntime.respondToUserForm({ + requestId: request.requestId, + action: 'accept', + values: { replicas: 1.5 }, + }), + ); + assert.equal(toolRuntime.pendingUserFormCount(), 1); + + assert.equal( + toolRuntime.respondToUserForm({ + requestId: request.requestId, + action: 'accept', + values: { replicas: 3, regions: ['us', 'eu'] }, + }), + true, + ); + assert.deepEqual(await pending, { + action: 'accept', + values: { replicas: 3, regions: ['us', 'eu'] }, + }); + assert.equal(events.filter((event) => event.type === 'form_answer_ack').length, 1); + }); + + test('keeps decline distinct and rejects a late answer after Turn closure', async () => { + const events: SessionEvent[] = []; + const toolRuntime = runtime(events); + const first = toolRuntime + .settleToolCall({ + tool: formTool(), + turnId: 'turn-1', + toolCallId: 'tool-1', + input: {}, + abortSignal: new AbortController().signal, + eventSink: sink(events), + }) + .then((settlement) => settlement.result); + await new Promise((resolve) => setImmediate(resolve)); + const firstRequest = events.find((event) => event.type === 'form_request'); + assert.ok(firstRequest); + assert.equal( + toolRuntime.respondToUserForm({ requestId: firstRequest.requestId, action: 'decline' }), + true, + ); + assert.deepEqual(await first, { action: 'decline' }); + + const second = toolRuntime + .settleToolCall({ + tool: formTool(), + turnId: 'turn-1', + toolCallId: 'tool-2', + input: {}, + abortSignal: new AbortController().signal, + eventSink: sink(events), + }) + .then((settlement) => settlement.result); + while (events.filter((event) => event.type === 'form_request').length < 2) { + await new Promise((resolve) => setImmediate(resolve)); + } + const secondRequest = events.filter((event) => event.type === 'form_request')[1]; + assert.ok(secondRequest); + await toolRuntime.endTurn('aborted'); + assert.deepEqual(await second, { + error: `Turn turn-1 aborted before user form ${secondRequest.requestId} was answered`, + }); + assert.equal( + toolRuntime.respondToUserForm({ requestId: secondRequest.requestId, action: 'cancel' }), + false, + ); + }); + + test('withdraws the exact hosted form when its producer is cancelled', async () => { + const events: SessionEvent[] = []; + const producer = new AbortController(); + let admitted: { requestId: string; settlement: HostedFormSettlement } | undefined; + const withdrawals: string[] = []; + const toolRuntime = createTestToolRuntime({ + sessionId: 'session-1', + header: header(), + connection: { providerType: 'openai', slug: 'c' } as never, + modelId: 'm', + appendMessage: async () => {}, + newId: (() => { + let id = 0; + return () => `id-${++id}`; + })(), + now: () => 1, + getPermissionPauseTarget: () => null, + hostedInteraction: { + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + admitUserQuestionRequest: async () => { + throw new Error('Unexpected user question'); + }, + admitFormRequest: async (input) => { + admitted = { requestId: input.request.requestId, settlement: input.settlement }; + }, + withdrawFormRequest: async (requestId) => { + withdrawals.push(requestId); + await admitted?.settlement.applyClosure('producer_cancelled'); + }, + admitSandboxBoundaryRequest: async () => { + throw new Error('Unexpected sandbox boundary'); + }, + }, + }); + const pending = toolRuntime.settleToolCall({ + tool: formTool(producer.signal), + turnId: 'turn-1', + toolCallId: 'tool-1', + input: {}, + abortSignal: new AbortController().signal, + eventSink: sink(events), + }); + while (!admitted) await new Promise((resolve) => setImmediate(resolve)); + + producer.abort(new DOMException('Provider invocation ended', 'AbortError')); + await pending; + + assert.deepEqual(withdrawals, [admitted.requestId]); + assert.equal(toolRuntime.pendingUserFormCount(), 0); + assert.equal(events.filter((event) => event.type === 'form_answer_ack').length, 0); + }); + + test('waits for hosted admission before withdrawing a cancelled producer form', async () => { + const events: SessionEvent[] = []; + const producer = new AbortController(); + let admitted: { requestId: string; settlement: HostedFormSettlement } | undefined; + let finishAdmission!: () => void; + const admissionGate = new Promise((resolve) => { + finishAdmission = resolve; + }); + const withdrawals: string[] = []; + const toolRuntime = createTestToolRuntime({ + sessionId: 'session-1', + header: header(), + connection: { providerType: 'openai', slug: 'c' } as never, + modelId: 'm', + appendMessage: async () => {}, + newId: (() => { + let id = 0; + return () => `id-${++id}`; + })(), + now: () => 1, + getPermissionPauseTarget: () => null, + hostedInteraction: { + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + admitUserQuestionRequest: async () => { + throw new Error('Unexpected user question'); + }, + admitFormRequest: async (input) => { + admitted = { requestId: input.request.requestId, settlement: input.settlement }; + await admissionGate; + }, + withdrawFormRequest: async (requestId) => { + withdrawals.push(requestId); + await admitted?.settlement.applyClosure('producer_cancelled'); + }, + admitSandboxBoundaryRequest: async () => { + throw new Error('Unexpected sandbox boundary'); + }, + }, + }); + const pending = toolRuntime.settleToolCall({ + tool: formTool(producer.signal), + turnId: 'turn-1', + toolCallId: 'tool-1', + input: {}, + abortSignal: new AbortController().signal, + eventSink: sink(events), + }); + while (!admitted) await new Promise((resolve) => setImmediate(resolve)); + + producer.abort(new DOMException('Provider invocation ended', 'AbortError')); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(withdrawals, []); + finishAdmission(); + await pending; + + assert.deepEqual(withdrawals, [admitted.requestId]); + assert.equal(toolRuntime.pendingUserFormCount(), 0); + }); +}); diff --git a/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts index fb240bb362..e4a71162ac 100644 --- a/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts @@ -219,6 +219,12 @@ describe('ToolRuntime session sandbox boundary', () => { admitUserQuestionRequest: async () => { throw new Error('Unexpected user question'); }, + admitFormRequest: async () => { + throw new Error('Unexpected user form'); + }, + withdrawFormRequest: async () => { + throw new Error('Unexpected user form withdrawal'); + }, admitSandboxBoundaryRequest: async ({ request, settlement }) => { admittedRequest = request; captured = settlement; diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index a507862d0e..03213161d3 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -1904,7 +1904,9 @@ async function appendUserMessageOnce( function isInteractionResumeAck(event: SessionEvent): boolean { return ( - event.type === 'sandbox_boundary_decision_ack' || event.type === 'user_question_answer_ack' + event.type === 'sandbox_boundary_decision_ack' || + event.type === 'user_question_answer_ack' || + event.type === 'form_answer_ack' ); } diff --git a/packages/runtime/src/interaction-authority.ts b/packages/runtime/src/interaction-authority.ts index 2f13d36ee9..b4463624f2 100644 --- a/packages/runtime/src/interaction-authority.ts +++ b/packages/runtime/src/interaction-authority.ts @@ -20,19 +20,24 @@ import { isDeepStrictEqual } from 'node:util'; import type { + FormAnswerAckEvent, + FormRequestEvent, SandboxBoundaryDecisionAckEvent, SandboxBoundaryRequestEvent, + SessionEvent, UserQuestionAnswerAckEvent, UserQuestionRequestEvent, } from '@maka/core/events'; import type { InteractionCanonicalPermissionOutcome, InteractionClosureReason, + InteractionFormResult, InteractionPermissionRequest, } from '@maka/core/interaction'; import type { SandboxBoundarySettlement } from '@maka/core/sandbox-boundary'; import type { HostedInteractionBridge, + HostedFormSettlement, HostedSandboxBoundarySettlement, HostedUserQuestionAnswer, HostedUserQuestionSettlement, @@ -65,6 +70,10 @@ export type RuntimeUserQuestionOutcome = | { kind: 'question_answer'; answer: RuntimeUserQuestionAnswer } | { kind: 'closure'; reason: RuntimeUserQuestionClosureReason }; +export type RuntimeFormOutcome = + | { kind: 'form_answer'; answer: InteractionFormResult } + | { kind: 'closure'; reason: RuntimeUserQuestionClosureReason }; + export type RuntimeSandboxBoundaryOutcome = | { kind: 'sandbox_boundary_decision'; settlement: SandboxBoundarySettlement } | { kind: 'closure'; reason: RuntimeUserQuestionClosureReason }; @@ -79,6 +88,12 @@ export interface RuntimeUserQuestionContinuation waitForPublication(): Promise; } +export interface RuntimeFormContinuation + extends RuntimeInteractionContinuationIdentity, + HostedFormSettlement { + waitForPublication(): Promise; +} + export interface RuntimeSandboxBoundaryContinuation extends RuntimeInteractionContinuationIdentity, HostedSandboxBoundarySettlement { @@ -90,6 +105,10 @@ export interface RuntimeInteractionContinuationAuthority { request: UserQuestionRequestEvent; continuation: RuntimeUserQuestionContinuation; }): Promise; + acceptFormRequest(input: { + request: FormRequestEvent; + continuation: RuntimeFormContinuation; + }): Promise; acceptSandboxBoundaryRequest(input: { request: SandboxBoundaryRequestEvent; continuation: RuntimeSandboxBoundaryContinuation; @@ -101,6 +120,7 @@ export interface RuntimeInteractionRunFacet RuntimeInteractionRunIdentity {} export interface RuntimeInteractionRunOwner extends RuntimeInteractionRunFacet { + withdrawFormRequest(requestId: string): Promise; close(reason: RuntimeInteractionRunClosureReason): Promise; release(): void; } @@ -181,11 +201,38 @@ export class RuntimeInteractionFailStopError extends Error { type LocalClosureFinalizer = () => void; -type HostedInteractionRequestEvent = UserQuestionRequestEvent | SandboxBoundaryRequestEvent; +type HostedInteractionRequestEvent = + | UserQuestionRequestEvent + | FormRequestEvent + | SandboxBoundaryRequestEvent; type HostedInteractionSettlementAckEvent = | UserQuestionAnswerAckEvent + | FormAnswerAckEvent | SandboxBoundaryDecisionAckEvent; -type RuntimeHostedInteractionOutcome = RuntimeUserQuestionOutcome | RuntimeSandboxBoundaryOutcome; + +export function isHostedInteractionRequestEvent( + event: SessionEvent, +): event is HostedInteractionRequestEvent { + return ( + event.type === 'user_question_request' || + event.type === 'form_request' || + event.type === 'sandbox_boundary_request' + ); +} + +export function isHostedInteractionSettlementAckEvent( + event: SessionEvent, +): event is HostedInteractionSettlementAckEvent { + return ( + event.type === 'user_question_answer_ack' || + event.type === 'form_answer_ack' || + event.type === 'sandbox_boundary_decision_ack' + ); +} +type RuntimeHostedInteractionOutcome = + | RuntimeUserQuestionOutcome + | RuntimeFormOutcome + | RuntimeSandboxBoundaryOutcome; interface TrackedContinuationBase { readonly requestId: string; @@ -204,12 +251,20 @@ interface TrackedQuestionContinuation extends TrackedContinuationBase { readonly continuation: RuntimeUserQuestionContinuation; } +interface TrackedFormContinuation extends TrackedContinuationBase { + readonly request: FormRequestEvent; + readonly continuation: RuntimeFormContinuation; +} + interface TrackedSandboxBoundaryContinuation extends TrackedContinuationBase { readonly request: SandboxBoundaryRequestEvent; readonly continuation: RuntimeSandboxBoundaryContinuation; } -type TrackedContinuation = TrackedQuestionContinuation | TrackedSandboxBoundaryContinuation; +type TrackedContinuation = + | TrackedQuestionContinuation + | TrackedFormContinuation + | TrackedSandboxBoundaryContinuation; /** Exact-Run bridge between RuntimeKernel and backend Interaction producers. */ export class RuntimeInteractionRunBinding implements HostedInteractionBridge { @@ -295,6 +350,44 @@ export class RuntimeInteractionRunBinding implements HostedInteractionBridge { } } + async admitFormRequest(input: { + request: FormRequestEvent; + settlement: HostedFormSettlement; + }): Promise { + const tracked = this.trackForm(input.request, input.settlement); + try { + await this.owner.acceptFormRequest({ + request: input.request, + continuation: tracked.continuation, + }); + } catch (error) { + tracked.completePublicationBarrier(); + if (!tracked.settlementStarted) this.continuations.delete(tracked.requestId); + throw error; + } + if (tracked.settlementStarted) { + try { + await tracked.settlementPromise; + throw new RuntimeInteractionInvariantError( + `Form ${tracked.requestId} settled during pending-only admission`, + ); + } finally { + tracked.completePublicationBarrier(); + } + } + tracked.admissionState = 'pending'; + if (this.publicationsSealed) { + tracked.completePublicationBarrier(); + throw new RuntimeInteractionInvariantError( + `Form ${tracked.requestId} completed admission after Interaction publication sealed`, + ); + } + } + + withdrawFormRequest(requestId: string): Promise { + return this.owner.withdrawFormRequest(requestId); + } + async admitSandboxBoundaryRequest(input: { request: SandboxBoundaryRequestEvent; settlement: HostedSandboxBoundarySettlement; @@ -490,6 +583,47 @@ export class RuntimeInteractionRunBinding implements HostedInteractionBridge { return tracked; } + private trackForm( + request: FormRequestEvent, + local: HostedFormSettlement, + ): TrackedFormContinuation { + this.assertNewContinuation(request); + let tracked!: TrackedFormContinuation; + const publication = createInteractionPublicationBarrier(); + const continuation: RuntimeFormContinuation = Object.freeze({ + requestId: request.requestId, + turnId: this.turnId, + runId: this.runId, + waitForPublication: () => publication.publicationBarrier, + applyAnswer: (answer: InteractionFormResult) => + this.settleTracked( + tracked, + () => local.applyAnswer(answer), + { kind: 'form_answer', answer }, + 'form answer', + ), + applyClosure: (reason: RuntimeUserQuestionClosureReason) => + this.settleTracked( + tracked, + () => local.applyClosure(reason), + { kind: 'closure', reason }, + 'form closure', + ), + }); + tracked = { + requestId: request.requestId, + request, + continuation, + ...publication, + admissionState: undefined, + published: false, + settlementStarted: false, + settled: false, + }; + this.continuations.set(request.requestId, tracked); + return tracked; + } + private trackSandboxBoundary( request: SandboxBoundaryRequestEvent, local: HostedSandboxBoundarySettlement, @@ -603,6 +737,7 @@ function settlementMatchesAck( const outcome = tracked.outcome; if (!outcome) return false; if (event.type === 'user_question_answer_ack') return outcome.kind === 'question_answer'; + if (event.type === 'form_answer_ack') return outcome.kind === 'form_answer'; if (outcome.kind !== 'sandbox_boundary_decision') return false; const { request, boundary } = outcome.settlement; return ( diff --git a/packages/runtime/src/mcp-tools.ts b/packages/runtime/src/mcp-tools.ts index a7c48941db..13a4ee5225 100644 --- a/packages/runtime/src/mcp-tools.ts +++ b/packages/runtime/src/mcp-tools.ts @@ -26,6 +26,7 @@ import type { McpToolDescriptor, McpToolSnapshot, } from '@maka/core/mcp'; +import type { InteractionFormInput, InteractionFormResult } from '@maka/core/interaction'; import type { PermissionMode, ToolCategory } from '@maka/core/permission'; import type { ExecutionBoundary } from '@maka/core/sandbox-boundary'; import type { ToolRecoveryMode } from '@maka/core/runtime-event'; @@ -57,6 +58,7 @@ export interface McpToolProvider { export interface McpPreparedToolCall { execute(options?: { readonly emitProgress?: (current: number, total: number) => void; + readonly requestInteraction?: McpToolCallOptions['requestInteraction']; }): Promise; cancel(): Promise | void; } @@ -66,6 +68,10 @@ export interface McpToolCallOptions { readonly timeoutMs?: number; readonly context: McpToolInvocationContext; readonly emitProgress?: (current: number, total: number) => void; + readonly requestInteraction?: ( + form: InteractionFormInput, + options?: { readonly cancellationSignal?: AbortSignal }, + ) => Promise; } export interface McpToolInvocationContext { @@ -137,6 +143,12 @@ export function buildMcpTools( ...(executionContext.emitProgress ? { emitProgress: executionContext.emitProgress } : {}), + ...(executionContext.requestUserForm + ? { + requestInteraction: (form, interactionOptions) => + executionContext.requestUserForm!(form, interactionOptions), + } + : {}), }), cancel: () => prepared.cancel(), }; @@ -171,6 +183,14 @@ export function buildMcpTools( cwd: context.cwd, }, ...(context.emitProgress ? { emitProgress: context.emitProgress } : {}), + ...(context.requestUserForm + ? { + requestInteraction: ( + form: InteractionFormInput, + interactionOptions?: { readonly cancellationSignal?: AbortSignal }, + ) => context.requestUserForm!(form, interactionOptions), + } + : {}), }); }, toModelOutput: ({ output }) => mcpResultToModelOutput(output), diff --git a/packages/runtime/src/runtime-event-read-model.ts b/packages/runtime/src/runtime-event-read-model.ts index 62ab789737..2390b3d528 100644 --- a/packages/runtime/src/runtime-event-read-model.ts +++ b/packages/runtime/src/runtime-event-read-model.ts @@ -289,6 +289,18 @@ export function projectRuntimeEventsToStoredMessages( projected = true; } + if (event.actions?.formRequest) { + // The matching function_call/function_response own the legacy rows; + // this request is live interaction state only. + projected = true; + } + + if (event.actions?.formAnswerAccepted) { + // InteractionStore owns the canonical result. This Run-local audit fact + // intentionally has no legacy chat row. + projected = true; + } + if (event.actions?.permissionAnswerAccepted) { projectCanonicalPermissionOutcome( event, diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index f9974c0a57..2ec0173e0a 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -146,6 +146,8 @@ import { RuntimeInteractionFailStopError, RuntimeInteractionInvariantError, bindRuntimeInteractionRun, + isHostedInteractionRequestEvent, + isHostedInteractionSettlementAckEvent, type RuntimeInteractionAuthority, type RuntimeInteractionRunBinding, type RuntimeInteractionRunClosureReason, @@ -1590,10 +1592,7 @@ export class RuntimeKernel implements RuntimeKernelLike { binding: RuntimeInteractionRunBinding | undefined, event: SessionEvent, ): void { - if ( - binding && - (event.type === 'user_question_request' || event.type === 'sandbox_boundary_request') - ) { + if (binding && isHostedInteractionRequestEvent(event)) { binding.assertPendingAdmission(event); } } @@ -3337,10 +3336,7 @@ async function interactionResumeAllowed( interactionRun: RuntimeInteractionRunBinding | undefined, event: SessionEvent, ): Promise { - if ( - !interactionRun || - (event.type !== 'user_question_answer_ack' && event.type !== 'sandbox_boundary_decision_ack') - ) { + if (!interactionRun || !isHostedInteractionSettlementAckEvent(event)) { return true; } return await interactionRun.canResumeAfterSettlementAck(event); diff --git a/packages/runtime/src/session-event-runtime-mapper.ts b/packages/runtime/src/session-event-runtime-mapper.ts index 5c53a3a595..d40f028623 100644 --- a/packages/runtime/src/session-event-runtime-mapper.ts +++ b/packages/runtime/src/session-event-runtime-mapper.ts @@ -115,6 +115,8 @@ function resolveBase(event: SessionEvent, ctx: RuntimeEventMapContext) { * - sandbox_boundary_request → role 'system', author 'system' * - sandbox_boundary_decision_ack → role 'system', author 'user' * - user_question_answer_ack → role 'system', author 'user' + * - form_request → role 'system', author 'system' + * - form_answer_ack → role 'system', author 'user' * - plan_submitted → role 'system', author 'agent' * - token_usage → role 'system', author 'system' * - error → role 'system', author 'system' @@ -453,6 +455,30 @@ function mapBackendSessionEvent( }, refs: { toolCallId: event.toolUseId }, }; + case 'form_request': + return { + ...base, + role: 'system', + author: 'system', + actions: { + formRequest: { + requestId: event.requestId, + toolUseId: event.toolUseId, + message: event.message, + requester: event.requester, + fields: event.fields, + }, + }, + refs: { toolCallId: event.toolUseId }, + }; + case 'form_answer_ack': + return { + ...base, + role: 'system', + author: 'user', + actions: { formAnswerAccepted: { requestId: event.requestId } }, + refs: { toolCallId: event.toolUseId }, + }; // ── Steering: a user message injected mid-turn at a step boundary ───── // Persisted as a first-class user event so the ledger, transcript, and diff --git a/packages/runtime/src/session-projection-helpers.ts b/packages/runtime/src/session-projection-helpers.ts index b57598339e..f470699290 100644 --- a/packages/runtime/src/session-projection-helpers.ts +++ b/packages/runtime/src/session-projection-helpers.ts @@ -134,11 +134,13 @@ export function statusFromEvent( case 'sandbox_boundary_request': return { status: 'waiting_for_user', blockedReason: 'permission_required' }; case 'user_question_request': + case 'form_request': return { status: 'waiting_for_user' }; case 'sandbox_boundary_decision_ack': if (options.allowInteractionResume === false) return undefined; return { status: 'running' }; case 'user_question_answer_ack': + case 'form_answer_ack': if (options.allowInteractionResume === false) return undefined; return { status: 'running' }; case 'error': diff --git a/packages/runtime/src/stream-graph-projection.ts b/packages/runtime/src/stream-graph-projection.ts index c99078e02e..3a6068af95 100644 --- a/packages/runtime/src/stream-graph-projection.ts +++ b/packages/runtime/src/stream-graph-projection.ts @@ -37,6 +37,7 @@ export const AGENT_GRAPH_RECORD_FACETS = [ 'permission_request', 'permission_decision', 'user_question_request', + 'form_request', 'transfer', 'usage', 'completed', @@ -55,7 +56,10 @@ export type AgentGraphActivationStatus = | 'aborted' | 'cancelled'; -export type AgentGraphSupervisorAttentionReason = 'permission_request' | 'user_question_request'; +export type AgentGraphSupervisorAttentionReason = + | 'permission_request' + | 'user_question_request' + | 'form_request'; export type AgentGraphSupervisorSignal = | { @@ -521,6 +525,7 @@ function runtimeEventFacets(event: RuntimeEvent, run: AgentRunHeader): AgentGrap if (actions?.permissionRequest) facets.push('permission_request'); if (actions?.permissionDecision) facets.push('permission_decision'); if (actions?.userQuestionRequest) facets.push('user_question_request'); + if (actions?.formRequest) facets.push('form_request'); if (actions?.transferToAgent) facets.push('transfer'); if (actions?.tokenUsage) facets.push('usage'); @@ -541,6 +546,9 @@ function runtimeEventSupervisorSignals( if (event.actions?.userQuestionRequest) { signals.push({ kind: 'attention', reason: 'user_question_request' }); } + if (event.actions?.formRequest) { + signals.push({ kind: 'attention', reason: 'form_request' }); + } const terminalStatus = runtimeEventTerminalStatus(event, run); if (terminalStatus) { signals.push({ kind: 'terminal', status: terminalStatus }); diff --git a/packages/runtime/src/stream-graph-read-model.ts b/packages/runtime/src/stream-graph-read-model.ts index 6233c2ce73..5510f927b7 100644 --- a/packages/runtime/src/stream-graph-read-model.ts +++ b/packages/runtime/src/stream-graph-read-model.ts @@ -941,6 +941,11 @@ function projectClientSessionEvent( facets: ['user_question_request'], signals: [{ kind: 'attention', reason: 'user_question_request' }], }; + case 'form_request': + return { + facets: ['form_request'], + signals: [{ kind: 'attention', reason: 'form_request' }], + }; case 'token_usage': return { facets: ['usage'], signals: [] }; case 'error': diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index cb81443d34..a12fdce73e 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -32,6 +32,8 @@ import { import { serializedByteLength } from '@maka/core/serialized-byte-length'; import { encodeToolStepProgress, ToolOutcomeUnknownError } from '@maka/core/events'; import type { + FormAnswerAckEvent, + FormRequestEvent, SandboxBoundaryDecisionAckEvent, SandboxBoundaryRequestEvent, SessionEvent, @@ -47,11 +49,20 @@ import type { } from '@maka/core/events'; import type { ToolCallMessage, ToolResultMessage } from '@maka/core/session'; import type { + HostedFormSettlement, HostedInteractionBridge, HostedSandboxBoundarySettlement, HostedUserQuestionAnswer, HostedUserQuestionSettlement, } from '@maka/core/backend-types'; +import { + isInteractionAnswerValidForRequest, + projectInteractionFormRequest, + type InteractionFormInput, + type InteractionFormRequest, + type InteractionFormResponse, + type InteractionFormResult, +} from '@maka/core/interaction'; import type { PermissionMode, ToolCategory, ToolExecutionFacts } from '@maka/core/permission'; import type { RuntimeExecutionConnection } from '@maka/core/llm-connections'; import type { OrchestrationMode } from '@maka/core/orchestration'; @@ -299,6 +310,10 @@ export interface MakaToolContext { view?: 'result' | 'events' | 'runtime_events' | 'all'; }) => Promise; askUserQuestion?: (questions: UserQuestion[]) => Promise; + requestUserForm?: ( + form: InteractionFormInput, + options?: { readonly cancellationSignal?: AbortSignal }, + ) => Promise; requestSandboxBoundary?: ( expansion: SandboxBoundaryExpansion, justification: string, @@ -545,10 +560,15 @@ export class ToolRuntime { UserQuestionResponse, { toolUseId: string; questions: UserQuestion[]; hosted: boolean } >(); + private readonly userForms = new AwaitRegistry< + InteractionFormResponse, + { toolUseId: string; request: InteractionFormRequest; hosted: boolean } + >(); private readonly turnId: string; private readonly hostedInteraction: HostedInteractionBridge | undefined; private sandboxBoundaryClosureDeferred = false; private questionClosureDeferred = false; + private formClosureDeferred = false; private activeSubagentToolCount = 0; private childAgentRunLimiter = new AdmissionLimiter(MAX_ACTIVE_CHILD_AGENT_RUNS_PER_TURN); /** @@ -632,6 +652,7 @@ export class ToolRuntime { } const hasHostedPending = this.userQuestions.entries().some(([, question]) => question.hosted); + const hasHostedFormPending = this.userForms.entries().some(([, form]) => form.hosted); if (hasHostedBoundaryPending) { this.sandboxBoundaryClosureDeferred = true; this.finishDeferredSandboxBoundaryTurnClosure(); @@ -652,6 +673,16 @@ export class ToolRuntime { ); this.questionClosureDeferred = false; } + if (hasHostedFormPending) { + this.formClosureDeferred = true; + this.finishDeferredFormTurnClosure(); + } else { + this.userForms.close( + (requestId) => + new Error(`Turn ${turnId} ${reason} before user form ${requestId} was answered`), + ); + this.formClosureDeferred = false; + } this.resetTurnState(); // The stop path settles the run's terminal fact right after the // backend's stop resolves, and that stop awaits this method. Unwinds @@ -686,6 +717,22 @@ export class ToolRuntime { return this.settleUserQuestionAnswer(turnId, response, pending); } + respondToUserForm(response: InteractionFormResponse): boolean { + if (!response || typeof response.requestId !== 'string') { + throw new Error('Invalid user form response'); + } + const pending = this.userForms + .entries() + .find(([requestId]) => requestId === response.requestId)?.[1]; + if (!pending) return false; + if (pending.hosted) { + throw new RuntimeInteractionInvariantError( + `Hosted form ${response.requestId} must settle through its captured continuation`, + ); + } + return this.settleUserFormAnswer(response, pending); + } + async respondToSandboxBoundaryResponse(response: { requestId: string; decision: SandboxBoundaryDecision; @@ -736,6 +783,22 @@ export class ToolRuntime { return resolved; } + private settleUserFormAnswer( + response: InteractionFormResponse, + pending: { toolUseId: string; request: InteractionFormRequest; hosted: boolean }, + ): boolean { + const answer = + response.action === 'accept' + ? { kind: 'form' as const, action: 'accept' as const, values: response.values } + : { kind: 'form' as const, action: response.action }; + if (!isInteractionAnswerValidForRequest(pending.request, answer)) { + throw new Error('Invalid user form response'); + } + const resolved = this.userForms.resolve(response.requestId, response) !== null; + this.finishDeferredFormTurnClosure(); + return resolved; + } + closeUserQuestion( turnId: string, requestId: string, @@ -748,10 +811,22 @@ export class ToolRuntime { return closed; } + closeUserForm(requestId: string, reason: RuntimeInteractionClosureReason): boolean { + const closed = + this.userForms.reject(requestId, new RuntimeInteractionClosedError(requestId, reason)) !== + null; + this.finishDeferredFormTurnClosure(); + return closed; + } + pendingUserQuestionCount(): number { return this.userQuestions.pendingCount(); } + pendingUserFormCount(): number { + return this.userForms.pendingCount(); + } + /** * Settle one resolved Maka tool call. Tool/business failures resolve with a * provider-facing error output; durable runtime commit failures still reject. @@ -1650,6 +1725,15 @@ export class ToolRuntime { }), askUserQuestion: (questions) => this.askUserQuestion(turnId, toolUseId, questions, ctx.abortSignal, queue), + requestUserForm: (form, options) => + this.requestUserForm( + turnId, + toolUseId, + form, + ctx.abortSignal, + queue, + options?.cancellationSignal, + ), requestSandboxBoundary: (expansion, justification) => this.requestSandboxBoundary( turnId, @@ -2554,6 +2638,127 @@ export class ToolRuntime { }; } + private async requestUserForm( + turnId: string, + toolUseId: string, + form: InteractionFormInput, + abortSignal: AbortSignal, + queue: DurableSessionEventSink, + producerCancellationSignal?: AbortSignal, + ): Promise { + const interactionSignal = composeChildAbortSignal(abortSignal, producerCancellationSignal); + throwIfAborted(interactionSignal); + const hostedRun = this.interactionRun(); + const requestId = this.input.newId(); + const request = projectInteractionFormRequest({ toolUseId, ...form }); + const parked = this.userForms.park(requestId, { + toolUseId, + request, + hosted: hostedRun !== undefined, + }); + const onAbort = (): void => { + if (hostedRun) return; + this.userForms.reject(requestId, abortErrorFromSignal(abortSignal)); + this.finishDeferredFormTurnClosure(); + }; + let hostedAdmission: Promise | undefined; + let producerWithdrawal: Promise | undefined; + const onProducerCancellation = (): void => { + if (abortSignal.aborted) return; + if (hostedRun) { + producerWithdrawal ??= Promise.resolve().then(async () => { + try { + await hostedAdmission; + } catch { + return; + } + await hostedRun.withdrawFormRequest(requestId); + }); + } else if (producerCancellationSignal) { + this.userForms.reject(requestId, abortErrorFromSignal(producerCancellationSignal)); + this.finishDeferredFormTurnClosure(); + } + }; + abortSignal.addEventListener('abort', onAbort, { once: true }); + producerCancellationSignal?.addEventListener('abort', onProducerCancellation, { once: true }); + if (hostedRun) void parked.catch(() => undefined); + try { + const requestEvent: FormRequestEvent = { + type: 'form_request', + id: this.input.newId(), + turnId, + ts: this.input.now(), + requestId, + toolUseId, + message: request.message, + requester: request.requester, + fields: request.fields, + }; + if (hostedRun) { + const settlement = this.createFormSettlement(turnId, requestId); + const admission = Promise.resolve().then(() => + hostedRun.admitFormRequest({ request: requestEvent, settlement }), + ); + hostedAdmission = admission; + try { + await racePromiseWithAbort(admission, interactionSignal); + } catch (error) { + if (interactionSignal.aborted) { + void admission.catch((admissionError) => { + this.userForms.reject( + requestId, + admissionError instanceof Error + ? admissionError + : new RuntimeInteractionFailStopError( + `Could not confirm admission for form ${requestId}`, + admissionError, + ), + ); + this.finishDeferredFormTurnClosure(); + }); + throw abortErrorFromSignal(interactionSignal); + } + this.userForms.reject( + requestId, + error instanceof Error + ? error + : new RuntimeInteractionFailStopError( + `Could not confirm admission for form ${requestId}`, + error, + ), + ); + this.finishDeferredFormTurnClosure(); + await parked.catch(() => undefined); + throw interactionAuthorityError( + `Could not confirm admission for form ${requestId}`, + error, + ); + } + } + throwIfAborted(interactionSignal); + queue.push(requestEvent); + const response = await racePromiseWithAbort(parked, interactionSignal); + throwIfAborted(interactionSignal); + const answerAck: FormAnswerAckEvent = { + type: 'form_answer_ack', + id: this.input.newId(), + turnId, + ts: this.input.now(), + requestId, + toolUseId, + }; + if (hostedRun) await this.publishHostedSettlementAck(queue, answerAck); + else queue.push(answerAck); + return response.action === 'accept' + ? { action: 'accept', values: response.values } + : { action: response.action }; + } finally { + abortSignal.removeEventListener('abort', onAbort); + producerCancellationSignal?.removeEventListener('abort', onProducerCancellation); + if (producerWithdrawal) await producerWithdrawal; + } + } + private async askUserQuestion( turnId: string, toolUseId: string, @@ -2929,6 +3134,18 @@ export class ToolRuntime { ); } + private finishDeferredFormTurnClosure(): void { + const turnId = this.turnId; + if (!this.formClosureDeferred || this.userForms.pendingCount() !== 0) return; + this.formClosureDeferred = false; + this.userForms.close( + (requestId) => + new RuntimeInteractionInvariantError( + `Hosted form ${requestId} escaped exact Run closure for turn ${turnId}`, + ), + ); + } + private finishDeferredSandboxBoundaryTurnClosure(): void { const turnId = this.turnId; if (!this.sandboxBoundaryClosureDeferred || this.sandboxBoundaryRequests.pendingCount() !== 0) { @@ -3016,6 +3233,37 @@ export class ToolRuntime { }, }); } + + private createFormSettlement(turnId: string, requestId: string): HostedFormSettlement { + return Object.freeze({ + applyAnswer: async (answer: InteractionFormResult): Promise => { + if (Object.hasOwn(answer, 'requestId')) { + throw new RuntimeInteractionInvariantError( + `Form settlement ${requestId} received a routed answer`, + ); + } + const pending = this.userForms + .entries() + .find(([candidateId]) => candidateId === requestId)?.[1]; + const response: InteractionFormResponse = + answer.action === 'accept' + ? { requestId, action: 'accept', values: answer.values } + : { requestId, action: answer.action }; + if (!pending || !this.settleUserFormAnswer(response, pending)) { + throw new RuntimeInteractionInvariantError( + `Form settlement did not take ${requestId} from turn ${turnId}`, + ); + } + }, + applyClosure: async (reason: RuntimeUserQuestionClosureReason): Promise => { + if (!this.closeUserForm(requestId, reason)) { + throw new RuntimeInteractionInvariantError( + `Form closure did not take ${requestId} from turn ${turnId}`, + ); + } + }, + }); + } } async function validateDeclaredToolArgs(parameters: unknown, args: unknown): Promise { diff --git a/packages/storage/src/interaction-store.ts b/packages/storage/src/interaction-store.ts index 53b1260793..3aa5b4195c 100644 --- a/packages/storage/src/interaction-store.ts +++ b/packages/storage/src/interaction-store.ts @@ -31,6 +31,7 @@ import { decodeInteractionRequest, interactionCanonicalOutcomesEquivalent, isInteractionCanonicalOutcomeValidForRequest, + projectInteractionFormRequest, projectInteractionQuestionRequest, type InteractionCanonicalOutcome, type InteractionRequest, @@ -681,6 +682,17 @@ function normalizeRequest(value: unknown, source: DecodeSource): StoredInteracti if (!isDeepStrictEqual(request, canonical)) decodeFailure(source, 'Interaction question request is not canonical safe text'); request = canonical; + } else if (request.kind === 'form') { + const canonical = projectInteractionFormRequest({ + toolUseId: request.toolUseId, + message: request.message, + requester: request.requester, + fields: request.fields, + }); + if (!isDeepStrictEqual(request, canonical)) { + decodeFailure(source, 'Interaction form request is not canonical'); + } + request = canonical; } } catch (error) { if (error instanceof InteractionStoreError) throw error; diff --git a/packages/ui/src/__tests__/interaction-queue.test.ts b/packages/ui/src/__tests__/interaction-queue.test.ts index 7a466d4b7f..6d2090c595 100644 --- a/packages/ui/src/__tests__/interaction-queue.test.ts +++ b/packages/ui/src/__tests__/interaction-queue.test.ts @@ -21,6 +21,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import type { + FormRequestEvent, SandboxBoundaryRequestEvent, UserQuestionRequestEvent, } from '@maka/core/events'; @@ -64,6 +65,20 @@ function question(requestId: string): UserQuestionRequestEvent { }; } +function form(requestId: string): FormRequestEvent { + return { + type: 'form_request', + id: `evt_${requestId}`, + turnId: 'turn_1', + ts: 0, + requestId, + toolUseId: `call_${requestId}`, + message: 'Choose settings', + requester: { name: 'deploy' }, + fields: [{ kind: 'boolean', name: 'confirm', label: 'Confirm', required: true }], + }; +} + describe('composer interaction queue', () => { test('boundary and question requests share one FIFO per session', () => { let queues: InteractionQueues = {}; @@ -147,4 +162,10 @@ describe('composer interaction queue', () => { assert.equal(activeInteractionFor(reconciled, 's')?.type, 'user_question_request'); assert.equal(activeInteractionFor(reconciled, 's')?.requestId, 'missed'); }); + + test('does not hide the composer for a form until the form surface is installed', () => { + const queues = reconcileInteractions({}, 's', [form('form-1')]); + + assert.equal(activeInteractionFor(queues, 's'), undefined); + }); }); diff --git a/packages/ui/src/interaction-queue.ts b/packages/ui/src/interaction-queue.ts index 5022da146a..25c101ba55 100644 --- a/packages/ui/src/interaction-queue.ts +++ b/packages/ui/src/interaction-queue.ts @@ -17,11 +17,29 @@ * under the License. */ -import type { ActiveInteractionRequestEvent, SessionEvent } from '@maka/core/events'; +import type { + ActiveInteractionRequestEvent, + ClientCapabilityRequestEvent, + SandboxBoundaryRequestEvent, + SessionEvent, + UserQuestionRequestEvent, +} from '@maka/core/events'; -export type ComposerInteraction = ActiveInteractionRequestEvent; +/** Requests this surface can render and settle itself. */ +export type ComposerInteraction = + | SandboxBoundaryRequestEvent + | ClientCapabilityRequestEvent + | UserQuestionRequestEvent; export type InteractionQueues = Record; +function isComposerInteraction(event: ActiveInteractionRequestEvent): event is ComposerInteraction { + return ( + event.type === 'sandbox_boundary_request' || + event.type === 'client_capability_request' || + event.type === 'user_question_request' + ); +} + export function enqueueInteraction( queues: InteractionQueues, sessionId: string, @@ -88,9 +106,10 @@ export function reduceInteractionQueues( export function reconcileInteractions( queues: InteractionQueues, sessionId: string, - liveRequests: readonly ComposerInteraction[], + liveRequests: readonly ActiveInteractionRequestEvent[], ): InteractionQueues { - const liveById = new Map(liveRequests.map((request) => [request.requestId, request])); + const visibleRequests = liveRequests.filter(isComposerInteraction); + const liveById = new Map(visibleRequests.map((request) => [request.requestId, request])); const seen = new Set(); const reconciled: ComposerInteraction[] = []; for (const interaction of queues[sessionId] ?? []) { @@ -99,7 +118,7 @@ export function reconcileInteractions( seen.add(interaction.requestId); reconciled.push(live); } - for (const request of liveRequests) { + for (const request of visibleRequests) { if (!seen.has(request.requestId)) reconciled.push(request); } return { ...queues, [sessionId]: reconciled }; diff --git a/scripts/check-tui-copy.mjs b/scripts/check-tui-copy.mjs index 1ab7851129..84ebc5ed66 100644 --- a/scripts/check-tui-copy.mjs +++ b/scripts/check-tui-copy.mjs @@ -28,6 +28,7 @@ const root = fileURLToPath(new URL('..', import.meta.url)); export const COVERED_FILES = [ 'packages/cli/src/pi-tui-transcript-viewer.ts', 'packages/cli/src/pi-tui-turn.ts', + 'packages/cli/src/pi-tui-form-interaction.ts', 'packages/cli/src/pi-tui-runner.ts', 'packages/cli/src/pi-tui-mcp-status.ts', 'packages/cli/src/pi-transcript.ts',