Skip to content

Commit 066718d

Browse files
committed
fix(runtime): complete form interaction lifecycle
Generated-by: OpenAI Codex
1 parent 2b9f1f9 commit 066718d

10 files changed

Lines changed: 135 additions & 10 deletions

packages/core/src/__tests__/interaction.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1368,6 +1368,23 @@ describe('Interaction decoding and validity', () => {
13681368
],
13691369
}),
13701370
);
1371+
1372+
assert.throws(
1373+
() =>
1374+
projectInteractionFormRequest({
1375+
toolUseId: 'tool-form',
1376+
message: 'Optional values still consume the persisted answer envelope',
1377+
requester: { name: 'deploy' },
1378+
fields: Array.from({ length: 4 }, (_, index) => ({
1379+
kind: 'string' as const,
1380+
name: `optional-${index}`,
1381+
label: `Optional ${index}`,
1382+
required: false,
1383+
maxLength: 2_048,
1384+
})),
1385+
}),
1386+
/Interaction form (answer|outcome) exceeds serialized byte limit/,
1387+
);
13711388
});
13721389

13731390
test('compares canonical accepted form values structurally', () => {

packages/core/src/interaction.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -731,8 +731,22 @@ function projectInteractionFormField(field: InteractionFormField): InteractionFo
731731
}),
732732
};
733733
if (field.kind !== 'single_select' && field.kind !== 'multi_select') {
734+
// `name` is a protocol identity returned to the tool; a string default is
735+
// rendered into an input the user reads and accepts.
736+
if (field.kind === 'string' && field.default !== undefined) {
737+
return {
738+
...field,
739+
...display,
740+
default: projectInteractionReviewText(
741+
field.default,
742+
INTERACTION_FORM_VALUE_MAX_BYTES,
743+
true,
744+
),
745+
};
746+
}
734747
return { ...field, ...display };
735748
}
749+
// Select values are protocol identities; labels are their display text.
736750
const options = field.options.map((option) => ({
737751
...option,
738752
label: projectInteractionReviewText(option.label, INTERACTION_FORM_FIELD_LABEL_MAX_BYTES),
@@ -1248,6 +1262,45 @@ function assertFormHasAcceptedAnswer(request: InteractionFormRequest): void {
12481262
}
12491263
serializedLimit(answer, INTERACTION_ANSWER_SERIALIZED_MAX_BYTES, 'Interaction form answer');
12501264
assertAcceptedFormAnswerFitsCanonicalOutcome(answer);
1265+
assertEveryFormAnswerFitsCanonicalOutcome(request);
1266+
}
1267+
1268+
/**
1269+
* Admission must reserve the whole legal answer envelope, not only a smallest
1270+
* witness. This intentionally over-approximates format-constrained strings:
1271+
* rejecting an over-large form is safe, whereas accepting one that can later
1272+
* reject a valid user answer strands the interaction.
1273+
*/
1274+
function assertEveryFormAnswerFitsCanonicalOutcome(request: InteractionFormRequest): void {
1275+
const values = Object.fromEntries(
1276+
request.fields.map((field) => [field.name, formFieldMaximumEnvelope(field)]),
1277+
);
1278+
const answer = { kind: 'form' as const, action: 'accept' as const, values };
1279+
serializedLimit(answer, INTERACTION_ANSWER_SERIALIZED_MAX_BYTES, 'Interaction form answer');
1280+
assertAcceptedFormAnswerFitsCanonicalOutcome(answer);
1281+
}
1282+
1283+
function formFieldMaximumEnvelope(field: InteractionFormField): InteractionFormValue {
1284+
if (field.kind === 'string') {
1285+
const maximumCodePoints = Math.min(
1286+
field.maxLength ?? INTERACTION_FORM_VALUE_MAX_BYTES,
1287+
Math.floor(INTERACTION_FORM_VALUE_MAX_BYTES / 4),
1288+
);
1289+
return '😀'.repeat(maximumCodePoints);
1290+
}
1291+
if (field.kind === 'number' || field.kind === 'integer') return -1.7976931348623157e308;
1292+
if (field.kind === 'boolean') return false;
1293+
if (field.kind === 'single_select') {
1294+
return field.options.reduce(
1295+
(longest, option) =>
1296+
Buffer.byteLength(option.value) > Buffer.byteLength(longest) ? option.value : longest,
1297+
field.options[0]!.value,
1298+
);
1299+
}
1300+
return [...field.options]
1301+
.sort((left, right) => Buffer.byteLength(right.value) - Buffer.byteLength(left.value))
1302+
.slice(0, field.maxItems ?? field.options.length)
1303+
.map((option) => option.value);
12511304
}
12521305

12531306
function assertAcceptedFormAnswerFitsCanonicalOutcome(

packages/runtime/src/__tests__/session-projection-helpers.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,12 +130,18 @@ describe('session projection helpers', () => {
130130
assert.deepStrictEqual(statusFromEvent({ type: 'user_question_request', ts: 1 } as never), {
131131
status: 'waiting_for_user',
132132
});
133+
assert.deepStrictEqual(statusFromEvent({ type: 'form_request', ts: 1 } as never), {
134+
status: 'waiting_for_user',
135+
});
133136
assert.deepStrictEqual(
134137
statusFromEvent({ type: 'sandbox_boundary_decision_ack', ts: 1 } as never),
135138
{
136139
status: 'running',
137140
},
138141
);
142+
assert.deepStrictEqual(statusFromEvent({ type: 'form_answer_ack', ts: 1 } as never), {
143+
status: 'running',
144+
});
139145
assert.strictEqual(
140146
statusFromEvent({ type: 'sandbox_boundary_decision_ack', ts: 1 } as never, {
141147
allowInteractionResume: false,

packages/runtime/src/__tests__/stream-graph-projection.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,19 @@ describe('committed stream graph projection', () => {
566566
userQuestionAnswerAccepted: { requestId: 'question-1' },
567567
},
568568
}),
569+
runtimeEvent(run, {
570+
id: 'form-request',
571+
ts: baseTs + 4,
572+
actions: {
573+
formRequest: {
574+
requestId: 'form-1',
575+
toolUseId: 'tool-3',
576+
message: 'Choose settings',
577+
requester: { name: 'fixture' },
578+
fields: [{ kind: 'boolean', name: 'confirm', label: 'Confirm', required: true }],
579+
},
580+
},
581+
}),
569582
],
570583
},
571584
],
@@ -577,9 +590,11 @@ describe('committed stream graph projection', () => {
577590
[{ kind: 'attention', reason: 'permission_request' }],
578591
[{ kind: 'attention', reason: 'user_question_request' }],
579592
[],
593+
[{ kind: 'attention', reason: 'form_request' }],
580594
],
581595
);
582596
assert.deepEqual(records[2]?.facets, ['runtime_fact']);
597+
assert.deepEqual(records[3]?.facets, ['form_request']);
583598
assert.equal(replayAgentGraphRecords(records).operators.research?.status, 'running');
584599
});
585600

packages/runtime/src/agent-run.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1900,7 +1900,9 @@ async function appendUserMessageOnce(
19001900

19011901
function isInteractionResumeAck(event: SessionEvent): boolean {
19021902
return (
1903-
event.type === 'sandbox_boundary_decision_ack' || event.type === 'user_question_answer_ack'
1903+
event.type === 'sandbox_boundary_decision_ack' ||
1904+
event.type === 'user_question_answer_ack' ||
1905+
event.type === 'form_answer_ack'
19041906
);
19051907
}
19061908

packages/runtime/src/interaction-authority.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import type {
2424
FormRequestEvent,
2525
SandboxBoundaryDecisionAckEvent,
2626
SandboxBoundaryRequestEvent,
27+
SessionEvent,
2728
UserQuestionAnswerAckEvent,
2829
UserQuestionRequestEvent,
2930
} from '@maka/core/events';
@@ -207,6 +208,26 @@ type HostedInteractionSettlementAckEvent =
207208
| UserQuestionAnswerAckEvent
208209
| FormAnswerAckEvent
209210
| SandboxBoundaryDecisionAckEvent;
211+
212+
export function isHostedInteractionRequestEvent(
213+
event: SessionEvent,
214+
): event is HostedInteractionRequestEvent {
215+
return (
216+
event.type === 'user_question_request' ||
217+
event.type === 'form_request' ||
218+
event.type === 'sandbox_boundary_request'
219+
);
220+
}
221+
222+
export function isHostedInteractionSettlementAckEvent(
223+
event: SessionEvent,
224+
): event is HostedInteractionSettlementAckEvent {
225+
return (
226+
event.type === 'user_question_answer_ack' ||
227+
event.type === 'form_answer_ack' ||
228+
event.type === 'sandbox_boundary_decision_ack'
229+
);
230+
}
210231
type RuntimeHostedInteractionOutcome =
211232
| RuntimeUserQuestionOutcome
212233
| RuntimeFormOutcome

packages/runtime/src/runtime-kernel.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,8 @@ import {
146146
RuntimeInteractionFailStopError,
147147
RuntimeInteractionInvariantError,
148148
bindRuntimeInteractionRun,
149+
isHostedInteractionRequestEvent,
150+
isHostedInteractionSettlementAckEvent,
149151
type RuntimeInteractionAuthority,
150152
type RuntimeInteractionRunBinding,
151153
type RuntimeInteractionRunClosureReason,
@@ -1588,10 +1590,7 @@ export class RuntimeKernel implements RuntimeKernelLike {
15881590
binding: RuntimeInteractionRunBinding | undefined,
15891591
event: SessionEvent,
15901592
): void {
1591-
if (
1592-
binding &&
1593-
(event.type === 'user_question_request' || event.type === 'sandbox_boundary_request')
1594-
) {
1593+
if (binding && isHostedInteractionRequestEvent(event)) {
15951594
binding.assertPendingAdmission(event);
15961595
}
15971596
}
@@ -3332,10 +3331,7 @@ async function interactionResumeAllowed(
33323331
interactionRun: RuntimeInteractionRunBinding | undefined,
33333332
event: SessionEvent,
33343333
): Promise<boolean> {
3335-
if (
3336-
!interactionRun ||
3337-
(event.type !== 'user_question_answer_ack' && event.type !== 'sandbox_boundary_decision_ack')
3338-
) {
3334+
if (!interactionRun || !isHostedInteractionSettlementAckEvent(event)) {
33393335
return true;
33403336
}
33413337
return await interactionRun.canResumeAfterSettlementAck(event);

packages/runtime/src/session-projection-helpers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,11 +119,13 @@ export function statusFromEvent(
119119
case 'sandbox_boundary_request':
120120
return { status: 'waiting_for_user', blockedReason: 'permission_required' };
121121
case 'user_question_request':
122+
case 'form_request':
122123
return { status: 'waiting_for_user' };
123124
case 'sandbox_boundary_decision_ack':
124125
if (options.allowInteractionResume === false) return undefined;
125126
return { status: 'running' };
126127
case 'user_question_answer_ack':
128+
case 'form_answer_ack':
127129
if (options.allowInteractionResume === false) return undefined;
128130
return { status: 'running' };
129131
case 'error':

packages/runtime/src/stream-graph-projection.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export const AGENT_GRAPH_RECORD_FACETS = [
3737
'permission_request',
3838
'permission_decision',
3939
'user_question_request',
40+
'form_request',
4041
'transfer',
4142
'usage',
4243
'completed',
@@ -55,7 +56,10 @@ export type AgentGraphActivationStatus =
5556
| 'aborted'
5657
| 'cancelled';
5758

58-
export type AgentGraphSupervisorAttentionReason = 'permission_request' | 'user_question_request';
59+
export type AgentGraphSupervisorAttentionReason =
60+
| 'permission_request'
61+
| 'user_question_request'
62+
| 'form_request';
5963

6064
export type AgentGraphSupervisorSignal =
6165
| {
@@ -521,6 +525,7 @@ function runtimeEventFacets(event: RuntimeEvent, run: AgentRunHeader): AgentGrap
521525
if (actions?.permissionRequest) facets.push('permission_request');
522526
if (actions?.permissionDecision) facets.push('permission_decision');
523527
if (actions?.userQuestionRequest) facets.push('user_question_request');
528+
if (actions?.formRequest) facets.push('form_request');
524529
if (actions?.transferToAgent) facets.push('transfer');
525530
if (actions?.tokenUsage) facets.push('usage');
526531

@@ -541,6 +546,9 @@ function runtimeEventSupervisorSignals(
541546
if (event.actions?.userQuestionRequest) {
542547
signals.push({ kind: 'attention', reason: 'user_question_request' });
543548
}
549+
if (event.actions?.formRequest) {
550+
signals.push({ kind: 'attention', reason: 'form_request' });
551+
}
544552
const terminalStatus = runtimeEventTerminalStatus(event, run);
545553
if (terminalStatus) {
546554
signals.push({ kind: 'terminal', status: terminalStatus });

packages/runtime/src/stream-graph-read-model.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -941,6 +941,11 @@ function projectClientSessionEvent(
941941
facets: ['user_question_request'],
942942
signals: [{ kind: 'attention', reason: 'user_question_request' }],
943943
};
944+
case 'form_request':
945+
return {
946+
facets: ['form_request'],
947+
signals: [{ kind: 'attention', reason: 'form_request' }],
948+
};
944949
case 'token_usage':
945950
return { facets: ['usage'], signals: [] };
946951
case 'error':

0 commit comments

Comments
 (0)