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
10 changes: 5 additions & 5 deletions apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@
"nonTriviaTokens": 92
},
"src/renderer/app-shell-chat-actions.ts": {
"importDeclarations": 26,
"importDeclarations": 25,
"bridgePaths": {
"window.maka.newTasks.create": 1,
"window.maka.sessions.remove": 1,
Expand All @@ -324,7 +324,7 @@
"createAppShellChatActions"
],
"dependencyPaths": {
"../preload/bridge-contract.js": 2,
"../preload/bridge-contract.js": 1,
"./app-shell-copy.js": 1,
"./app-shell-session-ui-state.js": 1,
"./attachment-preflight.js": 1,
Expand All @@ -350,8 +350,8 @@
"@maka/runtime/skill-invocation": 1,
"@maka/ui": 1
},
"importSpecifiers": 39,
"nonTriviaTokens": 4089
"importSpecifiers": 38,
"nonTriviaTokens": 4086
},
"src/renderer/app-shell-chrome-actions.tsx": {
"importDeclarations": 5,
Expand Down Expand Up @@ -980,7 +980,7 @@
"react": 1
},
"importSpecifiers": 184,
"nonTriviaTokens": 15687
"nonTriviaTokens": 15686
},
"src/renderer/use-app-shell-composer-quotes.ts": {
"importDeclarations": 3,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export function createActionsDeps() {
transcriptRangeRef: { current: undefined },
setLiveTurnBySession: () => undefined,
setInteractionBySession: () => undefined,
respondToUserForm: async () => undefined,
showModelSetupToast: () => undefined,
toastApi: { error: () => undefined, info: () => undefined },
newChatModel: null,
Expand Down
86 changes: 86 additions & 0 deletions apps/desktop/src/main/__tests__/app-shell-form-interaction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/*
* 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 { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import type { InteractionQueues } from '@maka/ui';
import { createAppShellChatActions } from '../../renderer/app-shell-chat-actions.js';
import { createActionsDeps } from './app-shell-chat-actions-fixture.js';

function pendingForm(): InteractionQueues {
return {
'session-1': [{
type: 'form_request',
id: 'event-1',
turnId: 'turn-1',
ts: 1,
requestId: 'form-1',
toolUseId: 'tool-1',
message: 'Configure deployment',
requester: { name: 'deploy' },
fields: [{ kind: 'boolean', name: 'confirm', label: 'Confirm', required: true }],
}],
};
}

describe('AppShell form interaction response', () => {
it('retires the local prompt only after the platform accepts the answer', async () => {
const deps = createActionsDeps();
deps.activeIdRef.current = 'session-1';
let interactions = pendingForm();
let submitted: unknown;
const actions = createAppShellChatActions({
...deps,
respondToUserForm: async (sessionId, response) => {
submitted = { sessionId, response };
},
setInteractionBySession: (update) => {
interactions = update(interactions);
},
});

const response = { requestId: 'form-1', action: 'accept' as const, values: { confirm: true } };
await actions.respondToUserForm(response);

assert.deepEqual(submitted, { sessionId: 'session-1', response });
assert.deepEqual(interactions['session-1'], []);
});

it('keeps the prompt answerable when the platform rejects the answer', async () => {
const deps = createActionsDeps();
deps.activeIdRef.current = 'session-1';
let interactions = pendingForm();
let errors = 0;
const actions = createAppShellChatActions({
...deps,
respondToUserForm: async () => {
throw new Error('Host unavailable');
},
setInteractionBySession: (update) => {
interactions = update(interactions);
},
toastApi: { error: () => { errors += 1; }, info: () => undefined },
});

await actions.respondToUserForm({ requestId: 'form-1', action: 'cancel' });

assert.equal(interactions['session-1']?.[0]?.requestId, 'form-1');
assert.equal(errors, 1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ async function mountRegion(): Promise<{
respondToSandboxBoundary: () => {},
respondToClientCapability: () => {},
respondToUserQuestion: () => {},
respondToUserForm: () => {},
stop: () => {},
onSend: () => {},
onStop: () => {},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* 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 { strict as assert } from 'node:assert';
import { it } from 'node:test';
import { applyCompanionInteractionEvent } from '../../renderer/features/workbar/testing.js';

it('keeps companion forms pending until the Host acknowledgement arrives', () => {
let queues = applyCompanionInteractionEvent({}, 'fork-1', {
type: 'form_request',
id: 'form-event',
turnId: 'turn-1',
ts: 1,
requestId: 'form-1',
toolUseId: 'tool-1',
message: 'Configure deployment',
requester: { name: 'deploy' },
fields: [{ kind: 'boolean', name: 'confirm', label: 'Confirm', required: true }],
});
assert.equal(queues['fork-1']?.[0]?.requestId, 'form-1');

queues = applyCompanionInteractionEvent(queues, 'fork-1', {
type: 'form_answer_ack',
id: 'form-ack',
turnId: 'turn-1',
ts: 2,
requestId: 'form-1',
toolUseId: 'tool-1',
});
assert.deepEqual(queues['fork-1'], []);
});
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,69 @@ test('answers a Client Capability approval through the existing Interaction auth
await observer.close();
});

test("validates and forwards Desktop form responses to the pending Host interaction", async () => {
const pending = {
schemaVersion: 1 as const,
interactionId: "form-1",
sessionId: "session-1",
turnId: "turn-1",
runId: "run-1",
revision: 1 as const,
status: "pending" as const,
outcome: null,
request: {
kind: "form" as const,
toolUseId: "tool-1",
message: "Configure deployment",
requester: { name: "deploy" },
fields: [{ kind: "integer" as const, name: "replicas", label: "Replicas", required: true }],
},
};
const observer = observerWithSnapshot({ interactions: { pending: [pending] } });
const answers: unknown[] = [];
const ipc = ipcHarness();
registerExecutionIpc({
observer,
client: executionClient({
answerInteraction: async (input) => {
answers.push(input);
return {
...pending,
revision: 2,
status: "answered",
outcome: {
kind: "form_answer",
action: "accept",
values: { replicas: 3 },
committedAt: 2,
},
};
},
}),
}, ipc);

await ipc.invoke("sessions:respondToUserForm", "session-1", {
requestId: "form-1",
action: "accept",
values: { replicas: 3 },
});
assert.deepEqual(answers, [{
sessionId: "session-1",
interactionId: "form-1",
answer: { kind: "form", action: "accept", values: { replicas: 3 } },
}]);

await assert.rejects(
() => ipc.invoke("sessions:respondToUserForm", "session-1", {
requestId: "form-1",
action: "accept",
values: { replicas: Number.NaN },
}),
);
assert.equal(answers.length, 1);
await observer.close();
});

test("retries committed Branch and Revision copies with the renderer-owned identity", async () => {
const committed = new Map<string, SessionCatalogProjection>();
const lostResponses = new Set(["branch-copy-1", "revision-copy-1"]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2672,6 +2672,57 @@ test("rehydrates pending interactions and publishes answer acknowledgements", as
await observer.close();
});

test("publishes form answer acknowledgements for renderer queue retirement", async () => {
const pending = {
schemaVersion: 1 as const,
interactionId: "form-1",
sessionId: "session-1",
turnId: "turn-1",
runId: "run-1",
revision: 1 as const,
status: "pending" as const,
outcome: null,
request: {
kind: "form" as const,
toolUseId: "tool-1",
message: "Configure deployment",
requester: { name: "deploy" },
fields: [{ kind: "boolean" as const, name: "confirm", label: "Confirm", required: true }],
},
};
const observer = new RuntimeHostSessionObserver({
client: {
openSession: async () => runtimeHostSessionFixture({
snapshot: continuitySnapshot({ interactions: { pending: [pending] } }),
activeAssistantStreams: [],
transcript: Promise.resolve([]),
events: new AsyncFrameQueue(),
async close() {},
}),
},
emitSessionsChanged() {},
now: () => 80,
});
const target = eventTarget(2);
await observer.observe("session-1", "observer-1", target);
observer.publishInteractionAnswer({
...pending,
revision: 2,
status: "answered",
outcome: { kind: "form_answer", action: "accept", values: { confirm: true }, committedAt: 80 },
}, pending);

assert.deepEqual(target.events.at(-1), {
type: "form_answer_ack",
id: "host-interaction:form-1:2",
turnId: "turn-1",
ts: 80,
requestId: "form-1",
toolUseId: "tool-1",
});
await observer.close();
});

test("projects Host queue revisions and newly delivered steering messages", async () => {
const events = new AsyncFrameQueue();
const observer = new RuntimeHostSessionObserver({
Expand Down
42 changes: 42 additions & 0 deletions apps/desktop/src/main/__tests__/streaming-handoff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,48 @@ describe('single live-turn handoff', () => {
assert.equal(interactions.get()['session-1']?.[0]?.requestId, 'request-1');
});

it('queues and retires a form at the Host answer acknowledgement', () => {
const liveTurns = createStateSetter<Record<string, LiveTurnProjection>>({
'session-1': armLiveTurn('turn-1'),
});
const ref = { current: liveTurns.get() };
const interactions = createStateSetter<InteractionQueues>({});
const handlers = createAppShellSessionEventHandlers({
uiLocale: 'en',
activeIdRef: { current: 'session-1' },
liveTurnBySessionRef: ref,
refreshMessages: async () => true,
refreshSessions: async () => [],
setLiveTurnBySession: liveTurns.set,
setInteractionBySession: interactions.set,
showModelSetupToast: () => {},
toastApi: { error: () => {} },
});
handlers.handleEvent('session-1', {
type: 'form_request',
id: 'form-event',
turnId: 'turn-1',
ts: 1,
requestId: 'form-1',
toolUseId: 'tool-1',
message: 'Configure deployment',
requester: { name: 'deploy' },
fields: [{ kind: 'boolean', name: 'confirm', label: 'Confirm', required: true }],
});
assert.equal(interactions.get()['session-1']?.[0]?.requestId, 'form-1');

handlers.handleEvent('session-1', {
type: 'form_answer_ack',
id: 'form-ack',
turnId: 'turn-1',
ts: 2,
requestId: 'form-1',
toolUseId: 'tool-1',
});
assert.deepEqual(interactions.get()['session-1'], []);
assert.equal(liveTurns.get()['session-1']?.terminal, undefined);
});

it('hands an aborted projection over only after persisted messages cover it', async () => {
const liveTurns = createStateSetter<Record<string, LiveTurnProjection>>({
'session-1': {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ describe('createDesktopWorkbarServices', () => {
await services.sideChat.respondToSandboxBoundary('fork', {} as never);
await services.sideChat.respondToClientCapability('fork', {} as never);
await services.sideChat.respondToUserQuestion('fork', {} as never);
await services.sideChat.respondToUserForm('fork', {} as never);
services.sideChat.subscribeEvents('fork', eventHandler)();

assert.deepEqual(
Expand Down Expand Up @@ -237,6 +238,7 @@ describe('createDesktopWorkbarServices', () => {
'sessions.respondToSandboxBoundary',
'sessions.respondToClientCapability',
'sessions.respondToUserQuestion',
'sessions.respondToUserForm',
'sessions.subscribeEvents',
],
);
Expand Down
Loading