From 9fbc37f57decb2e342e6d0e984dbfa6a07d2dae9 Mon Sep 17 00:00:00 2001 From: xuyunfang Date: Sat, 10 Oct 2026 12:38:01 +0800 Subject: [PATCH 1/3] fix(subagent): deliver lane completions promptly while the parent sleeps Session 01a123da: four progress-subscribed lanes all finished by 03:37, yet the parent kept running `sleep 180` until past 04:04, convinced the lanes were still going. Progress notices were steered. The steering queue drains one message per turn by default ("one-at-a-time"), and progress arrives every 15s per lane, so a minutes-long tool call let dozens queue up. The parent read one stale progress notice per turn, and the lanes' completion notices sat behind that backlog. Each steered notice also forced one more model call after the parent meant to stop, which kept the sleep loop going. - Send progress and restart notices with triggerTurn: false. They land as context at the same turn boundary a steer would use, all at once, without entering the steering queue or forcing an extra model call. Completion, failure, interrupt, and needs-input keep steer + triggerTurn. - Tell the parent, when a lane starts, not to wait with sleep or polling: end the turn or carry on, since it is woken when the lane finishes, fails, or needs input. The subscribe description says the same, except for "none". - Order the background lane list by creation rather than by which lane streamed first, as its comment already claimed. --- .../src/features/step-subagent.ts | 22 +++-- .../src/features/subagent/lane-events.ts | 17 ++-- .../test/step-subagent-events.test.ts | 12 ++- .../subagent-lane-progress-backlog.test.ts | 81 +++++++++++++++++++ 4 files changed, 118 insertions(+), 14 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/subagent-lane-progress-backlog.test.ts diff --git a/packages/coding-agent/src/features/step-subagent.ts b/packages/coding-agent/src/features/step-subagent.ts index 9a76293..77efd10 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 abc807f..63c2d9b 100644 --- a/packages/coding-agent/src/features/subagent/lane-events.ts +++ b/packages/coding-agent/src/features/subagent/lane-events.ts @@ -103,11 +103,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 }, ); } diff --git a/packages/coding-agent/test/step-subagent-events.test.ts b/packages/coding-agent/test/step-subagent-events.test.ts index 5e775fe..86cbe60 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,11 +498,11 @@ 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 } }, ]); 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 0000000..0f70612 --- /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" }); + }); +}); From bd87c0c214909173f5e96d255ee947ca89514fe9 Mon Sep 17 00:00:00 2001 From: xuyunfang Date: Sat, 10 Oct 2026 13:53:40 +0800 Subject: [PATCH 2/3] fix(subagent): carry a background lane's full result in its final notification The final notification capped the lane output at 2,000 characters. It is the parent's only copy of the result, since agent_send can reply or stop but not fetch, so a longer report lost its ending (usually the summary) and the parent fell back to parsing the child's session file. Raise the cap to 50,000 characters, the per-task limit a blocking call returns. --- .../src/features/subagent/lane-events.ts | 10 +++++++- .../test/step-subagent-events.test.ts | 24 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/features/subagent/lane-events.ts b/packages/coding-agent/src/features/subagent/lane-events.ts index 63c2d9b..21f8454 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; @@ -152,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-subagent-events.test.ts b/packages/coding-agent/test/step-subagent-events.test.ts index 86cbe60..3595e72 100644 --- a/packages/coding-agent/test/step-subagent-events.test.ts +++ b/packages/coding-agent/test/step-subagent-events.test.ts @@ -507,3 +507,27 @@ test("progress notifications ride along without waking an idle parent", async () { 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]"); +}); From debc6b76f9ab61c51e4db103aa63e13898125a89 Mon Sep 17 00:00:00 2001 From: xuyunfang Date: Sat, 10 Oct 2026 14:34:06 +0800 Subject: [PATCH 3/3] feat(cli): keep /effort as the only thinking-level command and save on Enter Remove /thinking in favour of /effort. The picker drops the Shift+Tab hint and Ctrl+S; Enter now applies the level and saves it as the startup default, as does /effort . --- apps/cli/src/commands/index.ts | 2 +- apps/cli/src/ui/interactive-mode.ts | 35 +++++++------------ apps/cli/src/ui/runtime/input-dispatch.ts | 10 ++---- .../src/ui/view/dialogs/thinking-selector.ts | 19 +++------- .../interactive-mode-startup-input.test.ts | 2 -- apps/cli/test/interactive-mode-status.test.ts | 9 +++-- .../test/tui-acceptance-interactions.test.ts | 18 +++++----- packages/coding-agent/README.md | 2 +- packages/coding-agent/docs/settings.md | 4 +-- .../coding-agent/docs/step-integration.md | 2 +- .../coding-agent/src/core/slash-commands.ts | 7 +--- .../test/step-slash-commands.test.ts | 3 +- 12 files changed, 40 insertions(+), 73 deletions(-) diff --git a/apps/cli/src/commands/index.ts b/apps/cli/src/commands/index.ts index 983c6da..b10fd9b 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 572241e..c5a893d 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 a9f942c..86f1a6d 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 5b33df0..e44d8c5 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 371d3a6..19453e8 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 a92faaa..82781ab 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 27b8c0f..4579989 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 1f05c7f..ef73fed 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 3678118..4163996 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 968e42d..c79d8d3 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 6cab5df..5c98439 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/test/step-slash-commands.test.ts b/packages/coding-agent/test/step-slash-commands.test.ts index 3df0b8a..1a65dc0 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", () => {