From 48d2834bd57e31720665c2f58dd5c8fd27988776 Mon Sep 17 00:00:00 2001 From: MelodyVAR <61931019+MelodyVAR@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:07:13 +0000 Subject: [PATCH] fix(coding-agent): make Ultracode mode discoverable --- README.md | 26 +++ apps/cli/src/ui/interactive-mode.ts | 12 +- apps/cli/src/ui/view/chrome/footer.ts | 14 +- apps/cli/src/ui/view/chrome/step-welcome.ts | 15 +- .../tui-acceptance-snapshot.test.ts.snap | 17 +- apps/cli/test/footer-width.test.ts | 63 +++++++ apps/cli/test/step-welcome-tips.test.ts | 40 +++- apps/cli/test/step-welcome.test.ts | 46 ++++- .../test/tui-acceptance-interactions.test.ts | 142 +++++++++++++- docs/orchestration-lifecycle.md | 52 ++++- docs/step-welcome-wordmark.md | 10 +- packages/coding-agent/src/cli/args.ts | 18 +- .../src/features/workflow/step-workflow.ts | 4 +- .../src/features/workflow/ultraloop-opt-in.ts | 77 ++++++-- .../test/config-help-subcommands.test.ts | 8 + .../test/suite/ultracode-discovery.test.ts | 110 +++++++++++ .../test/workflow-ultraloop-opt-in.test.ts | 178 +++++++++++++++--- 17 files changed, 736 insertions(+), 96 deletions(-) create mode 100644 packages/coding-agent/test/suite/ultracode-discovery.test.ts diff --git a/README.md b/README.md index 6935a3e6..5a2374d6 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,32 @@ Then one that makes changes: Create a reference table for the error codes in src/api/errors.ts (code, meaning, trigger scenario) and save it to docs/errors.md ``` +### Ultracode: multi-agent workflows + +Use Ultracode to opt into multi-agent workflow orchestration for one task or for the current session. When workflows are available, enter these commands in the interactive TUI: + +| Command | Effect | +| --- | --- | +| `/ultracode on` | Enable workflow orchestration for the session | +| `/ultracode off` | Turn off session mode | +| `/ultracode status` | Show the current session mode | +| `/ultracode help` | Show usage and the current session mode | +| `/ultracode` | Same as `help`; leaves the mode unchanged | + +`/ultraloop` is an exact alias with the same `on`, `off`, `status`, and `help` subcommands; both names control the same mode. The editor completes these subcommands. Bare commands, `help`, and `status` leave the mode unchanged. + +While enabled, the status line shows `Ultracode on`. Session mode stays on across turns until `/ultracode off` or a session boundary, when it resets to off. + +For one turn, send `ultracode: ` (or `ultraloop: `) in the TUI or as a launch prompt: + +```bash +step -p "ultracode: Review the API changes and verify the tests" +``` + +If the commands are missing, check `STEP_DISABLE_WORKFLOW` and whether the host enables workflow support. A restricted tool profile must also expose `workflow`; the session switch does not expand tool access. + +The prompt prefix opts in only that turn and leaves session mode unchanged. Workflow availability and tool discovery alone do not grant consent to run workflows. Ultracode leaves tool permissions and model thinking settings unchanged. See the [workflow lifecycle](docs/orchestration-lifecycle.md) for opt-in and budget scope. + ### Resume a session ```bash diff --git a/apps/cli/src/ui/interactive-mode.ts b/apps/cli/src/ui/interactive-mode.ts index dd044edb..5a3e2cd3 100644 --- a/apps/cli/src/ui/interactive-mode.ts +++ b/apps/cli/src/ui/interactive-mode.ts @@ -234,7 +234,14 @@ 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", "effort", "thinking", "plan"]; +const STEP_SLASH_COMMAND_PRIORITY: readonly string[] = [ + "model", + "permissions", + "ultracode", + "effort", + "thinking", + "plan", +]; /** Exported for the acceptance test suite (tui-acceptance-interactions.test.ts). */ // 结构重构(代码结构方案步骤 4)时迁往 ui/runtime/input-dispatch.ts —— 斜杠命令分派属于交互编排。 @@ -727,6 +734,9 @@ export class InteractiveMode { thinkingLevel: this.session.model?.reasoning ? this.session.thinkingLevel : undefined, workspaceRoot: this.sessionManager.getCwd(), sessionId: this.sessionManager.getSessionId(), + ultracodeAvailable: + this.session.getAllTools().some((tool) => tool.name === "workflow") && + this.session.extensionRunner.getCommand("ultracode") !== undefined, }), { requestRender: () => this.redraw.requestRender(), diff --git a/apps/cli/src/ui/view/chrome/footer.ts b/apps/cli/src/ui/view/chrome/footer.ts index bf98097a..f0d11dc8 100644 --- a/apps/cli/src/ui/view/chrome/footer.ts +++ b/apps/cli/src/ui/view/chrome/footer.ts @@ -308,7 +308,8 @@ export class FooterComponent implements Component { const warning = (text: string) => theme.fg("warning", text); const error = (text: string) => theme.fg("error", text); - const permissionStatus = this.footerData.getExtensionStatuses().get("step-permission"); + const extensionStatuses = this.footerData.getExtensionStatuses(); + const permissionStatus = extensionStatuses.get("step-permission"); const statusPreset = permissionStatus?.match(/^Mode:\s*([^()]+?)(?:\s*\(auto-resume\))?$/u)?.[1]?.trim(); // Extension statuses use human-readable labels (for example "Read Only"), // while embedded hosts may expose the corresponding id ("read-only" or @@ -342,6 +343,11 @@ export class FooterComponent implements Component { const segments: string[] = [ displayMode.paint(`⏵ ${displayMode.label}`) + (cycleHint ? muted(` (${cycleHint})`) : ""), ]; + // Keep session mode ahead of model/path details so it survives compact-layout truncation. + if (safeWidth >= 80) { + const ultracodeStatus = sanitizeStatusText(extensionStatuses.get("ultracode") ?? ""); + if (ultracodeStatus) segments.push(muted(ultracodeStatus)); + } const model = state.model?.id; if (safeWidth >= 60 && model) { @@ -364,12 +370,10 @@ export class FooterComponent implements Component { } } - const extensionStatuses = this.footerData.getExtensionStatuses(); if (safeWidth >= 100) { for (const [key, status] of [...extensionStatuses.entries()].sort(([a], [b]) => a.localeCompare(b))) { - // Permission is already the leading segment; keeping its status here - // duplicates the mode on wide terminals. - if (key === "step-permission") continue; + // Permission and Ultracode already have leading segments. + if (key === "step-permission" || key === "ultracode") continue; const cleaned = status .replace(/[\r\n\t]+/gu, " ") .replace(/ +/gu, " ") diff --git a/apps/cli/src/ui/view/chrome/step-welcome.ts b/apps/cli/src/ui/view/chrome/step-welcome.ts index 2bcd4754..9678872c 100644 --- a/apps/cli/src/ui/view/chrome/step-welcome.ts +++ b/apps/cli/src/ui/view/chrome/step-welcome.ts @@ -26,6 +26,8 @@ export interface StepWelcomeInfo { thinkingLevel?: string; workspaceRoot: string; sessionId?: string; + /** Whether the workflow tool and Ultracode command are registered for this session. */ + ultracodeAvailable?: boolean; } const WELCOME_MARK = renderStepMark(); @@ -59,11 +61,11 @@ const WELCOME_TIPS = [ command: "/goal", description: "Set a goal and keep working toward it across turns.", }, - { - command: "ultracode", - description: "Include this keyword in your prompt to enable parallel subagents.", - }, ] as const; +const ULTRACODE_TIP = { + command: "/ultracode on", + description: "Use parallel agents for this session. One turn: ultracode: task.", +} as const; const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme", @@ -348,8 +350,9 @@ export class StepWelcomeComponent implements Component { return `${paintStepWordmarkBorder("│ ")}${row}${" ".repeat(padding)}${paintStepWordmarkBorder(" │")}`; }; const tips = ["", muted("Tips")]; - const prefixWidth = Math.max(...WELCOME_TIPS.map((tip) => visibleWidth(tip.command))) + 2; - for (const tip of WELCOME_TIPS) { + const welcomeTips = info.ultracodeAvailable ? [...WELCOME_TIPS, ULTRACODE_TIP] : WELCOME_TIPS; + const prefixWidth = Math.max(...welcomeTips.map((tip) => visibleWidth(tip.command))) + 2; + for (const tip of welcomeTips) { const prefix = tip.command + " ".repeat(prefixWidth - visibleWidth(tip.command)); if (innerWidth - prefixWidth < 24) { tips.push(...wrapMultiline(tip.command, innerWidth).map(brand)); diff --git a/apps/cli/test/__snapshots__/tui-acceptance-snapshot.test.ts.snap b/apps/cli/test/__snapshots__/tui-acceptance-snapshot.test.ts.snap index 32843bf5..b93002df 100644 --- a/apps/cli/test/__snapshots__/tui-acceptance-snapshot.test.ts.snap +++ b/apps/cli/test/__snapshots__/tui-acceptance-snapshot.test.ts.snap @@ -9,11 +9,9 @@ exports[`A. 欢迎屏快照 > 中终端走方块 mark 档(A1) > welcome-mark "│ ██ ██ │", "│ │", "│ Tips │", - "│ /cron View and manage scheduled tasks. │", - "│ /goal Set a goal and keep working toward │", - "│ it across turns. │", - "│ ultracode Include this keyword in your prompt │", - "│ to enable parallel subagents. │", + "│ /cron View and manage scheduled tasks. │", + "│ /goal Set a goal and keep working toward it a │", + "│ cross turns. │", "╰────────────────────────────────────────────────╯", "", ] @@ -33,9 +31,8 @@ exports[`A. 欢迎屏快照 > 宽终端走小鸟档(A1/A2/A4) > welcome-bird "│ ▀▀▀ ▀▀▀ │", "│ │", "│ Tips │", - "│ /cron View and manage scheduled tasks. │", - "│ /goal Set a goal and keep working toward it across turns. │", - "│ ultracode Include this keyword in your prompt to enable parallel subagents. │", + "│ /cron View and manage scheduled tasks. │", + "│ /goal Set a goal and keep working toward it across turns. │", "╰──────────────────────────────────────────────────────────────────────────────────────────────────╯", "", ] @@ -57,10 +54,6 @@ exports[`A. 欢迎屏快照 > 窄终端走徽章档且不越界(A1/A4) > wel "│ Set a goal and keep work │", "│ ing toward it across tur │", "│ ns. │", - "│ ultracode │", - "│ Include this keyword in │", - "│ your prompt to enable pa │", - "│ rallel subagents. │", "╰────────────────────────────╯", "", ] diff --git a/apps/cli/test/footer-width.test.ts b/apps/cli/test/footer-width.test.ts index 2a48014b..7b91511c 100644 --- a/apps/cli/test/footer-width.test.ts +++ b/apps/cli/test/footer-width.test.ts @@ -354,6 +354,69 @@ describe("FooterComponent width handling", () => { expect(plain).toContain("plan 1/2"); }); + it.each([ + { width: 80, showOtherStatuses: false }, + { width: 120, showOtherStatuses: true }, + ])("shows the session mode badge once at $width columns", ({ width, showOtherStatuses }) => { + const session = createSession({ + sessionName: "", + modelId: "step-3.8", + reasoning: true, + thinkingLevel: "high", + }); + const footer = new FooterComponent( + session, + createFooterData( + 1, + new Map([ + ["step-permission", "Mode: Ask"], + ["ultracode", "Ultracode on"], + ["plan-mode", "plan 1/2"], + ]), + ), + { presentation: "step", permissionCycleKey: () => "shift+tab" }, + ); + + const [line = ""] = footer.render(width); + const plain = stripAnsi(line); + expect(plain).toContain("Ultracode on"); + expect(plain.match(/Ultracode on/gu)).toHaveLength(1); + expect(plain).toContain("⏵ Ask (shift+tab)"); + expect(plain).not.toContain("Mode: Ask"); + expect(plain.includes("plan 1/2")).toBe(showOtherStatuses); + expect(plain).toMatch(/88% context left$/u); + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + }); + + it.each([80, 120])("removes the mode badge when its extension status is cleared at width %i", (width) => { + const statuses = new Map(); + const footer = new FooterComponent(createSession({ sessionName: "" }), createFooterData(1, statuses), { + presentation: "step", + }); + const disabled = footer.render(width); + expect(stripAnsi(disabled.join("\n"))).not.toContain("Ultracode"); + + statuses.set("ultracode", "Ultracode on"); + expect(stripAnsi(footer.render(width).join("\n"))).toContain("Ultracode on"); + statuses.delete("ultracode"); + expect(footer.render(width)).toEqual(disabled); + }); + + it("keeps the mode badge visible ahead of long model and workspace details at 80 columns", () => { + const session = createSession({ sessionName: "", modelId: `model-${"x".repeat(100)}` }); + vi.spyOn(session.sessionManager, "getCwd").mockReturnValue(`/tmp/${"workspace-".repeat(20)}`); + const footer = new FooterComponent(session, createFooterData(1, new Map([["ultracode", "Ultracode on"]])), { + presentation: "step", + permissionCycleKey: () => "shift+tab", + }); + + const [line = ""] = footer.render(80); + const plain = stripAnsi(line); + expect(plain).toContain("Ultracode on"); + expect(plain).toMatch(/88% context left$/u); + expect(visibleWidth(line)).toBeLessThanOrEqual(80); + }); + it("keeps the Step footer within narrow widths", () => { const footer = new FooterComponent(createSession({ sessionName: "" }), createFooterData(1), { presentation: "step", diff --git a/apps/cli/test/step-welcome-tips.test.ts b/apps/cli/test/step-welcome-tips.test.ts index e25f7e43..6cd93997 100644 --- a/apps/cli/test/step-welcome-tips.test.ts +++ b/apps/cli/test/step-welcome-tips.test.ts @@ -6,9 +6,12 @@ import { StepWelcomeComponent } from "../src/ui/view/chrome/step-welcome.ts"; afterEach(() => initTheme("dark")); describe("welcome tip alignment", () => { - it.each([39, 40, 80, 120])("aligns descriptions and wrapped lines at width %i", (width) => { + it.each([43, 44, 80, 120])("aligns descriptions and wrapped lines at width %i", (width) => { initTheme("step-blue"); - const component = new StepWelcomeComponent(() => ({ workspaceRoot: "/tmp/project" })); + const component = new StepWelcomeComponent(() => ({ + workspaceRoot: "/tmp/project", + ultracodeAvailable: true, + })); const lines = component.render(width).map(stripTerminalSequences); const tipsStart = lines.findIndex((line) => line.includes("Tips")) + 1; const tipsEnd = lines.findIndex((line) => line.includes("╰")); @@ -16,21 +19,21 @@ describe("welcome tip alignment", () => { const descriptions = [ ["/cron", "View and manage scheduled tasks."], ["/goal", "Set a goal and keep working toward it across turns."], - ["ultracode", "Include this keyword in your prompt to enable parallel subagents."], + ["/ultracode on", "Use parallel agents for this session. One turn: ultracode: task."], ] as const; for (const [command, description] of descriptions) { const start = tips.findIndex((line) => line.startsWith(`│ ${command} `)); expect(start).toBeGreaterThanOrEqual(0); - expect(tips[start]!.slice(0, 13)).toBe(`│ ${command.padEnd(11)}`); + expect(tips[start]!.slice(0, 17)).toBe(`│ ${command.padEnd(15)}`); const rows = [tips[start]!]; for (const line of tips.slice(start + 1)) { - if (!line.startsWith(`│ ${" ".repeat(11)}`)) break; + if (!line.startsWith(`│ ${" ".repeat(15)}`)) break; rows.push(line); } - const descriptionWidth = width - 15; + const descriptionWidth = width - 19; expect(rows).toHaveLength(Math.ceil(description.length / descriptionWidth)); for (const [index, line] of rows.entries()) { - expect(line.slice(13, -2).trimEnd()).toBe( + expect(line.slice(17, -2).trimEnd()).toBe( description.slice(index * descriptionWidth, (index + 1) * descriptionWidth).trimEnd(), ); } @@ -38,11 +41,14 @@ describe("welcome tip alignment", () => { for (const line of lines) expect(visibleWidth(line)).toBeLessThanOrEqual(width); }); - it.each([20, 30, 38])("stacks all descriptions consistently at width %i", (width) => { + it.each([20, 30, 38, 39, 40, 42])("stacks all descriptions consistently at width %i", (width) => { initTheme("step-blue"); - const component = new StepWelcomeComponent(() => ({ workspaceRoot: "/tmp/project" })); + const component = new StepWelcomeComponent(() => ({ + workspaceRoot: "/tmp/project", + ultracodeAvailable: true, + })); const lines = component.render(width).map(stripTerminalSequences); - for (const command of ["/cron", "/goal", "ultracode"]) { + for (const command of ["/cron", "/goal", "/ultracode on"]) { const index = lines.findIndex((line) => line.startsWith(`│ ${command} `)); expect(index).toBeGreaterThanOrEqual(0); expect(lines[index]!.slice(2, -2).trim()).toBe(command); @@ -50,4 +56,18 @@ describe("welcome tip alignment", () => { } for (const line of lines) expect(visibleWidth(line)).toBeLessThanOrEqual(width); }); + + it.each([35, 36, 80, 120])("uses only visible tips to align descriptions at width %i", (width) => { + initTheme("step-blue"); + const component = new StepWelcomeComponent(() => ({ + workspaceRoot: "/tmp/project", + ultracodeAvailable: false, + })); + const lines = component.render(width).map(stripTerminalSequences); + + expect(lines.some((line) => line.startsWith("│ /cron View and manage"))).toBe(true); + expect(lines.some((line) => line.startsWith("│ /goal Set a goal"))).toBe(true); + expect(lines.join("\n")).not.toContain("ultracode"); + for (const line of lines) expect(visibleWidth(line)).toBeLessThanOrEqual(width); + }); }); diff --git a/apps/cli/test/step-welcome.test.ts b/apps/cli/test/step-welcome.test.ts index 775bd745..b3bcd9bf 100644 --- a/apps/cli/test/step-welcome.test.ts +++ b/apps/cli/test/step-welcome.test.ts @@ -48,6 +48,49 @@ describe("StepWelcomeComponent", () => { expect(output).not.toContain("step-model · off"); }); + it("shows the session command and a separate one-turn example when Ultracode is available", () => { + initTheme("step-blue"); + const component = new StepWelcomeComponent(() => ({ + workspaceRoot: "/tmp/project", + ultracodeAvailable: true, + })); + + const output = component.render(120).map(stripTerminalSequences).join("\n"); + expect(output).toContain("/ultracode on"); + expect(output).toContain("Use parallel agents for this session."); + expect(output).toContain("One turn: ultracode: task."); + }); + + it.each([false, undefined])("omits the Ultracode tip when availability is %s", (ultracodeAvailable) => { + initTheme("step-blue"); + const component = new StepWelcomeComponent(() => ({ + workspaceRoot: "/tmp/project", + ultracodeAvailable, + })); + + for (const width of [30, 120]) { + const output = component.render(width).map(stripTerminalSequences).join("\n"); + expect(output).not.toMatch(/ultracode|ultraloop|parallel subagents/u); + expect(output).toContain("/cron"); + expect(output).toContain("/goal"); + } + }); + + it("reads capability availability again when rendering", () => { + initTheme("step-blue"); + let ultracodeAvailable = true; + const component = new StepWelcomeComponent(() => ({ + workspaceRoot: "/tmp/project", + ultracodeAvailable, + })); + + expect(component.render(120).join("\n")).toContain("/ultracode on"); + ultracodeAvailable = false; + expect(component.render(120).join("\n")).not.toContain("ultracode"); + ultracodeAvailable = true; + expect(component.render(120).join("\n")).toContain("/ultracode on"); + }); + it("removes the first-session hint after a message is projected", () => { initTheme("step-blue"); const component = new StepWelcomeComponent(() => ({ @@ -172,10 +215,11 @@ describe("StepWelcomeComponent", () => { expect(renderRequests).toBe(0); }); - it("does not overflow a very narrow terminal", () => { + it.each([false, true])("does not overflow narrow terminals with Ultracode available: %s", (ultracodeAvailable) => { initTheme("step-blue"); const component = new StepWelcomeComponent(() => ({ workspaceRoot: "/tmp/project", + ultracodeAvailable, })); for (const width of [1, 4, 7, 8, 11, 30, 46, 47, 58, 59, 60, 80, 100]) { for (const line of component.render(width)) expect(visibleWidth(line)).toBeLessThanOrEqual(width); diff --git a/apps/cli/test/tui-acceptance-interactions.test.ts b/apps/cli/test/tui-acceptance-interactions.test.ts index 7f6c6bfb..27b8c0fc 100644 --- a/apps/cli/test/tui-acceptance-interactions.test.ts +++ b/apps/cli/test/tui-acceptance-interactions.test.ts @@ -2,14 +2,27 @@ * TUI 验收交互套件(第 2 层)—— 对应《tui-acceptance-manual.md》F2/F6/F7/K6 项。 * * 验证三件交互级行为(不经真实终端): - * - F2 斜杠命令优先级:model/permissions/effort/thinking/plan 置顶,其余稳定排序; + * - F2 斜杠命令优先级:model/permissions/ultracode/effort/thinking/plan 置顶,其余稳定排序; * - F7/K6 Ctrl+L 重映射:step 模式 ctrl+l → app.redraw,model.select 让位; * native 模式不受影响;用户显式绑定永远优先。 */ -import { describe, expect, test, vi } from "vitest"; +import { + type AgentSession, + type AgentSessionRuntimeHost, + createSyntheticSourceInfo, + type ResolvedCommand, + SessionManager, + SettingsManager, + stopThemeWatcher, + type ToolInfo, +} from "@step-harness/coding-agent"; +import { type AutocompleteProvider, stripTerminalSequences, TuiMainScreen } from "@step-harness/pi-tui"; +import { Type } from "typebox"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { KeybindingsManager } from "../../../packages/coding-agent/src/core/keybindings.ts"; -import { applyStepKeybindingRemap, orderStepSlashCommands } from "../src/ui/interactive-mode.ts"; +import { initTheme } from "../../../packages/coding-agent/src/theme/theme.ts"; +import { applyStepKeybindingRemap, InteractiveMode, orderStepSlashCommands } from "../src/ui/interactive-mode.ts"; describe("F2. 斜杠命令优先级", () => { test("高频命令置顶,其余保持原有相对顺序", () => { @@ -31,6 +44,129 @@ describe("F2. 斜杠命令优先级", () => { const commands = [{ name: "b" }, { name: "a" }]; expect(orderStepSlashCommands(commands).map((command) => command.name)).toEqual(["b", "a"]); }); + + 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 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(commands.map((command) => command.name)).toEqual(names); + }); + + test("does not invent an Ultracode command when only the alias is registered", () => { + const commands = ["settings", "ultraloop", "model"].map((name) => ({ name })); + expect(orderStepSlashCommands(commands).map((command) => command.name)).toEqual([ + "model", + "settings", + "ultraloop", + ]); + }); +}); + +describe("Ultracode discovery from session registration", () => { + const modes: InteractiveMode[] = []; + const sourceInfo = createSyntheticSourceInfo("test:ultracode", { source: "test" }); + const workflow: ToolInfo = { + name: "workflow", + description: "Workflow orchestration", + parameters: Type.Object({}), + sourceInfo, + }; + + function createMode(getAllTools: AgentSession["getAllTools"], commandNames: string[]): InteractiveMode { + vi.spyOn(TuiMainScreen.prototype, "requestRender").mockImplementation(() => {}); + const commands = commandNames.map((name) => ({ + name, + invocationName: name, + sourceInfo, + handler: async () => {}, + })); + // Exercise the real UI constructor without starting a terminal, model, or workflow. + const runtimeHost = { + services: { agentDir: "/tmp/welcome-registration-test/.step" }, + setBeforeSessionInvalidate: () => {}, + setRebindSession: () => {}, + session: { + sessionManager: SessionManager.inMemory("/tmp/welcome-registration-test"), + settingsManager: SettingsManager.inMemory({ theme: "step-blue", enableSkillCommands: false }), + resourceLoader: { getThemes: () => ({ themes: [] }) }, + promptTemplates: [], + getAllTools, + getActiveToolNames: () => [], + extensionRunner: { + getRegisteredCommands: () => commands, + getCommand: (name: string) => commands.find((command) => command.invocationName === name), + }, + }, + }; + const mode = new InteractiveMode(runtimeHost as unknown as AgentSessionRuntimeHost, { + tuiStyle: "step", + tuiMode: "regular", + }); + modes.push(mode); + return mode; + } + + afterEach(() => { + for (const mode of modes) mode.stop(); + modes.length = 0; + stopThemeWatcher(); + initTheme("dark"); + vi.restoreAllMocks(); + }); + + test("shows the welcome command for deferred workflow tools and refreshes availability", () => { + const getAllTools = vi.fn((): ToolInfo[] => [workflow]); + const mode = createMode(getAllTools, ["ultracode", "ultraloop"]); + + expect(mode.stepWelcome?.render(120).join("\n")).toContain("/ultracode on"); + getAllTools.mockReturnValue([]); + expect(mode.stepWelcome?.render(120).join("\n")).not.toContain("ultracode"); + getAllTools.mockReturnValue([workflow]); + expect(mode.stepWelcome?.render(120).join("\n")).toContain("/ultracode on"); + }); + + test.each([ + { label: "workflow disabled", tools: [], commands: [] }, + { label: "workflow excluded", tools: [], commands: ["ultracode", "ultraloop"] }, + { label: "canonical command missing", tools: [workflow], commands: ["ultraloop"] }, + ])("omits the welcome tip with $label", ({ tools, commands }) => { + const mode = createMode(() => tools, commands); + + for (const width of [30, 120]) { + const output = mode.stepWelcome!.render(width).map(stripTerminalSequences).join("\n"); + expect(output).not.toMatch(/ultracode|ultraloop|parallel subagents/u); + expect(output).toContain("/goal"); + } + }); + + test("offers registered Ultracode near the top of bare slash completion and retains its alias", async () => { + const mode = createMode(() => [workflow], ["permissions", "plan", "ultraloop", "ultracode"]); + const provider = ( + mode as unknown as { createBaseAutocompleteProvider(): AutocompleteProvider } + ).createBaseAutocompleteProvider(); + 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.filter((name) => name === "ultracode")).toHaveLength(1); + expect(names.filter((name) => name === "ultraloop")).toHaveLength(1); + expect(names.indexOf("ultraloop")).toBeGreaterThan(5); + }); + + test("does not add unavailable workflow commands to bare slash completion", async () => { + const mode = createMode(() => [], ["permissions", "plan"]); + const provider = ( + mode as unknown as { createBaseAutocompleteProvider(): AutocompleteProvider } + ).createBaseAutocompleteProvider(); + const suggestions = await provider.getSuggestions(["/"], 0, 1, { signal: new AbortController().signal }); + const names = suggestions!.items.map((item) => item.value); + + expect(names).not.toContain("ultracode"); + expect(names).not.toContain("ultraloop"); + }); }); describe("F7/K6. Ctrl+L 重映射", () => { diff --git a/docs/orchestration-lifecycle.md b/docs/orchestration-lifecycle.md index b6e7bb30..93b7f0c0 100644 --- a/docs/orchestration-lifecycle.md +++ b/docs/orchestration-lifecycle.md @@ -7,18 +7,54 @@ namespace remain `step` and `.stepcode`. ## Ultracode and workflows +Ultracode and Ultraloop name the same workflow opt-in mode. Workflow registration +is enabled by default; `STEP_DISABLE_WORKFLOW=1` or an embedder opt-out hides the +commands and capability guidance. When the workflow +capability is registered, use these commands in the interactive editor: + +| Command | Effect | +| --- | --- | +| `/ultracode on` | Enable session mode | +| `/ultracode off` | Remove the standing session opt-in | +| `/ultracode status` | Show the current session mode | +| `/ultracode help` | Show usage and the current session mode | +| `/ultracode` | Same as `help`; leaves the mode unchanged | + +`/ultraloop` is an exact alias. Both names complete `on`, `off`, `status`, and +`help`, and share one state in the workflow extension. Bare commands, `help`, +and `status` leave that state unchanged. Enabling session mode projects +`Ultracode on` into the status line; disabling it or crossing a session boundary +clears the projection. The UI reads this state from the extension. + +For one turn, send `ultracode: ` or `ultraloop: ` in the TUI, or pass +it as a launch prompt, for example: + +```sh +step -p "ultracode: Review the API changes and verify the tests" +``` + A prompt containing `ultracode`, `ultraloop`, or an explicit workflow request opts -that prompt into workflow orchestration. `/ultraloop on` enables it for the -session; `/ultraloop off` removes that standing opt-in. The prompt's `+500k` or -`+1.5m` directive supplies a default token budget for its workflow calls. Saved -workflow invocations and skills can also authorize workflow use. The opt-in is a -model-guidance and journaling contract; off-consent calls are recorded, not -rejected by a hard permission gate. +that prompt into workflow orchestration without changing session mode. The +prompt's `+500k` or `+1.5m` directive supplies a default token budget for its +workflow calls. Saved workflow invocations and skills can also authorize +workflow use. + +Capability discovery is separate from consent. When workflows are registered, +the model receives guidance that maps both names to `workflow` and states the +current session mode and tool-profile availability. `find_tools` only searches +tools; it does not activate session mode or expand tool access. Registration, +welcome tips, and discovery guidance do not authorize workflow use. The guidance +does not replace the dynamic system prompt, so active-tool guidance can refresh +when active tools change. The `ultraloop-opt-in` reminder remains tied to a per-turn or +session signal. Opt-in is a model-guidance and journaling contract; off-consent +calls are recorded, not rejected by a hard permission gate. Tool permissions +and model thinking settings remain separate from this mode. A product run may contain multiple low-level attempts because of retries or context compaction. The opt-in and prompt budget survive those attempts and clear at `agent_settled`. The next submitted prompt replaces the prompt-specific -state. Session mode survives settlement and resets with the session. +state. Session mode survives settlement until `/ultracode off` (or +`/ultraloop off`) or a session boundary, when it resets to off. The runtime checks cancellation before entering the VM and after it returns. A tool call whose signal was already aborted does not start a child agent. @@ -181,7 +217,7 @@ The comparison uses official documentation retrieved on 2026-09-20: | Area | Current StepCode behavior | Follow-up for closer alignment | | --- | --- | --- | -| Workflow entry | Prompt keyword and `/ultraloop` session controls | Add `/effort ultracode` or an equivalent entry if that vocabulary is desired | +| Workflow entry | `/ultracode` and `/ultraloop` share session controls; `ultracode: ` and `ultraloop: ` opt in for one turn | — | | Workflow execution | Foreground tool with live agent counts/tasks, journals, cancellation, replay | Background workflow task view with pause/resume controls | | Goal completion | Working agent calls `update_goal`; user pause/resume and persisted budgets | Independent completion evaluator with visible verdict/reason and a no-progress stop policy | | Interval scheduling | `cron_create`, `cron_list`, `cron_delete`, `/cron` | `/loop` convenience command, including a design for completion-relative intervals | diff --git a/docs/step-welcome-wordmark.md b/docs/step-welcome-wordmark.md index 6ea02eeb..423df422 100644 --- a/docs/step-welcome-wordmark.md +++ b/docs/step-welcome-wordmark.md @@ -41,16 +41,16 @@ T 的竖笔居中,C 的开口上下对称,P 的内部字腔不再填入投 ### 使用提示 -信息框底部新增静态 Tips 区,保留上方原有信息。三条提示分别覆盖: +信息框底部的 Tips 区保留上方原有信息,始终显示 `/cron` 和 `/goal`;当前会话同时注册了 `workflow` 工具和 `/ultracode` 命令时,再显示 Ultracode 提示: - `/cron`:View and manage scheduled tasks. - `/goal`:Set a goal and keep working toward it across turns. -- `ultracode`:Include this keyword in your prompt to enable parallel subagents. +- `/ultracode on`:Use parallel agents for this session. One turn: ultracode: task. -每项只用一句话,不展示参数占位符、别名、子命令、预算或引擎细节;仅精简欢迎文案,不改变实际功能。 +Ultracode 提示给出会话开启命令和单轮提示词前缀;完整用法见 `/ultracode help`、`step --help` 和 [workflow 生命周期](orchestration-lifecycle.md)。`/ultraloop` 是等价别名,两者共用 `on`、`off`、`status`、`help` 子命令和同一状态;裸命令显示帮助与当前状态,不开启模式。 -命令使用当前主题 accent,说明使用 muted,配合原有 P 色边框。Tips 在所有布局中独占框内全宽,不挤入鹈鹕旁的窄信息列;命令按最长项的显示宽度补齐,再空两列,因此说明及其续行均从同一列开始。先按显示列宽折行再上色;说明列不足 24 列时,三项统一改为命令与说明分行显示。 -提示仅描述用法,不表示当前会话已经开启 workflow,也不探测原生模块或导入引擎。没有新增计时器、状态持久化或工具调用。 +命令使用当前主题 accent,说明使用 muted,配合原有 P 色边框。Tips 在所有布局中独占框内全宽,不挤入鹈鹕旁的窄信息列;命令按可见项中最长命令的显示宽度补齐,再空两列,因此说明及其续行均从同一列开始。先按显示列宽折行再上色;说明列不足 24 列时,所有可见项统一改为命令与说明分行显示。 +提示描述已注册能力和用法,不表示用户已经授权执行 workflow。`/ultracode on` 开启后,状态行显示 `Ultracode on`;会话模式跨轮保留,直到 `off` 或会话边界重置。`ultracode: `(也支持 `ultraloop: `)只授权当前轮,不改变会话模式。欢迎组件只读取已注册能力,不导入引擎或调用工具;会话模式与状态投影由 workflow 扩展维护。 ## 验证 diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index ded6b85e..d4d64ff6 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -553,6 +553,22 @@ export function printHelp(extensionFlags?: ExtensionFlag[]): void { const stepAuthCommandsText = IS_STEP_ENTRYPOINT ? `\n ${APP_NAME} login Sign in with the Step account (OAuth)\n ${APP_NAME} logout Remove the stored Step credential` : ""; + const stepUltracodeHelpText = IS_STEP_ENTRYPOINT + ? ` + +${chalk.bold("Ultracode (multi-agent workflows):")} + In the interactive editor, when workflows are available: + /ultracode on Enable session mode + /ultracode off Disable session mode + /ultracode status Show current session mode + /ultracode help Show usage and current session mode + /ultracode Same as help; leaves the mode unchanged + + /ultraloop [on|off|status|help] is an exact alias of /ultracode. + Session mode stays on until /ultracode off or a session boundary. + One turn: "ultracode: " (also "ultraloop: "). + From the shell: ${APP_NAME} -p "ultracode: "` + : ""; const extensionFlagsText = extensionFlags && extensionFlags.length > 0 ? `\n${chalk.bold("Extension CLI Flags:")}\n${extensionFlags @@ -577,7 +593,7 @@ ${chalk.bold("Commands:")} ${APP_NAME} config [-l] Open TUI to enable/disable package resources (Tab switches scope) ${APP_NAME} auth Print credentials or check provider readiness ${stepAuthCommandsText} - ${APP_NAME} --help Show help for install/remove/uninstall/update/list/config/auth + ${APP_NAME} --help Show help for install/remove/uninstall/update/list/config/auth${stepUltracodeHelpText} ${chalk.bold("Options:")} --provider Provider name (default: ${defaultProvider}) diff --git a/packages/coding-agent/src/features/workflow/step-workflow.ts b/packages/coding-agent/src/features/workflow/step-workflow.ts index 3a01da49..c9025de1 100644 --- a/packages/coding-agent/src/features/workflow/step-workflow.ts +++ b/packages/coding-agent/src/features/workflow/step-workflow.ts @@ -144,7 +144,7 @@ export function createStepWorkflowExtension(options: StepWorkflowExtensionOption renderResult: renderWorkflowResult, description: `Run an isolated JavaScript workflow that coordinates many agents through phase(), parallel(), pipeline(), agent(), and the HoH iterate() loop. Scripts run in a sandboxed VM (no process, require, network, wall clock, or randomness); every agent call is journaled under .stepcode/workflows/runs so runs can resume. Provide exactly one of script (inline), scriptPath, or name (saved workflow); /workflows lists saved workflows and recent runs. -OPT-IN REQUIRED. Use this tool only when the user has opted in via one of: the keyword "ultraloop" (or Claude Code's spelling "ultracode") in the current message; an explicit request such as "use a workflow", "run a workflow", "fan out agents", or "orchestrate this with subagents"; ultraloop turned on for the session (a system-reminder confirms it — see the Ultraloop section below); a saved workflow invoked by name; or a skill that instructs it. The harness attaches a customType:"ultraloop-opt-in" system-reminder to the opting turn — per-turn signals produce a "the user included ..." reminder, session-standing mode produces an "Ultraloop is on for the session ..." reminder that repeats every turn until /ultraloop off. Saved-workflow or skill invocations are valid opt-in even without a reminder. Never infer opt-in from task shape — a large or parallel-looking task is not consent, per-turn opt-in from an earlier turn does not carry forward, and this tool being registered (STEP_ENABLE_WORKFLOW) is an environment capability, not consent. Without any signal, prefer targeted tools: a single subagent (Agent tool), search_files, or read_file; for calendar-based deferral prefer cron_create; for a multi-turn objective prefer create_goal. +OPT-IN REQUIRED. Use this tool only when the user has opted in via one of: the keyword "ultraloop" (or Claude Code's spelling "ultracode") in the current message; an explicit request such as "use a workflow", "run a workflow", "fan out agents", or "orchestrate this with subagents"; ultraloop turned on for the session (a system-reminder confirms it — see the Ultraloop section below); a saved workflow invoked by name; or a skill that instructs it. The harness attaches a customType:"ultraloop-opt-in" system-reminder to the opting turn — per-turn signals produce a "the user included ..." reminder, session-standing mode produces an "Ultraloop is on for the session ..." reminder that repeats every turn until /ultraloop off. Saved-workflow or skill invocations are valid opt-in even without a reminder. Never infer opt-in from task shape — a large or parallel-looking task is not consent, per-turn opt-in from an earlier turn does not carry forward, and this tool being registered is an environment capability, not consent. Without any signal, prefer targeted tools: a single subagent (Agent tool), search_files, or read_file; for calendar-based deferral prefer cron_create; for a multi-turn objective prefer create_goal. Ultraloop session-standing mode. When a system-reminder confirms ultraloop is on for the session, the opt-in is standing until /ultraloop off: author and run a workflow for every substantive task by default, the goal is the most exhaustive, correct answer you can produce, and token cost is not a constraint. For multi-phase work (understand → design → implement → review), that often means several workflows in sequence — one per phase — so you stay in the loop between them. Lean toward orchestrating with workflows and adversarially verifying findings unless the work is already verified. Solo only on conversational turns or trivial mechanical edits. When session mode is off, revert to the per-turn opt-in rule above. @@ -180,7 +180,7 @@ Mechanics: promptSnippet: "Run a structured multi-agent workflow", promptGuidelines: [ 'Use workflow only when the user has opted in: the keyword "ultraloop" (or Claude Code\'s spelling "ultracode") or an explicit request in the current message (use a workflow / run a workflow / fan out agents / orchestrate this with subagents), ultraloop turned on for the session (a system-reminder confirms it), invoking a saved workflow by name, or a skill that instructs it. Otherwise prefer targeted tools by name: a single subagent (Agent tool), search_files, or read_file. For calendar-based deferral prefer cron_create; for a multi-turn objective prefer create_goal; workflow only when both fan-out and structure are needed.', - "Never infer workflow opt-in from task shape or size, and never carry a per-turn opt-in forward from an earlier turn; a harness system-reminder confirms keyword, phrase, and session-standing opt-ins (saved-workflow and skill invocations are valid without one), and the tool being registered (STEP_ENABLE_WORKFLOW) is an environment capability, not consent.", + "Never infer workflow opt-in from task shape or size, and never carry a per-turn opt-in forward from an earlier turn; a harness system-reminder confirms keyword, phrase, and session-standing opt-ins (saved-workflow and skill invocations are valid without one), and the tool being registered is an environment capability, not consent.", "When ultraloop is on for the session, the opt-in is standing: author and run a workflow for every substantive task by default, treat token cost as unconstrained, and aim for the most exhaustive correct answer. For multi-phase work, run several workflows in sequence — one per phase — so you stay in the loop between them. Lean toward adversarially verifying findings unless the work is already verified. Solo only on conversational turns or trivial mechanical edits.", "Use a short inline script for experiments and a saved script or scriptPath for repeatable work; /workflows lists saved workflows and recent runs, and /ultraloop toggles session-standing mode.", "Default to a medium run under ~15 agents unless the user asks for scale or ultraloop is on for the session, and log() anything dropped silently: top-N truncations, skipped retries, sampling.", diff --git a/packages/coding-agent/src/features/workflow/ultraloop-opt-in.ts b/packages/coding-agent/src/features/workflow/ultraloop-opt-in.ts index 58ed43f2..b8997ecb 100644 --- a/packages/coding-agent/src/features/workflow/ultraloop-opt-in.ts +++ b/packages/coding-agent/src/features/workflow/ultraloop-opt-in.ts @@ -74,6 +74,20 @@ export function buildUltraloopSessionReminder(): string { return "Ultraloop is on for the session — the user has authorized multi-agent orchestration until they turn it off with /ultraloop off. Author and run a workflow for every substantive task by default; the goal is the most exhaustive, correct answer you can produce, and token cost is not a constraint. For multi-phase work (understand → design → implement → review), run several workflows in sequence — one per phase — so you stay in the loop between them. Lean toward orchestrating with workflows and adversarially verifying findings unless the work is already verified. Solo only on conversational turns or trivial mechanical edits."; } +/** Explain the mode and its current scope without changing the active tool profile. */ +function buildUltraloopDiscovery(activeTools: readonly string[], sessionMode: boolean): string { + const route = activeTools.includes("workflow") + ? "The workflow tool is active; use it when the user requests orchestration." + : "The workflow tool is not active in the current tool profile. Explain that limitation; the session switch does not change tool access."; + return [ + "Ultracode and Ultraloop are names for the same multi-agent workflow capability.", + `Current session mode: ${sessionMode ? "on" : "off"}.`, + "Interactive controls: /ultracode on, /ultracode off, /ultracode status, /ultracode help; /ultraloop is an alias. For one turn, prefix the task with ultracode: or ultraloop:.", + route, + "This capability notice is not consent to start a workflow or enable session mode. For questions about these modes, explain the controls; running a workflow is unnecessary.", + ].join("\n"); +} + /** * Per-turn state shared between the opt-in extension (writer) and the * workflow tool (reader). Both are constructed by step-capabilities with the @@ -109,10 +123,11 @@ export function createUltraloopOptInExtension(options: UltraloopOptInExtensionOp }; // Session boundary resets standing mode; each new session starts opted-out. - pi.on("session_start", () => { + pi.on("session_start", (_event, ctx) => { sessionMode = false; optedInThisTurn = false; setTurnBudget(undefined); + ctx.ui.setStatus("ultracode", undefined); }); // Fires after the user submits a prompt, before the agent loop; the returned @@ -121,6 +136,7 @@ export function createUltraloopOptInExtension(options: UltraloopOptInExtensionOp const token = detectUltraloopOptIn(event.prompt); optedInThisTurn = token !== undefined; setTurnBudget(detectUltraloopBudget(event.prompt)); + const discovery = buildUltraloopDiscovery(pi.getActiveTools(), sessionMode); // Session-standing wins when both signals fire: the LLM already knows // workflow is authorized for the whole session; a per-turn reminder @@ -129,18 +145,27 @@ export function createUltraloopOptInExtension(options: UltraloopOptInExtensionOp return { message: { customType: "ultraloop-opt-in", - content: buildUltraloopSessionReminder(), + content: `${buildUltraloopSessionReminder()}\n\n${discovery}`, display: false, details: { source: "session" }, }, }; } - if (token === undefined) return; + if (token === undefined) { + return { + message: { + customType: "ultraloop-discovery", + content: discovery, + display: false, + details: { source: "discovery" }, + }, + }; + } return { message: { customType: "ultraloop-opt-in", - content: buildUltraloopReminder(token), + content: `${buildUltraloopReminder(token)}\n\n${discovery}`, display: false, details: { source: "turn", token }, }, @@ -163,30 +188,60 @@ export function createUltraloopOptInExtension(options: UltraloopOptInExtensionOp setTurnBudget(undefined); }); - pi.registerCommand("ultraloop", { + const command = { description: - 'Enable multi-agent workflow orchestration. Usage: /ultraloop [on|off|status] for session-standing mode; prefix a message with "ultraloop:" for one-turn opt-in.', + "Ultracode multi-agent workflows. Use /ultracode on, off, status, or help; /ultraloop is an alias.", + getArgumentCompletions: (prefix: string) => + [ + { value: "on", label: "on", description: "Enable workflows for this session" }, + { value: "off", label: "off", description: "Return to one-turn opt-in" }, + { value: "status", label: "status", description: "Show the current session mode" }, + { value: "help", label: "help", description: "Show session and one-turn usage" }, + ].filter((item) => item.value.startsWith(prefix.trim().toLowerCase())), handler: async (args: string, ctx: ExtensionCommandContext) => { const token = args.trim().toLowerCase(); if (token === "on") { sessionMode = true; + ctx.ui.setStatus("ultracode", "Ultracode on"); ctx.ui.notify( - "Ultraloop is on for the session. The workflow tool is authorized until you run /ultraloop off.", + "Ultracode is on for the session. Multi-agent workflows are authorized until /ultracode off or the session ends.", "info", ); return; } if (token === "off") { sessionMode = false; - ctx.ui.notify("Ultraloop session mode is off. Workflow now requires a per-turn opt-in signal.", "info"); + ctx.ui.setStatus("ultracode", undefined); + ctx.ui.notify("Ultracode session mode is off. Use ultracode: for one-turn opt-in.", "info"); + return; + } + if (token === "status") { + ctx.ui.notify(`Ultracode session mode: ${sessionMode ? "on" : "off"}.`, "info"); return; } - if (token === "" || token === "status") { - ctx.ui.notify(`Ultraloop session mode: ${sessionMode ? "on" : "off"}.`, "info"); + if (token === "" || token === "help") { + ctx.ui.notify( + [ + `Ultracode session mode: ${sessionMode ? "on" : "off"}.`, + "Ultracode (Ultraloop) coordinates parallel agents through workflows.", + "/ultracode on Enable for this session (can use more tokens)", + "/ultracode off Return to one-turn opt-in", + "/ultracode status Show the current mode", + "/ultracode help Show this help", + "One turn: ultracode: (or ultraloop: )", + "/ultraloop is an alias with the same controls and state.", + ].join("\n"), + "info", + ); return; } - ctx.ui.notify("Usage: /ultraloop [on|off|status]", "warning"); + ctx.ui.notify("Usage: /ultracode [on|off|status|help] (/ultraloop is an alias).", "warning"); }, + }; + pi.registerCommand("ultracode", command); + pi.registerCommand("ultraloop", { + ...command, + description: "Alias for /ultracode: multi-agent workflows with the same on/off/status/help controls.", }); }; } diff --git a/packages/coding-agent/test/config-help-subcommands.test.ts b/packages/coding-agent/test/config-help-subcommands.test.ts index 493e0768..69c2cc20 100644 --- a/packages/coding-agent/test/config-help-subcommands.test.ts +++ b/packages/coding-agent/test/config-help-subcommands.test.ts @@ -15,6 +15,7 @@ describe("config --help subcommands", () => { expect(result.help).not.toMatch(/^\s+step (?:login|logout)\s/m); expect(result.help).not.toContain("--approval-mode "); expect(result.help).not.toContain("STEP_APPROVAL_MODE"); + expect(result.help).not.toMatch(/ultra(?:code|loop)/i); }); test.each(["step", "custom-assistant"])( @@ -35,6 +36,13 @@ describe("config --help subcommands", () => { expect(result.help).toContain(`${appName} logout`); expect(result.help).toContain("--approval-mode "); expect(result.help).toContain("STEP_APPROVAL_MODE"); + for (const subcommand of ["on", "off", "status", "help"]) { + expect(result.help).toMatch(new RegExp(`^\\s+/ultracode ${subcommand}\\s`, "m")); + } + expect(result.help).toContain("/ultraloop [on|off|status|help]"); + expect(result.help).toContain(`${appName} -p "ultracode: "`); + expect(result.help).toContain('"ultraloop: "'); + expect(result.help).not.toMatch(/--ultra(?:code|loop)\b|\/effort ultracode\b/); }, ); }); diff --git a/packages/coding-agent/test/suite/ultracode-discovery.test.ts b/packages/coding-agent/test/suite/ultracode-discovery.test.ts new file mode 100644 index 00000000..d9df0b5a --- /dev/null +++ b/packages/coding-agent/test/suite/ultracode-discovery.test.ts @@ -0,0 +1,110 @@ +import { fauxAssistantMessage } from "@step-harness/providers"; +import { Type } from "typebox"; +import { afterEach, describe, expect, it } from "vitest"; +import type { ExtensionFactory } from "../../src/core/extensions/types.ts"; +import { createStepWorkflowExtension } from "../../src/features/workflow/step-workflow.ts"; +import { + createUltraloopOptInExtension, + type UltraloopTurnState, +} from "../../src/features/workflow/ultraloop-opt-in.ts"; +import { createHarness, type Harness } from "./harness.ts"; + +const harnesses: Harness[] = []; +const workflowCapabilities: ExtensionFactory = (api) => { + const turnState: UltraloopTurnState = {}; + createStepWorkflowExtension({ turnState })(api); + createUltraloopOptInExtension({ turnState })(api); +}; + +afterEach(() => { + for (const harness of harnesses.splice(0)) harness.cleanup(); +}); + +function latestDiscovery(harness: Harness): string { + const message = harness.session.messages + .filter((entry) => entry.role === "custom" && entry.customType === "ultraloop-discovery") + .at(-1); + return message?.role === "custom" && typeof message.content === "string" ? message.content : ""; +} + +describe("Ultracode entry through a real session", () => { + it("shares command state without model calls and explicitly clears prior standing consent", async () => { + const harness = await createHarness({ tools: [], extensionFactories: [workflowCapabilities] }); + harnesses.push(harness); + await harness.session.bindExtensions({}); + expect(harness.session.getActiveToolNames()).toContain("workflow"); + harness.setResponses([ + fauxAssistantMessage("available"), + fauxAssistantMessage("enabled"), + fauxAssistantMessage("off"), + ]); + + await harness.session.prompt("/ultracode help"); + expect(harness.getPendingResponseCount()).toBe(3); + expect(harness.session.messages).toEqual([]); + await harness.session.prompt("Explain the available modes"); + expect(latestDiscovery(harness)).toContain("/ultracode on"); + expect(latestDiscovery(harness)).toContain("Current session mode: off"); + + await harness.session.prompt("/ultracode on"); + expect(harness.getPendingResponseCount()).toBe(2); + await harness.session.prompt("Review the project"); + expect( + harness.session.messages.some( + (message) => message.role === "custom" && message.customType === "ultraloop-opt-in", + ), + ).toBe(true); + + await harness.session.prompt("/ultraloop off"); + expect(harness.getPendingResponseCount()).toBe(1); + const beforeOffTurn = harness.session.messages.length; + await harness.session.prompt("Explain one small function"); + const freshNotices = harness.session.messages + .slice(beforeOffTurn) + .filter((message) => message.role === "custom" && message.customType === "ultraloop-discovery"); + expect(freshNotices).toEqual([ + expect.objectContaining({ content: expect.stringContaining("Current session mode: off") }), + ]); + expect(harness.eventsOfType("tool_execution_start")).toEqual([]); + }); + + it("does not tell a restricted profile that tool search activates workflow", async () => { + const harness = await createHarness({ + tools: [ + { + name: "find_tools", + label: "find_tools", + description: "Inspect the tool catalog without changing active tools", + parameters: Type.Object({ query: Type.String() }), + execute: async () => ({ content: [{ type: "text", text: "No matching tools" }], details: undefined }), + }, + ], + allowedToolNames: ["find_tools"], + extensionFactories: [workflowCapabilities], + }); + harnesses.push(harness); + await harness.session.bindExtensions({}); + harness.setResponses([fauxAssistantMessage("Tool access is limited")]); + await harness.session.prompt("Explain the available modes"); + + expect(harness.session.getActiveToolNames()).toEqual(["find_tools"]); + expect(harness.session.getAllTools().some((tool) => tool.name === "workflow")).toBe(false); + expect(latestDiscovery(harness)).toContain("not active"); + expect(latestDiscovery(harness)).toContain("tool profile"); + expect(latestDiscovery(harness)).not.toContain("To load the workflow tool"); + expect(harness.eventsOfType("tool_execution_start")).toEqual([]); + }); + + it("keeps the normal system-prompt refresh when active tools change", async () => { + const harness = await createHarness({ tools: [], extensionFactories: [workflowCapabilities] }); + harnesses.push(harness); + await harness.session.bindExtensions({}); + expect(harness.session.systemPrompt).toContain("- workflow:"); + harness.setResponses([fauxAssistantMessage("available")]); + await harness.session.prompt("Explain the available modes"); + expect(latestDiscovery(harness)).toContain("Ultracode"); + harness.session.setActiveToolsByName([]); + expect(harness.session.getActiveToolNames()).toEqual([]); + expect(harness.session.systemPrompt).not.toContain("- workflow:"); + }); +}); diff --git a/packages/coding-agent/test/workflow-ultraloop-opt-in.test.ts b/packages/coding-agent/test/workflow-ultraloop-opt-in.test.ts index e58f9ca4..030b050d 100644 --- a/packages/coding-agent/test/workflow-ultraloop-opt-in.test.ts +++ b/packages/coding-agent/test/workflow-ultraloop-opt-in.test.ts @@ -13,40 +13,41 @@ afterEach(() => { vi.unstubAllEnvs(); }); +type Command = Parameters[1]; + interface Recorded { entries: Array<{ customType: string; data?: unknown }>; - handlers: Map unknown>; - commands: Map unknown }>; + handlers: Map unknown>; + commands: Map; notifications: Array<{ message: string; level: string }>; + statuses: Map; + activeTools: string[]; api: ExtensionAPI; } function harness(): Recorded { const entries: Array<{ customType: string; data?: unknown }> = []; - const handlers = new Map unknown>(); - const commands = new Map< - string, - { description: string; handler: (args: string, ctx: ExtensionCommandContext) => unknown } - >(); + const handlers = new Map unknown>(); + const commands = new Map(); const notifications: Array<{ message: string; level: string }> = []; + const statuses = new Map(); + const activeTools = ["find_tools"]; const api = { - on: (event: string, handler: (event: never) => unknown) => { - handlers.set(event, (payload: never) => handler(payload)); + on: (event: string, handler: (event: never, ctx: ExtensionCommandContext) => unknown) => { + handlers.set(event, (payload, ctx) => handler(payload, ctx ?? makeCtx(notifications, statuses))); }, registerTool: () => {}, - registerCommand: ( - name: string, - command: { description: string; handler: (args: string, ctx: ExtensionCommandContext) => unknown }, - ) => { + registerCommand: (name: string, command: Command) => { commands.set(name, command); }, appendEntry: (customType: string, data?: unknown) => { entries.push({ customType, data }); }, + getActiveTools: () => [...activeTools], sendMessage: () => {}, sendUserMessage: () => {}, } as unknown as ExtensionAPI; - return { entries, handlers, commands, notifications, api }; + return { entries, handlers, commands, notifications, statuses, activeTools, api }; } /** Install with both gates open so tests can exercise handler behaviour without a native binding. */ @@ -54,13 +55,20 @@ function install(h: Recorded): void { createUltraloopOptInExtension({ enabled: true, vmExecutor: () => {} })(h.api); } -function makeCtx(notifications: Array<{ message: string; level: string }>): ExtensionCommandContext { +function makeCtx( + notifications: Array<{ message: string; level: string }>, + statuses = new Map(), +): ExtensionCommandContext { return { cwd: "/tmp/ultraloop", mode: "tui", hasUI: true, ui: { notify: (message: string, level: string) => notifications.push({ message, level }), + setStatus: (key: string, text: string | undefined) => { + if (text === undefined) statuses.delete(key); + else statuses.set(key, text); + }, }, sessionManager: { getEntries: () => [], getSessionId: () => "test" }, } as unknown as ExtensionCommandContext; @@ -183,14 +191,14 @@ describe("createUltraloopOptInExtension", () => { expect(result?.message?.details).toEqual({ source: "turn", token: "ultraloop" }); }); - test("no signal and no session mode yields no reminder", () => { + test("no signal and no session mode yields no opt-in reminder", () => { const h = harness(); install(h); const result = h.handlers.get("before_agent_start")?.({ type: "before_agent_start", prompt: "fix the flaky assertion", } as never); - expect(result).toBeUndefined(); + expect((result as { message?: { customType: string } })?.message?.customType).not.toBe("ultraloop-opt-in"); }); test("off-consent workflow tool_call is journaled but not blocked", () => { @@ -261,13 +269,11 @@ describe("createUltraloopOptInExtension", () => { install(h); // Baseline: no signal, no session — no reminder. - expect( - h.handlers.get("before_agent_start")?.({ type: "before_agent_start", prompt: "hi" } as never), - ).toBeUndefined(); + expect(startOrdinaryTurn(h)?.message?.customType).not.toBe("ultraloop-opt-in"); // Turn session mode on via slash. void h.commands.get("ultraloop")?.handler("on", makeCtx(h.notifications)); - expect(h.notifications.at(-1)?.message).toContain("Ultraloop is on for the session"); + expect(h.notifications.at(-1)?.message).toContain("Ultracode is on for the session"); // Any subsequent turn — even without a keyword — produces the session reminder. const first = h.handlers.get("before_agent_start")?.({ @@ -291,11 +297,9 @@ describe("createUltraloopOptInExtension", () => { install(h); void h.commands.get("ultraloop")?.handler("on", makeCtx(h.notifications)); void h.commands.get("ultraloop")?.handler("off", makeCtx(h.notifications)); - expect(h.notifications.at(-1)?.message).toContain("Ultraloop session mode is off"); + expect(h.notifications.at(-1)?.message).toContain("Ultracode session mode is off"); - expect( - h.handlers.get("before_agent_start")?.({ type: "before_agent_start", prompt: "hi" } as never), - ).toBeUndefined(); + expect(startOrdinaryTurn(h)?.message?.customType).not.toBe("ultraloop-opt-in"); const kept = h.handlers.get("before_agent_start")?.({ type: "before_agent_start", @@ -308,10 +312,10 @@ describe("createUltraloopOptInExtension", () => { const h = harness(); install(h); void h.commands.get("ultraloop")?.handler("status", makeCtx(h.notifications)); - expect(h.notifications.at(-1)?.message).toBe("Ultraloop session mode: off."); + expect(h.notifications.at(-1)?.message).toBe("Ultracode session mode: off."); void h.commands.get("ultraloop")?.handler("on", makeCtx(h.notifications)); void h.commands.get("ultraloop")?.handler("", makeCtx(h.notifications)); // empty args → status - expect(h.notifications.at(-1)?.message).toBe("Ultraloop session mode: on."); + expect(h.notifications.at(-1)?.message).toContain("Ultracode session mode: on."); }); test("session mode is not journaled as off-consent", () => { @@ -337,16 +341,17 @@ describe("createUltraloopOptInExtension", () => { void h.commands.get("ultraloop")?.handler("on", makeCtx(h.notifications)); // Simulate new session boundary. h.handlers.get("session_start")?.({ type: "session_start" } as never); - expect( - h.handlers.get("before_agent_start")?.({ type: "before_agent_start", prompt: "hello" } as never), - ).toBeUndefined(); + expect(startOrdinaryTurn(h)?.message?.customType).not.toBe("ultraloop-opt-in"); }); test("unknown /ultraloop arg surfaces a usage warning", () => { const h = harness(); install(h); void h.commands.get("ultraloop")?.handler("please-toggle", makeCtx(h.notifications)); - expect(h.notifications.at(-1)).toEqual({ message: "Usage: /ultraloop [on|off|status]", level: "warning" }); + expect(h.notifications.at(-1)).toEqual({ + message: "Usage: /ultracode [on|off|status|help] (/ultraloop is an alias).", + level: "warning", + }); }); test("a +500k directive fills the shared turn state and clears with the turn", () => { @@ -389,3 +394,114 @@ test("internal attempt endings retain workflow consent and the turn budget until h.handlers.get("agent_settled")?.({ type: "agent_settled" } as never); expect(turnState.budgetTotal).toBeUndefined(); }); + +function startOrdinaryTurn(h: Recorded) { + return h.handlers.get("before_agent_start")?.({ + type: "before_agent_start", + prompt: "explain the available features", + systemPrompt: "base prompt", + } as never) as { systemPrompt?: string; message?: { customType: string; content: string } } | undefined; +} + +describe("Ultracode discovery and command aliases", () => { + test("registers both names over the same session state", async () => { + const h = harness(); + install(h); + const canonical = h.commands.get("ultracode"); + expect(canonical).toBeDefined(); + await canonical!.handler("on", makeCtx(h.notifications, h.statuses)); + expect(startOrdinaryTurn(h)?.message?.customType).toBe("ultraloop-opt-in"); + await h.commands.get("ultraloop")!.handler("off", makeCtx(h.notifications, h.statuses)); + expect(startOrdinaryTurn(h)?.message?.customType).not.toBe("ultraloop-opt-in"); + await h.commands.get("ultraloop")!.handler("on", makeCtx(h.notifications, h.statuses)); + await canonical!.handler("status", makeCtx(h.notifications, h.statuses)); + expect(h.notifications.at(-1)?.message).toContain("on"); + await canonical!.handler("off", makeCtx(h.notifications, h.statuses)); + expect(startOrdinaryTurn(h)?.message?.customType).not.toBe("ultraloop-opt-in"); + }); + + test.each(["ultracode", "ultraloop"])( + "%s help and bare command explain activation without enabling", + async (name) => { + const h = harness(); + install(h); + const command = h.commands.get(name); + expect(command).toBeDefined(); + for (const args of ["", "help"]) { + await command!.handler(args, makeCtx(h.notifications, h.statuses)); + const text = h.notifications.at(-1)?.message; + expect(text).toContain("off"); + expect(text).toContain("/ultracode on"); + expect(text).toContain("ultracode:"); + expect(text).toContain("/ultraloop"); + expect(startOrdinaryTurn(h)?.message?.customType).not.toBe("ultraloop-opt-in"); + expect(h.statuses.has("ultracode")).toBe(false); + } + }, + ); + + test.each(["ultracode", "ultraloop"])("%s offers its mode arguments before execution", async (name) => { + const h = harness(); + install(h); + const complete = h.commands.get(name)?.getArgumentCompletions; + expect(complete).toBeDefined(); + expect((await complete!(""))?.map((item) => item.value)).toEqual(["on", "off", "status", "help"]); + expect((await complete!("o"))?.map((item) => item.value)).toEqual(["on", "off"]); + expect((await complete!("st"))?.map((item) => item.value)).toEqual(["status"]); + expect(startOrdinaryTurn(h)?.message?.customType).not.toBe("ultraloop-opt-in"); + }); + + test("projects enabled mode and clears it on disable and session reset", async () => { + const h = harness(); + install(h); + const command = h.commands.get("ultraloop")!; + const ctx = makeCtx(h.notifications, h.statuses); + await command.handler("on", ctx); + expect(h.statuses.get("ultracode")).toBe("Ultracode on"); + await command.handler("help", ctx); + expect(h.statuses.get("ultracode")).toBe("Ultracode on"); + await command.handler("off", ctx); + expect(h.statuses.has("ultracode")).toBe(false); + await command.handler("on", ctx); + h.handlers.get("session_start")?.({ type: "session_start" } as never, ctx); + expect(h.statuses.has("ultracode")).toBe(false); + expect(startOrdinaryTurn(h)?.message?.customType).not.toBe("ultraloop-opt-in"); + }); + + test("explains mode and tool availability without granting consent or replacing the system prompt", () => { + const h = harness(); + install(h); + const result = startOrdinaryTurn(h); + expect(result?.message?.customType).toBe("ultraloop-discovery"); + expect(result?.message?.content).toContain("Ultracode"); + expect(result?.message?.content).toContain("Ultraloop"); + expect(result?.message?.content).toContain("/ultracode on"); + expect(result?.message?.content).toContain("Current session mode: off"); + expect(result?.message?.content).toContain("tool profile"); + expect(result?.message?.content).not.toContain("To load the workflow tool"); + expect(result?.message?.content).toContain("workflow"); + expect(result?.message?.content).toContain("not consent"); + expect(result).not.toHaveProperty("systemPrompt"); + h.handlers.get("tool_call")?.({ toolName: "workflow", toolCallId: "unapproved" } as never); + expect(h.entries.at(-1)?.data).toEqual({ offConsentCall: true, toolCallId: "unapproved" }); + }); + + test("describes active or unavailable tools accurately", () => { + const h = harness(); + install(h); + h.activeTools.splice(0, h.activeTools.length, "workflow"); + expect(startOrdinaryTurn(h)?.message?.content).toContain("workflow"); + expect(startOrdinaryTurn(h)?.message?.content).not.toContain("find_tools"); + h.activeTools.length = 0; + expect(startOrdinaryTurn(h)?.message?.content).toContain("not active"); + expect(startOrdinaryTurn(h)?.message?.customType).not.toBe("ultraloop-opt-in"); + }); + + test("disabled registration advertises neither alias nor model guidance", () => { + const h = harness(); + createUltraloopOptInExtension({ enabled: false })(h.api); + expect(h.commands.has("ultracode")).toBe(false); + expect(h.commands.has("ultraloop")).toBe(false); + expect(h.handlers.has("before_agent_start")).toBe(false); + }); +});