Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/src/main/__tests__/browser-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?.(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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'),
});
}
6 changes: 1 addition & 5 deletions apps/desktop/src/renderer/chat-composer-region.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
Button,
ClientCapabilityPrompt,
Composer,
type ComposerInteraction,
ComposerGoalProjectionConsumer,
SandboxBoundaryPrompt,
UserQuestionPrompt,
Expand Down Expand Up @@ -61,11 +62,6 @@ interface BoundaryUnreadableNotice {
onRetry(): void;
}

type ComposerInteraction =
| ComponentProps<typeof SandboxBoundaryPrompt>['request']
| ComponentProps<typeof ClientCapabilityPrompt>['request']
| ComponentProps<typeof UserQuestionPrompt>['request'];

/**
* The composer region of the chat surface (issue #1043): the composer
* interaction slot (permission / user-question prompts) plus the always-mounted
Expand Down
33 changes: 31 additions & 2 deletions packages/cli/src/__tests__/pi-transcript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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({
Expand All @@ -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(
Expand All @@ -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, []);
});

Expand Down
279 changes: 279 additions & 0 deletions packages/cli/src/__tests__/pi-tui-form-interaction.test.ts
Original file line number Diff line number Diff line change
@@ -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');
}
Loading