diff --git a/src/main/db/projectsThreads.ts b/src/main/db/projectsThreads.ts index 6d7e84342..871b3b3f6 100644 --- a/src/main/db/projectsThreads.ts +++ b/src/main/db/projectsThreads.ts @@ -5,6 +5,7 @@ import { getDb } from "./connection"; import { forgetMainCreatedThread, noteMainCreatedThread } from "./mainCreatedThreads"; import { notifyProjectThreadDataChanged } from "./projectThreadChanges"; import { projectMutableRow, rowToProject, rowToThread } from "./rowMappers"; +import { dbInterruptStaleDelegatedAgents } from "./runtimeItems"; // ── Public query functions (called from IPC handlers) ─────────────── @@ -176,8 +177,8 @@ export function dbSetThreadGroup(threadId: string, groupId: string, groupName: s } /** - * No agent session survives a host restart, so any persisted live status - * ("launching"/"working"/...) is stale by definition once the process boots. + * No agent session survives a host restart, so persisted live thread status + * and running delegated-agent rows are stale once the process boots. * DB-level counterpart of the renderer's `markThreadsInactiveOnLaunch`; the * headless server calls it at startup since it has no renderer to self-heal. */ @@ -187,6 +188,7 @@ export function dbMarkLiveThreadsInactive(): void { .set({ status: "inactive", attention: "none", activeTurnStartedAt: null }) .where(notInArray(schema.threads.status, ["inactive", "error"])) .run(); + dbInterruptStaleDelegatedAgents(); notifyProjectThreadDataChanged(); } diff --git a/src/main/db/runtimeItems.test.ts b/src/main/db/runtimeItems.test.ts index 44cb2a40a..33fb8149f 100644 --- a/src/main/db/runtimeItems.test.ts +++ b/src/main/db/runtimeItems.test.ts @@ -12,6 +12,7 @@ import { dbGetLatestThreadRuntimeAnchorItemId, dbGetThreadRuntimeItems, dbGetThreadRuntimeItemsPage, + dbInterruptStaleDelegatedAgents, dbReplaceThreadRuntimeItems, dbTruncateThreadRuntimeAfter, } from "./runtimeItems"; @@ -131,6 +132,87 @@ describe.skipIf(!sqliteAvailable)("runtimeItems incremental persistence", () => }); }); + it("durably interrupts delegated agents left running by the previous app process", () => { + dbReplaceThreadRuntimeItems("thread-1", [ + { + id: "subagent-running", + type: "tool_call", + state: "updated", + payload: { name: "Task", status: "running", isSubAgent: true }, + streams: {}, + }, + { + id: "crossagent-running", + type: "tool_call", + state: "completed", + payload: { + name: "Crossagent", + status: "running", + isCrossagent: true, + crossagentStatus: "running", + }, + streams: {}, + }, + { + id: "raw-crossagents-mcp", + type: "tool_call", + state: "started", + payload: { + name: "mcp__crossagents__spawn_agent", + status: "running", + isSubAgent: true, + }, + streams: {}, + }, + { + id: "ordinary-tool", + type: "tool_call", + state: "started", + payload: { name: "Read", status: "running" }, + streams: {}, + }, + { + id: "subagent-complete", + type: "tool_call", + state: "completed", + payload: { name: "Task", status: "success", isSubAgent: true }, + streams: {}, + }, + ]); + + expect(dbInterruptStaleDelegatedAgents()).toBe(2); + expect(dbInterruptStaleDelegatedAgents()).toBe(0); + + const byId = new Map(dbGetThreadRuntimeItems("thread-1").map((item) => [item.id, item])); + expect(byId.get("subagent-running")).toMatchObject({ + state: "completed", + payload: { + status: "error", + result: { error: "Interrupted: agent session ended before completion." }, + }, + }); + expect(byId.get("crossagent-running")).toMatchObject({ + state: "completed", + payload: { + status: "error", + crossagentStatus: "failed", + result: { error: "Interrupted: agent session ended before completion." }, + }, + }); + expect(byId.get("raw-crossagents-mcp")).toMatchObject({ + state: "started", + payload: { status: "running" }, + }); + expect(byId.get("ordinary-tool")).toMatchObject({ + state: "started", + payload: { status: "running" }, + }); + expect(byId.get("subagent-complete")).toMatchObject({ + state: "completed", + payload: { status: "success" }, + }); + }); + it("deduplicates repeated item starts and removes empty completed reasoning", () => { const start = { type: "item.started" as const, diff --git a/src/main/db/runtimeItems.ts b/src/main/db/runtimeItems.ts index 4862348a6..65622dc41 100644 --- a/src/main/db/runtimeItems.ts +++ b/src/main/db/runtimeItems.ts @@ -2,8 +2,13 @@ import Database from "better-sqlite3"; import type { RuntimeEvent, ThreadContextUsage, ToolCallPayload } from "@/shared/contracts"; import { RUNTIME_REQUEST_ITEM_TYPE } from "@/shared/contracts"; import { inlineImagePayloadRenders } from "@/shared/inlineImagePayload"; +import { msg } from "@/shared/messages"; import type { PersistedRuntimePage } from "@/shared/ipc/schemas"; -import { isSubAgentTool } from "@/shared/toolCallClassification"; +import { + interruptDelegatedAgentToolPayload, + isDelegatedAgentTool, + isSubAgentTool, +} from "@/shared/toolCallClassification"; import { getSqlite } from "./connection"; import { safeParse } from "./rowMappers"; @@ -190,6 +195,54 @@ export function dbGetThreadRuntimeItems(threadId: string): PersistedRuntimeItem[ return rows.map(mapRuntimeItemRow); } +/** + * No delegated-agent process survives a host restart. Persist the same terminal + * state the renderer applies during hydration so remote snapshots cannot keep + * advertising pre-restart Subagents or Crossagents as live. + */ +export function dbInterruptStaleDelegatedAgents(): number { + const sqlite = getSqlite(); + const candidates = sqlite + .prepare( + `SELECT thread_id, item_id, state, payload + FROM thread_runtime_items + WHERE type = 'tool_call' + AND (state != 'completed' OR payload LIKE '%"status"%running%')`, + ) + .all() as Array<{ + thread_id: string; + item_id: string; + state: string; + payload: string | null; + }>; + const update = sqlite.prepare( + `UPDATE thread_runtime_items + SET state = 'completed', payload = ? + WHERE thread_id = ? AND item_id = ?`, + ); + + return sqlite.transaction(() => { + let interrupted = 0; + for (const candidate of candidates) { + const parsed = candidate.payload ? safeParse(candidate.payload) : undefined; + const payload = + parsed && typeof parsed === "object" ? (parsed as ToolCallPayload) : undefined; + if (!payload || !isDelegatedAgentTool(payload)) continue; + if (candidate.state === "completed" && payload.status !== "running") continue; + const nextPayload = interruptDelegatedAgentToolPayload( + payload, + msg("runtime.delegatedAgentInterrupted"), + ); + interrupted += update.run( + JSON.stringify(nextPayload), + candidate.thread_id, + candidate.item_id, + ).changes; + } + return interrupted; + })(); +} + /** * Reads one runtime item by id. Exists so the remote image endpoint can resolve * a single inline image without loading a whole thread's payloads — a diff --git a/src/main/main.ts b/src/main/main.ts index b7b0a5499..7bb9eaa8a 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -24,6 +24,7 @@ import { dbGetThreads, dbInsertScheduleRun, dbInterruptScheduleRuns, + dbMarkLiveThreadsInactive, dbUpdateScheduleRun, dbUpsertThread, initDatabase, @@ -703,6 +704,7 @@ if (!hasSingleInstanceLock) { } initDatabase(paths.dbPath); + dbMarkLiveThreadsInactive(); const secretStorageKey = readOrCreateSafeStorageSecretKey(paths.baseDir); // Configure the same key in main so it can seal captured secrets (e.g. usage // login cookies); the supervisor configures it from the env var it receives. diff --git a/src/mobile/remoteSocketCoordinator.test.ts b/src/mobile/remoteSocketCoordinator.test.ts index c3f1b656a..6fe3cf745 100644 --- a/src/mobile/remoteSocketCoordinator.test.ts +++ b/src/mobile/remoteSocketCoordinator.test.ts @@ -137,7 +137,12 @@ function createClient(): ClientMock { }; } -function createHarness(input: { readonly initialLastSeenSeq?: number } = {}): Harness { +function createHarness( + input: { + readonly initialLastSeenSeq?: number; + readonly onEventStreamReset?: () => void; + } = {}, +): Harness { const client = createClient(); let selectedThreadId: string | null = "selected"; const createClientMock = vi.fn<() => ClientMock>(() => client); @@ -153,6 +158,7 @@ function createHarness(input: { readonly initialLastSeenSeq?: number } = {}): Ha onConnectionChange, onMessageChange, onOpenChange, + ...(input.onEventStreamReset ? { onEventStreamReset: input.onEventStreamReset } : {}), getPairingExpiredMessage: () => "localized pairing fallback", }); return { @@ -355,7 +361,8 @@ describe("remoteSocketCoordinator", () => { }); it("resets a stale cursor after a server restart and accepts the new event stream", async () => { - const harness = track(createHarness({ initialLastSeenSeq: 42 })); + const onEventStreamReset = vi.fn<() => void>(); + const harness = track(createHarness({ initialLastSeenSeq: 42, onEventStreamReset })); const socket = await start(harness); socket.open(); @@ -365,6 +372,7 @@ describe("remoteSocketCoordinator", () => { reason: "Server event stream reset; request a fresh snapshot.", }); expect(harness.coordinator.getLastSeenSeq()).toBe(0); + expect(onEventStreamReset).toHaveBeenCalledOnce(); socket.message({ type: "event", diff --git a/src/mobile/remoteSocketCoordinator.ts b/src/mobile/remoteSocketCoordinator.ts index baa34b298..67191adbf 100644 --- a/src/mobile/remoteSocketCoordinator.ts +++ b/src/mobile/remoteSocketCoordinator.ts @@ -38,6 +38,7 @@ export interface RemoteSocketCoordinatorOptions { readonly onConnectionChange: (state: SocketConnectionState) => void; readonly onMessageChange: (message: string) => void; readonly onOpenChange: (open: boolean) => void; + readonly onEventStreamReset?: () => void; /** Recent HTTP success lets scheduleReconnect keep the pill "online" while * only the event socket is down. Optional; defaults to always-unhealthy. */ readonly isHttpHealthy?: () => boolean; @@ -256,6 +257,7 @@ export function createRemoteSocketCoordinator( // The server's in-memory sequence can move backwards after a // desktop restart. Reset immediately so events from the new // stream are not discarded while the recovery snapshot loads. + if (parsed.seq < lastSeenSeq) options.onEventStreamReset?.(); lastSeenSeq = parsed.seq; scheduleRefresh({ recovery: true, resetLastSeenSeq: true }); } diff --git a/src/mobile/storeSync.applyThreadSnapshot.test.tsx b/src/mobile/storeSync.applyThreadSnapshot.test.tsx index 612749fac..5bf2d33d5 100644 --- a/src/mobile/storeSync.applyThreadSnapshot.test.tsx +++ b/src/mobile/storeSync.applyThreadSnapshot.test.tsx @@ -197,6 +197,56 @@ describe("applyThreadSnapshot", () => { expect(assistantStreamText("msg-1")).toBe("newer live text"); }); + it("accepts terminal delegated-agent state without clobbering newer live text", () => { + const store = useAppStore.getState(); + store.applyRuntimeEvents(THREAD_ID, [ + { type: "item.started", threadId: THREAD_ID, itemId: "msg-1", itemType: "assistant_message" }, + { + type: "content.delta", + threadId: THREAD_ID, + itemId: "msg-1", + stream: "assistant_text", + delta: "newer live text", + }, + { + type: "item.started", + threadId: THREAD_ID, + itemId: "subagent-old", + itemType: "tool_call", + payload: { name: "Task", status: "running", isSubAgent: true }, + }, + ]); + + applyThreadSnapshot( + makeSnapshot({ + status: "working", + items: [ + makeItem({ id: "msg-1", assistantText: "older snapshot text" }), + { + id: "subagent-old", + type: "tool_call", + state: "completed", + payload: { + name: "Task", + status: "error", + isSubAgent: true, + result: { error: "Interrupted: agent session ended before completion." }, + }, + streams: {}, + }, + ], + }), + ); + + expect(assistantStreamText("msg-1")).toBe("newer live text"); + expect( + useAppStore.getState().runtimeItemsByIdByThread[THREAD_ID]?.["subagent-old"], + ).toMatchObject({ + state: "completed", + payload: { status: "error" }, + }); + }); + it("does not overwrite a newer live payload with an active recovery snapshot", () => { const store = useAppStore.getState(); store.applyRuntimeEvents(THREAD_ID, [ diff --git a/src/mobile/useRemoteDesktop.ts b/src/mobile/useRemoteDesktop.ts index 712ffbf9c..abe323a83 100644 --- a/src/mobile/useRemoteDesktop.ts +++ b/src/mobile/useRemoteDesktop.ts @@ -331,6 +331,10 @@ export function useRemoteDesktop() { onOpenChange: (open) => { socketOpenRef.current = open; }, + onEventStreamReset: () => { + const threadId = selectedThreadIdRef.current; + if (threadId) useAppStore.getState().clearThreadRuntimeEvents(threadId); + }, isHttpHealthy: () => Date.now() - lastRefreshOkAtRef.current < 45_000, getPairingExpiredMessage: () => i18n._(msg`Pairing expired — pair again to reconnect.`), }); diff --git a/src/renderer/i18n/sharedMessages.ts b/src/renderer/i18n/sharedMessages.ts index cf2dc4749..7c945b92d 100644 --- a/src/renderer/i18n/sharedMessages.ts +++ b/src/renderer/i18n/sharedMessages.ts @@ -175,6 +175,9 @@ const SHARED_MESSAGE_DESCRIPTORS: Record = { "supervisor.exited": msg({ message: "Background process exited unexpectedly" }), "supervisor.notRunning": msg({ message: "Background process is not running" }), "supervisor.proposedPlan": msg({ message: "Proposed plan" }), + "runtime.delegatedAgentInterrupted": msg({ + message: "Interrupted: agent session ended before completion.", + }), "kimi.credentialsLocked": msg({ message: "Kimi Code could not update its credentials because another process is using the credential file. Close other Poracode or Kimi Code processes, then retry.", diff --git a/src/renderer/locales/de/messages.po b/src/renderer/locales/de/messages.po index e43e301c8..0c4f3f7cb 100644 --- a/src/renderer/locales/de/messages.po +++ b/src/renderer/locales/de/messages.po @@ -4827,6 +4827,10 @@ msgstr "Dateien" msgid "Files for {0}" msgstr "Dateien für {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Files for {projectName}" +msgstr "Dateien für {projectName}" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Files matching these globs are hidden from the @file mention search." @@ -5864,7 +5868,7 @@ msgstr "Anweisungen, denen Agenten folgen, wenn sie auswählen, an welchen Agent msgid "Interrupted" msgstr "Unterbrochen" -#: src/renderer/state/slices/runtimeEventSlice.ts +#: src/renderer/i18n/sharedMessages.ts msgid "Interrupted: agent session ended before completion." msgstr "Unterbrochen: Agentensitzung wurde vorzeitig beendet." @@ -11391,6 +11395,10 @@ msgstr "Terminal" msgid "Terminal for {0}" msgstr "Terminal für {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Terminal for {projectName}" +msgstr "Terminal für {projectName}" + #: src/mobile/TerminalAccessory.tsx #: src/renderer/views/SettingsOverlay/parts/settingsOptions.ts msgid "Terminal input" diff --git a/src/renderer/locales/en/messages.po b/src/renderer/locales/en/messages.po index cadb8dba0..e24b850a0 100644 --- a/src/renderer/locales/en/messages.po +++ b/src/renderer/locales/en/messages.po @@ -4827,6 +4827,10 @@ msgstr "Files" msgid "Files for {0}" msgstr "Files for {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Files for {projectName}" +msgstr "Files for {projectName}" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Files matching these globs are hidden from the @file mention search." @@ -5864,7 +5868,7 @@ msgstr "Instructions agents follow when choosing which agent or model to delegat msgid "Interrupted" msgstr "Interrupted" -#: src/renderer/state/slices/runtimeEventSlice.ts +#: src/renderer/i18n/sharedMessages.ts msgid "Interrupted: agent session ended before completion." msgstr "Interrupted: agent session ended before completion." @@ -11391,6 +11395,10 @@ msgstr "Terminal" msgid "Terminal for {0}" msgstr "Terminal for {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Terminal for {projectName}" +msgstr "Terminal for {projectName}" + #: src/mobile/TerminalAccessory.tsx #: src/renderer/views/SettingsOverlay/parts/settingsOptions.ts msgid "Terminal input" diff --git a/src/renderer/locales/es/messages.po b/src/renderer/locales/es/messages.po index 2261774ea..9e59396a8 100644 --- a/src/renderer/locales/es/messages.po +++ b/src/renderer/locales/es/messages.po @@ -4827,6 +4827,10 @@ msgstr "Archivos" msgid "Files for {0}" msgstr "Archivos de {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Files for {projectName}" +msgstr "Archivos de {projectName}" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Files matching these globs are hidden from the @file mention search." @@ -5864,7 +5868,7 @@ msgstr "Instrucciones que siguen los agentes al elegir a qué agente o modelo de msgid "Interrupted" msgstr "Interrumpida" -#: src/renderer/state/slices/runtimeEventSlice.ts +#: src/renderer/i18n/sharedMessages.ts msgid "Interrupted: agent session ended before completion." msgstr "Interrumpido: la sesión del agente terminó antes de completarse." @@ -11391,6 +11395,10 @@ msgstr "Terminal" msgid "Terminal for {0}" msgstr "Terminal para {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Terminal for {projectName}" +msgstr "Terminal para {projectName}" + #: src/mobile/TerminalAccessory.tsx #: src/renderer/views/SettingsOverlay/parts/settingsOptions.ts msgid "Terminal input" diff --git a/src/renderer/locales/fr/messages.po b/src/renderer/locales/fr/messages.po index 9a4702294..9d78d82ee 100644 --- a/src/renderer/locales/fr/messages.po +++ b/src/renderer/locales/fr/messages.po @@ -4827,6 +4827,10 @@ msgstr "Fichiers" msgid "Files for {0}" msgstr "Fichiers pour {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Files for {projectName}" +msgstr "Fichiers pour {projectName}" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Files matching these globs are hidden from the @file mention search." @@ -5864,7 +5868,7 @@ msgstr "Instructions que les agents suivent pour choisir à quel agent ou modèl msgid "Interrupted" msgstr "Interrompue" -#: src/renderer/state/slices/runtimeEventSlice.ts +#: src/renderer/i18n/sharedMessages.ts msgid "Interrupted: agent session ended before completion." msgstr "Interrompu : la session de l'agent s'est terminée avant la fin." @@ -11390,6 +11394,10 @@ msgstr "Terminal" msgid "Terminal for {0}" msgstr "Terminal pour {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Terminal for {projectName}" +msgstr "Terminal pour {projectName}" + #: src/mobile/TerminalAccessory.tsx #: src/renderer/views/SettingsOverlay/parts/settingsOptions.ts msgid "Terminal input" diff --git a/src/renderer/locales/ja/messages.po b/src/renderer/locales/ja/messages.po index ac55883c6..f222c86f2 100644 --- a/src/renderer/locales/ja/messages.po +++ b/src/renderer/locales/ja/messages.po @@ -4826,6 +4826,10 @@ msgstr "ファイル" msgid "Files for {0}" msgstr "{0}のファイル" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Files for {projectName}" +msgstr "{projectName}のファイル" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Files matching these globs are hidden from the @file mention search." @@ -5863,7 +5867,7 @@ msgstr "どのエージェントやモデルに委任するかを選ぶ際に、 msgid "Interrupted" msgstr "中断" -#: src/renderer/state/slices/runtimeEventSlice.ts +#: src/renderer/i18n/sharedMessages.ts msgid "Interrupted: agent session ended before completion." msgstr "中断されました: エージェントセッションが完了前に終了しました。" @@ -11389,6 +11393,10 @@ msgstr "ターミナル" msgid "Terminal for {0}" msgstr "{0}用のターミナル" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Terminal for {projectName}" +msgstr "{projectName}用のターミナル" + #: src/mobile/TerminalAccessory.tsx #: src/renderer/views/SettingsOverlay/parts/settingsOptions.ts msgid "Terminal input" diff --git a/src/renderer/locales/ko/messages.po b/src/renderer/locales/ko/messages.po index 719d9831d..35f8d6d38 100644 --- a/src/renderer/locales/ko/messages.po +++ b/src/renderer/locales/ko/messages.po @@ -4827,6 +4827,10 @@ msgstr "파일" msgid "Files for {0}" msgstr "{0}에 대한 파일" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Files for {projectName}" +msgstr "{projectName}에 대한 파일" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Files matching these globs are hidden from the @file mention search." @@ -5864,7 +5868,7 @@ msgstr "에이전트가 어떤 에이전트나 모델에 위임할지 선택할 msgid "Interrupted" msgstr "중단됨" -#: src/renderer/state/slices/runtimeEventSlice.ts +#: src/renderer/i18n/sharedMessages.ts msgid "Interrupted: agent session ended before completion." msgstr "중단됨: 에이전트 세션이 완료되기 전에 종료되었습니다." @@ -11391,6 +11395,10 @@ msgstr "터미널" msgid "Terminal for {0}" msgstr "{0}용 터미널" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Terminal for {projectName}" +msgstr "{projectName}용 터미널" + #: src/mobile/TerminalAccessory.tsx #: src/renderer/views/SettingsOverlay/parts/settingsOptions.ts msgid "Terminal input" diff --git a/src/renderer/locales/pl/messages.po b/src/renderer/locales/pl/messages.po index 6e18ecd93..554858222 100644 --- a/src/renderer/locales/pl/messages.po +++ b/src/renderer/locales/pl/messages.po @@ -4827,6 +4827,10 @@ msgstr "Pliki" msgid "Files for {0}" msgstr "Pliki dla {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Files for {projectName}" +msgstr "Pliki dla {projectName}" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Files matching these globs are hidden from the @file mention search." @@ -5864,7 +5868,7 @@ msgstr "Instrukcje, którymi kierują się agenci przy wyborze agenta lub modelu msgid "Interrupted" msgstr "Przerwano" -#: src/renderer/state/slices/runtimeEventSlice.ts +#: src/renderer/i18n/sharedMessages.ts msgid "Interrupted: agent session ended before completion." msgstr "Przerwano: sesja agenta zakończyła się przed jej ukończeniem." @@ -11391,6 +11395,10 @@ msgstr "Terminal" msgid "Terminal for {0}" msgstr "Terminal dla {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Terminal for {projectName}" +msgstr "Terminal dla {projectName}" + #: src/mobile/TerminalAccessory.tsx #: src/renderer/views/SettingsOverlay/parts/settingsOptions.ts msgid "Terminal input" diff --git a/src/renderer/locales/pt-BR/messages.po b/src/renderer/locales/pt-BR/messages.po index 72fe82347..a387991e4 100644 --- a/src/renderer/locales/pt-BR/messages.po +++ b/src/renderer/locales/pt-BR/messages.po @@ -4827,6 +4827,10 @@ msgstr "Arquivos" msgid "Files for {0}" msgstr "Arquivos para {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Files for {projectName}" +msgstr "Arquivos para {projectName}" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Files matching these globs are hidden from the @file mention search." @@ -5864,7 +5868,7 @@ msgstr "Instruções que os agentes seguem ao escolher para qual agente ou model msgid "Interrupted" msgstr "Interrompida" -#: src/renderer/state/slices/runtimeEventSlice.ts +#: src/renderer/i18n/sharedMessages.ts msgid "Interrupted: agent session ended before completion." msgstr "Interrompida: a sessão do agente terminou antes da conclusão." @@ -11391,6 +11395,10 @@ msgstr "Terminal" msgid "Terminal for {0}" msgstr "Terminal para {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Terminal for {projectName}" +msgstr "Terminal para {projectName}" + #: src/mobile/TerminalAccessory.tsx #: src/renderer/views/SettingsOverlay/parts/settingsOptions.ts msgid "Terminal input" diff --git a/src/renderer/locales/ru/messages.po b/src/renderer/locales/ru/messages.po index 97a264410..fb22ae436 100644 --- a/src/renderer/locales/ru/messages.po +++ b/src/renderer/locales/ru/messages.po @@ -4827,6 +4827,10 @@ msgstr "Файлы" msgid "Files for {0}" msgstr "Файлы для {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Files for {projectName}" +msgstr "Файлы для {projectName}" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Files matching these globs are hidden from the @file mention search." @@ -5864,7 +5868,7 @@ msgstr "Инструкции, которым следуют агенты при msgid "Interrupted" msgstr "Прервано" -#: src/renderer/state/slices/runtimeEventSlice.ts +#: src/renderer/i18n/sharedMessages.ts msgid "Interrupted: agent session ended before completion." msgstr "Прервано: сессия агента завершилась до окончания." @@ -11391,6 +11395,10 @@ msgstr "Терминал" msgid "Terminal for {0}" msgstr "Терминал для {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Terminal for {projectName}" +msgstr "Терминал для {projectName}" + #: src/mobile/TerminalAccessory.tsx #: src/renderer/views/SettingsOverlay/parts/settingsOptions.ts msgid "Terminal input" diff --git a/src/renderer/locales/tr/messages.po b/src/renderer/locales/tr/messages.po index 8fa9aef05..6efaedc81 100644 --- a/src/renderer/locales/tr/messages.po +++ b/src/renderer/locales/tr/messages.po @@ -4827,6 +4827,10 @@ msgstr "Dosyalar" msgid "Files for {0}" msgstr "{0} için dosyalar" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Files for {projectName}" +msgstr "{projectName} için dosyalar" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Files matching these globs are hidden from the @file mention search." @@ -5864,7 +5868,7 @@ msgstr "Aracıların hangi aracıya veya modele devredeceğini seçerken izledi msgid "Interrupted" msgstr "Kesintiye uğradı" -#: src/renderer/state/slices/runtimeEventSlice.ts +#: src/renderer/i18n/sharedMessages.ts msgid "Interrupted: agent session ended before completion." msgstr "Kesintiye uğradı: Ajan oturumu tamamlanmadan sona erdi." @@ -11391,6 +11395,10 @@ msgstr "Terminal" msgid "Terminal for {0}" msgstr "{0} için terminal" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Terminal for {projectName}" +msgstr "{projectName} için terminal" + #: src/mobile/TerminalAccessory.tsx #: src/renderer/views/SettingsOverlay/parts/settingsOptions.ts msgid "Terminal input" diff --git a/src/renderer/locales/uk/messages.po b/src/renderer/locales/uk/messages.po index c00eada7c..c39174583 100644 --- a/src/renderer/locales/uk/messages.po +++ b/src/renderer/locales/uk/messages.po @@ -4827,6 +4827,10 @@ msgstr "Файли" msgid "Files for {0}" msgstr "Файли для {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Files for {projectName}" +msgstr "Файли для {projectName}" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Files matching these globs are hidden from the @file mention search." @@ -5864,7 +5868,7 @@ msgstr "Інструкції, яких дотримуються агенти, о msgid "Interrupted" msgstr "Перервано" -#: src/renderer/state/slices/runtimeEventSlice.ts +#: src/renderer/i18n/sharedMessages.ts msgid "Interrupted: agent session ended before completion." msgstr "Перервано: сесія агента завершилася до завершення." @@ -11391,6 +11395,10 @@ msgstr "Термінал" msgid "Terminal for {0}" msgstr "Термінал для {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Terminal for {projectName}" +msgstr "Термінал для {projectName}" + #: src/mobile/TerminalAccessory.tsx #: src/renderer/views/SettingsOverlay/parts/settingsOptions.ts msgid "Terminal input" diff --git a/src/renderer/locales/vi/messages.po b/src/renderer/locales/vi/messages.po index e2969944f..b03ce328c 100644 --- a/src/renderer/locales/vi/messages.po +++ b/src/renderer/locales/vi/messages.po @@ -4827,6 +4827,10 @@ msgstr "Tập tin" msgid "Files for {0}" msgstr "Tập tin cho {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Files for {projectName}" +msgstr "Tập tin cho {projectName}" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Files matching these globs are hidden from the @file mention search." @@ -5864,7 +5868,7 @@ msgstr "Hướng dẫn mà các tác nhân tuân theo khi chọn tác nhân ho msgid "Interrupted" msgstr "Bị gián đoạn" -#: src/renderer/state/slices/runtimeEventSlice.ts +#: src/renderer/i18n/sharedMessages.ts msgid "Interrupted: agent session ended before completion." msgstr "Bị gián đoạn: phiên tác nhân đã kết thúc trước khi hoàn thành." @@ -11391,6 +11395,10 @@ msgstr "Terminal" msgid "Terminal for {0}" msgstr "Thiết bị đầu cuối cho {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Terminal for {projectName}" +msgstr "Thiết bị đầu cuối cho {projectName}" + #: src/mobile/TerminalAccessory.tsx #: src/renderer/views/SettingsOverlay/parts/settingsOptions.ts msgid "Terminal input" diff --git a/src/renderer/locales/zh-CN/messages.po b/src/renderer/locales/zh-CN/messages.po index c52f21dab..0268e99e9 100644 --- a/src/renderer/locales/zh-CN/messages.po +++ b/src/renderer/locales/zh-CN/messages.po @@ -4827,6 +4827,10 @@ msgstr "文件" msgid "Files for {0}" msgstr "{0}的文件" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Files for {projectName}" +msgstr "{projectName}的文件" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Files matching these globs are hidden from the @file mention search." @@ -5864,7 +5868,7 @@ msgstr "代理在选择要委派给哪个代理或模型时遵循的说明。" msgid "Interrupted" msgstr "已中断" -#: src/renderer/state/slices/runtimeEventSlice.ts +#: src/renderer/i18n/sharedMessages.ts msgid "Interrupted: agent session ended before completion." msgstr "已中断:代理会话在完成之前结束。" @@ -11390,6 +11394,10 @@ msgstr "终端" msgid "Terminal for {0}" msgstr "{0}的终端" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Terminal for {projectName}" +msgstr "{projectName}的终端" + #: src/mobile/TerminalAccessory.tsx #: src/renderer/views/SettingsOverlay/parts/settingsOptions.ts msgid "Terminal input" diff --git a/src/renderer/state/remote/sync.ts b/src/renderer/state/remote/sync.ts index 6c03f9448..782e04a47 100644 --- a/src/renderer/state/remote/sync.ts +++ b/src/renderer/state/remote/sync.ts @@ -1,8 +1,14 @@ -import { isThreadTurnActive, type RuntimeEvent, type Thread } from "@/shared/contracts"; +import { + isThreadTurnActive, + type RuntimeEvent, + type Thread, + type ToolCallPayload, +} from "@/shared/contracts"; import type { SupervisorEvent } from "@/shared/ipc"; import type { GitStatePatch } from "@/shared/gitState"; import type { RemoteGitSummaries, RemoteThreadSnapshot } from "@/shared/remote"; import { remoteGitStateEventSchema, remoteGitSummariesEventSchema } from "@/shared/remote"; +import { isDelegatedAgentTool } from "@/shared/toolCallClassification"; import { useAppStore } from "@/renderer/state/appStore"; import { normalizeRuntimeSnapshotLaunchConfig } from "@/renderer/state/slices/threadSlice"; import { useAgentStatusesStore } from "@/renderer/state/agentStatusesStore"; @@ -128,6 +134,7 @@ export function applyThreadSnapshot( state.reconcileStaleSubAgents(threadId); } } else if (options.fromServer) { + mergeAuthoritativeDelegatedAgentTerminals(threadId, snapshotItems); mergeMissedOlderSnapshotItems(threadId, snapshotItems); } @@ -146,6 +153,59 @@ export function applyThreadSnapshot( syncRuntimeRequestsFromSnapshot(snapshot); } +/** + * A host restart durably terminates delegated runs from the prior process. + * Accept those terminal parent updates even when a still-active thread has + * newer streamed text that correctly prevents a wholesale snapshot replace. + */ +function mergeAuthoritativeDelegatedAgentTerminals( + threadId: string, + snapshotItems: readonly RuntimeChatItem[], +): void { + useAppStore.setState((current) => { + const existingItems = current.runtimeItemsByIdByThread[threadId]; + if (!existingItems) return {}; + let nextItems: Record | undefined; + for (const incoming of snapshotItems) { + const existing = existingItems[incoming.id]; + if (!existing || !isDelegatedAgentTerminalTransition(existing, incoming)) continue; + nextItems ??= { ...existingItems }; + nextItems[incoming.id] = incoming; + } + if (!nextItems) return {}; + return { + runtimeItemsByIdByThread: { + ...current.runtimeItemsByIdByThread, + [threadId]: nextItems, + }, + runtimeStructuralVersionByThread: { + ...current.runtimeStructuralVersionByThread, + [threadId]: (current.runtimeStructuralVersionByThread[threadId] ?? 0) + 1, + }, + }; + }); +} + +function isDelegatedAgentTerminalTransition( + existing: RuntimeChatItem, + incoming: RuntimeChatItem, +): boolean { + if (existing.type !== "tool_call" || incoming.type !== "tool_call") return false; + const existingPayload = existing.payload as ToolCallPayload | undefined; + const incomingPayload = incoming.payload as ToolCallPayload | undefined; + if ( + !existingPayload || + !incomingPayload || + !isDelegatedAgentTool(existingPayload) || + !isDelegatedAgentTool(incomingPayload) + ) { + return false; + } + const existingRunning = existing.state !== "completed" || existingPayload.status === "running"; + const incomingTerminal = incoming.state === "completed" && incomingPayload.status !== "running"; + return existingRunning && incomingTerminal; +} + /** * Live-first path: streamed items win over a same-or-shorter active snapshot, * but a fresh server history can still know about items emitted BEFORE this diff --git a/src/renderer/state/remoteServersStore.test.ts b/src/renderer/state/remoteServersStore.test.ts index 4918324f3..aca8f38d8 100644 --- a/src/renderer/state/remoteServersStore.test.ts +++ b/src/renderer/state/remoteServersStore.test.ts @@ -1467,6 +1467,36 @@ describe("useRemoteServersStore", () => { expect(useRemoteServersStore.getState().openThread).toBeNull(); }); + it("drops a stale local runtime projection before opening remote history", async () => { + useRemoteServersStore.getState().setClientFactory(factoryFor(makeClient())); + await pairIsolated(() => makeSocket()); + const projectedThreadId = remoteThreadId("d1", "rt-1"); + useAppStore.getState().applyRuntimeEvent(projectedThreadId, { + type: "item.started", + threadId: projectedThreadId, + itemId: "stale-crossagent", + itemType: "tool_call", + payload: { + name: "stale-crossagent", + status: "running", + isCrossagent: true, + }, + }); + + expect(useAppStore.getState().runtimeItemIdsByThread[projectedThreadId]).toEqual([ + "stale-crossagent", + ]); + + await useRemoteServersStore.getState().openRemoteThread("d1", "rt-1"); + + expect(useAppStore.getState().runtimeItemIdsByThread[projectedThreadId]).toBeUndefined(); + expect(sync.applyThreadSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + thread: expect.objectContaining({ id: projectedThreadId }), + }), + ); + }); + it("streams a remote terminal through the open-thread connection", async () => { const send = vi.fn<(data: string) => void>(); const socket: RemoteSocketLike = { @@ -1846,6 +1876,15 @@ describe("useRemoteServersStore", () => { threadId: remoteThreadId("d1", "rt-1"), }); + const projectedThreadId = remoteThreadId("d1", "rt-1"); + useAppStore.getState().applyRuntimeEvent(projectedThreadId, { + type: "item.started", + threadId: projectedThreadId, + itemId: "pre-restart-subagent", + itemType: "tool_call", + payload: { name: "Task", status: "running", isSubAgent: true }, + }); + sockets[1]?.onmessage?.({ data: JSON.stringify({ type: "resync-required", @@ -1853,6 +1892,7 @@ describe("useRemoteServersStore", () => { reason: "The remote server restarted.", }), }); + expect(useAppStore.getState().runtimeItemIdsByThread[projectedThreadId]).toBeUndefined(); sockets[1]?.onclose?.(); await vi.advanceTimersByTimeAsync(1000); await Promise.resolve(); diff --git a/src/renderer/state/remoteServersStore.ts b/src/renderer/state/remoteServersStore.ts index 833ca06f6..7b8faf04f 100644 --- a/src/renderer/state/remoteServersStore.ts +++ b/src/renderer/state/remoteServersStore.ts @@ -686,6 +686,15 @@ export const useRemoteServersStore = create()( // process. Accept its lower cursor before the authoritative // snapshots advance it again, or every reconnect will ask // for an impossible pre-restart sequence forever. + const previousSeq = remoteServerSnapshotSeqByDesktopId.get(server.desktopId) ?? 0; + if (message.seq < previousSeq) { + const open = get().openThread; + if (open?.desktopId === server.desktopId) { + useAppStore + .getState() + .clearThreadRuntimeEvents(remoteThreadId(server.desktopId, open.threadId)); + } + } remoteServerSnapshotSeqByDesktopId.set(server.desktopId, message.seq); get().scheduleServerRefresh(server.desktopId); void resyncOpenThread(); @@ -957,6 +966,16 @@ export const useRemoteServersStore = create()( ); return false; } + const viewThreadId = remoteThreadId(desktopId, threadId); + const currentOpen = get().openThread; + if (currentOpen?.desktopId !== desktopId || currentOpen.threadId !== threadId) { + // This store is only a projection of the remote transcript. Drop a + // prior visit before fetching so a shorter active-thread snapshot + // cannot leave stale Goal/Plan/Subagent/Crossagent rows behind. + // Live events received during the fetch repopulate the empty store + // and remain protected by applyThreadSnapshot's live-first merge. + useAppStore.getState().clearThreadRuntimeEvents(viewThreadId); + } // Hydrate the thread's history into the shared, threadId-keyed runtime // store so the desktop ChatPane renders it (coexists with local threads). // A failed history fetch (server asleep/unreachable) must not reject. @@ -970,7 +989,6 @@ export const useRemoteServersStore = create()( } if (requestSeq !== openRemoteThreadRequestSeq) return false; const projectedSnapshot = projectRemoteThreadSnapshot(desktopId, snapshot); - const viewThreadId = projectedSnapshot.thread.id; const firstSnapshotItemId = projectedSnapshot.runtimeItems[0]?.id; const existingRuntimeItemIds = useAppStore.getState().runtimeItemIdsByThread[viewThreadId] ?? []; diff --git a/src/renderer/state/slices/runtimeEventSlice.ts b/src/renderer/state/slices/runtimeEventSlice.ts index 437e8be93..9bc562539 100644 --- a/src/renderer/state/slices/runtimeEventSlice.ts +++ b/src/renderer/state/slices/runtimeEventSlice.ts @@ -1,4 +1,3 @@ -import { msg } from "@lingui/core/macro"; import type { CanonicalItemType, CanonicalRequestType, @@ -11,8 +10,11 @@ import type { ToolCallPayload, } from "@/shared/contracts"; import type { PersistedRuntimeItem } from "@/shared/ipc"; -import { isDelegatedAgentTool } from "@/shared/toolCallClassification"; -import { i18n } from "@/renderer/i18n/i18n"; +import { msg } from "@/shared/messages"; +import { + interruptDelegatedAgentToolPayload, + isDelegatedAgentTool, +} from "@/shared/toolCallClassification"; import type { SliceCreator } from "./shared"; import { applyRuntimeEventsToState, @@ -20,8 +22,6 @@ import { mergeCompletedTurns, } from "./runtimeEventReducer"; -const STALE_SUB_AGENT_ERROR_MESSAGE = msg`Interrupted: agent session ended before completion.`; - /** * Frozen "Worked for X" record for a turn that has finished. Persisted so the * chat can keep prior turn timings visible across reloads. The most recent @@ -509,20 +509,9 @@ function terminateSubAgentItem(item: RuntimeChatItem): RuntimeChatItem { name: "Task", status: "error", }; - 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) } } - : {}), - }; return { ...item, state: "completed", - payload: nextPayload, + payload: interruptDelegatedAgentToolPayload(payload, msg("runtime.delegatedAgentInterrupted")), }; } diff --git a/src/server/createHeadlessRemoteHost.test.ts b/src/server/createHeadlessRemoteHost.test.ts index 128a895ad..80a6c763c 100644 --- a/src/server/createHeadlessRemoteHost.test.ts +++ b/src/server/createHeadlessRemoteHost.test.ts @@ -13,6 +13,7 @@ const h = vi.hoisted(() => ({ supervisorDispose: vi.fn<() => void>(), supervisorCall: vi.fn<() => Promise>(async () => ({})), initDatabase: vi.fn<(dbPath: string) => void>(), + dbMarkLiveThreadsInactive: vi.fn<() => void>(), closeDatabase: vi.fn<() => void>(), projects: [] as unknown[], sharedSettings: { @@ -57,7 +58,7 @@ vi.mock("@/main/db", () => ({ dbFailRemoteCommand: vi.fn<() => void>(), dbReplaceThreadRuntimeSnapshot: vi.fn<() => void>(), dbUpsertThread: vi.fn<() => void>(), - dbMarkLiveThreadsInactive: vi.fn<() => void>(), + dbMarkLiveThreadsInactive: () => h.dbMarkLiveThreadsInactive(), dbDeleteThread: vi.fn<() => void>(), dbGetSchedules: vi.fn<() => unknown[]>(() => []), dbGetSchedule: vi.fn<() => unknown>(() => null), @@ -119,6 +120,7 @@ describe("createHeadlessRemoteHost", () => { h.supervisorStart.mockReset(); h.supervisorDispose.mockReset(); h.initDatabase.mockReset(); + h.dbMarkLiveThreadsInactive.mockReset(); h.closeDatabase.mockReset(); h.supervisorCall.mockReset(); h.supervisorCall.mockResolvedValue({}); @@ -135,6 +137,7 @@ describe("createHeadlessRemoteHost", () => { const info = await host.start(); expect(h.initDatabase).toHaveBeenCalledWith(join(h.tmpBase, "state.sqlite")); + expect(h.dbMarkLiveThreadsInactive).toHaveBeenCalledOnce(); expect(h.supervisorStart).toHaveBeenCalledWith(h.tmpBase); expect(info.httpBaseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/$/); expect(info.wsBaseUrl).toMatch(/^ws:\/\/127\.0\.0\.1:\d+\/$/); diff --git a/src/shared/messages.ts b/src/shared/messages.ts index 880c75daa..d01ca8462 100644 --- a/src/shared/messages.ts +++ b/src/shared/messages.ts @@ -119,6 +119,9 @@ const messages = { "supervisor.notRunning": "Background process is not running", "supervisor.proposedPlan": "Proposed plan", + // ── Runtime ─────────────────────────────────────────────── + "runtime.delegatedAgentInterrupted": "Interrupted: agent session ended before completion.", + // ── Kimi Code ───────────────────────────────────────────── "kimi.credentialsLocked": "Kimi Code could not update its credentials because another process is using the credential file. Close other Poracode or Kimi Code processes, then retry.", diff --git a/src/shared/toolCallClassification.ts b/src/shared/toolCallClassification.ts index a3520cee5..4b1bbee7e 100644 --- a/src/shared/toolCallClassification.ts +++ b/src/shared/toolCallClassification.ts @@ -78,3 +78,18 @@ export function isCrossagentSpawnAgentTool(payload: ToolCallPayload | undefined) export function isDelegatedAgentTool(payload: ToolCallPayload | undefined): boolean { return payload?.isCrossagent === true ? isCrossagentTool(payload) : isSubAgentTool(payload); } + +export function interruptDelegatedAgentToolPayload( + payload: ToolCallPayload, + errorMessage: string, +): ToolCallPayload { + return { + ...payload, + status: "error", + ...(payload.isCrossagent && + (payload.crossagentStatus === undefined || payload.crossagentStatus === "running") + ? { crossagentStatus: "failed" as const } + : {}), + ...(payload.result === undefined ? { result: { error: errorMessage } } : {}), + }; +}