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 } : {}),
};