diff --git a/biome.json b/biome.json index f4361aeed..bd20dcfb1 100644 --- a/biome.json +++ b/biome.json @@ -9,7 +9,7 @@ "enabled": true, "includes": ["**"], "rules": { - "recommended": true, + "preset": "recommended", "a11y": "off", "correctness": { "noUnusedImports": "error", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 337e755da..ecc146865 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -31,6 +31,8 @@ - Fixed pressing Esc (or Alt+Up dequeue) while agent-authored messages were queued — advisor concern/blocker notes, hidden goal/plan/budget steers, IRC/extension asides — dumping their text into the user's editor. Editor restoration (`clearQueue()`), pending chips (`getQueuedMessages()`), and `popLastQueuedMessage()` now surface only genuinely user-authored queued messages (plain user turns and `attribution: "user"` custom messages like `/skill`). Plain Alt+Up dequeue leaves all other queued messages in place for the continuing stream; only the Esc interrupt path keeps just advisor cards (so abort's preservation still re-records them as visible advice) and drops other internal steers, so a user interrupt can't be silently undone by an auto-resume on leftover internal context. `queuedMessageCount` still reflects all actual queued work (advisor cards included) so `hasPendingMessages()`/RPC and the empty-submit abort gate stay accurate. - Fixed advisor `concern`/`blocker` advice being withheld from the running agent and then dumped as one burst at the next user prompt after a deliberate interrupt. A user interrupt latches advisor auto-resume suppression, but a non-user resume (synthetic/auto-continue, or a queued steer draining after the abort) leaves the run streaming with that latch still set, so every interrupting note was parked hidden in the next-turn queue instead of steered into the live turn — the agent never heard the advisor mid-run and the backlog flushed all at once on the next prompt. Suppression now only withholds interrupting advice while the agent is idle (or still tearing the interrupted turn down); once a turn is streaming again the note is steered in live, since steering an active run never auto-resumes a stopped one. - Fixed `omp --continue`/`-c` sometimes resuming into a subagent transcript instead of the interactive session. Subagent (and HTML-export) `SessionManager.open()` calls run in the parent's terminal and were clobbering the per-TTY `--continue` breadcrumb with their own artifact-dir session file; these headless opens now suppress the breadcrumb. `continueRecent()` also recovers already-poisoned breadcrumbs by resolving any session file inside a parent's artifacts dir (`/.jsonl`) back up to the top-level session. +- Fixed the Agent Hub stacking duplicate `Agent Hub · N running` frames and stranding garbage rows in scrollback while navigating with subagents still streaming. The hub was a non-fullscreen overlay composited over a live transcript, so each time a running subagent's progress grew the frame and scrolled the window the previously-painted hub copy was pushed permanently into the terminal's native scrollback (which the engine can't rewrite). It now renders inline in the editor slot — the same anchored region every other selector and the `ask` tool use — riding the normal append-only commit path, so the transcript commits above it exactly once and the hub repaints in place instead of leaking copies. (Avoids borrowing the alternate screen.) +- Fixed every subagent registering itself as its own parent in the agent registry (`parentId === id`), so the Agent Hub rendered each agent as `sub · of ` and the ←← parent-navigation gesture looped on the same agent. The SDK was reusing `parentTaskPrefix` — the agent's own artifact/output-id prefix — as the registry parent link; spawns now pass a separate `parentAgentId` (the spawning agent's id: `Main` for top-level `task` spawns, the parent subagent for nested spawns and eval `agent()`, the focused agent for `/tan`) and the registry records that as the parent. ## [16.0.2] - 2026-06-16 diff --git a/packages/coding-agent/src/eval/agent-bridge.ts b/packages/coding-agent/src/eval/agent-bridge.ts index b8d7cfaaf..e01a64fad 100644 --- a/packages/coding-agent/src/eval/agent-bridge.ts +++ b/packages/coding-agent/src/eval/agent-bridge.ts @@ -10,6 +10,7 @@ import { resolveAgentModelPatterns } from "../config/model-resolver"; import type { LocalProtocolOptions } from "../internal-urls"; import { MCPManager } from "../mcp/manager"; import subagentUserPromptTemplate from "../prompts/system/subagent-user-prompt.md" with { type: "text" }; +import { MAIN_AGENT_ID } from "../registry/agent-registry"; import * as taskDiscovery from "../task/discovery"; import * as taskExecutor from "../task/executor"; import { AgentOutputManager } from "../task/output-manager"; @@ -288,6 +289,7 @@ export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOption parentHindsightSessionState: options.session.getHindsightSessionState?.(), parentMnemopiSessionState: options.session.getMnemopiSessionState?.(), parentTelemetry: options.session.getTelemetry?.(), + parentAgentId: options.session.getAgentId?.() ?? MAIN_AGENT_ID, // Deliberately omit parentEvalSessionId: the parent's Python kernel is // blocked on this bridge call, so sharing the eval session would deadlock // (subagent queues behind the parent's in-flight execution, parent waits 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 d641c4834..5eb98ba65 100644 --- a/packages/coding-agent/src/modes/controllers/tan-command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/tan-command-controller.ts @@ -122,6 +122,7 @@ export class TanCommandController { agentId: cloneId, agentDisplayName: "tan", parentTaskPrefix: cloneId, + parentAgentId: ownerId, agentRegistry, disableExtensionDiscovery: true, }); diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 46f55ab9e..2d92b6736 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -507,6 +507,14 @@ export interface CreateAgentSessionOptions { agentRegistry?: AgentRegistry; /** Parent task ID prefix for nested artifact naming (e.g., "Extensions") */ parentTaskPrefix?: string; + /** + * Registry id of the spawning agent, recorded as this subagent's parent in + * the agent registry. Distinct from `parentTaskPrefix`, which is this agent's + * own artifact/output-id prefix (the executor passes the child's own id + * there, so it must never double as the parent link). Undefined for the + * top-level "Main" session, which has no parent. + */ + parentAgentId?: string; /** Inherited eval executor session id for subagents sharing parent eval state. */ parentEvalSessionId?: string; @@ -2338,7 +2346,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} id: resolvedAgentId, displayName: resolvedAgentDisplayName, kind: agentKind, - parentId: options.parentTaskPrefix, + parentId: options.parentAgentId, session: null, sessionFile: sessionManager.getSessionFile() ?? null, status: "running", diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 35129198b..694ceefe2 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -351,6 +351,12 @@ export interface ExecutorOptions { parentTelemetry?: AgentTelemetryConfig; /** Skills to autoload via sendCustomMessage before the first prompt */ autoloadSkills?: Skill[]; + /** + * Registry id of the spawning agent, recorded as this subagent's parent. + * Forwarded verbatim to the SDK; the executor never derives it (the spawner + * passes its own `getAgentId()`). + */ + parentAgentId?: string; } function parseStringifiedJson(value: unknown): unknown { @@ -2021,6 +2027,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise => { diff --git a/packages/coding-agent/test/eval/agent-bridge.test.ts b/packages/coding-agent/test/eval/agent-bridge.test.ts index 492087753..893303388 100644 --- a/packages/coding-agent/test/eval/agent-bridge.test.ts +++ b/packages/coding-agent/test/eval/agent-bridge.test.ts @@ -52,6 +52,7 @@ describe("runEvalAgent", () => { getSessionFile: () => null, mcpManager, localProtocolOptions, + getAgentId: () => "BridgeParent", } as unknown as ToolSession; await runEvalAgent({ prompt: "do work", agentType: "task" }, { session }); @@ -60,5 +61,6 @@ describe("runEvalAgent", () => { const options = runSubprocessSpy.mock.calls[0]?.[0]; expect(options?.mcpManager).toBe(mcpManager); expect(options?.localProtocolOptions).toBe(localProtocolOptions); + expect(options?.parentAgentId).toBe("BridgeParent"); }); }); 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 1a3fa8199..f3024eed1 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 @@ -229,6 +229,34 @@ describe("TanCommandController", () => { ); }); + it("parents the tan clone to the spawning agent, not to the clone itself", async () => { + const harness = createContext({ agentId: "FocusedParent" }); + 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 () => {}), + }; + const createAgentSessionSpy = vi + .spyOn(sdkModule, "createAgentSession") + .mockResolvedValue({ session: clone } as unknown as CreateAgentSessionResult); + const controller = new TanCommandController(harness.ctx); + await controller.start("follow the tangent"); + const capturedRun = harness.capturedRun; + if (!capturedRun) throw new Error("run function was not captured"); + await capturedRun({ jobId: "job-1", signal: new AbortController().signal, reportProgress: async () => {} }); + + const opts = createAgentSessionSpy.mock.calls[0]?.[0]; + // The clone's registry parent is the spawning (focused) agent. Its own + // `Tan-` artifact prefix must never double as the parent link, or the + // hub would render the tan parented to itself. + expect(opts?.parentAgentId).toBe("FocusedParent"); + expect(opts?.parentTaskPrefix).toMatch(/^Tan-/); + expect(opts?.parentTaskPrefix).not.toBe("FocusedParent"); + }); + 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); diff --git a/packages/coding-agent/test/task/executor-pass-through.test.ts b/packages/coding-agent/test/task/executor-pass-through.test.ts index d6017dac0..455515645 100644 --- a/packages/coding-agent/test/task/executor-pass-through.test.ts +++ b/packages/coding-agent/test/task/executor-pass-through.test.ts @@ -132,4 +132,24 @@ describe("runSubprocess parent-discovery pass-through (issue #2190)", () => { expect(forwarded?.preloadedExtensionPaths).toBeUndefined(); expect(forwarded?.preloadedCustomToolPaths).toBeUndefined(); }); + + it("records the spawning agent as parentAgentId, distinct from the child's own id and prefix", async () => { + const session = yieldEmittingSession(); + const spy = vi.spyOn(sdkModule, "createAgentSession").mockResolvedValue(createSessionResult(session)); + + const result = await runSubprocess({ + ...baseOptions, + id: "ChildAgent", + parentAgentId: "SpawnerAgent", + }); + + expect(result.exitCode).toBe(0); + const forwarded = spy.mock.calls[0]?.[0]; + // The registry parent is the spawning agent — never the child itself (the + // self-parent bug). The child's own id still drives both its agent id and + // its artifact/output-id prefix; those must not double as the parent link. + expect(forwarded?.parentAgentId).toBe("SpawnerAgent"); + expect(forwarded?.agentId).toBe("ChildAgent"); + expect(forwarded?.parentTaskPrefix).toBe("ChildAgent"); + }); }); diff --git a/packages/coding-agent/test/task/task-batch.test.ts b/packages/coding-agent/test/task/task-batch.test.ts index 393de8f32..9b3c58996 100644 --- a/packages/coding-agent/test/task/task-batch.test.ts +++ b/packages/coding-agent/test/task/task-batch.test.ts @@ -33,13 +33,16 @@ const taskAgent: AgentDefinition = { source: "bundled", }; -function createSession(options: { manager?: AsyncJobManager; settings?: Record } = {}): ToolSession { +function createSession( + options: { manager?: AsyncJobManager; settings?: Record; agentId?: string } = {}, +): ToolSession { return { cwd: "/tmp", hasUI: false, settings: Settings.isolated(options.settings ?? {}), getSessionFile: () => null, getSessionSpawns: () => "*", + getAgentId: () => options.agentId ?? null, asyncJobManager: options.manager, } as unknown as ToolSession; } @@ -243,15 +246,20 @@ describe("task.batch spawning", () => { it("spawns one background job per task item and forwards the shared context", async () => { mockDiscovery(); - const seen: Array<{ id?: string; context?: string; assignment?: string }> = []; + const seen: Array<{ id?: string; context?: string; assignment?: string; parentAgentId?: string }> = []; vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { - seen.push({ id: options.id, context: options.context, assignment: options.assignment }); + seen.push({ + id: options.id, + context: options.context, + assignment: options.assignment, + parentAgentId: options.parentAgentId, + }); return makeResult(options.id ?? "?"); }); const manager = createManager(); const tool = await TaskTool.create( - createSession({ manager, settings: { "async.enabled": true, "task.batch": true } }), + createSession({ manager, agentId: "ParentA", settings: { "async.enabled": true, "task.batch": true } }), ); const result = await tool.execute("tc-batch", { @@ -287,6 +295,9 @@ describe("task.batch spawning", () => { expect(spawn.context).toBe("# Goal\nShared background."); } expect(seen.map(spawn => spawn.assignment).sort()).toEqual(["Do A.", "Do B."]); + // Every spawn is parented to the spawning agent (not to itself): the + // registry "of " link must be the caller, never the child's id. + for (const spawn of seen) expect(spawn.parentAgentId).toBe("ParentA"); }); it("treats a one-item batch as a single spawn and forwards context", async () => {