Skip to content

Commit 661ee44

Browse files
committed
feat(desktop): answer structured form interactions
Render the Host-owned primitive form contract in the shared composer slot for both the main conversation and Side Chat. Preserve optional omission separately from explicit values, validate through the shared core contract, and expose cancel, decline, and accept without introducing surface-owned continuation state. Decode renderer responses at the Desktop IPC boundary, settle them through Runtime Host, and retire prompts only after the authoritative answer succeeds or its acknowledgement is projected. Cover all six field kinds, malformed responses, reconnect hydration, and both Desktop surfaces. Part of #4364. Generated-by: OpenAI Codex
1 parent 032d77f commit 661ee44

27 files changed

Lines changed: 965 additions & 6 deletions

apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@ async function mountRegion(): Promise<{
122122
activeSandboxBoundary: undefined,
123123
activeQuestion: undefined,
124124
respondToUserQuestion: () => {},
125+
activeForm: undefined,
126+
respondToUserForm: () => {},
125127
stop: () => {},
126128
onSend: () => {},
127129
onStop: () => {},
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import { strict as assert } from 'node:assert';
21+
import { it } from 'node:test';
22+
import { applyCompanionInteractionEvent } from '../../renderer/features/workbar/tools/side-chat/quote-companion-core.js';
23+
24+
it('keeps companion forms pending until the Host acknowledgement arrives', () => {
25+
let queues = applyCompanionInteractionEvent({}, 'fork-1', {
26+
type: 'form_request',
27+
id: 'form-event',
28+
turnId: 'turn-1',
29+
ts: 1,
30+
requestId: 'form-1',
31+
toolUseId: 'tool-1',
32+
message: 'Configure deployment',
33+
requester: { name: 'deploy' },
34+
fields: [{ kind: 'boolean', name: 'confirm', label: 'Confirm', required: true }],
35+
});
36+
assert.equal(queues['fork-1']?.[0]?.requestId, 'form-1');
37+
38+
queues = applyCompanionInteractionEvent(queues, 'fork-1', {
39+
type: 'form_answer_ack',
40+
id: 'form-ack',
41+
turnId: 'turn-1',
42+
ts: 2,
43+
requestId: 'form-1',
44+
toolUseId: 'tool-1',
45+
});
46+
assert.deepEqual(queues['fork-1'], []);
47+
});

apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,69 @@ test("keeps synthetic E2E interactions visible through Host hydration and retire
123123
await observer.close();
124124
});
125125

