| /* |
| * 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 { SandboxBoundaryExpansion } from '@maka/core/sandbox-boundary'; |
| import { PluginAgentService } from '../plugin-agent-service.js'; |
| import { PluginApprovalService } from '../plugin-approval-service.js'; |
| import { Context } from '../plugin-kernel.js'; |
| import { PluginUserQuestionService } from '../plugin-user-question-service.js'; |
| import type { MakaToolContext } from '../tool-runtime.js'; |
| |
| test('questions and approvals use the exact current Tool interaction authority', async () => { |
| const root = new Context(); |
| const agents = new PluginAgentService(root); |
| const approval = new PluginApprovalService(root, agents); |
| const questions = new PluginUserQuestionService(root, agents); |
| const calls: string[] = []; |
| const context: MakaToolContext = { |
| sessionId: 'session-a', |
| turnId: 'turn-a', |
| cwd: '/workspace', |
| toolCallId: 'call-a', |
| abortSignal: new AbortController().signal, |
| emitOutput: () => undefined, |
| askUserQuestion: async (items) => { |
| calls.push(`question:${items[0]?.question}`); |
| return { answers: [{ question: items[0]?.question ?? '', answer: 'yes' }] }; |
| }, |
| requestUserForm: async (form) => { |
| calls.push(`form:${form.message}`); |
| return { action: 'accept', values: { choice: 'yes' } }; |
| }, |
| requestSandboxBoundary: async (expansion, justification) => { |
| calls.push(`approval:${justification}`); |
| return { |
| request: { |
| sessionId: 'session-a', |
| requestId: 'request-a', |
| status: 'approved', |
| baseRevision: 0, |
| expansion, |
| justification, |
| createdAt: 1, |
| settledAt: 2, |
| }, |
| boundary: { kind: 'bypass', revision: 1 }, |
| changed: true, |
| }; |
| }, |
| }; |
| |
| await agents.withInvocation(context, async () => { |
| await questions.ask([{ question: 'Continue?', options: [{ label: 'yes' }, { label: 'no' }] }]); |
| await questions.requestForm({ |
| message: 'Choose', |
| requester: { name: 'fixture' }, |
| fields: [ |
| { |
| kind: 'single_select', |
| name: 'choice', |
| label: 'Choice', |
| required: true, |
| options: [{ value: 'yes', label: 'Yes' }], |
| }, |
| ], |
| }); |
| await approval.request({ |
| expansion: { kind: 'workspace_write', paths: ['/workspace'] } as SandboxBoundaryExpansion, |
| justification: 'write output', |
| }); |
| }); |
| assert.deepEqual(calls, ['question:Continue?', 'form:Choose', 'approval:write output']); |
| await root.fiber.dispose(); |
| }); |
| |
| test('interaction services reject calls outside an Agent invocation', async () => { |
| const root = new Context(); |
| const agents = new PluginAgentService(root); |
| const approval = new PluginApprovalService(root, agents); |
| const questions = new PluginUserQuestionService(root, agents); |
| assert.throws(() => questions.ask([]), /active Agent invocation/u); |
| assert.throws( |
| () => approval.request({ expansion: {} as SandboxBoundaryExpansion, justification: 'x' }), |
| /active Agent invocation/u, |
| ); |
| await root.fiber.dispose(); |
| }); |
| |
| test('form custom cancellation preserves Host invocation cancellation', async () => { |
| const root = new Context(); |
| const agents = new PluginAgentService(root); |
| const questions = new PluginUserQuestionService(root, agents); |
| const hostAbort = new AbortController(); |
| const pluginAbort = new AbortController(); |
| let observed: AbortSignal | undefined; |
| const context: MakaToolContext = { |
| sessionId: 'session-a', |
| turnId: 'turn-a', |
| cwd: '/workspace', |
| toolCallId: 'call-a', |
| abortSignal: hostAbort.signal, |
| emitOutput: () => undefined, |
| requestUserForm: async (_form, options) => { |
| observed = options?.cancellationSignal; |
| return { action: 'cancel', values: {} }; |
| }, |
| }; |
| |
| await agents.withInvocation(context, () => |
| questions.requestForm( |
| { message: 'Choose', requester: { name: 'fixture' }, fields: [] }, |
| { signal: pluginAbort.signal }, |
| ), |
| ); |
| assert.equal(observed?.aborted, false); |
| hostAbort.abort(new Error('Host stopped')); |
| assert.equal(observed?.aborted, true); |
| assert.equal(pluginAbort.signal.aborted, false); |
| await root.fiber.dispose(); |
| }); |