From aedb66b121272b09bf6fcff8a548f78fda2cd80a Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Sun, 9 Aug 2026 15:24:46 -0700 Subject: [PATCH] feat(crossagent): propagate cancelled sub-agent status end to end - Emit `crossagentStatus` on sub-agent run completion in SubagentRunManager - Extend `ToolCallPayload` contract with `crossagentStatus` and validate via schema - Mark stale cancelled Crossagents MCP calls as failed in `runtimeEventSlice` - Render cancelled status overlay/label in SubAgentOverlay and SubAgentToolCall - Add "cancelled" translations to all 12 non-English catalogs - Clear non-optimistic user message maps when OpenCode mapper finishes - Add tests across supervisor, contracts, state, and ChatPane components --- .../thread/ChatPane/ChatPane.test.tsx | 37 +++++++++++ .../parts/items/SubAgentOverlay.test.tsx | 49 ++++++++++++++ .../ChatPane/parts/items/SubAgentOverlay.tsx | 38 ++++++++++- .../ChatPane/parts/items/SubAgentToolCall.tsx | 16 ++++- src/renderer/locales/de/messages.po | 4 ++ src/renderer/locales/en/messages.po | 4 ++ src/renderer/locales/es/messages.po | 4 ++ src/renderer/locales/fr/messages.po | 4 ++ src/renderer/locales/ja/messages.po | 4 ++ src/renderer/locales/ko/messages.po | 4 ++ src/renderer/locales/pl/messages.po | 4 ++ src/renderer/locales/pt-BR/messages.po | 4 ++ src/renderer/locales/ru/messages.po | 4 ++ src/renderer/locales/tr/messages.po | 4 ++ src/renderer/locales/uk/messages.po | 4 ++ src/renderer/locales/vi/messages.po | 4 ++ src/renderer/locales/zh-CN/messages.po | 4 ++ .../state/slices/runtimeEventSlice.test.ts | 28 ++++++++ .../state/slices/runtimeEventSlice.ts | 4 ++ src/shared/contracts/runtimeEvent.test.ts | 13 ++++ src/shared/contracts/runtimeEvent.ts | 1 + .../opencode/canonicalMapping/dispatch.ts | 37 ++++++++++- .../opencode/canonicalMapping/textItems.ts | 2 + .../opencode/sdkCanonicalMapping.test.ts | 64 +++++++++++++++++++ .../opencode/sdkCanonicalMappingState.ts | 6 ++ .../crossagentMcp/SubagentRunManager.test.ts | 27 +++++++- .../crossagentMcp/SubagentRunManager.ts | 13 +++- 27 files changed, 378 insertions(+), 9 deletions(-) diff --git a/src/renderer/components/thread/ChatPane/ChatPane.test.tsx b/src/renderer/components/thread/ChatPane/ChatPane.test.tsx index a264d64b1..02118c28b 100644 --- a/src/renderer/components/thread/ChatPane/ChatPane.test.tsx +++ b/src/renderer/components/thread/ChatPane/ChatPane.test.tsx @@ -1128,6 +1128,43 @@ describe("ChatPane", () => { expect(screen.queryByRole("button", { name: "Subagent Result" })).not.toBeInTheDocument(); }); + it("shows an intentionally cancelled Crossagent without an error indicator", async () => { + const thread = makeThread(); + useAppStore.getState().applyRuntimeEvent(thread.id, { + type: "item.started", + threadId: thread.id, + itemId: "crossagent-cancelled", + itemType: "tool_call", + payload: { + name: "cancel probe", + status: "running", + isCrossagent: true, + crossagentStatus: "running", + }, + }); + useAppStore.getState().applyRuntimeEvent(thread.id, { + type: "item.completed", + threadId: thread.id, + itemId: "crossagent-cancelled", + payload: { + name: "cancel probe", + status: "error", + isCrossagent: true, + crossagentStatus: "cancelled", + }, + }); + + renderChatPane(thread); + await waitFor(() => expect(hydrateThreadRuntimeItems).toHaveBeenCalledWith(thread.id)); + + const row = await screen.findByRole("button", { + name: "Open Crossagent: Crossagent: cancel probe", + }); + expect(row).toHaveTextContent("cancelled"); + expect(row).toHaveAccessibleDescription("cancelled"); + expect(screen.queryByLabelText("error")).not.toBeInTheDocument(); + }); + it("separates the collapsed Agent label from its step count", async () => { const thread = makeThread(); useAppStore.getState().applyRuntimeEvent(thread.id, { diff --git a/src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.test.tsx b/src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.test.tsx index 72ce57c3d..9f35f2fc7 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.test.tsx +++ b/src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.test.tsx @@ -447,6 +447,55 @@ describe("SubAgentContent", () => { ); }); + it("shows an explicit terminal status for a cancelled Crossagent", async () => { + const threadId = "thread-1"; + const runningParent = makeSubAgentItem("parent-1"); + const parentItem: RuntimeChatItem = { + ...runningParent, + state: "completed", + payload: { + ...(runningParent.payload as ToolCallPayload), + status: "error", + isCrossagent: true, + crossagentStatus: "cancelled", + }, + }; + + useAppStore.setState({ + runtimeItemIdsByThread: { [threadId]: [parentItem.id] }, + runtimeItemsByIdByThread: { [threadId]: { [parentItem.id]: parentItem } }, + runtimeStructuralVersionByThread: { [threadId]: 1 }, + }); + + render(); + + expect(await screen.findByText("Cancelled")).toBeInTheDocument(); + }); + + it("derives the terminal status for persisted Crossagents without the new status field", async () => { + const threadId = "thread-1"; + const runningParent = makeSubAgentItem("parent-1"); + const parentItem: RuntimeChatItem = { + ...runningParent, + state: "completed", + payload: { + ...(runningParent.payload as ToolCallPayload), + status: "success", + isCrossagent: true, + }, + }; + + useAppStore.setState({ + runtimeItemIdsByThread: { [threadId]: [parentItem.id] }, + runtimeItemsByIdByThread: { [threadId]: { [parentItem.id]: parentItem } }, + runtimeStructuralVersionByThread: { [threadId]: 1 }, + }); + + render(); + + expect(await screen.findByText("Completed")).toBeInTheDocument(); + }); + it("hands an open target to its host and consumes the transient store signal", async () => { const threadId = "thread-1"; const parentItem = makeSubAgentItem("parent-1"); diff --git a/src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx b/src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx index a699d9235..1f6cb3e65 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx +++ b/src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx @@ -1,4 +1,5 @@ import { useEffect, useId, useRef, type ReactNode } from "react"; +import { Surface } from "@heroui/react"; import { Trans, useLingui } from "@lingui/react/macro"; import { Bot, X } from "lucide-react"; import type { ProjectLocation, ToolCallPayload } from "@/shared/contracts"; @@ -21,6 +22,7 @@ import { ChatScrollControls, type ChatScrollControlsHandle } from "../../ChatScr import { ChatTurnElapsedFooter, type TurnTiming } from "../../ChatTurnElapsed"; import { MessageList } from "../MessageList"; import { buildSubAgentProgressParts } from "./subAgentProgressMeta"; +import { chatMessageSurfaceClass } from "./chatMessageSurface"; import { deriveToolDisplay, isCrossagentTool, isWorkflowTool } from "./toolDisplay"; import { WorkflowOverlayBody } from "./WorkflowOverlayBody"; import { parseWorkflowInfo, type WorkflowInfo } from "./workflowDisplay"; @@ -138,6 +140,12 @@ export function SubAgentContent({ } : null; const turn = resolveSubAgentTurnTiming(item, payload, isRunning); + const crossagentStatus = + isCrossagent && !isRunning + ? payload?.crossagentStatus === "running" + ? null + : (payload?.crossagentStatus ?? (payload?.status === "success" ? "completed" : "failed")) + : null; const renderWorkflow = !!(workflow && workflow.manifestPath); return ( @@ -164,6 +172,7 @@ export function SubAgentContent({ entries={childEntries} stickToBottom={isRunning} turn={turn} + crossagentStatus={crossagentStatus} workflow={workflow} workflowProgress={workflowProgress} /> @@ -330,6 +339,7 @@ function ChildList({ entries, stickToBottom, turn, + crossagentStatus, workflow, workflowProgress, }: { @@ -338,6 +348,7 @@ function ChildList({ entries: readonly ChatTimelineEntry[]; stickToBottom: boolean; turn: TurnTiming | null; + crossagentStatus: "completed" | "failed" | "cancelled" | null; workflow: WorkflowInfo | null; workflowProgress: WorkflowOverlayProgress | null; }) { @@ -374,7 +385,14 @@ function ChildList({ ) : null } - footer={turn ? : null} + footer={ + crossagentStatus || turn ? ( + <> + {crossagentStatus ? : null} + {turn ? : null} + + ) : null + } emptyContent={ workflow ? ( @@ -407,6 +425,24 @@ function ChildList({ ); } +function CrossagentStatusFooter({ status }: { status: "completed" | "failed" | "cancelled" }) { + const { t } = useLingui(); + const label = + status === "completed" ? t`Completed` : status === "cancelled" ? t`Cancelled` : t`Failed`; + return ( +
+ + + {label} + + +
+ ); +} + function resolveSubAgentTurnTiming( item: RuntimeChatItem, payload: ToolCallPayload | undefined, diff --git a/src/renderer/components/thread/ChatPane/parts/items/SubAgentToolCall.tsx b/src/renderer/components/thread/ChatPane/parts/items/SubAgentToolCall.tsx index b115106a7..7ee45f7dd 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/SubAgentToolCall.tsx +++ b/src/renderer/components/thread/ChatPane/parts/items/SubAgentToolCall.tsx @@ -1,4 +1,4 @@ -import { memo, useState, type ReactNode } from "react"; +import { memo, useId, useState, type ReactNode } from "react"; import { Tooltip } from "@heroui/react"; import { msg } from "@lingui/core/macro"; import { Trans, useLingui } from "@lingui/react/macro"; @@ -81,9 +81,11 @@ export const SubAgentToolCall = memo(function SubAgentToolCall({ (workflowRun.run === null || isWorkflowRunLive(workflowRun.run)); const isRunning = !isCompleted || workflowIsLive; const titleRef = useShimmer(isRunning); + const statusDescriptionId = useId(); if (!payload?.name) return null; const display = deriveToolDisplay(payload); const isCrossagent = isCrossagentTool(payload); + const describesCancelledStatus = isCrossagent && payload.crossagentStatus === "cancelled"; const displayTitle = normalizeCallTitleSeparator(display.title); const displayPrefix = display.parts ? normalizeCallTitleSeparator(display.parts.prefix) @@ -118,6 +120,7 @@ export const SubAgentToolCall = memo(function SubAgentToolCall({ aria-label={ isCrossagent ? t`Open Crossagent: ${display.title}` : t`Open subagent: ${display.title}` } + {...(describesCancelledStatus ? { "aria-describedby": statusDescriptionId } : {})} > @@ -155,7 +158,10 @@ export const SubAgentToolCall = memo(function SubAgentToolCall({ )} {status.rightLabel ? ( - + {status.rightLabel} ) : null} @@ -268,6 +274,12 @@ function resolveStatus( rightLabelClassName: "!text-[color:var(--muted)]", }; } + if (payload?.crossagentStatus === "cancelled") { + return { + rightLabel: cancelled, + rightLabelClassName: "!text-[color:var(--muted)]", + }; + } if (payload?.status === "error") { const icon = ; return { diff --git a/src/renderer/locales/de/messages.po b/src/renderer/locales/de/messages.po index 7745009a2..879c0b922 100644 --- a/src/renderer/locales/de/messages.po +++ b/src/renderer/locales/de/messages.po @@ -1957,10 +1957,12 @@ msgstr "Entfernen des Projekts abbrechen" msgid "Cancel workflow" msgstr "Workflow abbrechen" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentToolCall.tsx #: src/renderer/components/thread/ChatPane/parts/items/WorkflowOverlayBody.tsx msgid "cancelled" msgstr "abgebrochen" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/components/thread/ThreadGoalDock.tsx #: src/renderer/utils/prStatus.ts #: src/renderer/views/GitHubActionsView/GitHubActionsRunList.tsx @@ -2754,6 +2756,7 @@ msgstr "Füllen Sie die Eingabeaufforderungen in diesem Terminal aus. Wird gesch msgid "completed" msgstr "abgeschlossen" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/utils/prStatus.ts msgid "Completed" msgstr "Abgeschlossen" @@ -4568,6 +4571,7 @@ msgstr "Factory Droid" msgid "failed" msgstr "fehlgeschlagen" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/components/thread/ThreadGoalDock.tsx #: src/renderer/utils/prStatus.ts #: src/renderer/views/GitHubActionsView/GitHubActionsRunList.tsx diff --git a/src/renderer/locales/en/messages.po b/src/renderer/locales/en/messages.po index 0ba9c69c8..85594b124 100644 --- a/src/renderer/locales/en/messages.po +++ b/src/renderer/locales/en/messages.po @@ -1962,10 +1962,12 @@ msgstr "Cancel removing project" msgid "Cancel workflow" msgstr "Cancel workflow" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentToolCall.tsx #: src/renderer/components/thread/ChatPane/parts/items/WorkflowOverlayBody.tsx msgid "cancelled" msgstr "cancelled" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/components/thread/ThreadGoalDock.tsx #: src/renderer/utils/prStatus.ts #: src/renderer/views/GitHubActionsView/GitHubActionsRunList.tsx @@ -2759,6 +2761,7 @@ msgstr "Complete the prompts in this terminal. Closes when finished." msgid "completed" msgstr "completed" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/utils/prStatus.ts msgid "Completed" msgstr "Completed" @@ -4573,6 +4576,7 @@ msgstr "Factory Droid" msgid "failed" msgstr "failed" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/components/thread/ThreadGoalDock.tsx #: src/renderer/utils/prStatus.ts #: src/renderer/views/GitHubActionsView/GitHubActionsRunList.tsx diff --git a/src/renderer/locales/es/messages.po b/src/renderer/locales/es/messages.po index 21ed2c49a..6e2ea679e 100644 --- a/src/renderer/locales/es/messages.po +++ b/src/renderer/locales/es/messages.po @@ -1957,10 +1957,12 @@ msgstr "Cancelar eliminación del proyecto" msgid "Cancel workflow" msgstr "Cancelar flujo de trabajo" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentToolCall.tsx #: src/renderer/components/thread/ChatPane/parts/items/WorkflowOverlayBody.tsx msgid "cancelled" msgstr "cancelado" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/components/thread/ThreadGoalDock.tsx #: src/renderer/utils/prStatus.ts #: src/renderer/views/GitHubActionsView/GitHubActionsRunList.tsx @@ -2754,6 +2756,7 @@ msgstr "Completa las instrucciones en este terminal. Se cerrará al finalizar." msgid "completed" msgstr "completado" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/utils/prStatus.ts msgid "Completed" msgstr "Completado" @@ -4568,6 +4571,7 @@ msgstr "Factory Droid" msgid "failed" msgstr "fallido" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/components/thread/ThreadGoalDock.tsx #: src/renderer/utils/prStatus.ts #: src/renderer/views/GitHubActionsView/GitHubActionsRunList.tsx diff --git a/src/renderer/locales/fr/messages.po b/src/renderer/locales/fr/messages.po index 7f391fd13..ba16c3d5e 100644 --- a/src/renderer/locales/fr/messages.po +++ b/src/renderer/locales/fr/messages.po @@ -1957,10 +1957,12 @@ msgstr "Annuler la suppression du projet" msgid "Cancel workflow" msgstr "Annuler le workflow" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentToolCall.tsx #: src/renderer/components/thread/ChatPane/parts/items/WorkflowOverlayBody.tsx msgid "cancelled" msgstr "annulé" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/components/thread/ThreadGoalDock.tsx #: src/renderer/utils/prStatus.ts #: src/renderer/views/GitHubActionsView/GitHubActionsRunList.tsx @@ -2754,6 +2756,7 @@ msgstr "Complétez les invites dans ce terminal. Se ferme une fois terminé." msgid "completed" msgstr "terminé" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/utils/prStatus.ts msgid "Completed" msgstr "Terminé" @@ -4568,6 +4571,7 @@ msgstr "Factory Droid" msgid "failed" msgstr "échoué" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/components/thread/ThreadGoalDock.tsx #: src/renderer/utils/prStatus.ts #: src/renderer/views/GitHubActionsView/GitHubActionsRunList.tsx diff --git a/src/renderer/locales/ja/messages.po b/src/renderer/locales/ja/messages.po index 45ec144bf..a195933ca 100644 --- a/src/renderer/locales/ja/messages.po +++ b/src/renderer/locales/ja/messages.po @@ -1956,10 +1956,12 @@ msgstr "プロジェクトの削除をキャンセル" msgid "Cancel workflow" msgstr "ワークフローをキャンセル" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentToolCall.tsx #: src/renderer/components/thread/ChatPane/parts/items/WorkflowOverlayBody.tsx msgid "cancelled" msgstr "キャンセルされました" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/components/thread/ThreadGoalDock.tsx #: src/renderer/utils/prStatus.ts #: src/renderer/views/GitHubActionsView/GitHubActionsRunList.tsx @@ -2753,6 +2755,7 @@ msgstr "このターミナルでプロンプトを完了します。終了した msgid "completed" msgstr "完了しました" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/utils/prStatus.ts msgid "Completed" msgstr "完了" @@ -4567,6 +4570,7 @@ msgstr "Factory Droid" msgid "failed" msgstr "失敗しました" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/components/thread/ThreadGoalDock.tsx #: src/renderer/utils/prStatus.ts #: src/renderer/views/GitHubActionsView/GitHubActionsRunList.tsx diff --git a/src/renderer/locales/ko/messages.po b/src/renderer/locales/ko/messages.po index 7a1ce1929..6d3e9503e 100644 --- a/src/renderer/locales/ko/messages.po +++ b/src/renderer/locales/ko/messages.po @@ -1957,10 +1957,12 @@ msgstr "프로젝트 제거 취소" msgid "Cancel workflow" msgstr "워크플로 취소" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentToolCall.tsx #: src/renderer/components/thread/ChatPane/parts/items/WorkflowOverlayBody.tsx msgid "cancelled" msgstr "취소됨" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/components/thread/ThreadGoalDock.tsx #: src/renderer/utils/prStatus.ts #: src/renderer/views/GitHubActionsView/GitHubActionsRunList.tsx @@ -2754,6 +2756,7 @@ msgstr "이 터미널의 프롬프트를 완료하세요. 완료되면 닫힙니 msgid "completed" msgstr "완료" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/utils/prStatus.ts msgid "Completed" msgstr "완료됨" @@ -4568,6 +4571,7 @@ msgstr "Factory Droid" msgid "failed" msgstr "실패" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/components/thread/ThreadGoalDock.tsx #: src/renderer/utils/prStatus.ts #: src/renderer/views/GitHubActionsView/GitHubActionsRunList.tsx diff --git a/src/renderer/locales/pl/messages.po b/src/renderer/locales/pl/messages.po index 4acfba00d..8fd7e6bd5 100644 --- a/src/renderer/locales/pl/messages.po +++ b/src/renderer/locales/pl/messages.po @@ -1957,10 +1957,12 @@ msgstr "Anuluj usuwanie projektu" msgid "Cancel workflow" msgstr "Anuluj przepływ pracy" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentToolCall.tsx #: src/renderer/components/thread/ChatPane/parts/items/WorkflowOverlayBody.tsx msgid "cancelled" msgstr "anulowane" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/components/thread/ThreadGoalDock.tsx #: src/renderer/utils/prStatus.ts #: src/renderer/views/GitHubActionsView/GitHubActionsRunList.tsx @@ -2754,6 +2756,7 @@ msgstr "Wypełnij monity w tym terminalu. Zamyka się po zakończeniu." msgid "completed" msgstr "ukończone" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/utils/prStatus.ts msgid "Completed" msgstr "Ukończono" @@ -4568,6 +4571,7 @@ msgstr "Factory Droid" msgid "failed" msgstr "nie powiodło się" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/components/thread/ThreadGoalDock.tsx #: src/renderer/utils/prStatus.ts #: src/renderer/views/GitHubActionsView/GitHubActionsRunList.tsx diff --git a/src/renderer/locales/pt-BR/messages.po b/src/renderer/locales/pt-BR/messages.po index 36a290d4b..cca201bf1 100644 --- a/src/renderer/locales/pt-BR/messages.po +++ b/src/renderer/locales/pt-BR/messages.po @@ -1957,10 +1957,12 @@ msgstr "Cancelar remoção do projeto" msgid "Cancel workflow" msgstr "Cancelar fluxo de trabalho" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentToolCall.tsx #: src/renderer/components/thread/ChatPane/parts/items/WorkflowOverlayBody.tsx msgid "cancelled" msgstr "cancelado" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/components/thread/ThreadGoalDock.tsx #: src/renderer/utils/prStatus.ts #: src/renderer/views/GitHubActionsView/GitHubActionsRunList.tsx @@ -2754,6 +2756,7 @@ msgstr "Preencha os prompts neste terminal. Fecha quando terminar." msgid "completed" msgstr "concluído" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/utils/prStatus.ts msgid "Completed" msgstr "Concluído" @@ -4568,6 +4571,7 @@ msgstr "Factory Droid" msgid "failed" msgstr "falhou" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/components/thread/ThreadGoalDock.tsx #: src/renderer/utils/prStatus.ts #: src/renderer/views/GitHubActionsView/GitHubActionsRunList.tsx diff --git a/src/renderer/locales/ru/messages.po b/src/renderer/locales/ru/messages.po index 4fac98930..81c181b17 100644 --- a/src/renderer/locales/ru/messages.po +++ b/src/renderer/locales/ru/messages.po @@ -1957,10 +1957,12 @@ msgstr "Отменить удаление проекта" msgid "Cancel workflow" msgstr "Отменить рабочий процесс" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentToolCall.tsx #: src/renderer/components/thread/ChatPane/parts/items/WorkflowOverlayBody.tsx msgid "cancelled" msgstr "отменено" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/components/thread/ThreadGoalDock.tsx #: src/renderer/utils/prStatus.ts #: src/renderer/views/GitHubActionsView/GitHubActionsRunList.tsx @@ -2754,6 +2756,7 @@ msgstr "Выполните подсказки в этом терминале. О msgid "completed" msgstr "завершено" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/utils/prStatus.ts msgid "Completed" msgstr "Завершено" @@ -4568,6 +4571,7 @@ msgstr "Factory Droid" msgid "failed" msgstr "не удалось" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/components/thread/ThreadGoalDock.tsx #: src/renderer/utils/prStatus.ts #: src/renderer/views/GitHubActionsView/GitHubActionsRunList.tsx diff --git a/src/renderer/locales/tr/messages.po b/src/renderer/locales/tr/messages.po index 538ee5904..3bf5c3347 100644 --- a/src/renderer/locales/tr/messages.po +++ b/src/renderer/locales/tr/messages.po @@ -1957,10 +1957,12 @@ msgstr "Projeyi kaldırmayı iptal et" msgid "Cancel workflow" msgstr "İş akışını iptal et" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentToolCall.tsx #: src/renderer/components/thread/ChatPane/parts/items/WorkflowOverlayBody.tsx msgid "cancelled" msgstr "iptal edildi" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/components/thread/ThreadGoalDock.tsx #: src/renderer/utils/prStatus.ts #: src/renderer/views/GitHubActionsView/GitHubActionsRunList.tsx @@ -2754,6 +2756,7 @@ msgstr "Bu terminaldeki istemleri tamamlayın. Bittiğinde kapanır." msgid "completed" msgstr "tamamlandı" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/utils/prStatus.ts msgid "Completed" msgstr "Tamamlandı" @@ -4568,6 +4571,7 @@ msgstr "Factory Droid" msgid "failed" msgstr "başarısız oldu" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/components/thread/ThreadGoalDock.tsx #: src/renderer/utils/prStatus.ts #: src/renderer/views/GitHubActionsView/GitHubActionsRunList.tsx diff --git a/src/renderer/locales/uk/messages.po b/src/renderer/locales/uk/messages.po index e9ffa5377..9ff856de8 100644 --- a/src/renderer/locales/uk/messages.po +++ b/src/renderer/locales/uk/messages.po @@ -1957,10 +1957,12 @@ msgstr "Скасувати видалення проєкту" msgid "Cancel workflow" msgstr "Скасувати робочий процес" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentToolCall.tsx #: src/renderer/components/thread/ChatPane/parts/items/WorkflowOverlayBody.tsx msgid "cancelled" msgstr "скасовано" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/components/thread/ThreadGoalDock.tsx #: src/renderer/utils/prStatus.ts #: src/renderer/views/GitHubActionsView/GitHubActionsRunList.tsx @@ -2754,6 +2756,7 @@ msgstr "Виконайте підказки в цьому терміналі. В msgid "completed" msgstr "завершено" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/utils/prStatus.ts msgid "Completed" msgstr "Завершено" @@ -4568,6 +4571,7 @@ msgstr "Factory Droid" msgid "failed" msgstr "не вдалося" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/components/thread/ThreadGoalDock.tsx #: src/renderer/utils/prStatus.ts #: src/renderer/views/GitHubActionsView/GitHubActionsRunList.tsx diff --git a/src/renderer/locales/vi/messages.po b/src/renderer/locales/vi/messages.po index f84243f8f..69125c4a6 100644 --- a/src/renderer/locales/vi/messages.po +++ b/src/renderer/locales/vi/messages.po @@ -1957,10 +1957,12 @@ msgstr "Hủy xóa dự án" msgid "Cancel workflow" msgstr "Hủy quy trình làm việc" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentToolCall.tsx #: src/renderer/components/thread/ChatPane/parts/items/WorkflowOverlayBody.tsx msgid "cancelled" msgstr "đã hủy" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/components/thread/ThreadGoalDock.tsx #: src/renderer/utils/prStatus.ts #: src/renderer/views/GitHubActionsView/GitHubActionsRunList.tsx @@ -2754,6 +2756,7 @@ msgstr "Hoàn thành các lời nhắc trong thiết bị đầu cuối này. Đ msgid "completed" msgstr "hoàn thành" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/utils/prStatus.ts msgid "Completed" msgstr "Đã hoàn tất" @@ -4568,6 +4571,7 @@ msgstr "Factory Droid" msgid "failed" msgstr "thất bại" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/components/thread/ThreadGoalDock.tsx #: src/renderer/utils/prStatus.ts #: src/renderer/views/GitHubActionsView/GitHubActionsRunList.tsx diff --git a/src/renderer/locales/zh-CN/messages.po b/src/renderer/locales/zh-CN/messages.po index 35e226b19..239d1f8f0 100644 --- a/src/renderer/locales/zh-CN/messages.po +++ b/src/renderer/locales/zh-CN/messages.po @@ -1957,10 +1957,12 @@ msgstr "取消移除项目" msgid "Cancel workflow" msgstr "取消工作流" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentToolCall.tsx #: src/renderer/components/thread/ChatPane/parts/items/WorkflowOverlayBody.tsx msgid "cancelled" msgstr "已取消" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/components/thread/ThreadGoalDock.tsx #: src/renderer/utils/prStatus.ts #: src/renderer/views/GitHubActionsView/GitHubActionsRunList.tsx @@ -2754,6 +2756,7 @@ msgstr "完成此终端中的提示。完成后关闭。" msgid "completed" msgstr "已完成" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/utils/prStatus.ts msgid "Completed" msgstr "已完成" @@ -4568,6 +4571,7 @@ msgstr "Factory Droid" msgid "failed" msgstr "失败了" +#: src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx #: src/renderer/components/thread/ThreadGoalDock.tsx #: src/renderer/utils/prStatus.ts #: src/renderer/views/GitHubActionsView/GitHubActionsRunList.tsx diff --git a/src/renderer/state/slices/runtimeEventSlice.test.ts b/src/renderer/state/slices/runtimeEventSlice.test.ts index 6eb674e94..5ca4965fd 100644 --- a/src/renderer/state/slices/runtimeEventSlice.test.ts +++ b/src/renderer/state/slices/runtimeEventSlice.test.ts @@ -503,6 +503,34 @@ describe("runtimeEventSlice.applyRuntimeEvent", () => { }); }); + it("marks a stale Crossagent terminal in both status fields", () => { + apply("t1", { + type: "item.started", + threadId: "t1", + itemId: "crossagent-tool", + itemType: "tool_call", + payload: { + name: "Crossagent", + status: "running", + isCrossagent: true, + crossagentStatus: "running", + }, + }); + + store.getState().reconcileStaleSubAgents("t1"); + + expect(store.getState().runtimeItemsByIdByThread["t1"]?.["crossagent-tool"]).toMatchObject({ + state: "completed", + payload: { + status: "error", + crossagentStatus: "failed", + result: { + error: "Interrupted: agent session ended before completion.", + }, + }, + }); + }); + it("does not force-complete stale Crossagents MCP calls tagged by older mappers", () => { apply("t1", { type: "item.started", diff --git a/src/renderer/state/slices/runtimeEventSlice.ts b/src/renderer/state/slices/runtimeEventSlice.ts index cfe59aa91..437e8be93 100644 --- a/src/renderer/state/slices/runtimeEventSlice.ts +++ b/src/renderer/state/slices/runtimeEventSlice.ts @@ -512,6 +512,10 @@ function terminateSubAgentItem(item: RuntimeChatItem): RuntimeChatItem { const nextPayload: ToolCallPayload = { ...payload, status: "error", + ...(payload.isCrossagent && + (payload.crossagentStatus === undefined || payload.crossagentStatus === "running") + ? { crossagentStatus: "failed" as const } + : {}), ...(payload.result === undefined ? { result: { error: i18n._(STALE_SUB_AGENT_ERROR_MESSAGE) } } : {}), diff --git a/src/shared/contracts/runtimeEvent.test.ts b/src/shared/contracts/runtimeEvent.test.ts index b23656d40..4f1a98a26 100644 --- a/src/shared/contracts/runtimeEvent.test.ts +++ b/src/shared/contracts/runtimeEvent.test.ts @@ -293,6 +293,19 @@ describe("item payload schemas", () => { isSubAgent: true, }; expect(toolCallPayloadSchema.parse(roundTrip(payload))).toEqual(payload); + expect( + toolCallPayloadSchema.parse({ + name: "Crossagent", + status: "error", + isCrossagent: true, + crossagentStatus: "cancelled", + }), + ).toEqual({ + name: "Crossagent", + status: "error", + isCrossagent: true, + crossagentStatus: "cancelled", + }); expect(toolCallPayloadSchema.safeParse({ name: "Bash" }).success).toBe(false); }); diff --git a/src/shared/contracts/runtimeEvent.ts b/src/shared/contracts/runtimeEvent.ts index a922036e9..ec41b132e 100644 --- a/src/shared/contracts/runtimeEvent.ts +++ b/src/shared/contracts/runtimeEvent.ts @@ -276,6 +276,7 @@ export const toolCallPayloadSchema = z.object({ */ subAgentType: z.string().min(1).optional(), isCrossagent: z.boolean().optional(), + crossagentStatus: z.enum(["running", "completed", "failed", "cancelled"]).optional(), workflow: toolCallWorkflowSchema.optional(), }); export type ToolCallPayload = z.infer; diff --git a/src/supervisor/agents/opencode/canonicalMapping/dispatch.ts b/src/supervisor/agents/opencode/canonicalMapping/dispatch.ts index 6cc63f9d3..e8093c366 100644 --- a/src/supervisor/agents/opencode/canonicalMapping/dispatch.ts +++ b/src/supervisor/agents/opencode/canonicalMapping/dispatch.ts @@ -48,7 +48,22 @@ function handlePart(state: OpenCodeMapperState, part: Part, events: RuntimeEvent // prompt text. OpenCode echoes the same text back as a TextPart on the // user message — emitting it as assistant text would mirror the prompt // into a phantom assistant bubble. - if (state.messageRoles.get(part.messageID) === "user") return; + if (state.messageRoles.get(part.messageID) === "user") { + const itemId = state.userItems.get(part.messageID); + if (!itemId || !state.nonOptimisticUserMessages.has(part.messageID)) return; + const textParts = state.userMessageTextParts.get(part.messageID) ?? new Map(); + textParts.set(part.id, part.text); + state.userMessageTextParts.set(part.messageID, textParts); + events.push({ + type: "item.updated", + threadId: state.threadId, + itemId, + payload: { + content: [...textParts.values()].map((text) => ({ kind: "text" as const, text })), + }, + }); + return; + } state.partTypes.set(part.id, "text"); const itemId = ensureAssistantItemForMessage(state, part.messageID, events); emitTextDelta(state, part.id, itemId, part.text, "assistant_text", events); @@ -167,7 +182,22 @@ function mapCanonicalEvent( return events; } case "message.part.removed": { - const { partID } = event.properties; + const { messageID, partID } = event.properties; + const userTextParts = state.userMessageTextParts.get(messageID); + if (userTextParts?.delete(partID)) { + if (userTextParts.size === 0) state.userMessageTextParts.delete(messageID); + const itemId = state.userItems.get(messageID); + if (itemId && state.nonOptimisticUserMessages.has(messageID)) { + events.push({ + type: "item.updated", + threadId: state.threadId, + itemId, + payload: { + content: [...userTextParts.values()].map((text) => ({ kind: "text" as const, text })), + }, + }); + } + } const tool = state.toolItems.get(partID); if (tool) { events.push({ @@ -196,6 +226,7 @@ function mapCanonicalEvent( // item.started would either create a phantom item (different id) or // be no-op'd by the per-id dedupe. Skip the emit either way. if (!optimistic) { + state.nonOptimisticUserMessages.add(info.id); events.push({ type: "item.started", threadId: state.threadId, @@ -257,6 +288,8 @@ function mapCanonicalEvent( events.push({ type: "item.completed", threadId: state.threadId, itemId: u }); state.userItems.delete(messageID); } + state.nonOptimisticUserMessages.delete(messageID); + state.userMessageTextParts.delete(messageID); return events; } case "permission.asked": { diff --git a/src/supervisor/agents/opencode/canonicalMapping/textItems.ts b/src/supervisor/agents/opencode/canonicalMapping/textItems.ts index 6ae281224..005e10571 100644 --- a/src/supervisor/agents/opencode/canonicalMapping/textItems.ts +++ b/src/supervisor/agents/opencode/canonicalMapping/textItems.ts @@ -136,6 +136,8 @@ export function closeOpenItems(state: OpenCodeMapperState): RuntimeEvent[] { events.push({ type: "item.completed", threadId: state.threadId, itemId }); } state.userItems.clear(); + state.nonOptimisticUserMessages.clear(); + state.userMessageTextParts.clear(); state.partTypes.clear(); state.emittedText.clear(); state.messageRoles.clear(); diff --git a/src/supervisor/agents/opencode/sdkCanonicalMapping.test.ts b/src/supervisor/agents/opencode/sdkCanonicalMapping.test.ts index a6bf50067..205fa9c91 100644 --- a/src/supervisor/agents/opencode/sdkCanonicalMapping.test.ts +++ b/src/supervisor/agents/opencode/sdkCanonicalMapping.test.ts @@ -97,6 +97,22 @@ function partUpdatedTextEvent(messageID: string, partID: string, text: string): }; } +function partRemovedEvent(messageID: string, partID: string): Event { + return { + id: "evt-" + Math.random().toString(36).slice(2), + type: "message.part.removed", + properties: { sessionID: "ses_test", messageID, partID }, + }; +} + +function messageRemovedEvent(messageID: string): Event { + return { + id: "evt-" + Math.random().toString(36).slice(2), + type: "message.removed", + properties: { sessionID: "ses_test", messageID }, + }; +} + describe("sdkCanonicalMapping — text streaming", () => { it("opens an assistant item on the first delta and emits content.delta", () => { const state = createOpenCodeMapperState("thread-1"); @@ -1272,6 +1288,54 @@ describe("sdkCanonicalMapping — user message dedup", () => { expect(events[0]).toMatchObject({ type: "item.started", itemType: "user_message" }); }); + it("fills a non-optimistic user row from the provider text part", () => { + const state = createOpenCodeMapperState("thread-1"); + const start = mapOpenCodeEvent(userMessageUpdatedEvent("msg_user_1"), state); + const itemId = start[0]?.type === "item.started" ? start[0].itemId : undefined; + + const events = mapOpenCodeEvent( + partUpdatedTextEvent("msg_user_1", "prt_user_1", "Inspect the renderer."), + state, + ); + + expect(events).toEqual([ + { + type: "item.updated", + threadId: "thread-1", + itemId, + payload: { content: [{ kind: "text", text: "Inspect the renderer." }] }, + }, + ]); + }); + + it("removes deleted text from a non-optimistic user row", () => { + const state = createOpenCodeMapperState("thread-1"); + const start = mapOpenCodeEvent(userMessageUpdatedEvent("msg_user_1"), state); + const itemId = start[0]?.type === "item.started" ? start[0].itemId : undefined; + mapOpenCodeEvent(partUpdatedTextEvent("msg_user_1", "prt_user_1", "First"), state); + mapOpenCodeEvent(partUpdatedTextEvent("msg_user_1", "prt_user_2", "Second"), state); + + expect(mapOpenCodeEvent(partRemovedEvent("msg_user_1", "prt_user_1"), state)).toEqual([ + { + type: "item.updated", + threadId: "thread-1", + itemId, + payload: { content: [{ kind: "text", text: "Second" }] }, + }, + ]); + }); + + it("releases non-optimistic user state when its message is removed", () => { + const state = createOpenCodeMapperState("thread-1"); + mapOpenCodeEvent(userMessageUpdatedEvent("msg_user_1"), state); + mapOpenCodeEvent(partUpdatedTextEvent("msg_user_1", "prt_user_1", "Inspect."), state); + + mapOpenCodeEvent(messageRemovedEvent("msg_user_1"), state); + + expect(state.nonOptimisticUserMessages.has("msg_user_1")).toBe(false); + expect(state.userMessageTextParts.has("msg_user_1")).toBe(false); + }); + it("skips text parts that belong to a known user message", () => { const state = createOpenCodeMapperState("thread-1"); state.pendingUserMessageItemIds.push("user-optimistic-1"); diff --git a/src/supervisor/agents/opencode/sdkCanonicalMappingState.ts b/src/supervisor/agents/opencode/sdkCanonicalMappingState.ts index 139f3fc03..82eb9462a 100644 --- a/src/supervisor/agents/opencode/sdkCanonicalMappingState.ts +++ b/src/supervisor/agents/opencode/sdkCanonicalMappingState.ts @@ -27,6 +27,10 @@ export interface OpenCodeMapperState { assistantItems: Map; /** Map UserMessage.id → canonical user item id. */ userItems: Map; + /** User messages created without a renderer-owned optimistic row. */ + nonOptimisticUserMessages: Set; + /** Text parts accumulated for a non-optimistic user message. */ + userMessageTextParts: Map>; /** Map reasoning Part.id → canonical reasoning item id + parent messageID. */ reasoningItems: Map; /** Map tool Part.id → { itemId, itemType }. */ @@ -97,6 +101,8 @@ export function createOpenCodeMapperState(threadId: string): OpenCodeMapperState threadId, assistantItems: new Map(), userItems: new Map(), + nonOptimisticUserMessages: new Set(), + userMessageTextParts: new Map(), reasoningItems: new Map(), toolItems: new Map(), partTypes: new Map(), diff --git a/src/supervisor/crossagentMcp/SubagentRunManager.test.ts b/src/supervisor/crossagentMcp/SubagentRunManager.test.ts index 1fb1f0460..14551c422 100644 --- a/src/supervisor/crossagentMcp/SubagentRunManager.test.ts +++ b/src/supervisor/crossagentMcp/SubagentRunManager.test.ts @@ -32,6 +32,8 @@ class FakeHandle implements StructuredSessionHandle { startTurns: Array<{ prompt: string; config: ThreadConfig }> = []; resolvedRequests: Array<{ requestId: string | number; response: unknown }> = []; + constructor(private readonly interruptError?: string) {} + setListener(listener: StructuredSessionListener): void { this.listener = listener; } @@ -40,6 +42,7 @@ class FakeHandle implements StructuredSessionHandle { } async interruptTurn(): Promise { this.interrupted = true; + if (this.interruptError) this.listener?.onError(this.interruptError); } async resolveServerRequest(requestId: string | number, response: unknown): Promise { this.resolvedRequests.push({ requestId, response }); @@ -82,6 +85,7 @@ function makeHarness(options?: { statusCapabilities?: AgentCapability | null; createFailures?: number; deferCreate?: boolean; + interruptError?: string; }): Harness { const handles: FakeHandle[] = []; const inputs: CreateStructuredSessionInput[] = []; @@ -119,7 +123,7 @@ function makeHarness(options?: { createFailures -= 1; throw new Error("session launch failed"); } - const handle = new FakeHandle(); + const handle = new FakeHandle(options?.interruptError); handles.push(handle); return handle; }, @@ -458,6 +462,27 @@ describe("SubagentRunManager", () => { expect(h.manager.getStatus(runId).status).toBe("cancelled"); }); + it("keeps explicit cancellation terminal when provider teardown reports Aborted", async () => { + const h = makeHarness({ interruptError: "Aborted" }); + const { runId } = h.manager.spawn(PARENT, { agent: "codex", prompt: "go" }); + await flush(); + + await h.manager.cancel(runId); + + expect(h.manager.getStatus(runId)).toMatchObject({ status: "cancelled", output: "" }); + const completion = h.appended + .map(({ event }) => event) + .find( + (event): event is Extract => + event.type === "item.completed" && event.itemId === `sub:${runId}`, + ); + expect(completion?.payload).toMatchObject({ + status: "error", + crossagentStatus: "cancelled", + }); + expect(completion?.payload).not.toHaveProperty("result"); + }); + it("cancelAllForThread cancels live children and evicts records", async () => { const h = makeHarness(); const { runId } = h.manager.spawn(PARENT, { agent: "codex", prompt: "go" }); diff --git a/src/supervisor/crossagentMcp/SubagentRunManager.ts b/src/supervisor/crossagentMcp/SubagentRunManager.ts index 047708bac..503277323 100644 --- a/src/supervisor/crossagentMcp/SubagentRunManager.ts +++ b/src/supervisor/crossagentMcp/SubagentRunManager.ts @@ -211,6 +211,7 @@ export class SubagentRunManager { name: firstAttempt.label, status: "running", isCrossagent: true, + crossagentStatus: "running", }; this.deps.host.appendRuntimeEvent(parentThreadId, { type: "item.started", @@ -294,8 +295,8 @@ export class SubagentRunManager { const record = this.ownedRun(runId, parentThreadId); if (!record) return; record.cancelRequested = true; + this.settle(record, "cancelled", undefined, { teardown: false }); await this.attemptRunner.teardown(record); - this.settle(record, "cancelled"); } /** @@ -609,7 +610,12 @@ export class SubagentRunManager { * the synthetic tile completion (which drains buffered child events in the * router), and release waiters. */ - private settle(record: RunRecord, status: SubagentRunStatus, errorMessage?: string): void { + private settle( + record: RunRecord, + status: SubagentRunStatus, + errorMessage?: string, + options?: { teardown?: boolean }, + ): void { if (record.settled) return; record.settled = true; if (record.status === "running") record.status = status; @@ -634,7 +640,7 @@ export class SubagentRunManager { } } - void this.attemptRunner.teardown(record); + if (options?.teardown !== false) void this.attemptRunner.teardown(record); const text = errorMessage ? `${record.output}\n${errorMessage}`.trim() : record.output; if (errorMessage) { @@ -650,6 +656,7 @@ export class SubagentRunManager { name: record.label, status: record.status === "completed" ? "success" : "error", isCrossagent: true, + crossagentStatus: record.status, ...(record.stepCount > 0 ? { progress: { stepCount: record.stepCount } } : {}), ...(text ? { result: text } : {}), };