You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by zh...@apache.org on 2026/09/01 15:24:47 UTC

(maka) 01/06: feat(desktop): answer structured form interactions

This is an automated email from the ASF dual-hosted git repository.

me2seeks pushed a commit to branch feat/4364-desktop-form-interactions
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 2aabb45e023411a4a45fea22df2f77be48b61839
Author: me2seeks <me...@gmail.com>
AuthorDate: Tue Sep 1 00:04:04 2026 +0800

    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
---
 .../chat-composer-region-draft-handoff.test.ts     |   2 +
 .../__tests__/quote-companion-interactions.test.ts |  47 +++++
 ...runtime-host-session-execution-ipc-main.test.ts |  63 ++++++
 .../runtime-host-session-observer.test.ts          |  51 +++++
 .../src/main/__tests__/streaming-handoff.test.ts   |  42 ++++
 .../__tests__/workbar-services-adapter.test.ts     |   2 +
 .../runtime-host-session-execution-ipc-main.ts     |  23 +++
 .../src/main/runtime-host-session-observer.ts      |   5 +
 apps/desktop/src/preload/bridge-contract.d.ts      |   2 +
 apps/desktop/src/preload/preload.ts                |   4 +
 .../desktop/src/renderer/app-shell-chat-actions.ts |  25 +++
 .../src/renderer/app-shell-session-events.ts       |   2 +
 apps/desktop/src/renderer/app-shell.tsx            |   4 +
 apps/desktop/src/renderer/chat-composer-region.tsx |  13 +-
 .../desktop/src/renderer/features/workbar/ports.ts |   5 +
 .../src/renderer/features/workbar/testing.ts       |   1 +
 .../tools/side-chat/quote-companion-core.ts        |   5 +-
 .../tools/side-chat/quote-companion-panel.tsx      |  13 +-
 .../workbar/tools/side-chat/use-quote-companion.ts |  22 +-
 .../platform/desktop/create-workbar-services.ts    |   2 +
 .../src/renderer/styles/interaction-prompts.css    |  74 +++++++
 apps/desktop/stories/form-interaction.stories.tsx  | 103 ++++++++++
 packages/ui/src/conversation-copy.ts               |  17 ++
 .../ui/src/form-interaction-prompt-state.test.ts   | 128 ++++++++++++
 packages/ui/src/form-interaction-prompt-state.ts   |  90 +++++++++
 packages/ui/src/form-interaction-prompt.tsx        | 225 +++++++++++++++++++++
 packages/ui/src/index.ts                           |   2 +
 27 files changed, 967 insertions(+), 5 deletions(-)

