From d54553d599f633c86a830a9d11cf02af9caffa1f Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 31 Aug 2026 23:08:17 +0800 Subject: [PATCH 01/14] feat(runtime): establish durable form interactions Define a bounded provider-neutral primitive form contract and carry its request and acknowledgement facts through the Runtime Event Log. Broker pending forms through the existing InteractionStore authority so schema-invalid answers remain pending, concurrent equivalent answers converge on one canonical outcome, and Turn closure or Host restart closes the exact continuation. Part of #4364. Generated-by: OpenAI Codex --- .../core/src/__tests__/interaction.test.ts | 241 +++++++ .../core/src/__tests__/runtime-event.test.ts | 20 +- packages/core/src/backend-types.ts | 12 +- packages/core/src/events.ts | 20 + packages/core/src/interaction.ts | 639 +++++++++++++++++- packages/core/src/runtime-event.ts | 41 +- .../__tests__/interaction-coordinator.test.ts | 134 +++- .../__tests__/interaction-protocol.test.ts | 50 ++ .../src/adapter/session-projector.ts | 11 + packages/runtime-host/src/protocol/index.ts | 4 +- .../runtime-host/src/protocol/interaction.ts | 10 + .../src/server/interaction-coordinator.ts | 155 ++++- .../src/server/interaction-projection.ts | 39 +- .../src/server/root-turn-coordinator.ts | 2 +- .../src/__tests__/fake-backend.test.ts | 1 + .../__tests__/interaction-authority.test.ts | 69 ++ .../runtime-event-read-model.test.ts | 14 + .../runtime-kernel-interaction.test.ts | 2 + .../session-event-runtime-mapper.test.ts | 63 ++ .../session-manager-terminal-ledger.test.ts | 1 + .../src/__tests__/session-manager.test.ts | 1 + .../tool-runtime-form-interaction.test.ts | 200 ++++++ .../tool-runtime-sandbox-boundary.test.ts | 3 + packages/runtime/src/interaction-authority.ts | 115 +++- .../runtime/src/runtime-event-read-model.ts | 12 + .../src/session-event-runtime-mapper.ts | 26 + packages/runtime/src/tool-runtime.ts | 212 ++++++ packages/storage/src/interaction-store.ts | 12 + 28 files changed, 2061 insertions(+), 48 deletions(-) create mode 100644 packages/runtime/src/__tests__/tool-runtime-form-interaction.test.ts diff --git a/packages/core/src/__tests__/interaction.test.ts b/packages/core/src/__tests__/interaction.test.ts index 9b820797e9..029ae5a459 100644 --- a/packages/core/src/__tests__/interaction.test.ts +++ b/packages/core/src/__tests__/interaction.test.ts @@ -35,6 +35,7 @@ import { isInteractionCanonicalOutcomeValidForRequest, projectInteractionClientCapabilityRequest, projectInteractionPermissionRequest, + projectInteractionFormRequest, projectInteractionQuestionRequest, projectInteractionSandboxBoundaryRequest, } from '../interaction.js'; @@ -1059,4 +1060,244 @@ 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('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, + }, + ], + }), + ); + }); + + 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, + ); + }); }); 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..a3ffdcae98 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,10 @@ export interface HostedInteractionBridge { request: UserQuestionRequestEvent; settlement: HostedUserQuestionSettlement; }): Promise; + admitFormRequest(input: { + request: FormRequestEvent; + settlement: HostedFormSettlement; + }): 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..da91a85c0f 100644 --- a/packages/core/src/interaction.ts +++ b/packages/core/src/interaction.ts @@ -59,6 +59,15 @@ 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', @@ -94,6 +103,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 +184,7 @@ export interface InteractionClientCapabilityRequest { export type InteractionRequest = | InteractionPermissionRequest | InteractionQuestionRequest + | InteractionFormRequest | InteractionSandboxBoundaryRequest | InteractionClientCapabilityRequest; @@ -125,6 +201,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 +227,7 @@ export interface InteractionClientCapabilityAnswer { export type InteractionAnswer = | InteractionPermissionAnswer | InteractionQuestionAnswer + | InteractionFormAnswer | InteractionSandboxBoundaryAnswer | InteractionClientCapabilityAnswer; @@ -155,6 +245,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 +280,7 @@ export interface InteractionCanonicalClosureOutcome { export type InteractionCanonicalOutcome = | InteractionCanonicalPermissionOutcome | InteractionCanonicalQuestionOutcome + | InteractionCanonicalFormOutcome | InteractionCanonicalSandboxBoundaryOutcome | InteractionCanonicalClientCapabilityOutcome | InteractionCanonicalClosureOutcome; @@ -186,6 +290,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 +304,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 +324,12 @@ 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 SANDBOX_BOUNDARY_ANSWER_SHAPE = defineObjectShape()( ['kind', 'decision'], [], @@ -226,6 +346,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 +368,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 +417,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 +455,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 +478,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 = { @@ -372,6 +549,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 +662,12 @@ export function projectInteractionQuestionRequest( return decodeInteractionRequest(projected) as InteractionQuestionRequest; } +export function projectInteractionFormRequest( + input: InteractionFormProjectionInput, +): InteractionFormRequest { + return decodeInteractionRequest({ kind: 'form', ...input }) as InteractionFormRequest; +} + export function projectInteractionSandboxBoundaryRequest(input: { readonly expansion: SandboxBoundaryExpansion; readonly justification: string; @@ -509,9 +707,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 +746,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 +774,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 +800,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 +850,371 @@ 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; + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const date = new Date(Date.UTC(year, month - 1, day)); + return ( + date.getUTCFullYear() === year && + date.getUTCMonth() === month - 1 && + date.getUTCDate() === day + ); + } + return ( + /^\d{4}-\d{2}-\d{2}T/.test(value) && + /(?:Z|[+-]\d{2}:\d{2})$/.test(value) && + Number.isFinite(Date.parse(value)) + ); +} + +/** 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'); +} + +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 +1273,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 +1297,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 +1344,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__/interaction-coordinator.test.ts b/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts index bae9e4b7fc..3b978c79bd 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,80 @@ 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.close('turn_terminal'); + assert.deepEqual(closures, ['turn_terminal']); + assert.deepEqual((await store.readInteraction('form_2'))?.outcome?.outcome, { + kind: 'closure', + reason: 'turn_terminal', + committedAt: 102, + }); + 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 +853,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/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/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 077a0d58c6..1226da4808 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -100,7 +100,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 106 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 107 as const; +// 107: Session Interaction snapshots, forwarded Runtime events, and Agent Graph +// activity may carry the provider-neutral `form` request/answer contract. // 106: Session transcripts gain five `system_note` kinds // (`context_provider_dropping`, `context_window_suggestion`, // `context_window_overrun`, `context_reported_window_exceeded`, diff --git a/packages/runtime-host/src/protocol/interaction.ts b/packages/runtime-host/src/protocol/interaction.ts index 3fe0907d9d..c1115ac447 100644 --- a/packages/runtime-host/src/protocol/interaction.ts +++ b/packages/runtime-host/src/protocol/interaction.ts @@ -25,6 +25,11 @@ import { type InteractionAnswer, type InteractionCanonicalOutcome, type InteractionClosureReason, + type InteractionFormAnswer, + type InteractionFormField, + type InteractionFormRequest, + type InteractionFormResult, + type InteractionFormValue, type InteractionPermissionAnswer, type InteractionPermissionDecisionFields, type InteractionPermissionPrompt, @@ -42,6 +47,11 @@ export type { InteractionAnswer, InteractionCanonicalOutcome, InteractionClosureReason, + InteractionFormAnswer, + InteractionFormField, + InteractionFormRequest, + InteractionFormResult, + InteractionFormValue, InteractionPermissionAnswer, InteractionPermissionDecisionFields, InteractionPermissionPrompt, diff --git a/packages/runtime-host/src/server/interaction-coordinator.ts b/packages/runtime-host/src/server/interaction-coordinator.ts index 68b36e0dc5..589517d04c 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,8 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { acceptUserQuestionRequest: ( input: Parameters[0], ) => this.#acceptUserQuestionRequest(run, input), + acceptFormRequest: (input: Parameters[0]) => + this.#acceptFormRequest(run, input), acceptSandboxBoundaryRequest: ( input: Parameters[0], ) => this.#acceptSandboxBoundaryRequest(run, input), @@ -481,6 +498,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 +701,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 +723,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 +805,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 +822,7 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { ); } - async #answerQuestion( + async #answerStoredInteraction( record: InteractionRecord, answer: InteractionAnswerInput['answer'], admission: SessionAdmissionLease, @@ -771,18 +833,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 +931,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); @@ -950,10 +1020,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 +1124,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 +1160,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 +1367,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 +1440,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..4cfbea72c3 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -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..0673427f59 100644 --- a/packages/runtime/src/__tests__/fake-backend.test.ts +++ b/packages/runtime/src/__tests__/fake-backend.test.ts @@ -44,6 +44,7 @@ test('Fake question publication waits for exact hosted admission', async () => { admissionStarted.resolve(); await allowAdmission.promise; }, + acceptFormRequest: 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..25cbb72508 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,7 @@ describe('Runtime Interaction authority seam', () => { runId: 'wrong-run', acceptSandboxBoundaryRequest: async () => {}, acceptUserQuestionRequest: async () => {}, + acceptFormRequest: async () => {}, close: async (reason) => { log.push(`close:${reason}`); }, @@ -204,6 +206,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 +602,7 @@ function authority( close: async () => {}, release: () => {}, ...overrides, + acceptFormRequest: overrides.acceptFormRequest ?? (async () => {}), }), }; } 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..8f3ce30567 100644 --- a/packages/runtime/src/__tests__/runtime-kernel-interaction.test.ts +++ b/packages/runtime/src/__tests__/runtime-kernel-interaction.test.ts @@ -100,6 +100,7 @@ describe('RuntimeKernel Interaction close cleanup', () => { ...identity, acceptSandboxBoundaryRequest: async () => {}, acceptUserQuestionRequest: async () => {}, + acceptFormRequest: async () => {}, close: async () => { closeCalls += 1; closeStarted.resolve(); @@ -483,6 +484,7 @@ function runtimeFixture(options: RuntimeFixtureOptions = {}): { ...identity, acceptSandboxBoundaryRequest: async () => {}, acceptUserQuestionRequest: async () => {}, + acceptFormRequest: 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..6da3e1c27b 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,7 @@ function hostedInteractionAuthority(): RuntimeInteractionAuthority { ...identity, acceptSandboxBoundaryRequest: async () => {}, acceptUserQuestionRequest: async () => {}, + acceptFormRequest: 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..cc9d56622f 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -14762,6 +14762,7 @@ function testInteractionAuthority(): RuntimeInteractionAuthority { ...identity, acceptSandboxBoundaryRequest: async () => {}, acceptUserQuestionRequest: async () => {}, + acceptFormRequest: async () => {}, close: async () => {}, release: () => {}, }), 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..af70508124 --- /dev/null +++ b/packages/runtime/src/__tests__/tool-runtime-form-interaction.test.ts @@ -0,0 +1,200 @@ +/* + * 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 { 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(): 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' }, + ], + }, + ], + }); + }, + }; +} + +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, + ); + }); +}); 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..acab112c5b 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,9 @@ describe('ToolRuntime session sandbox boundary', () => { admitUserQuestionRequest: async () => { throw new Error('Unexpected user question'); }, + admitFormRequest: async () => { + throw new Error('Unexpected user form'); + }, admitSandboxBoundaryRequest: async ({ request, settlement }) => { admittedRequest = request; captured = settlement; diff --git a/packages/runtime/src/interaction-authority.ts b/packages/runtime/src/interaction-authority.ts index 2f13d36ee9..3594b34eaa 100644 --- a/packages/runtime/src/interaction-authority.ts +++ b/packages/runtime/src/interaction-authority.ts @@ -20,6 +20,8 @@ import { isDeepStrictEqual } from 'node:util'; import type { + FormAnswerAckEvent, + FormRequestEvent, SandboxBoundaryDecisionAckEvent, SandboxBoundaryRequestEvent, UserQuestionAnswerAckEvent, @@ -28,11 +30,13 @@ import type { 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 +69,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 +87,12 @@ export interface RuntimeUserQuestionContinuation waitForPublication(): Promise; } +export interface RuntimeFormContinuation + extends RuntimeInteractionContinuationIdentity, + HostedFormSettlement { + waitForPublication(): Promise; +} + export interface RuntimeSandboxBoundaryContinuation extends RuntimeInteractionContinuationIdentity, HostedSandboxBoundarySettlement { @@ -90,6 +104,10 @@ export interface RuntimeInteractionContinuationAuthority { request: UserQuestionRequestEvent; continuation: RuntimeUserQuestionContinuation; }): Promise; + acceptFormRequest(input: { + request: FormRequestEvent; + continuation: RuntimeFormContinuation; + }): Promise; acceptSandboxBoundaryRequest(input: { request: SandboxBoundaryRequestEvent; continuation: RuntimeSandboxBoundaryContinuation; @@ -181,11 +199,18 @@ 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; +type RuntimeHostedInteractionOutcome = + | RuntimeUserQuestionOutcome + | RuntimeFormOutcome + | RuntimeSandboxBoundaryOutcome; interface TrackedContinuationBase { readonly requestId: string; @@ -204,12 +229,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 +328,40 @@ 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`, + ); + } + } + async admitSandboxBoundaryRequest(input: { request: SandboxBoundaryRequestEvent; settlement: HostedSandboxBoundarySettlement; @@ -490,6 +557,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 +711,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/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/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/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index cb81443d34..b542c7a6b3 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,7 @@ export interface MakaToolContext { view?: 'result' | 'events' | 'runtime_events' | 'all'; }) => Promise; askUserQuestion?: (questions: UserQuestion[]) => Promise; + requestUserForm?: (form: InteractionFormInput) => Promise; requestSandboxBoundary?: ( expansion: SandboxBoundaryExpansion, justification: string, @@ -545,10 +557,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 +649,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 +670,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 +714,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 +780,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 +808,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 +1722,8 @@ export class ToolRuntime { }), askUserQuestion: (questions) => this.askUserQuestion(turnId, toolUseId, questions, ctx.abortSignal, queue), + requestUserForm: (form) => + this.requestUserForm(turnId, toolUseId, form, ctx.abortSignal, queue), requestSandboxBoundary: (expansion, justification) => this.requestSandboxBoundary( turnId, @@ -2554,6 +2628,101 @@ export class ToolRuntime { }; } + private async requestUserForm( + turnId: string, + toolUseId: string, + form: InteractionFormInput, + abortSignal: AbortSignal, + queue: DurableSessionEventSink, + ): Promise { + throwIfAborted(abortSignal); + 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(); + }; + abortSignal.addEventListener('abort', onAbort, { 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 = hostedRun.admitFormRequest({ request: requestEvent, settlement }); + try { + await racePromiseWithAbort(admission, abortSignal); + } catch (error) { + if (abortSignal.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(abortSignal); + } + 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(abortSignal); + queue.push(requestEvent); + const response = await racePromiseWithAbort(parked, abortSignal); + throwIfAborted(abortSignal); + 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); + } + } + private async askUserQuestion( turnId: string, toolUseId: string, @@ -2929,6 +3098,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 +3197,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; From 08cbef603630578e3a027cf7e138124c9c396d29 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 31 Aug 2026 23:17:53 +0800 Subject: [PATCH 02/14] fix(runtime): complete form interaction adapter seam Expose one closed decoder for renderer-to-runtime form responses so surface adapters do not copy protocol validation. Queue the same canonical continuity refresh for form requests that user questions already receive. Refs #4364. Generated-by: OpenAI Codex --- .../core/src/__tests__/interaction.test.ts | 28 +++++++++++++++++++ packages/core/src/interaction.ts | 26 +++++++++++++++++ .../src/server/root-turn-coordinator.ts | 2 +- 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/core/src/__tests__/interaction.test.ts b/packages/core/src/__tests__/interaction.test.ts index 029ae5a459..844ff3c8c5 100644 --- a/packages/core/src/__tests__/interaction.test.ts +++ b/packages/core/src/__tests__/interaction.test.ts @@ -29,6 +29,7 @@ import { InteractionPermissionProjectionError, decodeInteractionAnswer, decodeInteractionCanonicalOutcome, + decodeInteractionFormResponse, decodeInteractionRequest, interactionCanonicalOutcomesEquivalent, isInteractionAnswerValidForRequest, @@ -1300,4 +1301,31 @@ describe('Interaction decoding and validity', () => { 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/interaction.ts b/packages/core/src/interaction.ts index da91a85c0f..8fd56b9b8a 100644 --- a/packages/core/src/interaction.ts +++ b/packages/core/src/interaction.ts @@ -330,6 +330,12 @@ const FORM_ACCEPT_ANSWER_SHAPE = defineObjectShape< 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'], [], @@ -506,6 +512,26 @@ export function decodeInteractionAnswer(value: unknown): InteractionAnswer { 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; diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index 4cfbea72c3..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); } } From 3b972bda15b6ad842275e7fcbe3a938919875138 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 1 Sep 2026 15:45:55 +0800 Subject: [PATCH 03/14] fix(runtime): harden structured form boundaries --- .../core/src/__tests__/interaction.test.ts | 96 ++++++++++++++++++ packages/core/src/interaction.ts | 97 ++++++++++++++++--- 2 files changed, 181 insertions(+), 12 deletions(-) diff --git a/packages/core/src/__tests__/interaction.test.ts b/packages/core/src/__tests__/interaction.test.ts index 844ff3c8c5..23466386cb 100644 --- a/packages/core/src/__tests__/interaction.test.ts +++ b/packages/core/src/__tests__/interaction.test.ts @@ -34,6 +34,7 @@ import { interactionCanonicalOutcomesEquivalent, isInteractionAnswerValidForRequest, isInteractionCanonicalOutcomeValidForRequest, + isInteractionFormFieldValueValid, projectInteractionClientCapabilityRequest, projectInteractionPermissionRequest, projectInteractionFormRequest, @@ -1157,6 +1158,101 @@ describe('Interaction decoding and validity', () => { ); }); + 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('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', diff --git a/packages/core/src/interaction.ts b/packages/core/src/interaction.ts index 8fd56b9b8a..9998aae715 100644 --- a/packages/core/src/interaction.ts +++ b/packages/core/src/interaction.ts @@ -509,6 +509,9 @@ 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); } @@ -691,7 +694,53 @@ export function projectInteractionQuestionRequest( export function projectInteractionFormRequest( input: InteractionFormProjectionInput, ): InteractionFormRequest { - return decodeInteractionRequest({ kind: 'form', ...input }) as 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') { + return { ...field, ...display }; + } + 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: { @@ -1161,23 +1210,31 @@ function matchesStringFormat( if (format === 'date') { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); if (!match) return false; - const year = Number(match[1]); - const month = Number(match[2]); - const day = Number(match[3]); - const date = new Date(Date.UTC(year, month - 1, day)); - return ( - date.getUTCFullYear() === year && - date.getUTCMonth() === month - 1 && - date.getUTCDate() === day - ); + 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 ( - /^\d{4}-\d{2}-\d{2}T/.test(value) && - /(?:Z|[+-]\d{2}:\d{2})$/.test(value) && + 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 = {}; @@ -1190,6 +1247,22 @@ function assertFormHasAcceptedAnswer(request: InteractionFormRequest): void { throw new Error('Interaction form has no valid accepted answer'); } serializedLimit(answer, INTERACTION_ANSWER_SERIALIZED_MAX_BYTES, 'Interaction form answer'); + assertAcceptedFormAnswerFitsCanonicalOutcome(answer); +} + +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 { From 5a788b929bf6c5622a64e69a488d448860febe54 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 1 Sep 2026 23:23:02 +0800 Subject: [PATCH 04/14] fix(desktop): accept form interactions in composer region --- apps/desktop/src/renderer/chat-composer-region.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx index 2c25797fd7..c3ffcbd76e 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 From 7a07e8e3a4ec2e5cead007f0636b58bbb1bf6459 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 2 Sep 2026 00:36:06 +0800 Subject: [PATCH 05/14] fix(runtime): complete form interaction lifecycle Generated-by: OpenAI Codex --- .../core/src/__tests__/interaction.test.ts | 17 ++++++ packages/core/src/interaction.ts | 53 +++++++++++++++++++ .../session-projection-helpers.test.ts | 6 +++ .../__tests__/stream-graph-projection.test.ts | 15 ++++++ packages/runtime/src/agent-run.ts | 4 +- packages/runtime/src/interaction-authority.ts | 21 ++++++++ packages/runtime/src/runtime-kernel.ts | 12 ++--- .../runtime/src/session-projection-helpers.ts | 2 + .../runtime/src/stream-graph-projection.ts | 10 +++- .../runtime/src/stream-graph-read-model.ts | 5 ++ 10 files changed, 135 insertions(+), 10 deletions(-) diff --git a/packages/core/src/__tests__/interaction.test.ts b/packages/core/src/__tests__/interaction.test.ts index 23466386cb..d52adac401 100644 --- a/packages/core/src/__tests__/interaction.test.ts +++ b/packages/core/src/__tests__/interaction.test.ts @@ -1368,6 +1368,23 @@ describe('Interaction decoding and validity', () => { ], }), ); + + 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('compares canonical accepted form values structurally', () => { diff --git a/packages/core/src/interaction.ts b/packages/core/src/interaction.ts index 9998aae715..a815610491 100644 --- a/packages/core/src/interaction.ts +++ b/packages/core/src/interaction.ts @@ -731,8 +731,22 @@ function projectInteractionFormField(field: InteractionFormField): InteractionFo }), }; if (field.kind !== 'single_select' && field.kind !== 'multi_select') { + // `name` is a protocol identity returned to the tool; a string default is + // rendered into an input the user reads and accepts. + if (field.kind === 'string' && field.default !== undefined) { + return { + ...field, + ...display, + default: projectInteractionReviewText( + field.default, + INTERACTION_FORM_VALUE_MAX_BYTES, + true, + ), + }; + } 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), @@ -1248,6 +1262,45 @@ function assertFormHasAcceptedAnswer(request: InteractionFormRequest): void { } 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') { + const maximumCodePoints = Math.min( + field.maxLength ?? INTERACTION_FORM_VALUE_MAX_BYTES, + Math.floor(INTERACTION_FORM_VALUE_MAX_BYTES / 4), + ); + return '😀'.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) => + Buffer.byteLength(option.value) > Buffer.byteLength(longest) ? option.value : longest, + field.options[0]!.value, + ); + } + return [...field.options] + .sort((left, right) => Buffer.byteLength(right.value) - Buffer.byteLength(left.value)) + .slice(0, field.maxItems ?? field.options.length) + .map((option) => option.value); } function assertAcceptedFormAnswerFitsCanonicalOutcome( 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/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 3594b34eaa..374c132705 100644 --- a/packages/runtime/src/interaction-authority.ts +++ b/packages/runtime/src/interaction-authority.ts @@ -24,6 +24,7 @@ import type { FormRequestEvent, SandboxBoundaryDecisionAckEvent, SandboxBoundaryRequestEvent, + SessionEvent, UserQuestionAnswerAckEvent, UserQuestionRequestEvent, } from '@maka/core/events'; @@ -207,6 +208,26 @@ type HostedInteractionSettlementAckEvent = | UserQuestionAnswerAckEvent | FormAnswerAckEvent | SandboxBoundaryDecisionAckEvent; + +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 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-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': From 8f32fe192c25a763cbda8975e5e9fa7a030e5ed2 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 2 Sep 2026 00:44:11 +0800 Subject: [PATCH 06/14] fix(desktop): keep unsupported forms out of composer queue Generated-by: OpenAI Codex --- .../src/__tests__/interaction-queue.test.ts | 21 ++++++++++++++ packages/ui/src/interaction-queue.ts | 29 +++++++++++++++---- 2 files changed, 45 insertions(+), 5 deletions(-) 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 }; From d1b6643c42c121496f3a10a7a8e4500667ed0382 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 2 Sep 2026 11:27:39 +0800 Subject: [PATCH 07/14] fix(runtime-host): publish form graph activity Generated-by: OpenAI Codex --- .../src/__tests__/agent-graph-protocol.test.ts | 9 +++++++++ packages/runtime-host/src/protocol/agent-graph.ts | 13 +++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) 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..506a628a4c 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,15 @@ 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/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' || From 781c232f5c96a8cc564a9dd56b103222a695063b Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 2 Sep 2026 15:40:31 +0800 Subject: [PATCH 08/14] style(runtime-host): format form graph regression Generated-by: OpenAI Codex --- .../runtime-host/src/__tests__/agent-graph-protocol.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 506a628a4c..a66a4bbfbf 100644 --- a/packages/runtime-host/src/__tests__/agent-graph-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/agent-graph-protocol.test.ts @@ -100,7 +100,8 @@ describe('Agent Graph Client protocol', () => { signals: [{ kind: 'attention' as const, reason: 'form_request' as const }], }; assert.deepEqual( - decodeAgentGraphClientSnapshot({ ...snapshot, recentActivity: [formActivity] }).recentActivity, + decodeAgentGraphClientSnapshot({ ...snapshot, recentActivity: [formActivity] }) + .recentActivity, [formActivity], ); AGENT_GRAPH_OPERATION_SPECS['agent.graph.query'].assertOutputForInput?.( From 74f0e180f4e8973085f1f5845b8407d5ecfafbef Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 2 Sep 2026 15:40:32 +0800 Subject: [PATCH 09/14] fix(core): preserve projected form default semantics Generated-by: OpenAI Codex --- .../core/src/__tests__/interaction.test.ts | 51 +++++++++++++++++++ packages/core/src/interaction.ts | 19 ++++--- 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/packages/core/src/__tests__/interaction.test.ts b/packages/core/src/__tests__/interaction.test.ts index d52adac401..fe45461a87 100644 --- a/packages/core/src/__tests__/interaction.test.ts +++ b/packages/core/src/__tests__/interaction.test.ts @@ -1200,6 +1200,57 @@ describe('Interaction decoding and validity', () => { 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', + }, + { + kind: 'string', + name: 'safe-contact', + label: 'Safe contact', + required: false, + default: 'owner@example.test', + format: 'email', + }, + ], + }); + + 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({ diff --git a/packages/core/src/interaction.ts b/packages/core/src/interaction.ts index a815610491..76ef555475 100644 --- a/packages/core/src/interaction.ts +++ b/packages/core/src/interaction.ts @@ -731,17 +731,20 @@ function projectInteractionFormField(field: InteractionFormField): InteractionFo }), }; if (field.kind !== 'single_select' && field.kind !== 'multi_select') { - // `name` is a protocol identity returned to the tool; a string default is - // rendered into an input the user reads and accepts. + // `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 { - ...field, + ...fieldWithoutDefault, ...display, - default: projectInteractionReviewText( - field.default, - INTERACTION_FORM_VALUE_MAX_BYTES, - true, - ), + ...(projectedDefault === canonicalDefault ? { default: canonicalDefault } : {}), }; } return { ...field, ...display }; From 6213416e12a46419dc1a7a01e09e781e2a696131 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Thu, 3 Sep 2026 13:57:27 +0800 Subject: [PATCH 10/14] fix(core): prove the escaped answer envelope at admission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admission reserved the answer envelope with four raw bytes per code point, but enforcement measures post-serialization bytes, where JSON escaping inflates a code point to as much as six. A schema-legal answer of backslashes, newlines, or control characters could pass admission yet be rejected at decode, stranding the pending interaction. Keep the schema semantics — maxLength stays a code-point count and each value stays bounded at INTERACTION_FORM_VALUE_MAX_BYTES raw bytes — and prove serializability at admission: the string envelope is now all control characters (one code point and one raw byte each, six serialized bytes after escaping), and select envelopes pick the serialized-longest option rather than the raw-longest. A form whose limits permit an undeliverable answer is rejected up front instead of stranding the interaction after the user submits. Regressions pin the preserved character semantics (2,048 plain characters or 1,024 backslashes still satisfy a maxLength: 2048 field), admission rejection for limits that cannot guarantee delivery, and escape-heavy answers that decode and deliver for admissible forms. --- .../core/src/__tests__/interaction.test.ts | 88 +++++++++++++++++++ packages/core/src/interaction.ts | 21 +++-- 2 files changed, 102 insertions(+), 7 deletions(-) diff --git a/packages/core/src/__tests__/interaction.test.ts b/packages/core/src/__tests__/interaction.test.ts index fe45461a87..f950ff40bd 100644 --- a/packages/core/src/__tests__/interaction.test.ts +++ b/packages/core/src/__tests__/interaction.test.ts @@ -1233,6 +1233,7 @@ describe('Interaction decoding and validity', () => { required: false, default: 'password=secret@example.test', format: 'email', + maxLength: 512, }, { kind: 'string', @@ -1241,6 +1242,7 @@ describe('Interaction decoding and validity', () => { required: false, default: 'owner@example.test', format: 'email', + maxLength: 512, }, ], }); @@ -1438,6 +1440,92 @@ describe('Interaction decoding and validity', () => { ); }); + 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('compares canonical accepted form values structurally', () => { const first = decodeInteractionCanonicalOutcome({ kind: 'form_answer', diff --git a/packages/core/src/interaction.ts b/packages/core/src/interaction.ts index 76ef555475..9bb911d703 100644 --- a/packages/core/src/interaction.ts +++ b/packages/core/src/interaction.ts @@ -1285,27 +1285,34 @@ function assertEveryFormAnswerFitsCanonicalOutcome(request: InteractionFormReque function formFieldMaximumEnvelope(field: InteractionFormField): InteractionFormValue { if (field.kind === 'string') { - const maximumCodePoints = Math.min( - field.maxLength ?? INTERACTION_FORM_VALUE_MAX_BYTES, - Math.floor(INTERACTION_FORM_VALUE_MAX_BYTES / 4), - ); - return '😀'.repeat(maximumCodePoints); + // Admission must prove every legal answer serializes. String values keep + // their schema semantics — maxLength in code points, raw UTF-8 bytes + // bounded at INTERACTION_FORM_VALUE_MAX_BYTES — while enforcement measures + // post-serialization bytes, where JSON escaping inflates a code point to + // as much as six bytes (backslash, quote, newline, control characters). + // The worst legal value is therefore all control characters: one code + // point and one raw byte each, six serialized bytes after escaping. + return ''.repeat(field.maxLength ?? INTERACTION_FORM_VALUE_MAX_BYTES); } 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) => - Buffer.byteLength(option.value) > Buffer.byteLength(longest) ? option.value : longest, + serializedByteLength(option.value) > serializedByteLength(longest) ? option.value : longest, field.options[0]!.value, ); } return [...field.options] - .sort((left, right) => Buffer.byteLength(right.value) - Buffer.byteLength(left.value)) + .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 { From b3b10728a2c24f4bdb452f28c653377f9484536a Mon Sep 17 00:00:00 2001 From: me2seeks Date: Thu, 3 Sep 2026 14:49:52 +0800 Subject: [PATCH 11/14] fix(core): reserve date and date-time envelopes in their legal language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A string field without maxLength reserved 2,048 control characters even when the format was date or date-time — an estimate that can never pass the format check yet inflates to 12 KiB, so a form asking for a calendar day was rejected before publication while its whole legal answer is 64 bytes. Compute the worst value inside each format's legal language instead: date is fixed-length over [0-9-], and date-time adds only characters that never JSON-escape, with fractional seconds bounding length at the field caps. Other formats and unconstrained strings keep the six-bytes-per-code-point worst case, since control characters remain legal there. Regressions cover date and date-time fields admitted with no maxLength, their canonical answers decoding, and the fractional-seconds worst case staying deliverable. --- .../core/src/__tests__/interaction.test.ts | 39 +++++++++++++++++++ packages/core/src/interaction.ts | 22 +++++++---- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/packages/core/src/__tests__/interaction.test.ts b/packages/core/src/__tests__/interaction.test.ts index f950ff40bd..c824cda0c8 100644 --- a/packages/core/src/__tests__/interaction.test.ts +++ b/packages/core/src/__tests__/interaction.test.ts @@ -1526,6 +1526,45 @@ describe('Interaction decoding and validity', () => { ); }); + 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', diff --git a/packages/core/src/interaction.ts b/packages/core/src/interaction.ts index 9bb911d703..383e61f5e7 100644 --- a/packages/core/src/interaction.ts +++ b/packages/core/src/interaction.ts @@ -1285,14 +1285,20 @@ function assertEveryFormAnswerFitsCanonicalOutcome(request: InteractionFormReque function formFieldMaximumEnvelope(field: InteractionFormField): InteractionFormValue { if (field.kind === 'string') { - // Admission must prove every legal answer serializes. String values keep - // their schema semantics — maxLength in code points, raw UTF-8 bytes - // bounded at INTERACTION_FORM_VALUE_MAX_BYTES — while enforcement measures - // post-serialization bytes, where JSON escaping inflates a code point to - // as much as six bytes (backslash, quote, newline, control characters). - // The worst legal value is therefore all control characters: one code - // point and one raw byte each, six serialized bytes after escaping. - return ''.repeat(field.maxLength ?? INTERACTION_FORM_VALUE_MAX_BYTES); + // 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; From bd323f3bf279b1855f242ce05b5b8023f5f22b9b Mon Sep 17 00:00:00 2001 From: Zhang <96464454+me2seeks@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:41:20 +0800 Subject: [PATCH 12/14] feat(runtime-host): broker nested capability forms (#4397) * feat(runtime): withdraw producer-owned forms Let an exact hosted Run withdraw one form without closing its surrounding Turn. Commit producer cancellation through the existing InteractionStore authority, preserve an already-claimed Run closure, and compose provider-local cancellation with the Tool invocation signal. Part of #4364. Generated-by: OpenAI Codex * feat(runtime-host): carry nested capability forms Add strict request/result frames and expose one provider-neutral requestInteraction callback for admitted Client Capability invocations. Keep correlation inside the client channel and publish a new compatibility epoch for peers that understand the round trip.\n\nPart of #4364.\n\nGenerated-by: OpenAI Codex * feat(runtime-host): broker nested capability forms Route Client Capability interaction requests through the Runtime-owned form callback. Pause provider execution time only while the canonical form is pending, bound result delivery, and rearm a fresh execution timeout after delivery.\n\nClose the exact producer-owned form before settling provider failure, cancellation, or connection loss, while preserving Runtime Host as the only Interaction authority.\n\nPart of #4364.\n\nGenerated-by: OpenAI Codex * fix(runtime-host): order nested form cleanup * test(desktop): complete capability interaction fake * fix(runtime-host): forward forms after capability admission * fix(runtime-host): await prior capability releases * style: format nested capability form files The nested capability form sources predate the formatter rules now on main; rebase onto the current parent and reformat so the changed-file biome gate passes again. No semantic change. * test: give nested form fixtures an explicit string bound Admission now proves every legal answer serializes, so a string field without maxLength is no longer admissible. Bound the fixtures to keep them representative of forms a provider can actually publish. --- .../src/main/__tests__/browser-tools.test.ts | 1 + .../runtime-host-desktop-candidate.test.ts | 1 + .../runtime-host-native-capabilities.test.ts | 2 + ...e-host-capability-provider-command.test.ts | 1 + packages/core/src/backend-types.ts | 2 + packages/core/src/interaction.ts | 1 + .../client-capability-channel.test.ts | 177 ++++++++ .../client-capability-coordinator.test.ts | 82 ++++ ...ient-capability-interaction-broker.test.ts | 397 ++++++++++++++++++ .../client-capability-protocol.test.ts | 88 ++++ .../__tests__/interaction-coordinator.test.ts | 20 +- .../src/__tests__/protocol.test.ts | 4 + .../src/client/client-capability-channel.ts | 102 +++++ .../src/client/client-capability.ts | 3 + .../src/protocol/client-capability.ts | 92 +++- packages/runtime-host/src/protocol/index.ts | 4 +- .../server/client-capability-coordinator.ts | 25 +- .../client-capability-invocation-broker.ts | 349 ++++++++++++--- .../src/server/interaction-coordinator.ts | 55 +++ .../src/__tests__/fake-backend.test.ts | 1 + .../__tests__/interaction-authority.test.ts | 2 + .../runtime/src/__tests__/mcp-tools.test.ts | 88 ++++ .../runtime-kernel-interaction.test.ts | 2 + .../session-manager-terminal-ledger.test.ts | 1 + .../src/__tests__/session-manager.test.ts | 1 + .../tool-runtime-form-interaction.test.ts | 169 ++++++-- .../tool-runtime-sandbox-boundary.test.ts | 3 + packages/runtime/src/interaction-authority.ts | 5 + packages/runtime/src/mcp-tools.ts | 20 + packages/runtime/src/tool-runtime.ts | 58 ++- 30 files changed, 1654 insertions(+), 102 deletions(-) create mode 100644 packages/runtime-host/src/__tests__/client-capability-interaction-broker.test.ts 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/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/core/src/backend-types.ts b/packages/core/src/backend-types.ts index a3ffdcae98..7b9d45854f 100644 --- a/packages/core/src/backend-types.ts +++ b/packages/core/src/backend-types.ts @@ -160,6 +160,8 @@ export interface HostedInteractionBridge { 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/interaction.ts b/packages/core/src/interaction.ts index 383e61f5e7..a05a4cde46 100644 --- a/packages/core/src/interaction.ts +++ b/packages/core/src/interaction.ts @@ -72,6 +72,7 @@ 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', 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 3b978c79bd..52ebb394de 100644 --- a/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts @@ -230,13 +230,27 @@ describe('HostInteractionCoordinator', () => { closure: (reason) => closures.push(reason), }), }); - await owner.close('turn_terminal'); - assert.deepEqual(closures, ['turn_terminal']); + await owner.withdrawFormRequest('form_2'); + assert.deepEqual(closures, ['producer_cancelled']); assert.deepEqual((await store.readInteraction('form_2'))?.outcome?.outcome, { kind: 'closure', - reason: 'turn_terminal', + 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(); }); 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/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/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 589517d04c..be899c983f 100644 --- a/packages/runtime-host/src/server/interaction-coordinator.ts +++ b/packages/runtime-host/src/server/interaction-coordinator.ts @@ -254,6 +254,7 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { ) => 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), @@ -969,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; diff --git a/packages/runtime/src/__tests__/fake-backend.test.ts b/packages/runtime/src/__tests__/fake-backend.test.ts index 0673427f59..c61f3447e5 100644 --- a/packages/runtime/src/__tests__/fake-backend.test.ts +++ b/packages/runtime/src/__tests__/fake-backend.test.ts @@ -45,6 +45,7 @@ test('Fake question publication waits for exact hosted admission', async () => { 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 25cbb72508..ebca8c44c3 100644 --- a/packages/runtime/src/__tests__/interaction-authority.test.ts +++ b/packages/runtime/src/__tests__/interaction-authority.test.ts @@ -91,6 +91,7 @@ describe('Runtime Interaction authority seam', () => { acceptSandboxBoundaryRequest: async () => {}, acceptUserQuestionRequest: async () => {}, acceptFormRequest: async () => {}, + withdrawFormRequest: async () => {}, close: async (reason) => { log.push(`close:${reason}`); }, @@ -603,6 +604,7 @@ function authority( 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-kernel-interaction.test.ts b/packages/runtime/src/__tests__/runtime-kernel-interaction.test.ts index 8f3ce30567..2216a52160 100644 --- a/packages/runtime/src/__tests__/runtime-kernel-interaction.test.ts +++ b/packages/runtime/src/__tests__/runtime-kernel-interaction.test.ts @@ -101,6 +101,7 @@ describe('RuntimeKernel Interaction close cleanup', () => { acceptSandboxBoundaryRequest: async () => {}, acceptUserQuestionRequest: async () => {}, acceptFormRequest: async () => {}, + withdrawFormRequest: async () => {}, close: async () => { closeCalls += 1; closeStarted.resolve(); @@ -485,6 +486,7 @@ function runtimeFixture(options: RuntimeFixtureOptions = {}): { acceptSandboxBoundaryRequest: async () => {}, acceptUserQuestionRequest: async () => {}, acceptFormRequest: async () => {}, + withdrawFormRequest: async () => {}, close: async () => { markCloseStarted(); if (options.deferredClose) await closeReleased; 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 6da3e1c27b..9c343b10b4 100644 --- a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts +++ b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts @@ -2926,6 +2926,7 @@ function hostedInteractionAuthority(): RuntimeInteractionAuthority { 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 cc9d56622f..a0babc9ecb 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -14763,6 +14763,7 @@ function testInteractionAuthority(): RuntimeInteractionAuthority { acceptSandboxBoundaryRequest: async () => {}, acceptUserQuestionRequest: async () => {}, acceptFormRequest: async () => {}, + withdrawFormRequest: async () => {}, close: async () => {}, release: () => {}, }), diff --git a/packages/runtime/src/__tests__/tool-runtime-form-interaction.test.ts b/packages/runtime/src/__tests__/tool-runtime-form-interaction.test.ts index af70508124..8959f36e04 100644 --- a/packages/runtime/src/__tests__/tool-runtime-form-interaction.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-form-interaction.test.ts @@ -19,6 +19,7 @@ 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'; @@ -49,7 +50,7 @@ function header(): SessionHeader { }; } -function formTool(): MakaTool> { +function formTool(cancellationSignal?: AbortSignal): MakaTool> { return { name: 'SyntheticForm', description: 'Exercise the provider-neutral form seam.', @@ -57,30 +58,33 @@ function formTool(): MakaTool> { 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' }, - ], - }, - ], - }); + 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, + ); }, }; } @@ -197,4 +201,119 @@ describe('ToolRuntime form Interaction', () => { 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 acab112c5b..e4a71162ac 100644 --- a/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts @@ -222,6 +222,9 @@ describe('ToolRuntime session sandbox boundary', () => { 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/interaction-authority.ts b/packages/runtime/src/interaction-authority.ts index 374c132705..b4463624f2 100644 --- a/packages/runtime/src/interaction-authority.ts +++ b/packages/runtime/src/interaction-authority.ts @@ -120,6 +120,7 @@ export interface RuntimeInteractionRunFacet RuntimeInteractionRunIdentity {} export interface RuntimeInteractionRunOwner extends RuntimeInteractionRunFacet { + withdrawFormRequest(requestId: string): Promise; close(reason: RuntimeInteractionRunClosureReason): Promise; release(): void; } @@ -383,6 +384,10 @@ export class RuntimeInteractionRunBinding implements HostedInteractionBridge { } } + withdrawFormRequest(requestId: string): Promise { + return this.owner.withdrawFormRequest(requestId); + } + async admitSandboxBoundaryRequest(input: { request: SandboxBoundaryRequestEvent; settlement: HostedSandboxBoundarySettlement; 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/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index b542c7a6b3..a12fdce73e 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -310,7 +310,10 @@ export interface MakaToolContext { view?: 'result' | 'events' | 'runtime_events' | 'all'; }) => Promise; askUserQuestion?: (questions: UserQuestion[]) => Promise; - requestUserForm?: (form: InteractionFormInput) => Promise; + requestUserForm?: ( + form: InteractionFormInput, + options?: { readonly cancellationSignal?: AbortSignal }, + ) => Promise; requestSandboxBoundary?: ( expansion: SandboxBoundaryExpansion, justification: string, @@ -1722,8 +1725,15 @@ export class ToolRuntime { }), askUserQuestion: (questions) => this.askUserQuestion(turnId, toolUseId, questions, ctx.abortSignal, queue), - requestUserForm: (form) => - this.requestUserForm(turnId, toolUseId, form, ctx.abortSignal, queue), + requestUserForm: (form, options) => + this.requestUserForm( + turnId, + toolUseId, + form, + ctx.abortSignal, + queue, + options?.cancellationSignal, + ), requestSandboxBoundary: (expansion, justification) => this.requestSandboxBoundary( turnId, @@ -2634,8 +2644,10 @@ export class ToolRuntime { form: InteractionFormInput, abortSignal: AbortSignal, queue: DurableSessionEventSink, + producerCancellationSignal?: AbortSignal, ): Promise { - throwIfAborted(abortSignal); + const interactionSignal = composeChildAbortSignal(abortSignal, producerCancellationSignal); + throwIfAborted(interactionSignal); const hostedRun = this.interactionRun(); const requestId = this.input.newId(); const request = projectInteractionFormRequest({ toolUseId, ...form }); @@ -2649,7 +2661,26 @@ export class ToolRuntime { 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 = { @@ -2665,11 +2696,14 @@ export class ToolRuntime { }; if (hostedRun) { const settlement = this.createFormSettlement(turnId, requestId); - const admission = hostedRun.admitFormRequest({ request: requestEvent, settlement }); + const admission = Promise.resolve().then(() => + hostedRun.admitFormRequest({ request: requestEvent, settlement }), + ); + hostedAdmission = admission; try { - await racePromiseWithAbort(admission, abortSignal); + await racePromiseWithAbort(admission, interactionSignal); } catch (error) { - if (abortSignal.aborted) { + if (interactionSignal.aborted) { void admission.catch((admissionError) => { this.userForms.reject( requestId, @@ -2682,7 +2716,7 @@ export class ToolRuntime { ); this.finishDeferredFormTurnClosure(); }); - throw abortErrorFromSignal(abortSignal); + throw abortErrorFromSignal(interactionSignal); } this.userForms.reject( requestId, @@ -2701,10 +2735,10 @@ export class ToolRuntime { ); } } - throwIfAborted(abortSignal); + throwIfAborted(interactionSignal); queue.push(requestEvent); - const response = await racePromiseWithAbort(parked, abortSignal); - throwIfAborted(abortSignal); + const response = await racePromiseWithAbort(parked, interactionSignal); + throwIfAborted(interactionSignal); const answerAck: FormAnswerAckEvent = { type: 'form_answer_ack', id: this.input.newId(), @@ -2720,6 +2754,8 @@ export class ToolRuntime { : { action: response.action }; } finally { abortSignal.removeEventListener('abort', onAbort); + producerCancellationSignal?.removeEventListener('abort', onProducerCancellation); + if (producerWithdrawal) await producerWithdrawal; } } From ec86ce618af744828958e87daf34a24a50ba91c6 Mon Sep 17 00:00:00 2001 From: Zhang <96464454+me2seeks@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:41:41 +0800 Subject: [PATCH 13/14] feat(tui): answer structured form interactions (#4392) * feat(tui): answer structured form interactions Review and edit Host-owned forms in the existing TUI interaction queue, preserving optional omission and all six primitive field kinds. Route accepted, declined, and cancelled responses through the generic Runtime Host interaction operation, while non-interactive runs stop the exact Turn instead of dropping the request. Keep requester provenance and sensitive-data guidance visible, validate with the shared Core contract, and retire stale overlays when authoritative transcript state changes. Part of #4364. Generated-by: OpenAI Codex * fix(tui): retain reconnecting form drafts * test(tui): cover form constraint copy variables * style(tui): format form interaction sources The TUI form sources predate the formatter rules now on main; rebase onto the current parent and reformat so the changed-file biome gate passes. Reflow and trailing commas only, no semantic change. --- .../cli/src/__tests__/pi-transcript.test.ts | 33 +- .../__tests__/pi-tui-form-interaction.test.ts | 279 ++++++++ .../cli/src/__tests__/pi-tui-runner.test.ts | 234 +++++++ .../runtime-host-run-command.test.ts | 61 ++ .../runtime-host-session-driver.test.ts | 86 ++- .../src/__tests__/tui-copy-catalog.test.ts | 3 + packages/cli/src/pi-transcript.ts | 17 +- packages/cli/src/pi-tui-form-interaction.ts | 653 ++++++++++++++++++ packages/cli/src/pi-tui-runner.ts | 169 ++++- packages/cli/src/runtime-host-run-command.ts | 10 +- .../cli/src/runtime-host-session-channel.ts | 2 + .../cli/src/runtime-host-session-driver.ts | 18 + packages/cli/src/session-driver.ts | 2 + packages/cli/src/tui-copy-catalog.ts | 54 ++ packages/core/package.json | 1 + 15 files changed, 1603 insertions(+), 19 deletions(-) create mode 100644 packages/cli/src/__tests__/pi-tui-form-interaction.test.ts create mode 100644 packages/cli/src/pi-tui-form-interaction.ts 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-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", From ee350c0f7649c09559b32ec7a58a492ab7631936 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Fri, 4 Sep 2026 00:18:02 +0800 Subject: [PATCH 14/14] fix(cli): cover the form interaction TUI under copy boundaries --- scripts/check-tui-copy.mjs | 1 + 1 file changed, 1 insertion(+) 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',