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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user