diff --git a/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts b/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts
index a6f0302ac7..26394dad8f 100644
--- a/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts
+++ b/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts
@@ -123,6 +123,8 @@ async function mountRegion(): Promise<{
               respondToSandboxBoundary: () => {},
               respondToClientCapability: () => {},
               respondToUserQuestion: () => {},
+              activeForm: undefined,
+              respondToUserForm: () => {},
               stop: () => {},
               onSend: () => {},
               onStop: () => {},
diff --git a/apps/desktop/src/main/__tests__/quote-companion-interactions.test.ts b/apps/desktop/src/main/__tests__/quote-companion-interactions.test.ts
new file mode 100644
index 0000000000..5dfd987348
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/quote-companion-interactions.test.ts
@@ -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/tools/side-chat/quote-companion-core.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'], []);
+});
diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts
index c5b865ed70..76a2a80bf1 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts
@@ -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"]);
diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts
index b4f7c03a9c..275ef8aeb9 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts
@@ -2497,6 +2497,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({
diff --git a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts
index df1ac3b1c6..16e8f1de69 100644
--- a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts
+++ b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts
@@ -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': {
diff --git a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts
index 43d806fa21..641668eca7 100644
--- a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts
+++ b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts
@@ -181,6 +181,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(
@@ -235,6 +236,7 @@ describe('createDesktopWorkbarServices', () => {
         'sessions.respondToSandboxBoundary',
         'sessions.respondToClientCapability',
         'sessions.respondToUserQuestion',
+        'sessions.respondToUserForm',
         'sessions.subscribeEvents',
       ],
     );
diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts
index 5699b58242..3bb468062e 100644
--- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts
+++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts
@@ -31,6 +31,7 @@ import {
 } from '@maka/core/session';
 import { type ActiveInteractionRequestEvent, type AttachmentRef } from '@maka/core/events';
 import { type PermissionMode } from '@maka/core/permission';
+import { decodeInteractionFormResponse } from '@maka/core/interaction';
 import { type SandboxBoundaryResponse } from '@maka/core/sandbox-boundary';
 import type { AttachmentApprovalRegistry } from "./attachment-approval.js";
 import {
@@ -656,6 +657,28 @@ export function registerRuntimeHostSessionExecutionIpc(
       deps.observer.publishInteractionAnswer(answered, pending);
     },
   );
+  ipcMain.handle(
+    "sessions:respondToUserForm",
+    async (_event, sessionId: string, input: unknown) => {
+      const response = decodeInteractionFormResponse(input);
+      const pending = await requireInteraction(
+        deps.observer,
+        sessionId,
+        response.requestId,
+      );
+      if (pending.request.kind !== "form") {
+        throw new Error("Interaction is not a form request");
+      }
+      const answered = await deps.client.answerInteraction({
+        sessionId,
+        interactionId: response.requestId,
+        answer: response.action === "accept"
+          ? { kind: "form", action: "accept", values: response.values }
+          : { kind: "form", action: response.action },
+      });
+      deps.observer.publishInteractionAnswer(answered, pending);
+    },
+  );
 
   ipcMain.handle("sessions:compact", async (_event, sessionId: string) => {
     const turnId = newId();
diff --git a/apps/desktop/src/main/runtime-host-session-observer.ts b/apps/desktop/src/main/runtime-host-session-observer.ts
index acf5e12377..08121b2954 100644
--- a/apps/desktop/src/main/runtime-host-session-observer.ts
+++ b/apps/desktop/src/main/runtime-host-session-observer.ts
@@ -554,6 +554,11 @@ export class RuntimeHostSessionObserver {
         type: "user_question_answer_ack",
         ...base,
       });
+    } else if (answered.outcome.kind === "form_answer") {
+      this.#broadcast(answered.sessionId, {
+        type: "form_answer_ack",
+        ...base,
+      });
     } else if (answered.outcome.kind === "sandbox_boundary_decision") {
       this.#broadcast(answered.sessionId, {
         type: "sandbox_boundary_decision_ack",
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index 95f2c2a3bf..c69877f7b7 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -52,6 +52,7 @@ import type {
   ShellRunUpdate,
 } from '@maka/core/events';
 import type { UserQuestionResponse } from '@maka/core/user-question';
+import type { InteractionFormResponse } from '@maka/core/interaction';
 import type { RuntimeHostProfileKind } from '@maka/runtime-host/profile-kind';
 import type { PermissionMode } from '@maka/core/permission';
 import type { CollaborationMode } from '@maka/core/collaboration';
@@ -1182,6 +1183,7 @@ export interface MakaBridge {
       response: ClientCapabilityResponse,
     ): Promise<void>;
     respondToUserQuestion(sessionId: string, response: UserQuestionResponse): Promise<void>;
+    respondToUserForm(sessionId: string, response: InteractionFormResponse): Promise<void>;
     saveConversationToFile(input: {
       markdown: string;
       defaultName: string;
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index 226ce44a52..b587a80463 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -134,6 +134,7 @@ import type {
   ShellRunUpdate,
 } from '@maka/core/events';
 import type { UserQuestionResponse } from '@maka/core/user-question';
+import type { InteractionFormResponse } from '@maka/core/interaction';
 import type { PermissionMode } from '@maka/core/permission';
 import type { CollaborationMode } from '@maka/core/collaboration';
 import type { OrchestrationMode } from '@maka/core/orchestration';
@@ -2075,6 +2076,9 @@ const makaBridge = {
     respondToUserQuestion(sessionId: string, response: UserQuestionResponse): Promise<void> {
       return invokeSessionRuntimeHost('sessions:respondToUserQuestion', sessionId, response);
     },
+    respondToUserForm(sessionId: string, response: InteractionFormResponse): Promise<void> {
+      return invokeSessionRuntimeHost('sessions:respondToUserForm', sessionId, response);
+    },
     /**
      * PR-CMD-PALETTE-SAVE-CONVERSATION-FILE-0: write the renderer-formatted
      * conversation markdown to a user-chosen file. Renderer owns the
diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts
index 275740b9c4..0222f61caa 100644
--- a/apps/desktop/src/renderer/app-shell-chat-actions.ts
+++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts
@@ -30,6 +30,7 @@ import type { TurnOrchestration } from '@maka/core/runtime-inputs';
 import type { UiLocale } from '@maka/core/ui-locale';
 import type { DesktopSessionSummary } from '../preload/bridge-contract.js';
 import type { UserQuestionResponse } from '@maka/core/user-question';
+import type { InteractionFormResponse } from '@maka/core/interaction';
 import { DEFAULT_SESSION_NAME } from '@maka/core/session-name';
 import {
   armLiveTurn,
@@ -142,6 +143,7 @@ export interface AppShellChatActions {
   ): Promise<boolean>;
   respondToSandboxBoundary(response: SandboxBoundaryResponse): Promise<void>;
   respondToUserQuestion(response: UserQuestionResponse): Promise<void>;
+  respondToUserForm(response: InteractionFormResponse): Promise<void>;
   refreshMessages(sessionId: string, options?: RefreshMessagesOptions): Promise<boolean>;
   retryMessages(sessionId: string): Promise<void>;
 }
@@ -773,6 +775,28 @@ export function createAppShellChatActions(deps: {
     }
   }
 
+  async function respondToUserForm(response: InteractionFormResponse) {
+    const sessionId = activeIdRef.current;
+    if (!sessionId) return;
+    try {
+      await window.maka.sessions.respondToUserForm(sessionId, response);
+      onInteractionChanged?.(sessionId);
+      setInteractionBySession((current) => dequeueInteractionByRequestId(current, sessionId, response.requestId));
+    } catch (error) {
+      if (activeIdRef.current !== sessionId) return;
+      if (isSessionWorkspaceUnavailableError(error)) {
+        showSessionWorkspaceUnavailableToast(toastApi, uiLocale, { sessionId });
+      } else {
+        toastApi.error(
+          copy.responseFailedTitle,
+          localizedShellErrorMessage(error, copy.responseFailedFallback, uiLocale),
+          undefined,
+          { sessionId },
+        );
+      }
+    }
+  }
+
   async function refreshMessages(sessionId: string, options: RefreshMessagesOptions = {}): Promise<boolean> {
     try {
       if (activeIdRef.current !== sessionId) return false;
@@ -838,6 +862,7 @@ export function createAppShellChatActions(deps: {
     enqueueMessage,
     respondToSandboxBoundary,
     respondToUserQuestion,
+    respondToUserForm,
     refreshMessages,
     retryMessages,
   };
diff --git a/apps/desktop/src/renderer/app-shell-session-events.ts b/apps/desktop/src/renderer/app-shell-session-events.ts
index cf3491ca7f..62c3d478c6 100644
--- a/apps/desktop/src/renderer/app-shell-session-events.ts
+++ b/apps/desktop/src/renderer/app-shell-session-events.ts
@@ -393,6 +393,7 @@ export function createAppShellSessionEventHandlers(options: {
       case 'sandbox_boundary_request':
       case 'client_capability_request':
       case 'user_question_request':
+      case 'form_request':
         onInteractionChanged?.(sessionId);
         break;
       // The runtime drops its owner on this ack, not on the tool result that
@@ -400,6 +401,7 @@ export function createAppShellSessionEventHandlers(options: {
       // same point its boundary sibling settles on, below.
       case 'user_question_answer_ack':
       case 'client_capability_decision_ack':
+      case 'form_answer_ack':
         onInteractionChanged?.(sessionId);
         break;
       case 'sandbox_boundary_decision_ack':
diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index c882b04f64..a6e1c3af6f 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -826,6 +826,7 @@ function AppShellContent({
     [sessions, onboarding.snapshot?.sessionSendOutcomes],
   );
   const activeInteraction = activeInteractionFor(interactionBySession, ownerActiveId);
+  const activeForm = activeInteraction?.type === 'form_request' ? activeInteraction : undefined;
   const activeSession = activeCatalogSession;
   const activeMessageQueue = activeId ? messageQueueBySession[activeId] : undefined;
   const activeMessageSubmitting = transientMessages.length > 0;
@@ -1823,6 +1824,7 @@ function AppShellContent({
     enqueueMessage,
     respondToSandboxBoundary,
     respondToUserQuestion,
+    respondToUserForm,
     refreshMessages,
     retryMessages,
   } = useStableActions(createAppShellChatActions, {
@@ -2968,6 +2970,8 @@ function AppShellContent({
                   respondToSandboxBoundary={respondToSandboxBoundary}
                   respondToClientCapability={workbar.commands.respondToClientCapability}
                   respondToUserQuestion={respondToUserQuestion}
+                  activeForm={activeForm}
+                  respondToUserForm={respondToUserForm}
                   stop={stop}
                   directoryComposerProps={directoryComposerProps}
                   directoryPickerEnabled={!!(
diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx
index c3ffcbd76e..1ab6c139ae 100644
--- a/apps/desktop/src/renderer/chat-composer-region.tsx
+++ b/apps/desktop/src/renderer/chat-composer-region.tsx
@@ -25,6 +25,7 @@ import {
   Composer,
   type ComposerInteraction,
   ComposerGoalProjectionConsumer,
+  FormInteractionPrompt,
   SandboxBoundaryPrompt,
   UserQuestionPrompt,
 } from '@maka/ui';
@@ -64,7 +65,7 @@ interface BoundaryUnreadableNotice {
 
 /**
  * The composer region of the chat surface (issue #1043): the composer
- * interaction slot (permission / user-question prompts) plus the always-mounted
+ * interaction slot (boundary / question / form prompts) plus the always-mounted
  * Composer itself.
  *
  * AppShell renders this as a stable sibling of the section switch, so it is
@@ -108,6 +109,8 @@ interface ChatComposerRegionProps
   respondToSandboxBoundary: ComponentProps<typeof SandboxBoundaryPrompt>['onRespond'];
   respondToClientCapability: ComponentProps<typeof ClientCapabilityPrompt>['onRespond'];
   respondToUserQuestion: ComponentProps<typeof UserQuestionPrompt>['onRespond'];
+  activeForm: ComponentProps<typeof FormInteractionPrompt>['request'] | undefined;
+  respondToUserForm: ComponentProps<typeof FormInteractionPrompt>['onRespond'];
   stop: ComponentProps<typeof UserQuestionPrompt>['onStop'];
   boundaryUnreadableNotice?: BoundaryUnreadableNotice;
   directoryComposerProps: Pick<
@@ -129,6 +132,8 @@ export function ChatComposerRegion({
   respondToSandboxBoundary,
   respondToClientCapability,
   respondToUserQuestion,
+  activeForm,
+  respondToUserForm,
   stop,
   boundaryUnreadableNotice,
   directoryComposerProps,
@@ -244,6 +249,12 @@ export function ChatComposerRegion({
             stopPending={activeId ? stopPendingBySession[activeId] === true : false}
           />
         )}
+        {activeForm && (
+          <FormInteractionPrompt
+            request={activeForm}
+            onRespond={respondToUserForm}
+          />
+        )}
       </div>
       <ComposerGoalProjectionConsumer>
         {(goalProjection) => (
diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts
index 37c4c87fc7..524c1e05fa 100644
--- a/apps/desktop/src/renderer/features/workbar/ports.ts
+++ b/apps/desktop/src/renderer/features/workbar/ports.ts
@@ -44,6 +44,7 @@ import type {
 import type { SessionTrace } from '@maka/core/session-trace';
 import type { SessionTodoItem } from '@maka/core/session-todo';
 import type { UserQuestionResponse } from '@maka/core/user-question';
+import type { InteractionFormResponse } from '@maka/core/interaction';
 import type { Result } from '@maka/core/result';
 import type { ContextDiagnosticsResult } from '@maka/runtime-host/protocol';
 import type { MergedUsageSummary } from '@maka/core/usage-ledger-merge';
@@ -265,6 +266,10 @@ export interface SideChatSessionPort {
     sessionId: string,
     response: UserQuestionResponse,
   ): Promise<void>;
+  respondToUserForm(
+    sessionId: string,
+    response: InteractionFormResponse,
+  ): Promise<void>;
   subscribeEvents(
     sessionId: string,
     handler: (event: SessionEvent) => void,
diff --git a/apps/desktop/src/renderer/features/workbar/testing.ts b/apps/desktop/src/renderer/features/workbar/testing.ts
index 8bf5c6bb0a..24bdf0d6b3 100644
--- a/apps/desktop/src/renderer/features/workbar/testing.ts
+++ b/apps/desktop/src/renderer/features/workbar/testing.ts
@@ -138,6 +138,7 @@ export function createFakeWorkbarServices(
       respondToSandboxBoundary: async () => undefined,
       respondToClientCapability: async () => undefined,
       respondToUserQuestion: async () => undefined,
+      respondToUserForm: async () => undefined,
       subscribeEvents: (_sessionId, _handler, onSeeded) => {
         onSeeded?.();
         return noopSubscription();
diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts
index a12512b0fc..674d117469 100644
--- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts
+++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts
@@ -428,7 +428,7 @@ export function companionRunEventEffect(
 
 /**
  * Route a companion event into its interaction queue, mirroring the main shell:
- * boundary / question requests enqueue, their acks / tool results dequeue, and
+ * boundary / question / form requests enqueue, their acks / tool results dequeue, and
  * a terminal event clears the queue.
  */
 export function applyCompanionInteractionEvent(
@@ -440,9 +440,12 @@ export function applyCompanionInteractionEvent(
     case 'sandbox_boundary_request':
     case 'client_capability_request':
     case 'user_question_request':
+    case 'form_request':
       return enqueueInteraction(queues, sessionId, event);
     case 'sandbox_boundary_decision_ack':
     case 'client_capability_decision_ack':
+    case 'user_question_answer_ack':
+    case 'form_answer_ack':
       return dequeueInteractionByRequestId(queues, sessionId, event.requestId);
     case 'tool_result':
       return dequeueInteractionByToolUseId(queues, sessionId, event.toolUseId);
diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx
index 8f3f0dfbb3..91b5378b65 100644
--- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx
+++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx
@@ -25,6 +25,7 @@ import {
   ChatSurfaceLayout,
   Composer,
   ClientCapabilityPrompt,
+  FormInteractionPrompt,
   SandboxBoundaryPrompt,
   UserQuestionPrompt,
   useToast,
@@ -180,7 +181,8 @@ export function QuoteCompanionPanel(props: {
   const activeInteraction =
     companion.activeSandboxBoundary ??
     companion.activeClientCapability ??
-    companion.activeQuestion;
+    companion.activeQuestion ??
+    companion.activeForm;
   const deriveTurnPresentation = useCallback<
     NonNullable<ComponentProps<typeof ChatView>['deriveTurnPresentation']>
   >(
@@ -221,7 +223,8 @@ export function QuoteCompanionPanel(props: {
             )}
             {(companion.activeSandboxBoundary ||
               companion.activeClientCapability ||
-              companion.activeQuestion) && (
+              companion.activeQuestion ||
+              companion.activeForm) && (
               <div className="maka-composer-interaction-slot">
                 {companion.activeSandboxBoundary && (
                   <SandboxBoundaryPrompt
@@ -242,6 +245,12 @@ export function QuoteCompanionPanel(props: {
                     onStop={() => void companion.stop()}
                   />
                 )}
+                {companion.activeForm && (
+                  <FormInteractionPrompt
+                    request={companion.activeForm}
+                    onRespond={companion.respondToUserForm}
+                  />
+                )}
               </div>
             )}
             <Composer
diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts
index e65526dfb6..a4d57e6908 100644
--- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts
+++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts
@@ -30,6 +30,7 @@ import {
 import type {
   SandboxBoundaryRequestEvent,
   ClientCapabilityRequestEvent,
+  FormRequestEvent,
   QuoteRef,
   SessionEvent,
   UserQuestionRequestEvent,
@@ -41,6 +42,7 @@ import type { SessionSummary, StoredMessage } from '@maka/core/session';
 import type { UiLocale } from '@maka/core/ui-locale';
 import type { ChatModelChoice } from '@maka/core/chat-model-choice';
 import type { UserQuestionResponse } from '@maka/core/user-question';
+import type { InteractionFormResponse } from '@maka/core/interaction';
 import { useWorkbarServices } from '../../services-context.js';
 import type { WorkbarIngestInput } from '../../ports.js';
 import {
@@ -137,10 +139,11 @@ export interface UseQuoteCompanionResult {
   error: string | null;
   /** The model the companion inherited from the source (shown read-only). */
   activeModel: { llmConnectionSlug: string; model: string } | undefined;
-  /** Pending sandbox-boundary / user-question prompt raised by the companion's run. */
+  /** Pending sandbox-boundary / question / form prompt raised by the companion's run. */
   activeSandboxBoundary: SandboxBoundaryRequestEvent | undefined;
   activeClientCapability: ClientCapabilityRequestEvent | undefined;
   activeQuestion: UserQuestionRequestEvent | undefined;
+  activeForm: FormRequestEvent | undefined;
   /** Returns whether the send was accepted; false leaves the draft + staged
    *  quotes in place so the user can retry. */
   send: (text: string, attachmentItems?: WorkbarIngestInput[]) => Promise<boolean>;
@@ -152,6 +155,7 @@ export interface UseQuoteCompanionResult {
   respondToSandboxBoundary: (response: SandboxBoundaryResponse) => Promise<void>;
   respondToClientCapability: (response: ClientCapabilityResponse) => Promise<void>;
   respondToUserQuestion: (response: UserQuestionResponse) => Promise<void>;
+  respondToUserForm: (response: InteractionFormResponse) => Promise<void>;
 }
 
 /** The last streamed assistant message id of a turn — the settlement anchor. */
@@ -1042,6 +1046,19 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan
     [mountedRef, sideChat],
   );
 
+  const respondToUserForm = useCallback(
+    async (response: InteractionFormResponse): Promise<void> => {
+      const id = companionIdRef.current;
+      if (!mountedRef.current || !id) return;
+      try {
+        await sideChat.respondToUserForm(id, response);
+      } catch {
+        if (mountedRef.current) setError(copyRef.current.errors.respondFailed);
+      }
+    },
+    [mountedRef, sideChat],
+  );
+
   // Only the companion's own turns render; the forked parent history stays as
   // hidden model context.
   const messages = allMessages.filter(
@@ -1064,6 +1081,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan
     activeInteraction?.type === 'client_capability_request' ? activeInteraction : undefined;
   const activeQuestion =
     activeInteraction?.type === 'user_question_request' ? activeInteraction : undefined;
+  const activeForm = activeInteraction?.type === 'form_request' ? activeInteraction : undefined;
 
   return {
     companionSession: companion,
@@ -1082,6 +1100,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan
     activeSandboxBoundary,
     activeClientCapability,
     activeQuestion,
+    activeForm,
     send,
     steer,
     setPermissionMode,
@@ -1090,5 +1109,6 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan
     respondToSandboxBoundary,
     respondToClientCapability,
     respondToUserQuestion,
+    respondToUserForm,
   };
 }
diff --git a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts
index 31fdc82832..56e1c6530b 100644
--- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts
+++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts
@@ -164,6 +164,8 @@ export function createDesktopWorkbarServices(
         bridge.sessions.respondToClientCapability(sessionId, response),
       respondToUserQuestion: (sessionId, response) =>
         bridge.sessions.respondToUserQuestion(sessionId, response),
+      respondToUserForm: (sessionId, response) =>
+        bridge.sessions.respondToUserForm(sessionId, response),
       subscribeEvents: (sessionId, handler, onSeeded, onSeedError) =>
         bridge.sessions.subscribeEvents(sessionId, handler, onSeeded, undefined, onSeedError),
       subscribeSessionChanges: (handler) => bridge.sessions.subscribeChanges(handler),
diff --git a/apps/desktop/src/renderer/styles/interaction-prompts.css b/apps/desktop/src/renderer/styles/interaction-prompts.css
index 4b5a21d8ef..31b0e3b335 100644
--- a/apps/desktop/src/renderer/styles/interaction-prompts.css
+++ b/apps/desktop/src/renderer/styles/interaction-prompts.css
@@ -129,6 +129,71 @@
   grid-column: 4;
 }
 
+.maka-form-interaction-header p,
+.maka-form-interaction-field p {
+  margin: 0;
+}
+
+.maka-form-interaction-header p {
+  font: var(--maka-text-supporting);
+  color: var(--text-secondary);
+}
+
+.maka-form-interaction-fields {
+  display: grid;
+  gap: var(--space-2);
+  max-height: min(420px, 50vh);
+  padding-right: var(--space-1);
+  overflow-y: auto;
+}
+
+.maka-form-interaction-field {
+  display: grid;
+  gap: var(--space-1);
+  padding: var(--space-2);
+  border: var(--border-width-hairline) solid var(--border);
+  border-radius: var(--radius-control);
+}
+
+.maka-form-interaction-field-heading {
+  display: flex;
+  align-items: baseline;
+  justify-content: space-between;
+  gap: var(--space-2);
+  color: var(--text-primary);
+}
+
+.maka-form-interaction-field-heading > :first-child {
+  font: var(--maka-text-body-medium);
+}
+
+.maka-form-interaction-field-heading > :last-child,
+.maka-form-interaction-field-description {
+  font: var(--maka-text-supporting);
+  color: var(--text-secondary);
+}
+
+.maka-form-interaction-checkboxes {
+  display: grid;
+  gap: var(--space-1);
+}
+
+.maka-form-interaction-error {
+  font: var(--maka-text-supporting);
+  color: var(--destructive-text);
+}
+
+.maka-form-interaction-actions,
+.maka-form-interaction-primary-actions {
+  display: flex;
+  align-items: center;
+  gap: var(--space-2);
+}
+
+.maka-form-interaction-actions {
+  justify-content: space-between;
+}
+
 .maka-interaction-header {
   min-width: 0;
 }
@@ -175,4 +240,13 @@
   .maka-question-submit {
     grid-column: auto;
   }
+
+  .maka-form-interaction-actions,
+  .maka-form-interaction-primary-actions {
+    width: 100%;
+  }
+
+  .maka-form-interaction-primary-actions {
+    justify-content: flex-end;
+  }
 }
diff --git a/apps/desktop/stories/form-interaction.stories.tsx b/apps/desktop/stories/form-interaction.stories.tsx
new file mode 100644
index 0000000000..9456211636
--- /dev/null
+++ b/apps/desktop/stories/form-interaction.stories.tsx
@@ -0,0 +1,103 @@
+/*
+ * 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 type { Meta, StoryObj } from '@storybook/react-vite';
+import type { FormRequestEvent } from '@maka/core/events';
+import { FormInteractionPrompt } from '@maka/ui';
+
+// Fidelity convention (#1433): every story below names the real app path
+// that reaches it. See apps/desktop/stories/FIDELITY.md.
+const meta = {
+  title: 'Product/Form Interaction',
+  component: FormInteractionPrompt,
+  parameters: { layout: 'fullscreen' },
+  decorators: [
+    (Story) => (
+      <div
+        className="maka-panel maka-panel-detail"
+        style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}
+      >
+        <div className="maka-detail-with-artifacts">
+          <div className="mainColumn" style={{ justifyContent: 'flex-end' }}>
+            <Story />
+          </div>
+        </div>
+      </div>
+    ),
+  ],
+} satisfies Meta<typeof FormInteractionPrompt>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+const REQUEST: FormRequestEvent = {
+  type: 'form_request',
+  id: 'form-event',
+  ts: Date.now(),
+  turnId: 'form-turn',
+  requestId: 'form-request',
+  toolUseId: 'form-tool',
+  message: '配置生产环境发布',
+  requester: { name: 'create_release', source: 'Acme Deploy MCP' },
+  fields: [
+    {
+      kind: 'string',
+      name: 'version',
+      label: '版本号',
+      description: '将显示在发布记录和通知中。',
+      required: true,
+      default: 'v2.4.0',
+      minLength: 2,
+    },
+    {
+      kind: 'integer',
+      name: 'replicas',
+      label: '实例数量',
+      required: true,
+      default: 3,
+      minimum: 1,
+      maximum: 20,
+    },
+    {
+      kind: 'single_select',
+      name: 'channel',
+      label: '发布通道',
+      required: true,
+      default: 'stable',
+      options: [
+        { value: 'stable', label: 'Stable' },
+        { value: 'canary', label: 'Canary' },
+      ],
+    },
+    {
+      kind: 'boolean',
+      name: 'notify',
+      label: '发送发布通知',
+      description: '可选;启用后通知项目成员。',
+      required: false,
+    },
+  ],
+};
+
+// Real path: a running tool requests structured input → Runtime Host parks the
+// exact invocation → Desktop replaces the composer with this form. Accept,
+// Decline, and Cancel all answer that same Host-owned interaction.
+export const PendingDeploymentForm: Story = {
+  args: { request: REQUEST, onRespond: () => {} },
+};
diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts
index 1046134007..ebd4e6127c 100644
--- a/packages/ui/src/conversation-copy.ts
+++ b/packages/ui/src/conversation-copy.ts
@@ -237,6 +237,21 @@ export interface ConversationCopy {
     submit: string;
     next: string;
   };
+  forms: {
+    requester: (name: string) => string;
+    requesterWithSource: (name: string, source: string) => string;
+    required: string;
+    optional: string;
+    include: (label: string) => string;
+    enabled: (label: string) => string;
+    enterValue: string;
+    enterNumber: string;
+    invalid: string;
+    cancel: string;
+    decline: string;
+    accept: string;
+    submitting: string;
+  };
   mentions: {
     noFiles: string;
     noSkills: string;
@@ -518,6 +533,7 @@ const CONVERSATION_COPY = {
       allowSession: '本任务允许',
     },
     questions: { other: '其他', otherDescription: '输入一个不同的答案。', otherAriaLabel: '其他答案', otherPlaceholder: '输入你的答案', stop: '停止', stopping: '停止中…', previous: '上一题', submitting: '正在提交…', submit: '提交答案', next: '下一题' },
+    forms: { requester: (name) => `由 ${name} 请求`, requesterWithSource: (name, source) => `由 ${name} 请求 · ${source}`, required: '必填', optional: '选填', include: (label) => `提供:${label}`, enabled: (label) => `启用:${label}`, enterValue: '输入内容', enterNumber: '输入数字', invalid: '请提供符合要求的值。', cancel: '取消', decline: '拒绝', accept: '提交', submitting: '正在提交…' },
     mentions: { noFiles: '未找到文件', noSkills: '暂无技能', noCommandsOrSkills: '没有匹配的命令或技能', filesAriaLabel: '工作区文件', skillsAriaLabel: '技能', commandsAndSkillsAriaLabel: '命令和技能', commandsGroup: '命令', skillsGroup: 'Skills', loading: '加载中…' },
     workspace: {
       choose: '选择项目', current: '当前项目', addProject: '添加项目', manageProjects: '管理项目', noProject: '无项目', relink: '重新定位', unavailable: '不可用',
@@ -676,6 +692,7 @@ const CONVERSATION_COPY = {
       allowSession: 'Allow for this task',
     },
     questions: { other: 'Other', otherDescription: 'Enter a different answer.', otherAriaLabel: 'Other answer', otherPlaceholder: 'Enter your answer', stop: 'Stop', stopping: 'Stopping…', previous: 'Previous', submitting: 'Submitting…', submit: 'Submit answers', next: 'Next' },
+    forms: { requester: (name) => `Requested by ${name}`, requesterWithSource: (name, source) => `Requested by ${name} · ${source}`, required: 'Required', optional: 'Optional', include: (label) => `Provide ${label}`, enabled: (label) => `Enable ${label}`, enterValue: 'Enter a value', enterNumber: 'Enter a number', invalid: 'Provide a value that meets the requirements.', cancel: 'Cancel', decline: 'Decline', accept: 'Submit', submitting: 'Submitting…' },
     mentions: { noFiles: 'No files found', noSkills: 'No skills available', noCommandsOrSkills: 'No matching commands or skills', filesAriaLabel: 'Workspace files', skillsAriaLabel: 'Skills', commandsAndSkillsAriaLabel: 'Commands and skills', commandsGroup: 'Commands', skillsGroup: 'Skills', loading: 'Loading…' },
     workspace: {
       choose: 'Choose project', current: 'Current project', addProject: 'Add project', manageProjects: 'Manage projects', noProject: 'No project', relink: 'Relink', unavailable: 'Unavailable',
diff --git a/packages/ui/src/form-interaction-prompt-state.test.ts b/packages/ui/src/form-interaction-prompt-state.test.ts
new file mode 100644
index 0000000000..202a029196
--- /dev/null
+++ b/packages/ui/src/form-interaction-prompt-state.test.ts
@@ -0,0 +1,128 @@
+/*
+ * 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 { FormRequestEvent } from '@maka/core/events';
+import {
+  buildInteractionFormResponse,
+  createInteractionFormDrafts,
+  interactionFormFieldDraftIsValid,
+} from './form-interaction-prompt-state.js';
+
+const request: FormRequestEvent = {
+  id: 'event-form-1',
+  type: 'form_request',
+  turnId: 'turn-1',
+  ts: 1,
+  requestId: 'form-1',
+  toolUseId: 'tool-1',
+  message: 'Create the release',
+  requester: { name: 'release_form', source: 'Acme MCP' },
+  fields: [
+    { name: 'name', label: 'Name', kind: 'string', required: true, minLength: 2 },
+    { name: 'retries', label: 'Retries', kind: 'integer', required: false, minimum: 0, default: 2 },
+    { name: 'notify', label: 'Notify', kind: 'boolean', required: false },
+    { name: 'channel', label: 'Channel', kind: 'single_select', required: true, options: [{ value: 'stable', label: 'Stable' }] },
+    { name: 'owners', label: 'Owners', kind: 'multi_select', required: false, minItems: 1, options: [{ value: 'a', label: 'A' }] },
+  ],
+};
+
+test('form drafts preserve defaults and distinguish omitted optional booleans from false', () => {
+  assert.deepEqual(createInteractionFormDrafts(request.fields), [
+    { included: true, value: '' },
+    { included: true, value: '2' },
+    { included: false, value: false },
+    { included: true, value: '' },
+    { included: false, value: [] },
+  ]);
+});
+
+test('accepted response parses numbers and omits excluded optional fields', () => {
+  const drafts = createInteractionFormDrafts(request.fields).map((draft) => ({ ...draft }));
+  drafts[0] = { included: true, value: 'v1' };
+  drafts[3] = { included: true, value: 'stable' };
+  assert.deepEqual(buildInteractionFormResponse(request, drafts), {
+    requestId: 'form-1',
+    action: 'accept',
+    values: { name: 'v1', retries: 2, channel: 'stable' },
+  });
+});
+
+test('number fields preserve and accept finite fractional values', () => {
+  const numberRequest: FormRequestEvent = {
+    ...request,
+    requestId: 'form-number',
+    fields: [
+      {
+        name: 'threshold',
+        label: 'Threshold',
+        kind: 'number',
+        required: true,
+        default: 1.5,
+      },
+    ],
+  };
+  const drafts = createInteractionFormDrafts(numberRequest.fields);
+  assert.deepEqual(drafts, [{ included: true, value: '1.5' }]);
+  assert.deepEqual(buildInteractionFormResponse(numberRequest, drafts), {
+    requestId: 'form-number',
+    action: 'accept',
+    values: { threshold: 1.5 },
+  });
+});
+
+test('explicit optional false remains an accepted value', () => {
+  const drafts = createInteractionFormDrafts(request.fields).map((draft) => ({ ...draft }));
+  drafts[0] = { included: true, value: 'v1' };
+  drafts[2] = { included: true, value: false };
+  drafts[3] = { included: true, value: 'stable' };
+  const response = buildInteractionFormResponse(request, drafts);
+  assert.equal(response?.action, 'accept');
+  if (response?.action !== 'accept') assert.fail('expected an accepted response');
+  assert.deepEqual(response.values.notify, false);
+});
+
+test('invalid integer, required value, and multi-select bounds block acceptance', () => {
+  const drafts = createInteractionFormDrafts(request.fields).map((draft) => ({ ...draft }));
+  drafts[0] = { included: true, value: 'v1' };
+  drafts[1] = { included: true, value: '2.5' };
+  drafts[3] = { included: true, value: 'stable' };
+  assert.equal(interactionFormFieldDraftIsValid(request.fields[1]!, drafts[1]!), false);
+  assert.equal(buildInteractionFormResponse(request, drafts), null);
+
+  drafts[1] = { included: true, value: '2' };
+  drafts[4] = { included: true, value: [] };
+  assert.equal(buildInteractionFormResponse(request, drafts), null);
+});
+
+test('protocol field names remain own data properties', () => {
+  const reservedNameRequest: FormRequestEvent = {
+    ...request,
+    fields: [{ name: '__proto__', label: 'Prototype', kind: 'string', required: true }],
+  };
+  const response = buildInteractionFormResponse(
+    reservedNameRequest,
+    [{ included: true, value: 'unchanged' }],
+  );
+  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__, 'unchanged');
+});
diff --git a/packages/ui/src/form-interaction-prompt-state.ts b/packages/ui/src/form-interaction-prompt-state.ts
new file mode 100644
index 0000000000..376760932a
--- /dev/null
+++ b/packages/ui/src/form-interaction-prompt-state.ts
@@ -0,0 +1,90 @@
+/*
+ * 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 {
+  isInteractionFormFieldValueValid,
+  type InteractionFormField,
+  type InteractionFormResponse,
+  type InteractionFormValue,
+} from '@maka/core/interaction';
+import type { FormRequestEvent } from '@maka/core/events';
+
+export interface InteractionFormFieldDraft {
+  /** Optional fields need an explicit presence bit so omitted and false/empty stay distinct. */
+  readonly included: boolean;
+  readonly value: string | boolean | readonly string[];
+}
+
+export function createInteractionFormDrafts(
+  fields: readonly InteractionFormField[],
+): InteractionFormFieldDraft[] {
+  return fields.map((field) => ({
+    included: field.required || field.default !== undefined,
+    value: initialDraftValue(field),
+  }));
+}
+
+export function interactionFormFieldDraftIsValid(
+  field: InteractionFormField,
+  draft: InteractionFormFieldDraft,
+): boolean {
+  if (!draft.included) return !field.required;
+  const value = interactionFormDraftValue(field, draft);
+  return value !== undefined && isInteractionFormFieldValueValid(field, value);
+}
+
+export function buildInteractionFormResponse(
+  request: FormRequestEvent,
+  drafts: readonly InteractionFormFieldDraft[],
+): InteractionFormResponse | null {
+  const entries: Array<[string, InteractionFormValue]> = [];
+  for (const [index, field] of request.fields.entries()) {
+    const draft = drafts[index];
+    if (!draft || !interactionFormFieldDraftIsValid(field, draft)) return null;
+    if (!draft.included) continue;
+    const value = interactionFormDraftValue(field, draft);
+    if (value === undefined) return null;
+    entries.push([field.name, value]);
+  }
+  return { requestId: request.requestId, action: 'accept', values: Object.fromEntries(entries) };
+}
+
+function initialDraftValue(field: InteractionFormField): InteractionFormFieldDraft['value'] {
+  if (field.default !== undefined) {
+    if (field.kind === 'number' || field.kind === 'integer') return String(field.default);
+    return field.default;
+  }
+  if (field.kind === 'boolean') return false;
+  if (field.kind === 'multi_select') return [];
+  return '';
+}
+
+function interactionFormDraftValue(
+  field: InteractionFormField,
+  draft: InteractionFormFieldDraft,
+): InteractionFormValue | undefined {
+  if (field.kind === 'number' || field.kind === 'integer') {
+    if (typeof draft.value !== 'string' || draft.value.trim().length === 0) return undefined;
+    const value = Number(draft.value);
+    return Number.isFinite(value) ? value : undefined;
+  }
+  if (field.kind === 'boolean') return typeof draft.value === 'boolean' ? draft.value : undefined;
+  if (field.kind === 'multi_select') return Array.isArray(draft.value) ? draft.value : undefined;
+  return typeof draft.value === 'string' ? draft.value : undefined;
+}
diff --git a/packages/ui/src/form-interaction-prompt.tsx b/packages/ui/src/form-interaction-prompt.tsx
new file mode 100644
index 0000000000..c9bc39d12a
--- /dev/null
+++ b/packages/ui/src/form-interaction-prompt.tsx
@@ -0,0 +1,225 @@
+/*
+ * 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 { useEffect, useId, useRef, useState } from 'react';
+import type { FormRequestEvent } from '@maka/core/events';
+import type { InteractionFormField, InteractionFormResponse } from '@maka/core/interaction';
+import { Button, CheckboxInput, RadioList, RadioListItem, TextInput } from '@astryxdesign/core';
+import { getConversationCopy } from './conversation-copy.js';
+import {
+  buildInteractionFormResponse,
+  createInteractionFormDrafts,
+  interactionFormFieldDraftIsValid,
+  type InteractionFormFieldDraft,
+} from './form-interaction-prompt-state.js';
+import { useUiLocale } from './locale-context.js';
+import { useMountedRef } from './use-mounted-ref.js';
+
+export function FormInteractionPrompt(props: {
+  request: FormRequestEvent;
+  onRespond(response: InteractionFormResponse): void | Promise<void>;
+}) {
+  const copy = getConversationCopy(useUiLocale()).forms;
+  const titleId = useId();
+  const [drafts, setDrafts] = useState<InteractionFormFieldDraft[]>(
+    () => createInteractionFormDrafts(props.request.fields),
+  );
+  const [submitAttempted, setSubmitAttempted] = useState(false);
+  const [responsePending, setResponsePending] = useState(false);
+  const responsePendingRef = useRef(false);
+  const activeRequestIdRef = useRef(props.request.requestId);
+  const mountedRef = useMountedRef();
+
+  useEffect(() => {
+    activeRequestIdRef.current = props.request.requestId;
+    setDrafts(createInteractionFormDrafts(props.request.fields));
+    setSubmitAttempted(false);
+    responsePendingRef.current = false;
+    setResponsePending(false);
+  }, [props.request.fields, props.request.requestId]);
+
+  function updateDraft(index: number, next: InteractionFormFieldDraft) {
+    setDrafts((current) => current.map((draft, candidateIndex) => candidateIndex === index ? next : draft));
+  }
+
+  async function respond(response: InteractionFormResponse) {
+    if (responsePendingRef.current) return;
+    const requestId = props.request.requestId;
+    responsePendingRef.current = true;
+    setResponsePending(true);
+    try {
+      await props.onRespond(response);
+    } finally {
+      if (activeRequestIdRef.current === requestId) {
+        responsePendingRef.current = false;
+        if (mountedRef.current) setResponsePending(false);
+      }
+    }
+  }
+
+  function accept() {
+    const response = buildInteractionFormResponse(props.request, drafts);
+    if (!response) {
+      setSubmitAttempted(true);
+      return;
+    }
+    void respond(response);
+  }
+
+  const requester = props.request.requester.source
+    ? copy.requesterWithSource(props.request.requester.name, props.request.requester.source)
+    : copy.requester(props.request.requester.name);
+
+  return (
+    <section
+      className="maka-composer-interaction maka-form-interaction-prompt composer"
+      role="region"
+      aria-labelledby={titleId}
+    >
+      <div className="maka-composer-interaction-inner agents-parchment-paper-surface">
+        <header className="maka-interaction-header maka-form-interaction-header">
+          <h2 className="maka-interaction-title" id={titleId}>{props.request.message}</h2>
+          <p>{requester}</p>
+        </header>
+
+        <div className="maka-form-interaction-fields">
+          {props.request.fields.map((field, index) => {
+            const draft = drafts[index];
+            if (!draft) return null;
+            const invalid = submitAttempted && !interactionFormFieldDraftIsValid(field, draft);
+            return (
+              <div className="maka-form-interaction-field" key={field.name}>
+                <div className="maka-form-interaction-field-heading">
+                  <span>{field.label}</span>
+                  <span>{field.required ? copy.required : copy.optional}</span>
+                </div>
+                {field.description ? <p className="maka-form-interaction-field-description">{field.description}</p> : null}
+                {!field.required ? (
+                  <CheckboxInput
+                    label={copy.include(field.label)}
+                    value={draft.included}
+                    isDisabled={responsePending}
+                    onChange={(included) => updateDraft(index, { ...draft, included })}
+                  />
+                ) : null}
+                {draft.included ? renderFormControl({
+                  field,
+                  draft,
+                  disabled: responsePending,
+                  copy,
+                  onChange: (value) => updateDraft(index, { ...draft, value }),
+                }) : null}
+                {invalid ? <p className="maka-form-interaction-error" role="alert">{copy.invalid}</p> : null}
+              </div>
+            );
+          })}
+        </div>
+
+        <footer className="maka-interaction-actions maka-form-interaction-actions">
+          <div>
+            <Button
+              variant="ghost"
+              isDisabled={responsePending}
+              onClick={() => void respond({ requestId: props.request.requestId, action: 'cancel' })}
+              label={copy.cancel}
+            />
+          </div>
+          <div className="maka-form-interaction-primary-actions">
+            <Button
+              variant="ghost"
+              isDisabled={responsePending}
+              onClick={() => void respond({ requestId: props.request.requestId, action: 'decline' })}
+              label={copy.decline}
+            />
+            <Button
+              variant="primary"
+              isDisabled={responsePending}
+              onClick={accept}
+              label={responsePending ? copy.submitting : copy.accept}
+            />
+          </div>
+        </footer>
+      </div>
+    </section>
+  );
+}
+
+function renderFormControl(props: {
+  field: InteractionFormField;
+  draft: InteractionFormFieldDraft;
+  disabled: boolean;
+  copy: ReturnType<typeof getConversationCopy>['forms'];
+  onChange(value: InteractionFormFieldDraft['value']): void;
+}) {
+  const { field, draft } = props;
+  if (field.kind === 'boolean') {
+    return (
+      <CheckboxInput
+        label={props.copy.enabled(field.label)}
+        value={draft.value === true}
+        isDisabled={props.disabled}
+        onChange={props.onChange}
+      />
+    );
+  }
+  if (field.kind === 'single_select') {
+    return (
+      <RadioList
+        label={field.label}
+        isLabelHidden
+        value={typeof draft.value === 'string' ? draft.value : ''}
+        isDisabled={props.disabled}
+        onChange={props.onChange}
+      >
+        {field.options.map((option) => (
+          <RadioListItem key={option.value} value={option.value} label={option.label} />
+        ))}
+      </RadioList>
+    );
+  }
+  if (field.kind === 'multi_select') {
+    const selected = Array.isArray(draft.value) ? draft.value : [];
+    return (
+      <div className="maka-form-interaction-checkboxes" role="group" aria-label={field.label}>
+        {field.options.map((option) => (
+          <CheckboxInput
+            key={option.value}
+            label={option.label}
+            value={selected.includes(option.value)}
+            isDisabled={props.disabled}
+            onChange={(checked) => props.onChange(
+              checked ? [...selected, option.value] : selected.filter((value) => value !== option.value),
+            )}
+          />
+        ))}
+      </div>
+    );
+  }
+  return (
+    <TextInput
+      label={field.label}
+      isLabelHidden
+      value={typeof draft.value === 'string' ? draft.value : ''}
+      isDisabled={props.disabled}
+      placeholder={field.kind === 'string' ? props.copy.enterValue : props.copy.enterNumber}
+      onChange={props.onChange}
+      width="100%"
+    />
+  );
+}
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 33e05d252f..97616ec410 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -57,6 +57,8 @@ export * from './model-picker.js';
 export * from './interaction-queue.js';
 export * from './user-question-prompt.js';
 export * from './user-question-prompt-state.js';
+export * from './form-interaction-prompt.js';
+export * from './form-interaction-prompt-state.js';
 export * from './redact.js';
 export * from './thinking-stream.js';
 export * from './session-todo-panel.js';


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org