From 9cfefdedd4e76f72cdc8423ef1a47dc527046a53 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 15 Jun 2026 04:00:19 +0200 Subject: [PATCH] fix(coding-agent/modes): allowed /tan dispatch to queue during streaming turns - Removed the streaming guard that previously rejected /tan while the parent response was still generating. - Passed "deliverAs: \"nextTurn\"" when sending the background dispatch breadcrumb and kept "triggerTurn: false" so an in-flight turn is not steered. - Skipped rebuilding chat messages during streaming sessions and updated tests to cover the non-blocking dispatch path. --- packages/coding-agent/CHANGELOG.md | 3 + .../src/modes/components/agent-hub.ts | 6 ++ .../components/background-tan-message.ts | 36 +++++++++++ .../controllers/tan-command-controller.ts | 38 +++++++---- .../src/modes/utils/ui-helpers.ts | 6 ++ packages/coding-agent/src/session/messages.ts | 9 +++ .../src/session/session-manager.ts | 9 ++- .../tan-command-controller.test.ts | 63 ++++++++++++++++--- 8 files changed, 150 insertions(+), 20 deletions(-) create mode 100644 packages/coding-agent/src/modes/components/background-tan-message.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f1e336606..8d87d87d2 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,6 +13,9 @@ ### Fixed +- Fixed `/tan` refusing to launch while the main response was still streaming. The command exists to fork tangential work *alongside* an active session, so it now dispatches mid-stream and queues its handoff breadcrumb for the next turn instead of steering the in-flight one. +- Fixed `/tan` background agents never staying in the Agent Hub. The forked clone now uses an `.jsonl` session file (so the persisted-subagent scan keys it by the same id the live ref uses) and is parked rather than unregistered on completion, so it stays listed and its transcript stays readable. +- Fixed the `/tan` dispatch breadcrumb rendering its full raw `` block in the transcript. It now shows a single compact line (`Tangent dispatched [task] — `), styled as a sibling of the "Background job completed" line. - Fixed profile bootstrap parsing so built-in string flags like `--plan` no longer consume the profile-boundary marker and drop the trailing user message - Fixed a bug where goal mode was incorrectly deactivated/set to 'none' on every wall-clock-only update (when tokenDelta <= 0) during tool execution flushes, preventing OMP from writing a mode change to 'none' in the session history database while keeping in-memory/UI state fresh. - Fixed a bug where Gemini MALFORMED_FUNCTION_CALL tool-generation errors (which are transient) surfaced as terminal error blocks. Added "malformed function call" to the transient transport error classifier so the session automatically retries the turn. diff --git a/packages/coding-agent/src/modes/components/agent-hub.ts b/packages/coding-agent/src/modes/components/agent-hub.ts index 7e55064a1..9f9ec02fa 100644 --- a/packages/coding-agent/src/modes/components/agent-hub.ts +++ b/packages/coding-agent/src/modes/components/agent-hub.ts @@ -28,6 +28,7 @@ import { AgentLifecycleManager } from "../../registry/agent-lifecycle"; import { type AgentRef, AgentRegistry, type AgentStatus, MAIN_AGENT_ID } from "../../registry/agent-registry"; import type { AgentSession } from "../../session/agent-session"; import { + BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE, type CustomMessage, isSilentAbort, LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE, @@ -45,6 +46,7 @@ import type { ObservableSession, SessionObserverRegistry } from "../session-obse import { getEditorTheme, theme } from "../theme/theme"; import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers"; import { AssistantMessageComponent } from "./assistant-message"; +import { createBackgroundTanDispatchBlock } from "./background-tan-message"; import { BashExecutionComponent } from "./bash-execution"; import { BranchSummaryMessageComponent } from "./branch-summary-message"; import { CollabPromptMessageComponent } from "./collab-prompt-message"; @@ -1215,6 +1217,10 @@ export class AgentHubOverlayComponent extends Container { this.#chatLog.addChild(card); return; } + if (message.customType === BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE) { + this.#chatLog.addChild(createBackgroundTanDispatchBlock(message as CustomMessage)); + return; + } const handoffComponent = createHandoffSummaryMessageComponent( message as CustomMessage, this.#chatExpanded, diff --git a/packages/coding-agent/src/modes/components/background-tan-message.ts b/packages/coding-agent/src/modes/components/background-tan-message.ts new file mode 100644 index 000000000..fb2436b79 --- /dev/null +++ b/packages/coding-agent/src/modes/components/background-tan-message.ts @@ -0,0 +1,36 @@ +import { Text } from "@oh-my-pi/pi-tui"; +import type { BackgroundTanDispatchDetails, CustomMessage } from "../../session/messages"; +import { replaceTabs } from "../../tools/render-utils"; +import { theme } from "../theme/theme"; +import { TranscriptBlock } from "./transcript-container"; + +const TAN_WORK_PREVIEW_LENGTH = 56; + +function previewWork(work: string): string { + const singleLine = replaceTabs(work).trim().replace(/\s+/g, " "); + if (singleLine.length <= TAN_WORK_PREVIEW_LENGTH) return singleLine; + return `${singleLine.slice(0, TAN_WORK_PREVIEW_LENGTH - 1)}…`; +} + +/** + * Single-line transcript pill for a `/tan` background-dispatch breadcrumb, + * styled as a sibling of the "Background job completed" line. The full + * system-notice content (the persisted `content`) is for the model only — the + * user sees one compact line, not the raw `` block. + */ +export function createBackgroundTanDispatchBlock(message: CustomMessage): TranscriptBlock { + const details = (message as CustomMessage>).details; + const jobId = details?.jobId ?? "unknown"; + const work = details?.work ? previewWork(details.work) : undefined; + const line = [ + theme.fg("muted", `${theme.icon.output} Tangent dispatched`), + theme.fg("dim", "[task]"), + theme.fg("accent", jobId), + work ? theme.fg("dim", `${theme.format.dash} ${work}`) : undefined, + ] + .filter(Boolean) + .join(" "); + const block = new TranscriptBlock(); + block.addChild(new Text(line, 1, 0)); + return block; +} diff --git a/packages/coding-agent/src/modes/controllers/tan-command-controller.ts b/packages/coding-agent/src/modes/controllers/tan-command-controller.ts index b9d71b71d..d641c4834 100644 --- a/packages/coding-agent/src/modes/controllers/tan-command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/tan-command-controller.ts @@ -1,10 +1,12 @@ import * as fs from "node:fs/promises"; +import * as path from "node:path"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; import { prompt, Snowflake } from "@oh-my-pi/pi-utils"; import backgroundTanDispatchPrompt from "../../prompts/system/background-tan-dispatch.md" with { type: "text" }; import { AgentRegistry, MAIN_AGENT_ID } from "../../registry/agent-registry"; import * as sdk from "../../sdk"; import type { AgentSession } from "../../session/agent-session"; +import { BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE } from "../../session/messages"; import { SessionManager } from "../../session/session-manager"; import { createMCPProxyTools, createSubagentSettings } from "../../task/executor"; import type { InteractiveModeContext } from "../types"; @@ -44,10 +46,6 @@ export class TanCommandController { } const session = this.ctx.session; - if (session.isStreaming) { - this.ctx.showWarning("Wait for the current response to finish or abort it before using /tan."); - return; - } const model = session.model; if (!model) { @@ -84,19 +82,18 @@ export class TanCommandController { const enableLsp = this.ctx.settings.get("task.enableLsp") !== false; const agentRegistry = AgentRegistry.global(); const cloneId = `Tan-${Snowflake.next()}`; + const cloneFile = path.join(sessionDir, `${cloneId}.jsonl`); const label = `/tan ${previewWork(trimmedWork)}`; await this.ctx.sessionManager.ensureOnDisk(); await this.ctx.sessionManager.flush(); - let cloneFile = ""; let jobId = ""; try { const cloneManager = await SessionManager.forkFrom(parentFile, cwd, sessionDir, undefined, { suppressBreadcrumb: true, + sessionFile: cloneFile, }); - cloneFile = cloneManager.getSessionFile() ?? ""; - if (!cloneFile) throw new Error("Forked session did not create a session file."); jobId = manager.register( "task", @@ -145,7 +142,21 @@ export class TanCommandController { signal.removeEventListener("abort", abortClone); } } finally { - await clone?.dispose(); + // Keep the finished tan in the Agent Hub instead of unregistering it: + // flip the ref to parked BEFORE dispose so the sdk dispose wrapper + // skips its unregister, then null the disposed session so the hub + // treats it as a transcript-only parked agent. An aborted tan is + // terminal — let dispose unregister it. + if (clone) { + if (signal.aborted) { + agentRegistry.setStatus(cloneId, "aborted"); + await clone.dispose(); + } else { + agentRegistry.setStatus(cloneId, "parked"); + await clone.dispose(); + agentRegistry.detachSession(cloneId); + } + } } }, { ownerId }, @@ -157,17 +168,22 @@ export class TanCommandController { } const content = prompt.render(backgroundTanDispatchPrompt, { jobId, work: trimmedWork }); + // /tan is meant to run alongside an active session. While the parent turn is + // still streaming, queue the dispatch breadcrumb for the next turn rather than + // steering the in-flight response; when idle this same call appends + persists + // the entry immediately (identical to omitting deliverAs). + const wasStreaming = session.isStreaming; await session.sendCustomMessage( { - customType: "background-tan-dispatch", + customType: BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE, content, display: true, attribution: "user", details: { jobId, work: trimmedWork, sessionFile: cloneFile }, }, - { triggerTurn: false }, + { triggerTurn: false, deliverAs: "nextTurn" }, ); - this.ctx.rebuildChatFromMessages(); + if (!wasStreaming) this.ctx.rebuildChatFromMessages(); this.ctx.showStatus(`Dispatched background tan ${jobId}`); } } diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts index 21f3003e2..92eb02bef 100644 --- a/packages/coding-agent/src/modes/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts @@ -5,6 +5,7 @@ import { COLLAB_PROMPT_MESSAGE_TYPE, type CollabPromptDetails } from "../../coll import { settings } from "../../config/settings"; import { getFileSnapshotStore } from "../../edit/file-snapshot-store"; import { AssistantMessageComponent } from "../../modes/components/assistant-message"; +import { createBackgroundTanDispatchBlock } from "../../modes/components/background-tan-message"; import { BashExecutionComponent } from "../../modes/components/bash-execution"; import { BranchSummaryMessageComponent } from "../../modes/components/branch-summary-message"; import { CollabPromptMessageComponent } from "../../modes/components/collab-prompt-message"; @@ -33,6 +34,7 @@ import { materializeImageReferenceLinksSync } from "../../modes/image-references import { theme } from "../../modes/theme/theme"; import type { CompactionQueuedMessage, InteractiveModeContext } from "../../modes/types"; import { + BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE, type CustomMessage, isSilentAbort, LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE, @@ -238,6 +240,10 @@ export class UiHelpers { this.ctx.chatContainer.addChild(card); return [card]; } + if (message.customType === BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE) { + this.ctx.chatContainer.addChild(createBackgroundTanDispatchBlock(message as CustomMessage)); + break; + } const handoffComponent = createHandoffSummaryMessageComponent( message as CustomMessage, this.ctx.toolOutputExpanded, diff --git a/packages/coding-agent/src/session/messages.ts b/packages/coding-agent/src/session/messages.ts index 73140b219..ff07ec42f 100644 --- a/packages/coding-agent/src/session/messages.ts +++ b/packages/coding-agent/src/session/messages.ts @@ -34,6 +34,15 @@ import { formatOutputNotice } from "../tools/output-meta"; export const SKILL_PROMPT_MESSAGE_TYPE = "skill-prompt"; export const LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE = "lsp-late-diagnostic"; +export const BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE = "background-tan-dispatch"; + +/** Details persisted on a `/tan` background-dispatch breadcrumb. */ +export interface BackgroundTanDispatchDetails { + jobId: string; + work: string; + /** Forked clone session file, named `.jsonl`; the Agent Hub reads its transcript. */ + sessionFile: string; +} export interface SkillPromptDetails { name: string; diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index c919d90f9..f32055023 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -1483,13 +1483,18 @@ export class SessionManager { /** * Fork a session into the current project directory: copy history from another * session file while creating a fresh session file in this sessionDir. + * + * `options.sessionFile` pins the new session's file path (default: an + * auto-named `_.jsonl` in `sessionDir`). Callers that register + * the fork as a named agent (e.g. `/tan`) pass `.jsonl` so the + * persisted-subagent scan keys the agent by the same id the live ref uses. */ static async forkFrom( sourcePath: string, cwd: string, sessionDir?: string, storage: SessionStorage = new FileSessionStorage(), - options?: { suppressBreadcrumb?: boolean }, + options?: { suppressBreadcrumb?: boolean; sessionFile?: string }, ): Promise { const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage); const manager = new SessionManager(cwd, dir, true, storage); @@ -1501,7 +1506,7 @@ export class SessionManager { const sourceHeader = sourceEntries.find(entry => entry.type === "session") as SessionHeader | undefined; const history = sourceEntries.filter(entry => entry.type !== "session") as SessionEntry[]; - manager.#resetToNewSession({ parentSession: sourceHeader?.id }); + manager.#resetToNewSession({ parentSession: sourceHeader?.id }, options?.sessionFile); manager.#header.title = sourceHeader?.title; manager.#header.titleSource = sourceHeader?.titleSource; manager.#sessionName = manager.#header.title; diff --git a/packages/coding-agent/test/modes/controllers/tan-command-controller.test.ts b/packages/coding-agent/test/modes/controllers/tan-command-controller.test.ts index ee41a1887..1a3fa8199 100644 --- a/packages/coding-agent/test/modes/controllers/tan-command-controller.test.ts +++ b/packages/coding-agent/test/modes/controllers/tan-command-controller.test.ts @@ -5,7 +5,7 @@ import type { AsyncJobRegisterOptions } from "@oh-my-pi/pi-coding-agent/async/jo import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { TanCommandController } from "@oh-my-pi/pi-coding-agent/modes/controllers/tan-command-controller"; import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; -import { MAIN_AGENT_ID } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; +import { AgentRegistry, MAIN_AGENT_ID } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; import type { CreateAgentSessionResult } from "@oh-my-pi/pi-coding-agent/sdk"; import * as sdkModule from "@oh-my-pi/pi-coding-agent/sdk"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; @@ -128,15 +128,23 @@ describe("TanCommandController", () => { expect(harness.ctx.showStatus).toHaveBeenCalledWith("Usage: /tan "); }); - it("rejects while the parent session is streaming", async () => { + it("dispatches without disturbing an in-flight turn while streaming", async () => { const harness = createContext({ isStreaming: true }); const forkSpy = vi.spyOn(SessionManager, "forkFrom").mockResolvedValue(harness.cloneManager); const controller = new TanCommandController(harness.ctx); await controller.start("check something"); - expect(forkSpy).not.toHaveBeenCalled(); - expect(harness.ctx.showWarning).toHaveBeenCalled(); + expect(forkSpy).toHaveBeenCalled(); + expect(harness.ctx.showWarning).not.toHaveBeenCalled(); + // The breadcrumb is queued for the next turn, not steered into the live one, + // and the live chat is left to the streaming renderer (no synchronous rebuild). + expect(harness.ctx.session.sendCustomMessage).toHaveBeenCalledWith( + expect.objectContaining({ customType: "background-tan-dispatch" }), + { triggerTurn: false, deliverAs: "nextTurn" }, + ); + expect(harness.ctx.rebuildChatFromMessages).not.toHaveBeenCalled(); + expect(harness.ctx.showStatus).toHaveBeenCalledWith("Dispatched background tan job-123"); }); it("forks with breadcrumb suppression, registers under Main, and dispatches after receiving the job id", async () => { @@ -151,7 +159,7 @@ describe("TanCommandController", () => { harness.tempDir.path(), harness.parentFile.slice(0, -6), undefined, - { suppressBreadcrumb: true }, + { suppressBreadcrumb: true, sessionFile: expect.stringMatching(/Tan-.+\.jsonl$/) }, ); expect(harness.register).toHaveBeenCalledWith("task", "/tan write the release note", expect.any(Function), { ownerId: MAIN_AGENT_ID, @@ -161,9 +169,13 @@ describe("TanCommandController", () => { expect(harness.ctx.session.sendCustomMessage).toHaveBeenCalledWith( expect.objectContaining({ customType: "background-tan-dispatch", - details: { jobId: "job-123", work: "write the release note", sessionFile: harness.cloneFile }, + details: { + jobId: "job-123", + work: "write the release note", + sessionFile: expect.stringMatching(/Tan-.+\.jsonl$/), + }, }), - { triggerTurn: false }, + { triggerTurn: false, deliverAs: "nextTurn" }, ); expect(harness.ctx.rebuildChatFromMessages).toHaveBeenCalled(); expect(harness.ctx.showStatus).toHaveBeenCalledWith("Dispatched background tan job-123"); @@ -216,4 +228,41 @@ describe("TanCommandController", () => { }), ); }); + + it("parks the finished tan in the registry so it stays visible in the Agent Hub", async () => { + const harness = createContext(); + vi.spyOn(SessionManager, "forkFrom").mockResolvedValue(harness.cloneManager); + const clone = { + prompt: vi.fn(async () => {}), + waitForIdle: vi.fn(async () => {}), + getLastAssistantMessage: vi.fn(() => assistantText("done")), + abort: vi.fn(), + dispose: vi.fn(async () => {}), + }; + vi.spyOn(sdkModule, "createAgentSession").mockResolvedValue({ + session: clone, + } as unknown as CreateAgentSessionResult); + const registry = AgentRegistry.global(); + const setStatus = vi.spyOn(registry, "setStatus"); + const detachSession = vi.spyOn(registry, "detachSession"); + const unregister = vi.spyOn(registry, "unregister"); + const controller = new TanCommandController(harness.ctx); + + await controller.start("park me"); + const run = harness.capturedRun; + if (!run) throw new Error("run function was not captured"); + const result = await run({ + jobId: "job-123", + signal: new AbortController().signal, + reportProgress: async () => {}, + }); + + expect(result).toBe("done"); + // Parked (not unregistered) before dispose, then the disposed session is nulled + // out — the hub keeps the ref and reads its transcript from the session file. + expect(setStatus).toHaveBeenCalledWith(expect.stringMatching(/^Tan-/), "parked"); + expect(detachSession).toHaveBeenCalledWith(expect.stringMatching(/^Tan-/)); + expect(clone.dispose).toHaveBeenCalled(); + expect(unregister).not.toHaveBeenCalled(); + }); });