126+
test("validates and forwards Desktop form responses to the pending Host interaction", async () => {
127+
const pending = {
128+
schemaVersion: 1 as const,
129+
interactionId: "form-1",
130+
sessionId: "session-1",
131+
turnId: "turn-1",
132+
runId: "run-1",
133+
revision: 1 as const,
134+
status: "pending" as const,
135+
outcome: null,
136+
request: {
137+
kind: "form" as const,
138+
toolUseId: "tool-1",
139+
message: "Configure deployment",
140+
requester: { name: "deploy" },
141+
fields: [{ kind: "integer" as const, name: "replicas", label: "Replicas", required: true }],
142+
},
143+
};
144+
const observer = observerWithSnapshot({ interactions: { pending: [pending] } });
145+
const answers: unknown[] = [];
146+
const ipc = ipcHarness();
147+
registerExecutionIpc({
148+
observer,
149+
client: executionClient({
150+
answerInteraction: async (input) => {
151+
answers.push(input);
152+
return {
153+
...pending,
154+
revision: 2,
155+
status: "answered",
156+
outcome: {
157+
kind: "form_answer",
158+
action: "accept",
159+
values: { replicas: 3 },
160+
committedAt: 2,
161+
},
162+
};
163+
},
164+
}),
165+
}, ipc);
166+
167+
await ipc.invoke("sessions:respondToUserForm", "session-1", {
168+
requestId: "form-1",
169+
action: "accept",
170+
values: { replicas: 3 },
171+
});
172+
assert.deepEqual(answers, [{
173+
sessionId: "session-1",
174+
interactionId: "form-1",
175+
answer: { kind: "form", action: "accept", values: { replicas: 3 } },
176+
}]);
177+
178+
await assert.rejects(
179+
() => ipc.invoke("sessions:respondToUserForm", "session-1", {
180+
requestId: "form-1",
181+
action: "accept",
182+
values: { replicas: Number.NaN },
183+
}),
184+
);
185+
assert.equal(answers.length, 1);
186+
await observer.close();
187+
});
188+
126189
test("retries committed Branch and Revision copies with the renderer-owned identity", async () => {
127190
const committed = new Map<string, SessionCatalogProjection>();
128191
const lostResponses = new Set(["branch-copy-1", "revision-copy-1"]);

apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2497,6 +2497,57 @@ test("rehydrates pending interactions and publishes answer acknowledgements", as
24972497
await observer.close();
24982498
});
24992499

2500+
test("publishes form answer acknowledgements for renderer queue retirement", async () => {
2501+
const pending = {
2502+
schemaVersion: 1 as const,
2503+
interactionId: "form-1",
2504+
sessionId: "session-1",
2505+
turnId: "turn-1",
2506+
runId: "run-1",
2507+
revision: 1 as const,
2508+
status: "pending" as const,
2509+
outcome: null,
2510+
request: {
2511+
kind: "form" as const,
2512+
toolUseId: "tool-1",
2513+
message: "Configure deployment",
2514+
requester: { name: "deploy" },
2515+
fields: [{ kind: "boolean" as const, name: "confirm", label: "Confirm", required: true }],
2516+
},
2517+
};
2518+
const observer = new RuntimeHostSessionObserver({
2519+
client: {
2520+
openSession: async () => runtimeHostSessionFixture({
2521+
snapshot: continuitySnapshot({ interactions: { pending: [pending] } }),
2522+
activeAssistantStreams: [],
2523+
transcript: Promise.resolve([]),
2524+
events: new AsyncFrameQueue(),
2525+
async close() {},
2526+
}),
2527+
},
2528+
emitSessionsChanged() {},
2529+
now: () => 80,
2530+
});
2531+
const target = eventTarget(2);
2532+
await observer.observe("session-1", "observer-1", target);
2533+
observer.publishInteractionAnswer({
2534+
...pending,
2535+
revision: 2,
2536+
status: "answered",
2537+
outcome: { kind: "form_answer", action: "accept", values: { confirm: true }, committedAt: 80 },
2538+
}, pending);
2539+
2540+
assert.deepEqual(target.events.at(-1), {
2541+
type: "form_answer_ack",
2542+
id: "host-interaction:form-1:2",
2543+
turnId: "turn-1",
2544+
ts: 80,
2545+
requestId: "form-1",
2546+
toolUseId: "tool-1",
2547+
});
2548+
await observer.close();
2549+
});
2550+
25002551
test("projects Host queue revisions and newly delivered steering messages", async () => {
25012552
const events = new AsyncFrameQueue();
25022553
const observer = new RuntimeHostSessionObserver({

apps/desktop/src/main/__tests__/streaming-handoff.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,48 @@ describe('single live-turn handoff', () => {
655655
assert.equal(interactions.get()['session-1']?.[0]?.requestId, 'request-1');
656656
});
657657

658+
it('queues and retires a form at the Host answer acknowledgement', () => {
659+
const liveTurns = createStateSetter<Record<string, LiveTurnProjection>>({
660+
'session-1': armLiveTurn('turn-1'),
661+
});
662+
const ref = { current: liveTurns.get() };
663+
const interactions = createStateSetter<InteractionQueues>({});
664+
const handlers = createAppShellSessionEventHandlers({
665+
uiLocale: 'en',
666+
activeIdRef: { current: 'session-1' },
667+
liveTurnBySessionRef: ref,
668+
refreshMessages: async () => true,
669+
refreshSessions: async () => [],
670+
setLiveTurnBySession: liveTurns.set,
671+
setInteractionBySession: interactions.set,
672+
showModelSetupToast: () => {},
673+
toastApi: { error: () => {} },
674+
});
675+
handlers.handleEvent('session-1', {
676+
type: 'form_request',
677+
id: 'form-event',
678+
turnId: 'turn-1',
679+
ts: 1,
680+
requestId: 'form-1',
681+
toolUseId: 'tool-1',
682+
message: 'Configure deployment',
683+
requester: { name: 'deploy' },
684+
fields: [{ kind: 'boolean', name: 'confirm', label: 'Confirm', required: true }],
685+
});
686+
assert.equal(interactions.get()['session-1']?.[0]?.requestId, 'form-1');
687+
688+
handlers.handleEvent('session-1', {
689+
type: 'form_answer_ack',
690+
id: 'form-ack',
691+
turnId: 'turn-1',
692+
ts: 2,
693+
requestId: 'form-1',
694+
toolUseId: 'tool-1',
695+
});
696+
assert.deepEqual(interactions.get()['session-1'], []);
697+
assert.equal(liveTurns.get()['session-1']?.terminal, undefined);
698+
});
699+
658700
it('hands an aborted projection over only after persisted messages cover it', async () => {
659701
const liveTurns = createStateSetter<Record<string, LiveTurnProjection>>({
660702
'session-1': {

apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ describe('createDesktopWorkbarServices', () => {
180180
});
181181
await services.sideChat.respondToSandboxBoundary('fork', {} as never);
182182
await services.sideChat.respondToUserQuestion('fork', {} as never);
183+
await services.sideChat.respondToUserForm('fork', {} as never);
183184
services.sideChat.subscribeEvents('fork', eventHandler)();
184185

185186
assert.deepEqual(
@@ -233,6 +234,7 @@ describe('createDesktopWorkbarServices', () => {
233234
'sessions.regenerateTurn',
234235
'sessions.respondToSandboxBoundary',
235236
'sessions.respondToUserQuestion',
237+
'sessions.respondToUserForm',
236238
'sessions.subscribeEvents',
237239
],
238240
);

apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
} from '@maka/core/session';
3232
import { type ActiveInteractionRequestEvent, type AttachmentRef } from '@maka/core/events';
3333
import { type PermissionMode } from '@maka/core/permission';
34+
import { decodeInteractionFormResponse } from '@maka/core/interaction';
3435
import { type SandboxBoundaryResponse } from '@maka/core/sandbox-boundary';
3536
import type { AttachmentApprovalRegistry } from "./attachment-approval.js";
3637
import {
@@ -637,6 +638,28 @@ export function registerRuntimeHostSessionExecutionIpc(
637638
deps.observer.publishInteractionAnswer(answered, pending);
638639
},
639640
);
641+
ipcMain.handle(
642+
"sessions:respondToUserForm",
643+
async (_event, sessionId: string, input: unknown) => {
644+
const response = decodeInteractionFormResponse(input);
645+
const pending = await requireInteraction(
646+
deps.observer,
647+
sessionId,
648+
response.requestId,
649+
);
650+
if (pending.request.kind !== "form") {
651+
throw new Error("Interaction is not a form request");
652+
}
653+
const answered = await deps.client.answerInteraction({
654+
sessionId,
655+
interactionId: response.requestId,
656+
answer: response.action === "accept"
657+
? { kind: "form", action: "accept", values: response.values }
658+
: { kind: "form", action: response.action },
659+
});
660+
deps.observer.publishInteractionAnswer(answered, pending);
661+
},
662+
);
640663

641664
ipcMain.handle("sessions:compact", async (_event, sessionId: string) => {
642665
const turnId = newId();

apps/desktop/src/main/runtime-host-session-observer.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,11 @@ export class RuntimeHostSessionObserver {
554554
type: "user_question_answer_ack",
555555
...base,
556556
});
557+
} else if (answered.outcome.kind === "form_answer") {
558+
this.#broadcast(answered.sessionId, {
559+
type: "form_answer_ack",
560+
...base,
561+
});
557562
} else if (answered.outcome.kind === "sandbox_boundary_decision") {
558563
this.#broadcast(answered.sessionId, {
559564
type: "sandbox_boundary_decision_ack",

apps/desktop/src/preload/bridge-contract.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import type {
5151
ShellRunUpdate,
5252
} from '@maka/core/events';
5353
import type { UserQuestionResponse } from '@maka/core/user-question';
54+
import type { InteractionFormResponse } from '@maka/core/interaction';
5455
import type { RuntimeHostProfileKind } from '@maka/runtime-host/profile-kind';
5556
import type { PermissionMode } from '@maka/core/permission';
5657
import type { CollaborationMode } from '@maka/core/collaboration';
@@ -1168,6 +1169,7 @@ export interface MakaBridge {
11681169
reviseBeforeTurn(sessionId: string, input: DesktopReviseBeforeTurnInput): Promise<DesktopSessionSummary>;
11691170
respondToSandboxBoundary(sessionId: string, response: SandboxBoundaryResponse): Promise<void>;
11701171
respondToUserQuestion(sessionId: string, response: UserQuestionResponse): Promise<void>;
1172+
respondToUserForm(sessionId: string, response: InteractionFormResponse): Promise<void>;
11711173
saveConversationToFile(input: {
11721174
markdown: string;
11731175
defaultName: string;

apps/desktop/src/preload/preload.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ import type {
133133
ShellRunUpdate,
134134
} from '@maka/core/events';
135135
import type { UserQuestionResponse } from '@maka/core/user-question';
136+
import type { InteractionFormResponse } from '@maka/core/interaction';
136137
import type { PermissionMode } from '@maka/core/permission';
137138
import type { CollaborationMode } from '@maka/core/collaboration';
138139
import type { OrchestrationMode } from '@maka/core/orchestration';
@@ -2048,6 +2049,9 @@ const makaBridge = {
20482049
respondToUserQuestion(sessionId: string, response: UserQuestionResponse): Promise<void> {
20492050
return invokeSessionRuntimeHost('sessions:respondToUserQuestion', sessionId, response);
20502051
},
2052+
respondToUserForm(sessionId: string, response: InteractionFormResponse): Promise<void> {
2053+
return invokeSessionRuntimeHost('sessions:respondToUserForm', sessionId, response);
2054+
},
20512055
/**
20522056
* PR-CMD-PALETTE-SAVE-CONVERSATION-FILE-0: write the renderer-formatted
20532057
* conversation markdown to a user-chosen file. Renderer owns the

0 commit comments

Comments
 (0)