blob: 8629d0ef71bf1aeb3689cbcae25fead65935fbaa [file]
import { createTestToolRuntime } from './execution-boundary-test-helpers.js';
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import type { SessionEvent } from '@maka/core';
import type { SessionHeader, StoredMessage } from '@maka/core';
import { buildAskUserQuestionTool } from '../ask-user-question-tool.js';
import { ToolRuntime } from '../tool-runtime.js';
function header(): SessionHeader {
return {
id: 'session-1',
workspaceRoot: '/tmp/maka',
cwd: '/tmp/maka',
createdAt: 1,
lastUsedAt: 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,
};
}
describe('AskUserQuestion runtime round trip', () => {
test('parks the tool, emits one request, and persists one nullable JSON result', async () => {
const appended: StoredMessage[] = [];
const events: SessionEvent[] = [];
let id = 0;
const runtime = createTestToolRuntime({
sessionId: 'session-1',
header: header(),
connection: { providerType: 'openai', slug: 'c' } as never,
modelId: 'm',
appendMessage: async (message) => {
appended.push(message);
},
newId: () => `id-${++id}`,
now: () => 1,
getPermissionPauseTarget: () => null,
});
const resultPromise = runtime
.settleToolCall({
tool: buildAskUserQuestionTool(),
turnId: 'turn-1',
toolCallId: 'tool-1',
input: {
questions: [
{
question: 'Choose an approach',
options: [
{ label: 'Extend', description: 'Reuse the runtime seam' },
{ label: 'Separate' },
],
},
{
question: 'Keep the default?',
options: [{ label: 'Yes' }, { label: 'No' }],
},
],
},
abortSignal: new AbortController().signal,
eventSink: {
push: (event) => events.push(event),
pushAndWaitUntilConsumed: async (event) => {
events.push(event);
},
},
})
.then((settlement) => settlement.result);
await new Promise<void>((resolve) => setImmediate(resolve));
const request = events.find((event) => event.type === 'user_question_request');
assert.ok(request);
assert.equal(request.toolUseId, 'tool-1');
assert.equal(runtime.pendingUserQuestionCount(), 1);
assert.equal(
runtime.respondToUserQuestion({
requestId: request.requestId,
answers: ['Extend', null],
}),
true,
);
assert.deepEqual(await resultPromise, {
answers: [
{ question: 'Choose an approach', answer: 'Extend' },
{ question: 'Keep the default?', answer: null },
],
});
const results = appended.filter((message) => message.type === 'tool_result');
assert.equal(results.length, 1);
assert.deepEqual(results[0]?.content, {
kind: 'json',
value: {
answers: [
{ question: 'Choose an approach', answer: 'Extend' },
{ question: 'Keep the default?', answer: null },
],
},
});
});
test('turn abort rejects the parked tool and ignores a late response', async () => {
const events: SessionEvent[] = [];
let id = 0;
const runtime = createTestToolRuntime({
sessionId: 'session-1',
header: header(),
connection: { providerType: 'openai', slug: 'c' } as never,
modelId: 'm',
appendMessage: async () => {},
newId: () => `id-${++id}`,
now: () => 1,
getPermissionPauseTarget: () => null,
});
const resultPromise = runtime
.settleToolCall({
tool: buildAskUserQuestionTool(),
turnId: 'turn-1',
toolCallId: 'tool-1',
input: {
questions: [
{
question: 'Continue?',
options: [{ label: 'Yes' }, { label: 'No' }],
},
],
},
abortSignal: new AbortController().signal,
eventSink: {
push: (event) => events.push(event),
pushAndWaitUntilConsumed: async (event) => {
events.push(event);
},
},
})
.then((settlement) => settlement.result);
await new Promise<void>((resolve) => setImmediate(resolve));
const request = events.find((event) => event.type === 'user_question_request');
assert.ok(request);
runtime.endTurn('aborted');
assert.deepEqual(await resultPromise, {
error: `Turn turn-1 aborted before user question ${request.requestId} was answered`,
});
assert.equal(
runtime.respondToUserQuestion({
requestId: request.requestId,
answers: ['Yes'],
}),
false,
);
});
test('cell abort releases a parked question without waiting for turn teardown', async () => {
const events: SessionEvent[] = [];
let id = 0;
const runtime = createTestToolRuntime({
sessionId: 'session-1',
header: header(),
connection: { providerType: 'openai', slug: 'c' } as never,
modelId: 'm',
appendMessage: async () => {},
newId: () => `id-${++id}`,
now: () => 1,
getPermissionPauseTarget: () => null,
});
const controller = new AbortController();
const pending = runtime.settleToolCall({
tool: buildAskUserQuestionTool(),
turnId: 'turn-1',
toolCallId: 'tool-1',
input: {
questions: [{ question: 'Continue?', options: [{ label: 'Yes' }, { label: 'No' }] }],
},
abortSignal: controller.signal,
eventSink: {
push: (event) => events.push(event),
pushAndWaitUntilConsumed: async (event) => {
events.push(event);
},
},
});
while (!events.some((event) => event.type === 'user_question_request')) {
await new Promise((resolve) => setImmediate(resolve));
}
const request = events.find((event) => event.type === 'user_question_request');
assert.ok(request);
controller.abort(new Error('cell aborted'));
const settlement = await Promise.race([
pending,
new Promise<never>((_resolve, reject) =>
setTimeout(() => reject(new Error('question did not release after cell abort')), 50),
),
]);
assert.match(String((settlement.result as { error: string }).error), /cell aborted/i);
assert.equal(runtime.pendingUserQuestionCount(), 0);
assert.equal(
runtime.respondToUserQuestion({
requestId: request.requestId,
answers: ['Yes'],
}),
false,
);
});
});