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.
This commit is contained in:
can1357
2026-06-15 04:00:19 +02:00
parent ca28fab3aa
commit 9cfefdedd4
8 changed files with 150 additions and 20 deletions
+3
View File
@@ -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 `<agentId>.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 `<system-notice>` block in the transcript. It now shows a single compact line (`Tangent dispatched [task] <jobId> — <work>`), 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.
@@ -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<unknown>));
return;
}
const handoffComponent = createHandoffSummaryMessageComponent(
message as CustomMessage<unknown>,
this.#chatExpanded,
@@ -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 `<system-notice>` block.
*/
export function createBackgroundTanDispatchBlock(message: CustomMessage<unknown>): TranscriptBlock {
const details = (message as CustomMessage<Partial<BackgroundTanDispatchDetails>>).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;
}
@@ -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}`);
}
}
@@ -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<unknown>));
break;
}
const handoffComponent = createHandoffSummaryMessageComponent(
message as CustomMessage<unknown>,
this.ctx.toolOutputExpanded,
@@ -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 `<agentId>.jsonl`; the Agent Hub reads its transcript. */
sessionFile: string;
}
export interface SkillPromptDetails {
name: string;
@@ -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 `<timestamp>_<id>.jsonl` in `sessionDir`). Callers that register
* the fork as a named agent (e.g. `/tan`) pass `<agentId>.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<SessionManager> {
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;
@@ -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 <work>");
});
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();
});
});