From 12ffbdf946cb51f348b3fa942b2eed35c60831fe Mon Sep 17 00:00:00 2001 From: Tianning Li Date: Mon, 19 Oct 2026 22:01:20 +0800 Subject: [PATCH 1/2] feat(cli): add /btw for one-shot side questions Ask a question without disturbing the main conversation. `/btw ` opens an overlay, answers it, and leaves the session untouched: nothing is appended to the transcript, the session file, or the model's context. The answer comes from `modelRegistry.complete()`, which talks to the same provider with the same credentials but owns no agent and no session. That is what keeps the exchange out of the conversation, and it also means a main turn in flight is unaffected, since the side question never enters the agent loop. Tools are unreachable for the same reason: `complete()` has no tool loop. The question is sent against a copy of the current branch's messages, with a trailing assistant message holding unanswered tool calls dropped -- that is exactly the state a streaming main turn leaves behind, and providers that validate message order reject it. Prior side questions are kept in an in-memory `BtwHistory` (last 20) and replayed only into the next side question, so follow-ups have context while main turns stay blind to them. It is deliberately not persisted: writing it down would put the exchanges back into the session the command exists to keep them out of. Panel chords: esc close, c copy, x clear history, shift+left/right browse earlier exchanges, up/down scroll. The waiting spinner reuses pi-tui's Loader as a frame clock only, the way the bash card does, and its interval is released on both finish() and dispose(). --- packages/coding-agent/src/render/btw-panel.ts | 307 ++++++++++++++++++ .../coding-agent/src/step/slash-commands.ts | 139 ++++++++ .../test/step-slash-commands.test.ts | 163 +++++++++- 3 files changed, 608 insertions(+), 1 deletion(-) create mode 100644 packages/coding-agent/src/render/btw-panel.ts 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 }); From 589ca83c822a53da6fc590af42290bf708cf39b7 Mon Sep 17 00:00:00 2001 From: ZouR-Ma <2605315944@qq.com> Date: Tue, 20 Oct 2026 16:02:28 +0800 Subject: [PATCH 2/2] test(coding-agent): isolate tool output regression from MCP startup --- .../suite/agent-session-tool-output.test.ts | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) 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 10d6d049..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,7 +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 { StepPermissionController } from "../../src/step/permissions.ts"; import { createStepToolProfile } from "../../src/step/tool-profile.ts"; import { createHarness, type Harness } from "./harness.ts"; @@ -240,15 +240,24 @@ describe("AgentSession final tool output", () => { if (process.platform === "win32") return; 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", @@ -272,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 }); }