blob: 0bb97be2feac253a6342baf74eb57e395efb4fb2 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import type { HostedFormSettlement } from '@maka/core/backend-types';
import type { SessionEvent } from '@maka/core/events';
import type { SessionHeader } from '@maka/core/session';
import { z } from 'zod';
import { createTestToolRuntime } from './execution-boundary-test-helpers.js';
import type { MakaTool } from '../tool-runtime.js';
function header(): SessionHeader {
return {
id: 'session-1',
workspaceRoot: '/tmp/maka',
cwd: '/tmp/maka',
createdAt: 1,
name: 'Test',
titleIsManual: true,
isFlagged: false,
labels: [],
isArchived: false,
status: 'active',
statusUpdatedAt: 1,
hasUnread: false,
backend: 'ai-sdk',
llmConnectionSlug: 'c',
connectionLocked: true,
model: 'm',
permissionMode: 'ask',
schemaVersion: 1,
};
}
function formTool(cancellationSignal?: AbortSignal): MakaTool<Record<string, never>> {
return {
name: 'SyntheticForm',
description: 'Exercise the provider-neutral form seam.',
parameters: z.object({}),
nesting: 'direct_only',
impl: (_input, context) => {
if (!context.requestUserForm) throw new Error('Form Interaction is unavailable');
return context.requestUserForm(
{
message: 'Choose deployment settings',
requester: { name: 'deploy', source: 'Synthetic provider' },
fields: [
{
kind: 'integer',
name: 'replicas',
label: 'Replicas',
required: true,
minimum: 1,
maximum: 10,
},
{
kind: 'multi_select',
name: 'regions',
label: 'Regions',
required: false,
options: [
{ value: 'us', label: 'US' },
{ value: 'eu', label: 'EU' },
],
},
],
},
cancellationSignal ? { cancellationSignal } : undefined,
);
},
};
}
function runtime(events: SessionEvent[]) {
let id = 0;
return createTestToolRuntime({
sessionId: 'session-1',
header: header(),
connection: { providerType: 'openai', slug: 'c' } as never,
modelId: 'm',
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<void>((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<void>((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<void>((resolve) => setImmediate(resolve));
}
const secondRequest = events.filter((event) => event.type === 'form_request')[1];
assert.ok(secondRequest);
await toolRuntime.endTurn('aborted');
assert.deepEqual(await second, {
error: `Turn turn-1 aborted before user form ${secondRequest.requestId} was answered`,
});
assert.equal(
toolRuntime.respondToUserForm({ requestId: secondRequest.requestId, action: 'cancel' }),
false,
);
});
test('withdraws the exact hosted form when its producer is cancelled', async () => {
const events: SessionEvent[] = [];
const producer = new AbortController();
let admitted: { requestId: string; settlement: HostedFormSettlement } | undefined;
const withdrawals: string[] = [];
const toolRuntime = createTestToolRuntime({
sessionId: 'session-1',
header: header(),
connection: { providerType: 'openai', slug: 'c' } as never,
modelId: 'm',
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<void>((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<void>((resolve) => {
finishAdmission = resolve;
});
const withdrawals: string[] = [];
const toolRuntime = createTestToolRuntime({
sessionId: 'session-1',
header: header(),
connection: { providerType: 'openai', slug: 'c' } as never,
modelId: 'm',
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<void>((resolve) => setImmediate(resolve));
producer.abort(new DOMException('Provider invocation ended', 'AbortError'));
await new Promise<void>((resolve) => setImmediate(resolve));
assert.deepEqual(withdrawals, []);
finishAdmission();
await pending;
assert.deepEqual(withdrawals, [admitted.requestId]);
assert.equal(toolRuntime.pendingUserFormCount(), 0);
});
});