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:03:59 UTC

(maka) 02/07: fix(desktop): keep form responses behind feature boundaries

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 7ae4c92bbefb42ab97e2627312ee41123280d889
Author: me2seeks <me...@gmail.com>
AuthorDate: Tue Sep 1 02:26:43 2026 +0800

    fix(desktop): keep form responses behind feature boundaries
    
    Route main-surface form answers through the existing Desktop platform adapter, expose test-only companion helpers from the Workbar testing entry, and keep legacy AppShell debt monotonic while sharing response settlement behavior.
    
    Add focused coverage for answer acceptance and failure retention.
    
    Part of #4364.
---
 apps/desktop/renderer-architecture.json            |  4 +-
 .../__tests__/app-shell-chat-actions-fixture.ts    |  1 +
 .../__tests__/app-shell-form-interaction.test.ts   | 86 ++++++++++++++++++++++
 .../chat-composer-region-draft-handoff.test.ts     |  1 -
 .../__tests__/quote-companion-interactions.test.ts |  2 +-
 .../desktop/src/renderer/app-shell-chat-actions.ts | 84 ++++++---------------
 .../src/renderer/app-shell-session-events.ts       |  7 +-
 apps/desktop/src/renderer/app-shell.tsx            |  3 +-
 apps/desktop/src/renderer/chat-composer-region.tsx |  6 +-
 .../workbar/controller/use-workbar-controller.ts   | 18 ++++-
 10 files changed, 136 insertions(+), 76 deletions(-)

diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json
index db13adebe6..5a317e9c20 100644
--- a/apps/desktop/renderer-architecture.json
+++ b/apps/desktop/renderer-architecture.json
@@ -342,7 +342,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,
@@ -360,7 +360,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,
diff --git a/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts b/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts
index ad52d98382..3f40151bf2 100644
--- a/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts
+++ b/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts
@@ -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,
diff --git a/apps/desktop/src/main/__tests__/app-shell-form-interaction.test.ts b/apps/desktop/src/main/__tests__/app-shell-form-interaction.test.ts
new file mode 100644
index 0000000000..2c25b9fab6
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/app-shell-form-interaction.test.ts
@@ -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);
+  });
+});
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 26394dad8f..2763228463 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,7 +123,6 @@ async function mountRegion(): Promise<{
               respondToSandboxBoundary: () => {},
               respondToClientCapability: () => {},
               respondToUserQuestion: () => {},
-              activeForm: undefined,
               respondToUserForm: () => {},
               stop: () => {},
               onSend: () => {},
diff --git a/apps/desktop/src/main/__tests__/quote-companion-interactions.test.ts b/apps/desktop/src/main/__tests__/quote-companion-interactions.test.ts
index 5dfd987348..c6ec68741e 100644
--- a/apps/desktop/src/main/__tests__/quote-companion-interactions.test.ts
+++ b/apps/desktop/src/main/__tests__/quote-companion-interactions.test.ts
@@ -19,7 +19,7 @@
 
 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';
+import { applyCompanionInteractionEvent } from '../../renderer/features/workbar/testing.js';
 
 it('keeps companion forms pending until the Host acknowledgement arrives', () => {
   let queues = applyCompanionInteractionEvent({}, 'fork-1', {
diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts
index 0222f61caa..24b1f6cbcd 100644
--- a/apps/desktop/src/renderer/app-shell-chat-actions.ts
+++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts
@@ -19,7 +19,7 @@
 
 import type { ChatDefaultPermissionMode } from '@maka/core/settings';
 import type { CollaborationMode } from '@maka/core/collaboration';
-import type { DesktopNewTaskTarget } from '../preload/bridge-contract.js';
+import type * as DesktopBridge from '../preload/bridge-contract.js';
 import type { InlineReference, QuoteRef } from '@maka/core/events';
 import type { OrchestrationMode } from '@maka/core/orchestration';
 import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary';
@@ -28,9 +28,7 @@ import type { StoredMessage } from '@maka/core/session';
 import type { ThinkingLevel } from '@maka/core/model-thinking';
 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,
@@ -93,6 +91,11 @@ type PendingNewChatModel = {
 } | null;
 
 type PendingNewChatThinkingLevel = ThinkingLevel | null;
+type DesktopNewTaskTarget = DesktopBridge.DesktopNewTaskTarget;
+type DesktopSessionSummary = DesktopBridge.DesktopSessionSummary;
+type InteractionFormResponse = Parameters<
+  DesktopBridge.MakaBridge['sessions']['respondToUserForm']
+>[1];
 
 type ToastApi = {
   error(
@@ -181,6 +184,7 @@ export function createAppShellChatActions(deps: {
   onInteractionChanged?: (sessionId: string) => void;
   /** A boundary decision settled: the session's execution boundary may have moved. */
   onExecutionBoundaryChanged?: (sessionId: string) => void;
+  respondToUserForm: DesktopBridge.MakaBridge['sessions']['respondToUserForm'];
   showModelSetupToast: (
     description: string,
     reason?: string,
@@ -226,6 +230,7 @@ export function createAppShellChatActions(deps: {
     setInteractionBySession,
     onInteractionChanged,
     onExecutionBoundaryChanged,
+    respondToUserForm: submitUserForm,
     showModelSetupToast,
     toastApi,
     newChatModel,
@@ -721,67 +726,20 @@ export function createAppShellChatActions(deps: {
     }
   }
 
-  async function respondToSandboxBoundary(response: SandboxBoundaryResponse) {
+  async function respondToInteraction<Response extends { requestId: string }>(
+    response: Response,
+    submit: (sessionId: string, response: Response) => Promise<void>,
+    onApplied?: (sessionId: string) => void,
+  ) {
     const sessionId = activeIdRef.current;
     if (!sessionId) return;
     try {
-      await window.maka.sessions.respondToSandboxBoundary(sessionId, response);
+      await submit(sessionId, response);
       onInteractionChanged?.(sessionId);
-      // #1611: the answer has been applied to the authoritative boundary, so
-      // the permission label must stop describing the pre-decision one. The
-      // ack event covers decisions settled on other surfaces; this covers the
-      // one the user just made here, without waiting for the round trip.
-      onExecutionBoundaryChanged?.(sessionId);
+      onApplied?.(sessionId);
       setInteractionBySession((current) =>
         dequeueInteractionByRequestId(current, sessionId, response.requestId),
       );
-    } catch (error) {
-      // Same fire-and-forget call site as stop(), wrap so a failed
-      // permission response (main process busy / session dropped)
-      // surfaces instead of dying as UnhandledPromiseRejection.
-      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 respondToUserQuestion(response: UserQuestionResponse) {
-    const sessionId = activeIdRef.current;
-    if (!sessionId) return;
-    try {
-      await window.maka.sessions.respondToUserQuestion(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 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)) {
@@ -860,9 +818,15 @@ export function createAppShellChatActions(deps: {
   return {
     send,
     enqueueMessage,
-    respondToSandboxBoundary,
-    respondToUserQuestion,
-    respondToUserForm,
+    respondToSandboxBoundary: (response) =>
+      respondToInteraction(
+        response,
+        window.maka.sessions.respondToSandboxBoundary,
+        onExecutionBoundaryChanged,
+      ),
+    respondToUserQuestion: (response) =>
+      respondToInteraction(response, window.maka.sessions.respondToUserQuestion),
+    respondToUserForm: (response) => respondToInteraction(response, submitUserForm),
     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 62c3d478c6..ba9721d77a 100644
--- a/apps/desktop/src/renderer/app-shell-session-events.ts
+++ b/apps/desktop/src/renderer/app-shell-session-events.ts
@@ -365,9 +365,7 @@ export function createAppShellSessionEventHandlers(options: {
         });
         break;
       case 'message_admission':
-        if (event.outcome === 'retracted') {
-          removeTransientMessage?.(sessionId, event.messageId);
-        }
+        if (event.outcome === 'retracted') removeTransientMessage?.(sessionId, event.messageId);
         break;
       case 'steering_message':
         // The live Turn projection now renders this same messageId in place.
@@ -448,9 +446,8 @@ export function createAppShellSessionEventHandlers(options: {
       case 'complete': {
         onInteractionChanged?.(sessionId);
         setInteractionBySession((current) => clearInteractions(current, sessionId));
-        if (event.contextCompactionOutcome) {
+        if (event.contextCompactionOutcome)
           onContextCompactionOutcome?.(sessionId, event.turnId, event.contextCompactionOutcome);
-        }
         if (event.stopReason === 'end_turn' || event.stopReason === 'max_tokens') {
           const body = [...(before?.steps ?? [])].reverse().find((step) => step.text?.text)?.text?.text;
           notifyRunEnded?.({ kind: 'completed', sessionId, body });
diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index a6e1c3af6f..3a28016f5f 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -826,7 +826,6 @@ 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;
@@ -1848,6 +1847,7 @@ function AppShellContent({
     setInteractionBySession: sessionUiController.setInteractionBySession,
     onInteractionChanged: markInteractionChanged,
     onExecutionBoundaryChanged: reloadActiveExecutionBoundary,
+    respondToUserForm: workbar.commands.respondToUserForm,
     showModelSetupToast,
     toastApi,
     newChatModel: newChatModel ?? null,
@@ -2970,7 +2970,6 @@ function AppShellContent({
                   respondToSandboxBoundary={respondToSandboxBoundary}
                   respondToClientCapability={workbar.commands.respondToClientCapability}
                   respondToUserQuestion={respondToUserQuestion}
-                  activeForm={activeForm}
                   respondToUserForm={respondToUserForm}
                   stop={stop}
                   directoryComposerProps={directoryComposerProps}
diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx
index 935c923598..ab90a2a7d9 100644
--- a/apps/desktop/src/renderer/chat-composer-region.tsx
+++ b/apps/desktop/src/renderer/chat-composer-region.tsx
@@ -113,7 +113,6 @@ 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;
@@ -136,7 +135,6 @@ export function ChatComposerRegion({
   respondToSandboxBoundary,
   respondToClientCapability,
   respondToUserQuestion,
-  activeForm,
   respondToUserForm,
   stop,
   boundaryUnreadableNotice,
@@ -149,7 +147,9 @@ export function ChatComposerRegion({
     activeInteraction?.type === 'sandbox_boundary_request' ? activeInteraction : undefined;
   const activeClientCapability =
     activeInteraction?.type === 'client_capability_request' ? activeInteraction : undefined;
-  const activeQuestion = activeInteraction?.type === 'user_question_request' ? activeInteraction : undefined;
+  const activeQuestion =
+    activeInteraction?.type === 'user_question_request' ? activeInteraction : undefined;
+  const activeForm = activeInteraction?.type === 'form_request' ? activeInteraction : undefined;
   const previousNewTaskDraftKey = useRef(newTaskDraftKey);
   useLayoutEffect(() => {
     const previous = previousNewTaskDraftKey.current;
diff --git a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts
index 688248f0a2..e31146d0d8 100644
--- a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts
+++ b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts
@@ -28,6 +28,7 @@ import {
 } from 'react';
 import type { ClientCapabilityResponse } from '@maka/core/client-capability-grant';
 import type { QuoteRef } from '@maka/core/events';
+import type { InteractionFormResponse } from '@maka/core/interaction';
 import type { SessionSummary } from '@maka/core/session';
 import { Composer, useUiLocale } from '@maka/ui';
 import type { ChatModelChoice } from '@maka/ui';
@@ -76,6 +77,7 @@ export interface WorkbarControllerCommands {
   ): void;
   openSideChatWithQuote(quote: QuoteRef): void;
   respondToClientCapability(response: ClientCapabilityResponse): Promise<void>;
+  respondToUserForm(sessionId: string, response: InteractionFormResponse): Promise<void>;
   toggleRight(): void;
 }
 
@@ -663,8 +665,20 @@ export function useWorkbarController(
   );
 
   const commands = useMemo<WorkbarControllerCommands>(
-    () => ({ openTool, openSideChatWithQuote, respondToClientCapability, toggleRight }),
-    [openSideChatWithQuote, openTool, respondToClientCapability, toggleRight],
+    () => ({
+      openTool,
+      openSideChatWithQuote,
+      respondToClientCapability,
+      respondToUserForm: sideChat.respondToUserForm,
+      toggleRight,
+    }),
+    [
+      openSideChatWithQuote,
+      openTool,
+      respondToClientCapability,
+      sideChat.respondToUserForm,
+      toggleRight,
+    ],
   );
 
   const activeSideChatTabIds = useMemo(


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