diff --git a/apps/cli/src/commands/index.ts b/apps/cli/src/commands/index.ts index 983c6da3..b10fd9b9 100644 --- a/apps/cli/src/commands/index.ts +++ b/apps/cli/src/commands/index.ts @@ -2,7 +2,7 @@ * Shared command layer (S3 first-party set: auth / models / session / config). * * Backs both subcommands and in-UI slash commands from one definition. The 30+ - * pure-UI slash commands (/compact, /thinking, /copy, /export, ...) are not + * pure-UI slash commands (/compact, /effort, /copy, /export, ...) are not * commands and do not belong here. */ diff --git a/apps/cli/src/ui/interactive-mode.ts b/apps/cli/src/ui/interactive-mode.ts index 572241e6..c5a893d8 100644 --- a/apps/cli/src/ui/interactive-mode.ts +++ b/apps/cli/src/ui/interactive-mode.ts @@ -239,14 +239,7 @@ export function applyStepKeybindingRemap(keybindings: KeybindingsManager): void * model/effort/mode switches most often, so they lead the list instead of * following builtin registration order (feedback issue-c6b8e3bb543482b7). */ -const STEP_SLASH_COMMAND_PRIORITY: readonly string[] = [ - "model", - "permissions", - "ultracode", - "effort", - "thinking", - "plan", -]; +const STEP_SLASH_COMMAND_PRIORITY: readonly string[] = ["model", "permissions", "ultracode", "effort", "plan"]; /** Exported for the acceptance test suite (tui-acceptance-interactions.test.ts). */ // 结构重构(代码结构方案步骤 4)时迁往 ui/runtime/input-dispatch.ts —— 斜杠命令分派属于交互编排。 @@ -891,11 +884,9 @@ export class InteractiveMode { }; } - const thinkingCommands = slashCommands.filter( - (command) => command.name === "thinking" || command.name === "effort", - ); - for (const thinkingCommand of thinkingCommands) { - thinkingCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => { + const effortCommand = slashCommands.find((command) => command.name === "effort"); + if (effortCommand) { + effortCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => { return createFuzzyAutocompleteItems( this.session.getAvailableThinkingLevels(), prefix, @@ -4775,15 +4766,15 @@ export class InteractiveMode { return; } - this.selectThinkingLevel(level, false); + this.selectThinkingLevel(level); } - private selectThinkingLevel(level: ThinkingLevel, persist: boolean): void { + private selectThinkingLevel(level: ThinkingLevel): void { try { - this.session.setThinkingLevel(level, { persist }); + this.session.setThinkingLevel(level, { persist: true }); this.footer.invalidate(); this.updateEditorBorderColor(); - this.showStatus(persist ? `Default thinking level: ${level}` : `Thinking level: ${level}`); + this.showStatus(`Default thinking level: ${level}`); } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); } @@ -4791,10 +4782,6 @@ export class InteractiveMode { private showThinkingSelector(): void { this.showSelector((done) => { - const selectLevel = (level: ThinkingLevel, persist: boolean) => { - this.selectThinkingLevel(level, persist); - done(); - }; const availableLevels = this.session.getAvailableThinkingLevels(); const globalDefault = this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL; const model = this.session.model; @@ -4805,12 +4792,14 @@ export class InteractiveMode { const selector = new ThinkingSelectorComponent( this.session.thinkingLevel ?? DEFAULT_THINKING_LEVEL, availableLevels, - (level) => selectLevel(level, false), + (level) => { + this.selectThinkingLevel(level); + done(); + }, () => { done(); this.redraw.requestRender(); }, - (level) => selectLevel(level, true), defaultMarker, ); return { component: selector, focus: selector }; diff --git a/apps/cli/src/ui/runtime/input-dispatch.ts b/apps/cli/src/ui/runtime/input-dispatch.ts index a9f942cd..86f1a6df 100644 --- a/apps/cli/src/ui/runtime/input-dispatch.ts +++ b/apps/cli/src/ui/runtime/input-dispatch.ts @@ -4,7 +4,7 @@ * Everything here is relocated VERBATIM from InteractiveMode (only `this.` → `ctx.`). The * slash if-ladder ORDER, the per-branch `setText("")` placement (before vs after the * awaited command), the per-branch `addToHistory` position, the `stopLogoIntro()` - * first-in-onSubmit, the exact `/model` vs `/model ` (slice 7) and `/thinking`(10) vs + * first-in-onSubmit, the exact `/model` vs `/model ` (slice 7) and * `/effort`(8) offsets, the bash `!`/`!!` branch AFTER the slash checks, and the * compaction gate BEFORE the streaming gate are all load-bearing and preserved byte for * byte. @@ -291,12 +291,8 @@ export function wireSubmitHandler(ctx: RuntimeContext): void { await ctx.handleModelCommand(searchTerm); return; } - if (text === "/thinking" || text.startsWith("/thinking ") || text === "/effort" || text.startsWith("/effort ")) { - const searchTerm = text.startsWith("/thinking ") - ? text.slice(10).trim() - : text.startsWith("/effort ") - ? text.slice(8).trim() - : undefined; + if (text === "/effort" || text.startsWith("/effort ")) { + const searchTerm = text.startsWith("/effort ") ? text.slice(8).trim() : undefined; ctx.editor.setText(""); ctx.handleThinkingCommand(searchTerm); return; diff --git a/apps/cli/src/ui/view/dialogs/thinking-selector.ts b/apps/cli/src/ui/view/dialogs/thinking-selector.ts index 5b33df09..e44d8c53 100644 --- a/apps/cli/src/ui/view/dialogs/thinking-selector.ts +++ b/apps/cli/src/ui/view/dialogs/thinking-selector.ts @@ -1,12 +1,11 @@ import type { ThinkingLevel } from "@step-harness/agent-core"; -import { DynamicBorder, getSelectListTheme, keyDisplayText, theme } from "@step-harness/coding-agent"; +import { DynamicBorder, getSelectListTheme, theme } from "@step-harness/coding-agent"; import { Container, type Focusable, fuzzyFilter, getKeybindings, Input, - matchesKey, type SelectItem, SelectList, type SelectListLayoutOptions, @@ -30,7 +29,8 @@ const LEVEL_DESCRIPTIONS: Record = { }; /** - * Component that renders a thinking level selector with borders + * Component that renders a thinking level selector with borders. + * Enter applies the level and saves it as the startup default. */ export class ThinkingSelectorComponent extends Container implements Focusable { private searchInput: Input; @@ -39,7 +39,6 @@ export class ThinkingSelectorComponent extends Container implements Focusable { private allItems: SelectItem[]; private onSelect: (level: ThinkingLevel) => void; private onCancel: () => void; - private onSelectAsDefault?: (level: ThinkingLevel) => void; private _focused = false; get focused(): boolean { @@ -56,13 +55,11 @@ export class ThinkingSelectorComponent extends Container implements Focusable { availableLevels: ThinkingLevel[], onSelect: (level: ThinkingLevel) => void, onCancel: () => void, - onSelectAsDefault?: (level: ThinkingLevel) => void, defaultThinkingLevel?: ThinkingLevel, ) { super(); this.onSelect = onSelect; this.onCancel = onCancel; - this.onSelectAsDefault = onSelectAsDefault; this.allItems = availableLevels.map((level) => ({ value: level, @@ -76,8 +73,6 @@ export class ThinkingSelectorComponent extends Container implements Focusable { this.addChild(new Spacer(1)); this.addChild(new Text("Thinking Level", 0, 0)); this.addChild(new Spacer(1)); - this.addChild(new Text(`${keyDisplayText("app.thinking.cycle")} cycles thinking levels in-session`, 0, 0)); - this.addChild(new Spacer(1)); this.searchInput = new Input(); this.searchInput.onSubmit = () => this.selectList.handleInput("\r"); @@ -89,7 +84,7 @@ export class ThinkingSelectorComponent extends Container implements Focusable { this.selectListChildIndex = this.children.length; this.addChild(this.selectList); this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("dim", " Enter to select · Ctrl+S to set as default · Esc to cancel"), 0, 0)); + this.addChild(new Text(theme.fg("dim", " Enter to select · Esc to cancel"), 0, 0)); // Add bottom border this.addChild(new DynamicBorder()); @@ -117,12 +112,6 @@ export class ThinkingSelectorComponent extends Container implements Focusable { } handleInput(keyData: string): void { - if (matchesKey(keyData, "ctrl+s") && this.onSelectAsDefault) { - const item = this.selectList.getSelectedItem(); - if (item) this.onSelectAsDefault(item.value as ThinkingLevel); - return; - } - const kb = getKeybindings(); const isNav = kb.matches(keyData, "tui.select.up") || diff --git a/apps/cli/test/interactive-mode-startup-input.test.ts b/apps/cli/test/interactive-mode-startup-input.test.ts index 371d3a6a..19453e83 100644 --- a/apps/cli/test/interactive-mode-startup-input.test.ts +++ b/apps/cli/test/interactive-mode-startup-input.test.ts @@ -122,9 +122,7 @@ describe("InteractiveMode startup input", () => { }); it.each([ - ["/thinking", undefined], ["/effort", undefined], - ["/thinking high", "high"], ["/effort high", "high"], ])("routes %s through the native thinking command handler", async (input, expectedSearchTerm) => { const context = createSubmitContext(); diff --git a/apps/cli/test/interactive-mode-status.test.ts b/apps/cli/test/interactive-mode-status.test.ts index a92faaa9..82781abe 100644 --- a/apps/cli/test/interactive-mode-status.test.ts +++ b/apps/cli/test/interactive-mode-status.test.ts @@ -414,7 +414,7 @@ describe("InteractiveMode.setupAutocompleteProvider", () => { }); describe("InteractiveMode.createBaseAutocompleteProvider", () => { - test("offers the same thinking-level completions for /thinking and /effort", async () => { + test("offers thinking-level completions for /effort", async () => { type FakeInteractiveMode = { session: { scopedModels: []; @@ -456,12 +456,11 @@ describe("InteractiveMode.createBaseAutocompleteProvider", () => { provider.getSuggestions([line], 0, line.length, { signal: new AbortController().signal, }); - const thinkingCompletions = await getCompletions("/thinking h"); const effortCompletions = await getCompletions("/effort h"); - expect(thinkingCompletions?.items.map((item) => item.value)).toEqual(["high"]); - expect(effortCompletions).toEqual(thinkingCompletions); - expect(getAvailableThinkingLevels).toHaveBeenCalledTimes(2); + expect(effortCompletions?.items.map((item) => item.value)).toEqual(["high"]); + expect(await getCompletions("/thinking h")).toBeNull(); + expect(getAvailableThinkingLevels).toHaveBeenCalledOnce(); }); test("matches model command arguments across provider/model order", async () => { diff --git a/apps/cli/test/tui-acceptance-interactions.test.ts b/apps/cli/test/tui-acceptance-interactions.test.ts index 27b8c0fc..4579989e 100644 --- a/apps/cli/test/tui-acceptance-interactions.test.ts +++ b/apps/cli/test/tui-acceptance-interactions.test.ts @@ -2,7 +2,7 @@ * TUI 验收交互套件(第 2 层)—— 对应《tui-acceptance-manual.md》F2/F6/F7/K6 项。 * * 验证三件交互级行为(不经真实终端): - * - F2 斜杠命令优先级:model/permissions/ultracode/effort/thinking/plan 置顶,其余稳定排序; + * - F2 斜杠命令优先级:model/permissions/ultracode/effort/plan 置顶,其余稳定排序; * - F7/K6 Ctrl+L 重映射:step 模式 ctrl+l → app.redraw,model.select 让位; * native 模式不受影响;用户显式绑定永远优先。 */ @@ -26,7 +26,7 @@ import { applyStepKeybindingRemap, InteractiveMode, orderStepSlashCommands } fro describe("F2. 斜杠命令优先级", () => { test("高频命令置顶,其余保持原有相对顺序", () => { - const builtins = ["settings", "model", "tree", "thinking", "effort", "export", "quit"].map((name) => ({ + const builtins = ["settings", "model", "tree", "effort", "export", "quit"].map((name) => ({ name, })); const extensions = ["init", "permissions", "plugin", "plan", "status", "feedback"].map((name) => ({ @@ -35,9 +35,9 @@ describe("F2. 斜杠命令优先级", () => { const ordered = orderStepSlashCommands([...builtins, ...extensions]).map((command) => command.name); - expect(ordered.slice(0, 5)).toEqual(["model", "permissions", "effort", "thinking", "plan"]); + expect(ordered.slice(0, 4)).toEqual(["model", "permissions", "effort", "plan"]); // 未入优先级的命令保持传入顺序(稳定排序) - expect(ordered.slice(5)).toEqual(["settings", "tree", "export", "quit", "init", "plugin", "status", "feedback"]); + expect(ordered.slice(4)).toEqual(["settings", "tree", "export", "quit", "init", "plugin", "status", "feedback"]); }); test("无优先级命中时原样返回", () => { @@ -46,12 +46,12 @@ describe("F2. 斜杠命令优先级", () => { }); test("pins registered Ultracode near the top while keeping Ultraloop in the remaining commands", () => { - const names = ["settings", "ultraloop", "model", "permissions", "effort", "thinking", "plan", "ultracode", "quit"]; + const names = ["settings", "ultraloop", "model", "permissions", "effort", "plan", "ultracode", "quit"]; const commands = names.map((name) => ({ name })); const ordered = orderStepSlashCommands(commands).map((command) => command.name); - expect(ordered.slice(0, 6)).toEqual(["model", "permissions", "ultracode", "effort", "thinking", "plan"]); - expect(ordered.slice(6)).toEqual(["settings", "ultraloop", "quit"]); + expect(ordered.slice(0, 5)).toEqual(["model", "permissions", "ultracode", "effort", "plan"]); + expect(ordered.slice(5)).toEqual(["settings", "ultraloop", "quit"]); expect(commands.map((command) => command.name)).toEqual(names); }); @@ -150,10 +150,10 @@ describe("Ultracode discovery from session registration", () => { const suggestions = await provider.getSuggestions(["/"], 0, 1, { signal: new AbortController().signal }); const names = suggestions!.items.map((item) => item.value); - expect(names.slice(0, 6)).toEqual(["model", "permissions", "ultracode", "effort", "thinking", "plan"]); + expect(names.slice(0, 5)).toEqual(["model", "permissions", "ultracode", "effort", "plan"]); expect(names.filter((name) => name === "ultracode")).toHaveLength(1); expect(names.filter((name) => name === "ultraloop")).toHaveLength(1); - expect(names.indexOf("ultraloop")).toBeGreaterThan(5); + expect(names.indexOf("ultraloop")).toBeGreaterThan(4); }); test("does not add unavailable workflow commands to bare slash completion", async () => { diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 1f05c7f2..ef73fed0 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -112,7 +112,7 @@ Type `/` in the editor to trigger commands. [Extensions](#extensions) can regist | `/login`, `/logout` | Manage provider credentials | | [`/llama`](docs/llama-cpp.md) | Download, load, and unload llama.cpp router models | | `/model` | Switch models; Ctrl+S in the picker saves the startup default | -| `/thinking` | Switch thinking level; Ctrl+S in the picker saves the startup default | +| `/effort` | Switch thinking level and save it as the startup default | | `/scoped-models` | Enable/disable models for Ctrl+P cycling | | `/settings` | Theme, message delivery, transport, and other preferences | | `/resume` | Pick from previous sessions | diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 3678118e..41639965 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -7,7 +7,7 @@ Step uses JSON settings files with project settings overriding global settings. | `~/.stepcode/agent/settings.json` | Global (all projects) | | `.stepcode/settings.json` | Project (current directory) | -Edit directly or use `/settings` for common options. To save startup model defaults interactively, use `/model` and press Ctrl+S on the desired model. To save the startup thinking level, use `/thinking` and press Ctrl+S. +Edit directly or use `/settings` for common options. To save startup model defaults interactively, use `/model` and press Ctrl+S on the desired model. To save the startup thinking level, use `/effort` and press Enter. ## Project Trust @@ -29,7 +29,7 @@ Use `/trust` in interactive mode to save a project trust decision for future ses |---------|------|---------|-------------| | `defaultProvider` | string | - | Startup provider (e.g., `"anthropic"`, `"openai"`; saved with Ctrl+S in `/model`, or edited manually) | | `defaultModel` | string | - | Startup model ID (saved with Ctrl+S in `/model`, or edited manually) | -| `defaultThinkingLevel` | string | - | Startup thinking level (saved with Ctrl+S in `/thinking`, or edited manually): `"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`, `"max"` | +| `defaultThinkingLevel` | string | - | Startup thinking level (saved with Enter in `/effort`, or edited manually): `"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`, `"max"` | | `modelThinkingLevels` | object | - | Per-model startup thinking levels keyed by `"provider/modelId"`; configure from `/settings` → Default thinking level per model or edit manually | | `hideThinkingBlock` | boolean | `false` | Hide thinking blocks in output | | `showCacheMissNotices` | boolean | `false` | Show transcript notices for significant prompt-cache misses and compaction or branch-summary usage | diff --git a/packages/coding-agent/docs/step-integration.md b/packages/coding-agent/docs/step-integration.md index 968e42d8..c79d8d30 100644 --- a/packages/coding-agent/docs/step-integration.md +++ b/packages/coding-agent/docs/step-integration.md @@ -430,7 +430,7 @@ service and are not silently faked by this fork. | Model/provider selection, compaction, resume, fork, new session | Pi native commands and `SessionManager` | aligned | | StepCode OAuth login/logout and legacy credential normalization | `features/step-provider`, `step/auth.ts`, `step/models-endpoint-repair.ts` | aligned; endpoint repair is tested | | Global/project settings and session paths | Pi managers wrapped by `step/settings-manager.ts` and `step/session.ts` | aligned; writes use `.stepcode` | -| Step permissions, `/permissions`, `/init` | Step extension/facades over Pi selectors and hooks | aligned; `/effort` is Pi's native `/thinking` alias, while `/permission` and `/mode` remain absent | +| Step permissions, `/permissions`, `/init` | Step extension/facades over Pi selectors and hooks | aligned; `/effort` replaces Pi's native `/thinking`, while `/permission` and `/mode` remain absent | | Built-in `search_web` | Step tool profile backed by `stepsearch.web_search` over Streamable HTTP MCP | aligned; uses search-specific environment overrides and the Step login credential fallback | | Top-level `step feedback` and TUI `/feedback` | `step/feedback/`, `stepcode.ts`, and the Step extension over Pi's current session/UI | aligned; the body and optional session bundle use separate Step collector requests | | `/status`, self-memory/skills governance, `/refresh`, `/rewind`, `/copy` full-transcript semantics | Previous gateway/TUI product layer | not yet ported; no misleading alias is registered | diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index 6cab5dfa..5c984390 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -24,14 +24,9 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ argumentHint: "", }, { name: "tree", description: "Navigate session tree (switch branches)" }, - { - name: "thinking", - description: "Set thinking level", - argumentHint: "", - }, { name: "effort", - description: "Set thinking level (alias for /thinking)", + description: "Set thinking level", argumentHint: "", }, { diff --git a/packages/coding-agent/src/features/step-subagent.ts b/packages/coding-agent/src/features/step-subagent.ts index 9a76293b..77efd105 100644 --- a/packages/coding-agent/src/features/step-subagent.ts +++ b/packages/coding-agent/src/features/step-subagent.ts @@ -220,7 +220,7 @@ const StepAgentScopeSchema = StringEnum(["user", "project", "both"] as const, { const StepSubscribeSchema = StringEnum(["final", "progress", "none"] as const, { description: - 'Notification level for background lanes. "final" (default) sends one completion notification, "progress" adds throttled progress updates, "none" is fire-and-forget.', + 'Notification level for background lanes. "final" (default) sends one completion notification, "progress" adds throttled progress updates, "none" is fire-and-forget. Except with "none", completion, failure, and needs-input notifications wake you automatically, so never sleep or poll to wait for a lane.', default: "final", }); @@ -549,9 +549,9 @@ export function createStepSubagentExtension(options: StepSubagentExtensionOption const updateLaneWidget = (lane: BackgroundAgentLane): void => { if (!lane.ctx.hasUI) return; shownLanes.add(lane.id); - const batch = [...shownLanes] - .map((id) => lanes.get(id)) - .filter((entry): entry is BackgroundAgentLane => entry !== undefined); + // `lanes` iterates in creation order; `shownLanes` in first-update order, + // which depends on which child streams first and would shuffle the rows. + const batch = [...lanes.values()].filter((entry) => shownLanes.has(entry.id)); try { if (batch.every((entry) => entry.status !== "running")) { shownLanes.clear(); @@ -635,7 +635,19 @@ export function createStepSubagentExtension(options: StepSubagentExtensionOption const monitorHint = lane.subscribe === "none" ? "Fire-and-forget lane: no notifications will be sent." - : `Lane events arrive automatically as messages (subscribe: ${lane.subscribe}).`; + : [ + // Without this the parent, told to "wait for the lanes", has no + // wait primitive and falls back to `sleep` loops: the lane result + // then sits behind a sleep of up to minutes, and each progress + // notice landing mid-sleep keeps the loop going. + "Do not wait for it with sleep or polling: end your turn, or carry on with other work.", + "You are woken automatically with an when the lane finishes, fails, or needs input.", + lane.subscribe === "progress" + ? "Progress messages are informational; they need no reply." + : "", + ] + .filter(Boolean) + .join(" "); return makeToolResult( details, `Started background agent ${lane.id}${lane.alias ? ` (${lane.alias})` : ""}. ${monitorHint}`, diff --git a/packages/coding-agent/src/features/subagent/lane-events.ts b/packages/coding-agent/src/features/subagent/lane-events.ts index abc807fe..21f84540 100644 --- a/packages/coding-agent/src/features/subagent/lane-events.ts +++ b/packages/coding-agent/src/features/subagent/lane-events.ts @@ -39,6 +39,14 @@ export interface AgentNotificationDetails { detail?: string; } +/** + * Cap on the lane output a final notification carries. The notification is the + * parent's only copy of the result (agent_send can reply or stop, not fetch), + * so it matches what a blocking call returns per task instead of a short + * preview the parent would have to dig back out of the child's session file. + */ +const LANE_OUTPUT_MAX_CHARS = 50_000; + /** Minimum interval between background_progress notifications per lane. */ const PROGRESS_NOTIFY_INTERVAL_MS = 15_000; @@ -103,11 +111,18 @@ export function notifyLaneEvent( // A lane usually settles while the parent sits idle, waiting on it. A // steer alone only appends the message then, so the model never reads the // result until the user types again. Terminal and needs-input events wake - // the parent; progress and restart notices ride along with the next turn - // instead of waking it every 15s per lane. They leave triggerTurn unset - // rather than false: an explicit false would defer them to the end of a - // running turn instead of steering into it. - WAKING_EVENTS.has(event) ? { deliverAs: "steer", triggerTurn: true } : { deliverAs: "steer" }, + // the parent with a steer. + // + // Progress and restart notices must stay out of the steering queue. It + // drains one message per turn by default ("one-at-a-time"), and progress + // arrives every 15s per lane: while the parent sat in a long tool call + // (a 3-minute sleep, say) dozens queued up, the parent read one per turn, + // and the lanes' completion notices waited behind that backlog for over + // half an hour. A steer also forces another model call after the parent + // meant to stop. triggerTurn: false instead lands them as context: + // appended at once while idle, or batched in at the end of the running + // turn, which is the same boundary a steer would be injected at. + WAKING_EVENTS.has(event) ? { deliverAs: "steer", triggerTurn: true } : { triggerTurn: false }, ); } @@ -145,7 +160,7 @@ export function notifyLaneFinal(pi: ExtensionAPI, lane: BackgroundAgentLane): vo .map(({ label, cause }) => `- ${label}: ${truncateText(cause, 400)}`) : []; const detail = [ - output ? truncateText(output, 2_000) : "", + output ? truncateText(output, LANE_OUTPUT_MAX_CHARS) : "", failureReasons.length > 0 ? `Failure reasons:\n${failureReasons.join("\n")}` : "", ] .filter(Boolean) diff --git a/packages/coding-agent/test/step-slash-commands.test.ts b/packages/coding-agent/test/step-slash-commands.test.ts index 3df0b8a2..1a65dc0c 100644 --- a/packages/coding-agent/test/step-slash-commands.test.ts +++ b/packages/coding-agent/test/step-slash-commands.test.ts @@ -103,11 +103,12 @@ describe("Step Pi slash command adapters", () => { expect(BUILTIN_SLASH_COMMANDS.some((command) => command.name === "changelog")).toBe(false); }); - test("exposes /effort as a built-in /thinking alias", () => { + test("exposes /effort as the built-in thinking level command", () => { expect(BUILTIN_SLASH_COMMANDS.find((command) => command.name === "effort")).toMatchObject({ name: "effort", argumentHint: "", }); + expect(BUILTIN_SLASH_COMMANDS.some((command) => command.name === "thinking")).toBe(false); }); test("registers product adapters without replacing Pi's native commands", () => { diff --git a/packages/coding-agent/test/step-subagent-events.test.ts b/packages/coding-agent/test/step-subagent-events.test.ts index 5e775fe3..3595e726 100644 --- a/packages/coding-agent/test/step-subagent-events.test.ts +++ b/packages/coding-agent/test/step-subagent-events.test.ts @@ -161,6 +161,10 @@ test("background lane completion steers an escaped agent-notification", async () createContext("/workspace"), )) as AgentToolResult<{ agentId?: string }>; expect(result.content[0]).toMatchObject({ type: "text" }); + // The parent must end its turn rather than sleep-poll; the lane wakes it. + const started = result.content[0].type === "text" ? result.content[0].text : ""; + expect(started).toContain("Do not wait for it with sleep or polling"); + expect(started).toContain("woken automatically"); await waitFor(() => sent.length >= 1); const done = sent[0]; expect(done.customType).toBe("agent-notification"); @@ -494,12 +498,36 @@ test("progress notifications ride along without waking an idle parent", async () notifyLaneEvent(pi, lane, "background_needs_input", "which file?"); notifyLaneEvent(pi, lane, "background_failed", "boom"); - // Non-waking events must leave triggerTurn unset, not false: AgentSession - // defers an explicit false to the end of a running turn instead of steering. + // Non-waking events must stay out of the steering queue: it drains one + // message per turn, so a progress backlog would hold completions behind it. expect(sent).toEqual([ - { event: "background_progress", options: { deliverAs: "steer" } }, - { event: "background_restarted", options: { deliverAs: "steer" } }, + { event: "background_progress", options: { triggerTurn: false } }, + { event: "background_restarted", options: { triggerTurn: false } }, { event: "background_needs_input", options: { deliverAs: "steer", triggerTurn: true } }, { event: "background_failed", options: { deliverAs: "steer", triggerTurn: true } }, ]); }); + +test("a lane's final notification carries a long report in full", async () => { + const { api, tools, sent } = createApi(); + // Well past the old 2,000-character cap, with the conclusion at the end. + const report = `${"analysis line\n".repeat(600)}SUMMARY: all files reviewed`; + createStepSubagentExtension({ + includeBuiltinAgents: true, + agentDir: "/tmp/step-agent-test", + runner: async () => textResult(report), + })(api); + await tools + .get("subagent")! + .execute( + "call", + { agent: "general", task: "review", run_in_background: true } as never, + undefined, + undefined, + createContext("/workspace"), + ); + await waitFor(() => sent.some((message) => message.details?.event === "background_done")); + const done = sent.find((message) => message.details?.event === "background_done")!; + expect(done.content).toContain("SUMMARY: all files reviewed"); + expect(done.content).not.toContain("[output truncated]"); +}); diff --git a/packages/coding-agent/test/suite/regressions/subagent-lane-progress-backlog.test.ts b/packages/coding-agent/test/suite/regressions/subagent-lane-progress-backlog.test.ts new file mode 100644 index 00000000..0f706121 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/subagent-lane-progress-backlog.test.ts @@ -0,0 +1,81 @@ +import type { AgentMessage, AgentTool } from "@step-harness/agent-core"; +import { fauxAssistantMessage, fauxToolCall } from "@step-harness/providers"; +import { Type } from "typebox"; +import { afterEach, describe, expect, it } from "vitest"; +import type { ExtensionAPI } from "../../../src/core/extensions/types.ts"; +import type { BackgroundAgentLane } from "../../../src/features/step-subagent.ts"; +import { notifyLaneEvent } from "../../../src/features/subagent/lane-events.ts"; +import { createHarness, type Harness } from "../harness.ts"; + +function laneEvents(messages: AgentMessage[]): string[] { + return messages.flatMap((message) => { + if (message.role !== "custom" || message.customType !== "agent-notification") return []; + const event = (message.details as { event?: string } | undefined)?.event; + return event ? [event] : []; + }); +} + +// Session 01a123da: four progress-subscribed lanes ran while the parent sat in +// minutes-long tool calls. Progress was steered, the steering queue drains one +// message per turn, and the lanes' completion notices waited behind dozens of +// queued progress notices for over half an hour. +describe("background lane notifications during a long tool call", () => { + const harnesses: Harness[] = []; + + afterEach(() => { + while (harnesses.length > 0) { + harnesses.pop()?.cleanup(); + } + }); + + it("delivers a completion on the next model call, not behind queued progress", async () => { + let duringTool: (() => void) | undefined; + const slowTool: AgentTool = { + name: "wait", + label: "Wait", + description: "A long tool call, e.g. sleep", + parameters: Type.Object({}), + execute: async () => { + duringTool?.(); + return { content: [{ type: "text", text: "waited" }], details: {} }; + }, + }; + + const harness = await createHarness({ tools: [slowTool] }); + harnesses.push(harness); + const pi = { + sendMessage: (message: Parameters[0], options?: never) => { + void harness.session.sendCustomMessage(message, options); + }, + } as unknown as ExtensionAPI; + const lane = { + id: "96e529c1", + subscribe: "progress", + status: "running", + details: { results: [{ agent: "general" }] }, + } as unknown as BackgroundAgentLane; + duringTool = () => { + for (let turn = 1; turn <= 12; turn++) { + notifyLaneEvent(pi, lane, "background_progress", `step 1/1; turns ${turn}`); + } + lane.status = "completed"; + notifyLaneEvent(pi, lane, "background_done", "REPORT"); + }; + + harness.setResponses([ + fauxAssistantMessage([fauxToolCall("wait", {})], { stopReason: "toolUse" }), + fauxAssistantMessage("got the report"), + ]); + + await harness.session.prompt("start the lanes and wait"); + + const messages = harness.session.messages; + const reply = messages.findIndex((message) => message.role === "assistant" && message.stopReason === "stop"); + const beforeReply = laneEvents(messages.slice(0, reply)); + expect(beforeReply).toContain("background_done"); + expect(beforeReply.filter((event) => event === "background_progress")).toHaveLength(12); + // Nothing is left to replay after the parent's reply: no further model calls. + expect(laneEvents(messages.slice(reply + 1))).toEqual([]); + expect(messages.at(-1)).toMatchObject({ role: "assistant", stopReason: "stop" }); + }); +});