diff --git a/packages/coding-agent/src/render/btw-panel.ts b/packages/coding-agent/src/render/btw-panel.ts new file mode 100644 index 00000000..44fdeaf3 --- /dev/null +++ b/packages/coding-agent/src/render/btw-panel.ts @@ -0,0 +1,307 @@ +/** + * Side-question panel and history (`/btw`). + * + * Mirrors Claude Code's side question: the exchange never joins the main + * conversation. Claude Code forks the message list, runs one turn with + * `skipTranscript`, discards the returned messages, and keeps the answer in an + * in-memory `btwHistory` that only the next side question reads. Step's + * equivalent of that fork is `ctx.modelRegistry.complete()`, which talks to the + * same provider with the same credentials but owns no agent and no session, so + * nothing is appended anywhere. This module owns the panel and that history. + */ + +import type { Component, KeybindingsManager, TUI } from "@step-harness/pi-tui"; +import { + getKeybindings, + Loader, + matchesKey, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, +} from "@step-harness/pi-tui"; +import type { AssistantMessage, Message } from "@step-harness/providers"; +import type { Theme } from "../theme/theme.ts"; + +/** Marks the side question in telemetry and in the prompt; never a session entry. */ +export const BTW_QUERY_SOURCE = "side_question"; +export const BTW_PENDING_LABEL = "Answering…"; +export const BTW_IDLE_LABEL = "Waiting for the current turn…"; +export const BTW_NO_ANSWER = "No answer available for this side question. Try again, or ask in the main conversation."; +export const BTW_CANCELLED = "Side question cancelled"; + +/** Claude Code keeps the last 20 exchanges; match it so the prompt cost is bounded. */ +const BTW_HISTORY_LIMIT = 20; + +export interface BtwExchange { + question: string; + response: string; + /** The provider's own message, replayed verbatim into the next side question. */ + message: AssistantMessage; +} + +/** + * Side-question history, in memory only. + * + * Read by the next `/btw` so follow-ups have context, and never by a main turn. + * It dies with the process: persisting it would put the exchanges back into the + * session the whole feature exists to keep them out of. + */ +export class BtwHistory { + private exchanges: BtwExchange[] = []; + + append(question: string, message: AssistantMessage): void { + const response = assistantText(message); + this.exchanges = [...this.exchanges, { question, response, message }].slice(-BTW_HISTORY_LIMIT); + } + + get size(): number { + return this.exchanges.length; + } + + list(): readonly BtwExchange[] { + return this.exchanges; + } + + clear(): void { + this.exchanges = []; + } + + /** Prior exchanges as alternating user/assistant turns, for the next side question. */ + toMessages(): Message[] { + return this.exchanges.flatMap((exchange): Message[] => [ + { role: "user", content: [{ type: "text", text: exchange.question }], timestamp: exchange.message.timestamp }, + exchange.message, + ]); + } +} + +/** Concatenated text blocks of an assistant message; thinking and tool calls dropped. */ +export function assistantText(message: AssistantMessage): string { + return message.content + .filter((block): block is { type: "text"; text: string } => block.type === "text") + .map((block) => block.text) + .join(""); +} + +export type BtwPanelOutcome = "closed"; + +export interface BtwPanelOptions { + question: string; + theme: Theme; + /** Earlier exchanges, newest last; browsed with shift+left/right. */ + history: readonly BtwExchange[]; + /** + * Host TUI, used to animate the waiting spinner. Omit (tests, headless) and + * the panel renders the label with a static frame instead. + */ + tui?: TUI; + onClose: (outcome: BtwPanelOutcome) => void; + /** Copy the shown answer. Returns false when there is nothing to copy. */ + onCopy?: (text: string) => void; + onClearHistory?: () => void; + /** Repaint hook, supplied by the overlay host. */ + onChange?: () => void; +} + +/** + * The floating panel: question, answer, and the chords Claude Code offers. + * + * Index 0 is the live exchange; 1..n browse back through `history` newest + * first. Browsing a past exchange is read-only, so the live answer keeps + * updating underneath and is still there when the user comes back to it. + */ +export class BtwPanel implements Component { + private readonly options: BtwPanelOptions; + private readonly keybindings: KeybindingsManager; + private answer = ""; + private loading = true; + private loadingLabel = BTW_PENDING_LABEL; + private history: readonly BtwExchange[]; + private browseIndex = 0; + private scroll = 0; + private lastBodyHeight = 1; + private notice: string | undefined; + /** + * Spinner shown while waiting. `Loader` is used for its frame clock only: the + * panel builds its own rows, so the frame is captured out of the paint + * callback the same way the bash card does, and `Loader`'s own `requestRender` + * drives the repaint. + */ + private readonly loader: Loader | undefined; + private frame = "⠋"; + + constructor(options: BtwPanelOptions, keybindings: KeybindingsManager = getKeybindings()) { + this.options = options; + this.keybindings = keybindings; + this.history = options.history; + this.loader = options.tui + ? new Loader( + options.tui, + (frame) => { + this.frame = frame || "⠋"; + return frame; + }, + (text) => text, + "", + ) + : undefined; + } + + /** Called by the overlay host on close; stops the spinner's interval. */ + dispose(): void { + this.loader?.stop(); + } + + setAnswer(text: string): void { + this.answer = text; + this.options.onChange?.(); + } + + setLoadingLabel(label: string): void { + this.loadingLabel = label; + this.options.onChange?.(); + } + + finish(): void { + this.loading = false; + this.loader?.stop(); + this.options.onChange?.(); + } + + /** Expose what the panel is showing, so tests and `c` agree on one string. */ + get shownAnswer(): string { + if (this.browseIndex === 0) return this.answer; + return this.history[this.history.length - this.browseIndex]?.response ?? ""; + } + + private get shownQuestion(): string { + if (this.browseIndex === 0) return this.options.question; + return this.history[this.history.length - this.browseIndex]?.question ?? ""; + } + + handleInput(data: string): void { + const kb = this.keybindings; + if (kb.matches(data, "tui.select.cancel") || data === "\u0003") { + this.options.onClose("closed"); + return; + } + // Shift+arrows browse; plain arrows scroll. Checked first so a terminal that + // reports shift+left as a distinct sequence is not read as a bare left. + if (matchesKey(data, "shift+left")) { + this.browse(this.browseIndex + 1); + return; + } + if (matchesKey(data, "shift+right")) { + this.browse(this.browseIndex - 1); + return; + } + if (kb.matches(data, "tui.select.up")) { + this.scrollBy(-1); + return; + } + if (kb.matches(data, "tui.select.down")) { + this.scrollBy(1); + return; + } + if (data === "c") { + const text = this.shownAnswer.trim(); + this.notice = text.length === 0 ? "Nothing to copy" : "Copied"; + if (text.length > 0) this.options.onCopy?.(text); + this.options.onChange?.(); + return; + } + if (data === "x") { + this.history = []; + this.browseIndex = 0; + this.scroll = 0; + this.notice = "History cleared"; + this.options.onClearHistory?.(); + this.options.onChange?.(); + } + } + + private browse(next: number): void { + const clamped = Math.max(0, Math.min(this.history.length, next)); + if (clamped === this.browseIndex) return; + this.browseIndex = clamped; + this.scroll = 0; + this.notice = undefined; + this.options.onChange?.(); + } + + private scrollBy(delta: number): void { + const next = Math.max(0, this.scroll + delta); + if (next === this.scroll) return; + this.scroll = next; + this.options.onChange?.(); + } + + invalidate(): void {} + + render(width: number): string[] { + const safeWidth = Math.max(1, Math.floor(width)); + const contentWidth = Math.max(1, safeWidth - 2); + const theme = this.options.theme; + const fg = (color: Parameters[0], text: string): string => theme.fg(color, text); + const rows: string[] = []; + + const heading = `/btw ${this.shownQuestion}`; + for (const line of wrapTextWithAnsi(heading, contentWidth)) { + rows.push(` ${fg("accent", theme.bold(line))}`); + } + if (this.history.length > 0) { + const label = + this.browseIndex === 0 + ? `(+${this.history.length} earlier /btw)` + : `(${this.browseIndex} of ${this.history.length} back)`; + rows.push(` ${fg("dim", label)}`); + } + rows.push(""); + + const body = this.bodyRows(contentWidth, fg); + // Clamp the scroll against the body actually produced, so a shrinking + // answer cannot leave the view parked past its end. + this.lastBodyHeight = body.length; + const maxScroll = Math.max(0, body.length - 1); + if (this.scroll > maxScroll) this.scroll = maxScroll; + for (const line of body.slice(this.scroll)) rows.push(line); + + rows.push(""); + rows.push(` ${fg("muted", this.renderHint())}`); + return rows.map((row) => (visibleWidth(row) > safeWidth ? truncateToWidth(row, safeWidth, "", false) : row)); + } + + private bodyRows(contentWidth: number, fg: (color: Parameters[0], text: string) => string): string[] { + const live = this.browseIndex === 0; + if (live && this.loading) { + const spinner = fg("accent", this.frame); + return wrapTextWithAnsi(this.loadingLabel, Math.max(1, contentWidth - 2)).map( + (line, index) => ` ${index === 0 ? spinner : " "} ${fg("muted", line)}`, + ); + } + const text = this.shownAnswer.replace(/\s+$/u, ""); + if (text.length === 0) { + return wrapTextWithAnsi(BTW_NO_ANSWER, contentWidth).map((line) => ` ${fg("muted", line)}`); + } + // Plain rows, not Markdown: a re-parsed Markdown tree per repaint flickers + // on code fences, and the answer is one short response by construction. + const rows: string[] = []; + for (const line of text.split("\n")) { + for (const wrapped of wrapTextWithAnsi(line, contentWidth)) rows.push(` ${fg("text", wrapped)}`); + } + return rows; + } + + private renderHint(): string { + if (this.notice) return this.notice; + const parts = ["esc close", "c copy"]; + if (this.history.length > 0) parts.push("shift+←→ browse", "x clear history"); + if (this.lastBodyHeight > 1) parts.push("↑↓ scroll"); + return parts.join(" "); + } +} + +/** Build the overlay component for `ctx.ui.custom`; `onChange` repaints the host TUI. */ +export function createBtwPanel(options: BtwPanelOptions): BtwPanel { + return new BtwPanel(options, getKeybindings()); +} diff --git a/packages/coding-agent/src/step/slash-commands.ts b/packages/coding-agent/src/step/slash-commands.ts index 5fa22873..689e6db5 100644 --- a/packages/coding-agent/src/step/slash-commands.ts +++ b/packages/coding-agent/src/step/slash-commands.ts @@ -1,7 +1,20 @@ /** Step-only slash command adapters built on Pi's public extension actions. */ +import type { Message } from "@step-harness/providers"; import type { ExtensionAPI, ExtensionCommandContext, RegisteredCommand } from "../core/extensions/types.ts"; +import { convertToLlm } from "../core/messages.ts"; +import { sessionEntryToContextMessages } from "../core/session-manager.ts"; +import { + assistantText, + BTW_CANCELLED, + BTW_NO_ANSWER, + BtwHistory, + type BtwPanel, + type BtwPanelOutcome, + createBtwPanel, +} from "../render/btw-panel.ts"; import { getAvailableThemes } from "../theme/theme.ts"; +import { copyToClipboard } from "../utils/clipboard.ts"; import { buildFeedbackSessionBundle } from "./feedback/bundle.ts"; import { type FeedbackSubmitResult, @@ -118,6 +131,18 @@ export function registerStepPiCommandAdapters( telemetry, ); + registerTrackedCommand( + pi, + "btw", + { + description: "Ask a quick side question without interrupting the main conversation", + handler: async (args, ctx) => { + await handleStepBtwCommand(args, ctx); + }, + }, + telemetry, + ); + registerTrackedCommand( pi, "feedback", @@ -133,6 +158,120 @@ export function registerStepPiCommandAdapters( type RegisteredCommandOptions = Omit; +/** + * Prior side questions, for this process only. + * + * Claude Code keeps the same store on the session object and only the next side + * question reads it. Module scope here because the command adapters are + * registered once per process and a side question is never concurrent. + */ +const btwHistory = new BtwHistory(); + +const BTW_SYSTEM_REMINDER = [ + "This is a side question from the user. You must answer this question directly in a single response.", + "Tools are unavailable here. Do not continue, plan, or edit anything for the main task.", +].join("\n"); + +/** + * `/btw `: one side question that leaves the conversation untouched. + * + * Claude Code forks the message list, runs a single turn with `skipTranscript`, + * throws the returned messages away, and files the answer under `btwHistory`. + * `modelRegistry.complete()` is Step's equivalent: same provider, same + * credentials, no agent and no session, so there is nothing to append and + * nothing to rewind. A main turn in flight is untouched, because this never + * enters the agent loop at all. + */ +async function handleStepBtwCommand(args: string, ctx: ExtensionCommandContext): Promise { + const question = args.trim(); + if (!question) { + ctx.ui.notify("Usage: /btw ", "warning"); + return; + } + if (!ctx.hasUI || ctx.mode !== "tui") { + ctx.ui.notify("/btw requires an interactive UI.", "warning"); + return; + } + const model = ctx.model; + if (!model) { + ctx.ui.notify("/btw needs a model. Pick one with /model.", "warning"); + return; + } + + // Aborted when the panel closes, so esc does not leave a request in flight. + const controller = new AbortController(); + const context = { + systemPrompt: ctx.getSystemPrompt(), + messages: [ + ...forkBtwMessages(ctx), + ...btwHistory.toMessages(), + { + role: "user" as const, + content: [{ type: "text" as const, text: `${BTW_SYSTEM_REMINDER}\n\n${question}` }], + timestamp: Date.now(), + }, + ], + }; + + let panel: BtwPanel | undefined; + const answer = ctx.modelRegistry + .complete(model, context, { signal: controller.signal, maxTokens: 2048 }) + .then((message) => { + btwHistory.append(question, message); + panel?.setAnswer(assistantText(message)); + panel?.finish(); + return message; + }) + .catch(() => { + panel?.setAnswer(controller.signal.aborted ? BTW_CANCELLED : BTW_NO_ANSWER); + panel?.finish(); + return undefined; + }); + + await ctx.ui.custom((tui, theme, _keybindings, done) => { + panel = createBtwPanel({ + question, + theme, + tui, + history: btwHistory.list(), + onClose: (outcome) => { + panel = undefined; + done(outcome); + }, + onCopy: (text) => { + void copyToClipboard(text); + }, + onClearHistory: () => btwHistory.clear(), + onChange: () => tui.requestRender(), + }); + return panel; + }); + + panel = undefined; + controller.abort(); + // Settle the request so a rejection cannot surface as an unhandled rejection + // after the panel is gone. + await answer; +} + +/** + * The conversation so far, as the side question's starting point. + * + * Claude Code's fork sanitizes by dropping a trailing incomplete assistant + * message. Same thing here: a final assistant message whose tool calls have no + * results is rejected by providers that validate message order, and a main turn + * still streaming is exactly when that happens. + */ +function forkBtwMessages(ctx: ExtensionCommandContext): Message[] { + const agentMessages = ctx.sessionManager.buildContextEntries().flatMap(sessionEntryToContextMessages); + const messages = convertToLlm(agentMessages); + const last = messages.at(-1); + if (last?.role === "assistant" && last.content.some((block) => block.type === "toolCall")) { + return messages.slice(0, -1); + } + return messages; +} + function registerTrackedCommand( pi: ExtensionAPI, name: string, diff --git a/packages/coding-agent/test/step-slash-commands.test.ts b/packages/coding-agent/test/step-slash-commands.test.ts index dc15f196..c176f60e 100644 --- a/packages/coding-agent/test/step-slash-commands.test.ts +++ b/packages/coding-agent/test/step-slash-commands.test.ts @@ -10,6 +10,7 @@ import { createStepExtension } from "../src/features/step.ts"; import { formatStepStatus, registerStepPiCommandAdapters } from "../src/step/slash-commands.ts"; import { resolveStderrDevLogPath } from "../src/step/stderr-dev-log.ts"; import { initTheme, type Theme, theme } from "../src/theme/theme.ts"; +import { stripAnsi } from "../src/utils/ansi.ts"; const temporaryRoots: string[] = []; @@ -114,10 +115,170 @@ describe("Step Pi slash command adapters", () => { test("registers product adapters without replacing Pi's native commands", () => { const { commands } = registerCommands(); - expect([...commands.keys()]).toEqual(expect.arrayContaining(["plugin", "clear", "theme", "status"])); + expect([...commands.keys()]).toEqual(expect.arrayContaining(["plugin", "clear", "theme", "status", "btw"])); expect(commands.has("effort")).toBe(false); }); + test("/btw answers out of band and never touches the session", async () => { + const registerCommand = vi.fn(); + const commands = new Map(); + registerCommand.mockImplementation((name: string, command: RegisteredCommand) => { + commands.set(name, command); + }); + const sendMessage = vi.fn(); + registerStepPiCommandAdapters({ + registerCommand, + registerProvider: vi.fn(), + setThinkingLevel: vi.fn(), + sendMessage, + on: vi.fn(), + } as unknown as ExtensionAPI); + + const assistant = (text: string) => ({ + role: "assistant" as const, + content: [{ type: "text" as const, text }], + timestamp: 1, + api: "anthropic-messages", + provider: "step", + model: "m", + usage: {}, + stopReason: "stop", + }); + const complete = vi.fn().mockResolvedValue(assistant("four minutes")); + const notify = vi.fn(); + // The branch ends on an assistant message with an unanswered tool call: + // the fork must drop it or providers reject the request. + const branch = [ + { type: "message", message: { role: "user", content: [{ type: "text", text: "ship it" }], timestamp: 1 } }, + { + type: "message", + message: { + role: "assistant", + content: [{ type: "toolCall", id: "t1", name: "bash", arguments: {} }], + timestamp: 2, + api: "anthropic-messages", + provider: "step", + model: "m", + usage: {}, + stopReason: "toolUse", + }, + }, + ]; + let panel: { render(width: number): string[]; handleInput(data: string): void } | undefined; + const context = (custom?: unknown) => + ({ + ui: { notify, custom }, + hasUI: true, + mode: "tui", + model: { provider: "step", id: "m" }, + modelRegistry: { complete }, + getSystemPrompt: () => "SYSTEM", + sessionManager: { buildContextEntries: () => branch }, + }) as unknown as ExtensionCommandContext; + + const custom = vi.fn( + async (factory: (tui: unknown, theme: unknown, kb: unknown, done: (r: unknown) => void) => unknown) => { + panel = factory({ requestRender: vi.fn() }, theme, {}, vi.fn()) as typeof panel; + // complete() resolves on a microtask; let it land before reading the panel. + await Promise.resolve(); + await Promise.resolve(); + return undefined; + }, + ); + + await getCommand(commands, "btw").handler(" eta on the deploy? ", context(custom)); + + // Nothing is appended to the session: no custom message, no agent turn. + expect(sendMessage).not.toHaveBeenCalled(); + expect(complete).toHaveBeenCalledOnce(); + const [, sent] = complete.mock.calls[0] as [unknown, { systemPrompt: string; messages: unknown[] }]; + expect(sent.systemPrompt).toBe("SYSTEM"); + // The dangling tool call is gone; the question is the last message. + expect(sent.messages).toHaveLength(2); + expect(JSON.stringify(sent.messages.at(-1))).toContain("eta on the deploy?"); + expect(JSON.stringify(sent.messages.at(-1))).toContain("side question"); + expect(panel?.render(60).join("\n")).toContain("four minutes"); + + // The answer is remembered for the next side question, not for the main turn. + complete.mockClear(); + complete.mockResolvedValue(assistant("still four")); + await getCommand(commands, "btw").handler("and now?", context(custom)); + const [, second] = complete.mock.calls[0] as [unknown, { messages: unknown[] }]; + expect(JSON.stringify(second.messages)).toContain("four minutes"); + expect(panel?.render(60).join("\n")).toContain("1 earlier /btw"); + + // `x` drops the history, so the following side question starts clean. + panel?.handleInput("x"); + complete.mockClear(); + await getCommand(commands, "btw").handler("third", context(custom)); + const [, third] = complete.mock.calls[0] as [unknown, { messages: unknown[] }]; + expect(JSON.stringify(third.messages)).not.toContain("four minutes"); + }); + + test("/btw spins while waiting and releases the frame clock on close", async () => { + const { BtwPanel } = await import("../src/render/btw-panel.ts"); + vi.useFakeTimers(); + try { + const requestRender = vi.fn(); + const panel = new BtwPanel({ + question: "eta?", + theme, + history: [], + tui: { requestRender } as never, + onClose: vi.fn(), + }); + const frameOf = (): string => { + const line = stripAnsi(panel.render(40).join("\n")) + .split("\n") + .find((row) => row.includes("Answering")); + return line?.trim().charAt(0) ?? ""; + }; + + // A frame sits beside the label before any answer arrives, and it advances + // on the Loader's clock rather than only on repaint. + const first = frameOf(); + expect(first).toMatch(/[\u2807\u280b\u280f\u2819\u2826\u2827\u2834\u2838\u2839\u283c]/u); + vi.advanceTimersByTime(80); + expect(frameOf()).not.toBe(first); + expect(requestRender).toHaveBeenCalled(); + + // finish() and dispose() must each clear the interval, or every /btw + // leaks a timer that keeps requesting renders forever. + panel.finish(); + expect(stripAnsi(panel.render(40).join("\n"))).not.toContain("Answering"); + panel.dispose(); + requestRender.mockClear(); + vi.advanceTimersByTime(1000); + expect(requestRender).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + test("/btw rejects an empty question and a session with no model", async () => { + const { commands } = registerCommands(); + const notify = vi.fn(); + const complete = vi.fn(); + const base = { + ui: { notify }, + hasUI: true, + mode: "tui", + modelRegistry: { complete }, + getSystemPrompt: () => "", + sessionManager: { buildContextEntries: () => [] }, + }; + + await getCommand(commands, "btw").handler(" ", { + ...base, + model: { provider: "step", id: "m" }, + } as unknown as ExtensionCommandContext); + expect(notify).toHaveBeenLastCalledWith("Usage: /btw ", "warning"); + + await getCommand(commands, "btw").handler("anything", base as unknown as ExtensionCommandContext); + expect(notify).toHaveBeenLastCalledWith("/btw needs a model. Pick one with /model.", "warning"); + expect(complete).not.toHaveBeenCalled(); + }); + test("routes /clear to Pi's session replacement action", async () => { const { commands } = registerCommands(); const newSession = vi.fn().mockResolvedValue({ cancelled: false }); diff --git a/packages/coding-agent/test/suite/agent-session-tool-output.test.ts b/packages/coding-agent/test/suite/agent-session-tool-output.test.ts index 8a513bb2..5ac51677 100644 --- a/packages/coding-agent/test/suite/agent-session-tool-output.test.ts +++ b/packages/coding-agent/test/suite/agent-session-tool-output.test.ts @@ -7,8 +7,7 @@ import { fauxAssistantMessage, fauxToolCall } from "@step-harness/providers"; import { Type } from "typebox"; import { afterEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "../../src/core/tools/truncate.ts"; -import { createStepExtension } from "../../src/features/step.ts"; -import * as stepMcp from "../../src/step/mcp.ts"; +import { StepPermissionController } from "../../src/step/permissions.ts"; import { createStepToolProfile } from "../../src/step/tool-profile.ts"; import { createHarness, type Harness } from "./harness.ts"; @@ -239,19 +238,26 @@ describe("AgentSession final tool output", () => { it("keeps the tail and exit status of a failed command that bash already truncated", async () => { if (process.platform === "win32") return; - // PR #260: output retention must not wait for unrelated MCP discovery or StepPage installation. - vi.spyOn(stepMcp, "createStepMcpExtension").mockReturnValue(() => {}); const sandbox = mkdtempSync(join(tmpdir(), "step-tool-output-bash-")); vi.stubEnv("STEP_CODING_AGENT_DIR", join(sandbox, "agent")); + let harness: Harness | undefined; + let fullOutputPath: string | undefined; try { - const harness = await createHarness({ + harness = await createHarness({ tools: [], initialActiveToolNames: ["run_command"], settings: { compaction: { enabled: false } }, extensionFactories: [ - createStepExtension({ - permission: { env: {}, initialPreset: "approve-for-me", toolOverrides: { run_command: "allow" } }, - }), + (pi) => { + // Output retention needs the real permission gate, not unrelated + // plugin provisioning and MCP connections from the full Step extension. + const permissions = new StepPermissionController({ + env: {}, + initialPreset: "approve-for-me", + toolOverrides: { run_command: "allow" }, + }); + pi.on("tool_call", (event, ctx) => permissions.handleToolCall(event, ctx)); + }, (pi) => { const tool = createStepToolProfile(sandbox, { agentDir: join(sandbox, "agent") }).find( (candidate) => candidate.name === "run_command", @@ -275,12 +281,19 @@ describe("AgentSession final tool output", () => { }, ]); await harness.session.prompt("run the build"); - await harness.session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" }); expect(requestText).toContain("\n3000\n"); - expect(requestText).toMatch(/Full output: \S*step-bash-\S+\.log\]/); + const match = requestText.match(/Full output: (\S*step-bash-\S+\.log)\]/); + expect(match).not.toBeNull(); + fullOutputPath = match![1]; + expect(await readFile(fullOutputPath, "utf8")).toBe( + `${Array.from({ length: 3000 }, (_, n) => n + 1).join("\n")}\n`, + ); expect(requestText.endsWith("Command exited with code 1")).toBe(true); expect(requestText).not.toContain("Showing first"); } finally { + await harness?.session.abort(); + await harness?.session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" }); + if (fullOutputPath) rmSync(fullOutputPath, { force: true }); vi.unstubAllEnvs(); rmSync(sandbox, { recursive: true, force: true }); }