diff --git a/CHANGELOG.md b/CHANGELOG.md index e8752071..7917b1ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,5 +8,6 @@ ### Fixed +- Stop appending Ultracode capability tutorials on every turn. Announce session-mode transitions after user input, reuse unchanged state, and restore necessary reminders when context is compacted or replaced. - Recognize `/exit` as an alias for `/quit`, including while the agent is running or compacting. - Recognize `search_files`, `find_files`, and `list_directory` when selecting default system-prompt file-exploration guidance. diff --git a/docs/orchestration-lifecycle.md b/docs/orchestration-lifecycle.md index 93b7f0c0..beb36715 100644 --- a/docs/orchestration-lifecycle.md +++ b/docs/orchestration-lifecycle.md @@ -39,16 +39,27 @@ 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. +Capability discovery is separate from consent. Welcome tips, slash completion, +and command help expose the controls to users. The workflow tool description +explains both names and the opt-in rules to the model; ordinary opted-out turns +do not append a capability tutorial. `find_tools` only searches tools; it does +not activate session mode or expand tool access. + +Session-mode reminders follow the last runtime-authored state in the active +context: entering the mode adds an on reminder, unchanged turns add nothing, +and leaving an announced mode adds an off reminder that revokes standing consent. +A new per-turn opt-in still gets its own scoped reminder. The real user message +comes first, followed by the hidden runtime reminder. Internally these are custom +messages; the provider conversion represents them as `user` messages. + +Compaction, live-context editing, or switching branches may remove a previous +notice. The runtime reconciles the currently visible context and supplies a +request-local mode reminder when needed, including an off transition during +streaming. These fallback notices do not accumulate in stored session history. +The dynamic system prompt still refreshes when active tools change. 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 diff --git a/packages/coding-agent/src/features/step.ts b/packages/coding-agent/src/features/step.ts index 3d670edc..b722753c 100644 --- a/packages/coding-agent/src/features/step.ts +++ b/packages/coding-agent/src/features/step.ts @@ -222,14 +222,14 @@ export function createStepExtension(options: StepExtensionOptions = {}): Extensi nativeRetryOverridden = false; } if (autoResume) { - if (!nativeRetryOverridden && ctx.autoRetryEnabled !== true) { - nativeRetryOverridden = true; - } - try { - ctx.setAutoRetryEnabled(true); - } catch { - // Retry is a product convenience. A host that cannot persist settings - // must still be able to execute the current turn. + if (ctx.autoRetryEnabled !== true) { + if (!nativeRetryOverridden) nativeRetryOverridden = true; + try { + ctx.setAutoRetryEnabled(true); + } catch { + // Retry is a product convenience. A host that cannot persist settings + // must still be able to execute the current turn. + } } } else if (nativeRetryOverridden) { try { diff --git a/packages/coding-agent/src/features/workflow/step-workflow.ts b/packages/coding-agent/src/features/workflow/step-workflow.ts index c9025de1..3abfd8ab 100644 --- a/packages/coding-agent/src/features/workflow/step-workflow.ts +++ b/packages/coding-agent/src/features/workflow/step-workflow.ts @@ -144,7 +144,9 @@ 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 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. +Ultracode and Ultraloop name this same multi-agent capability. Interactive controls are /ultracode on, off, status, and help; /ultraloop is an alias. For a single task use ultracode: or ultraloop:. When asked about these modes, explain the controls without starting a workflow. These controls do not expand the active tool profile. + +OPT-IN REQUIRED. Use this tool only when the user has opted in via one of: the keyword "ultraloop" (or "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 after the user message on an opting turn. Session-mode reminders announce changes: the latest on/off state in the current context is authoritative; an unchanged state is not repeated, and an off reminder revokes earlier standing consent. 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. 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 b8997ecb..c76f3811 100644 --- a/packages/coding-agent/src/features/workflow/ultraloop-opt-in.ts +++ b/packages/coding-agent/src/features/workflow/ultraloop-opt-in.ts @@ -12,9 +12,8 @@ * * 2) session-standing mode: the user turns on ultraloop for the whole * session via /ultraloop on. A session-scoped system-reminder is - * attached to every subsequent turn until /ultraloop off, or the - * session ends. Mirrors Claude Code's "Ultracode is on for the session" - * opt-in. + * attached when the mode differs from the last state in the active + * context. Unchanged turns reuse it; turning off revokes standing consent. * * Soft gate only — off-consent workflow calls are journaled via appendEntry, * never blocked (no silent behavior; saved-workflow by name and skill-driven @@ -64,9 +63,12 @@ export function detectUltraloopBudget(prompt: string): number | undefined { return Number.isFinite(value) && value > 0 ? value : undefined; } -export function buildUltraloopReminder(token: string): string { +export function buildUltraloopReminder(token: string, workflowAvailable = true): string { const lower = token.toLowerCase(); const signal = lower === "ultraloop" || lower === "ultracode" ? `the keyword "${lower}"` : `the phrase "${token}"`; + if (!workflowAvailable) { + return `The user included ${signal}, requesting multi-agent orchestration for this turn. The workflow tool is not active in the current tool profile. Explain this limitation instead of attempting to call it; the session switch and find_tools do not expand tool access.`; + } return `The user included ${signal}, opting this turn into multi-agent orchestration — use the workflow tool to fulfill the request.`; } @@ -74,18 +76,45 @@ 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"); +function buildUltraloopSessionOffReminder(): string { + return "Ultracode session mode is off. Previous session-wide workflow authorization no longer applies. Use workflow only for a new explicit per-turn request, a saved workflow invoked by name, or a skill instruction. Do not carry earlier per-turn authorization forward."; +} + +interface ReminderRecord { + customType: string; + content: unknown; + details?: unknown; +} + +/** Inspect only runtime-authored entries in the selected context, not user text. */ +function lastSessionMode(records: readonly ReminderRecord[]): boolean | undefined { + for (let index = records.length - 1; index >= 0; index--) { + const record = records[index]!; + if (record.customType !== "ultraloop-opt-in" && record.customType !== "ultraloop-discovery") continue; + const details = record.details; + if (!details || typeof details !== "object") continue; + if ("sessionMode" in details && typeof details.sessionMode === "boolean") return details.sessionMode; + // Read reminders from sessions written before transition-based notices. + if ("source" in details && details.source === "session") return true; + if (record.customType === "ultraloop-discovery" && typeof record.content === "string") { + if (record.content.includes("Current session mode: off.")) return false; + if (record.content.includes("Current session mode: on.")) return true; + } + } + return undefined; +} + +function sessionModeReminder(sessionMode: boolean, previous: boolean | undefined) { + if (sessionMode ? previous === true : previous !== true) return undefined; + const content = sessionMode ? buildUltraloopSessionReminder() : buildUltraloopSessionOffReminder(); + return { + customType: "ultraloop-opt-in", + // Standing consent can outlive a tool-profile change. State the constraint + // conditionally rather than persisting a snapshot of tool availability. + content: `${content}\nIf the workflow tool is not active in the current tool profile, explain the limitation instead of attempting to call it. Consult the current active tools; session mode does not expand tool access.`, + display: false, + details: { source: "session", sessionMode }, + }; } /** @@ -130,48 +159,45 @@ export function createUltraloopOptInExtension(options: UltraloopOptInExtensionOp ctx.ui.setStatus("ultracode", undefined); }); - // Fires after the user submits a prompt, before the agent loop; the returned - // message lands in exactly this turn's context (BeforeAgentStartEventResult.message). - pi.on("before_agent_start", (event) => { + // BeforeAgentStartEventResult.message is appended after the real user input + // and retained in session history. Read the selected branch before deciding + // whether it needs a new state announcement; do not cache an emitted flag. + pi.on("before_agent_start", (event, ctx) => { const token = detectUltraloopOptIn(event.prompt); optedInThisTurn = token !== undefined; setTurnBudget(detectUltraloopBudget(event.prompt)); - const discovery = buildUltraloopDiscovery(pi.getActiveTools(), sessionMode); + const previous = lastSessionMode( + ctx.sessionManager.buildContextEntries().filter((entry) => entry.type === "custom_message"), + ); + const modeReminder = sessionModeReminder(sessionMode, previous); // Session-standing wins when both signals fire: the LLM already knows // workflow is authorized for the whole session; a per-turn reminder // on top is redundant noise. - if (sessionMode) { - return { - message: { - customType: "ultraloop-opt-in", - content: `${buildUltraloopSessionReminder()}\n\n${discovery}`, - display: false, - details: { source: "session" }, - }, - }; - } - - if (token === undefined) { - return { - message: { - customType: "ultraloop-discovery", - content: discovery, - display: false, - details: { source: "discovery" }, - }, - }; - } + if (sessionMode || token === undefined) return modeReminder ? { message: modeReminder } : undefined; return { message: { customType: "ultraloop-opt-in", - content: `${buildUltraloopReminder(token)}\n\n${discovery}`, + content: [modeReminder?.content, buildUltraloopReminder(token, pi.getActiveTools().includes("workflow"))] + .filter(Boolean) + .join("\n\n"), display: false, - details: { source: "turn", token }, + details: { source: "turn", token, ...(modeReminder ? { sessionMode: false } : {}) }, }, }; }); + // Compaction/live-context edits can remove an earlier announcement, and + // commands can toggle the mode during streaming without before_agent_start. + // Reconcile against the actual provider context in those cases. This fallback + // is request-local: it does not append duplicate entries to session history. + pi.on("context", (event) => { + const previous = lastSessionMode(event.messages.filter((message) => message.role === "custom")); + const message = sessionModeReminder(sessionMode, previous); + if (!message) return; + return { messages: [...event.messages, { role: "custom" as const, ...message, timestamp: Date.now() }] }; + }); + // Soft-gate telemetry: journal off-consent workflow calls; never block. // Either signal counts as consent — saved-workflow by name and skill-driven // calls without a detectable token remain valid per the static guidance. diff --git a/packages/coding-agent/src/step/system-prompt.ts b/packages/coding-agent/src/step/system-prompt.ts index 902c3b31..91a22789 100644 --- a/packages/coding-agent/src/step/system-prompt.ts +++ b/packages/coding-agent/src/step/system-prompt.ts @@ -269,7 +269,7 @@ export function buildStepSystemPromptAppendix( ? [ "# Workflow orchestration", [ - '- workflow is opt-in: reach for it only when the user\'s current message contains the keyword "ultraloop" (or its Claude Code spelling "ultracode") or an explicit request such as "use a workflow", "run a workflow", "fan out agents", or "orchestrate this with subagents"; ultraloop is on for the session (a system-reminder confirms it and repeats every turn until /ultraloop off); the user invokes a saved workflow by name; or a skill instructs it. A system-reminder confirms per-turn and session-standing opt-ins (saved-workflow and skill invocations are valid without one); never infer opt-in from task shape, and never carry a per-turn opt-in forward.', + '- workflow is opt-in: reach for it only when the user\'s current message contains the keyword "ultraloop" (or "ultracode") or an explicit request such as "use a workflow", "run a workflow", "fan out agents", or "orchestrate this with subagents"; ultraloop is on for the session (the latest on/off system-reminder in the current context reports its state; unchanged state is not repeated); the user invokes a saved workflow by name; or a skill instructs it. A system-reminder confirms per-turn and session-standing opt-ins (saved-workflow and skill invocations are valid without one); never infer opt-in from task shape, and never carry a per-turn opt-in forward.', "- When ultraloop is on for the session that 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. Multi-phase work often means 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. When session mode is off, revert to the per-turn opt-in rule.", "- Without any signal, prefer targeted tools — search_files, read_file, or a single subagent — and never reach for workflow on single-file edits, one-shot lookups, or exploration that a few targeted queries can answer.", "- With opt-in, workflow fits broad multi-subsystem audits, mechanical migrations across many sites, multi-way parallel review with adversarial verification, research sweeps, and HoH convergence on a spec.", diff --git a/packages/coding-agent/test/step-extension.test.ts b/packages/coding-agent/test/step-extension.test.ts index c7b21491..5d7e05a6 100644 --- a/packages/coding-agent/test/step-extension.test.ts +++ b/packages/coding-agent/test/step-extension.test.ts @@ -56,6 +56,33 @@ describe("Step extension", () => { } }, ); + test("does not persist retry when automatic mode finds it already enabled", () => { + const on = vi.fn(); + createStepExtension({ + permission: { env: {}, initialPreset: "approve-for-me" }, + stepSettings: () => ({ + getStepSettings: () => ({}), + setEffectiveStepSettings: vi.fn(), + }), + })({ + on, + sendUserMessage: vi.fn(), + registerProvider: vi.fn(), + registerCommand: vi.fn(), + } as unknown as ExtensionAPI); + const sessionStart = on.mock.calls.find(([event]) => event === "session_start")![1]; + const setAutoRetryEnabled = vi.fn(); + sessionStart( + {}, + { + hasUI: false, + ui: { notify: vi.fn(), setStatus: vi.fn() }, + autoRetryEnabled: true, + setAutoRetryEnabled, + }, + ); + expect(setAutoRetryEnabled).not.toHaveBeenCalled(); + }); test.each([ { name: "selected non-Bash interpreter", shellPath: process.execPath, prefix: undefined }, { name: "executed command prefix", shellPath: undefined, prefix: "rm -rf ./build" }, diff --git a/packages/coding-agent/test/suite/ultracode-discovery.test.ts b/packages/coding-agent/test/suite/ultracode-discovery.test.ts index d9df0b5a..d344fa26 100644 --- a/packages/coding-agent/test/suite/ultracode-discovery.test.ts +++ b/packages/coding-agent/test/suite/ultracode-discovery.test.ts @@ -7,7 +7,7 @@ import { createUltraloopOptInExtension, type UltraloopTurnState, } from "../../src/features/workflow/ultraloop-opt-in.ts"; -import { createHarness, type Harness } from "./harness.ts"; +import { createHarness, getMessageText, type Harness } from "./harness.ts"; const harnesses: Harness[] = []; const workflowCapabilities: ExtensionFactory = (api) => { @@ -20,55 +20,66 @@ afterEach(() => { for (const harness of harnesses.splice(0)) harness.cleanup(); }); -function latestDiscovery(harness: Harness): string { +function latestModeNotice(harness: Harness): string { const message = harness.session.messages - .filter((entry) => entry.role === "custom" && entry.customType === "ultraloop-discovery") + .filter((entry) => entry.role === "custom" && entry.customType === "ultraloop-opt-in") .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 () => { + it("only adds mode transitions, after real user input, at the provider boundary", 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"), - ]); + const requests: Array> = []; + harness.setResponses( + Array.from({ length: 7 }, () => (context) => { + requests.push(context.messages.map((message) => ({ role: message.role, text: getMessageText(message) }))); + return fauxAssistantMessage("OK"); + }), + ); await harness.session.prompt("/ultracode help"); - expect(harness.getPendingResponseCount()).toBe(3); + expect(harness.getPendingResponseCount()).toBe(7); 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("Explain another function"); + expect(harness.session.messages.some((message) => message.role === "custom")).toBe(false); await harness.session.prompt("/ultracode on"); - expect(harness.getPendingResponseCount()).toBe(2); + expect(harness.getPendingResponseCount()).toBe(5); await harness.session.prompt("Review the project"); - expect( - harness.session.messages.some( - (message) => message.role === "custom" && message.customType === "ultraloop-opt-in", - ), - ).toBe(true); + expect(latestModeNotice(harness)).toContain("Ultraloop is on for the session"); + await harness.session.prompt("Review the tests"); await harness.session.prompt("/ultraloop off"); - expect(harness.getPendingResponseCount()).toBe(1); - const beforeOffTurn = harness.session.messages.length; + expect(harness.getPendingResponseCount()).toBe(3); 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(latestModeNotice(harness)).toContain("session mode is off"); + await harness.session.prompt("Explain another small function"); + await harness.session.prompt("/ultracode on"); + await harness.session.prompt("Review once more"); + const isNotice = (text: string) => text.startsWith(""); + expect(requests.map((messages) => messages.filter((message) => isNotice(message.text)).length)).toEqual([ + 0, 0, 1, 1, 2, 2, 3, ]); + for (const index of [2, 4, 6]) { + const messages = requests[index]!; + expect(messages.at(-2)?.role).toBe("user"); + expect(isNotice(messages.at(-2)!.text)).toBe(false); + expect(messages.at(-1)?.role).toBe("user"); + expect(isNotice(messages.at(-1)!.text)).toBe(true); + } + const notices = harness.session.messages.filter((message) => message.role === "custom"); + expect(notices).toHaveLength(3); + expect(notices.every((message) => message.role === "custom" && message.display === false)).toBe(true); + expect(requests.flat().some((message) => message.text.includes("Ultracode and Ultraloop are names"))).toBe(false); expect(harness.eventsOfType("tool_execution_start")).toEqual([]); }); - it("does not tell a restricted profile that tool search activates workflow", async () => { + it.each(["session", "turn"])("explains unavailable workflow for a restricted profile (%s opt-in)", async (scope) => { const harness = await createHarness({ tools: [ { @@ -85,16 +96,48 @@ describe("Ultracode entry through a real session", () => { harnesses.push(harness); await harness.session.bindExtensions({}); harness.setResponses([fauxAssistantMessage("Tool access is limited")]); - await harness.session.prompt("Explain the available modes"); + if (scope === "session") await harness.session.prompt("/ultracode on"); + await harness.session.prompt(scope === "session" ? "Explain the available modes" : "ultracode: review this task"); 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(latestModeNotice(harness)).toContain("not active"); + expect(latestModeNotice(harness)).toContain("tool profile"); + expect(latestModeNotice(harness)).not.toContain("To load the workflow tool"); + expect(latestModeNotice(harness)).toContain("instead of attempting to call it"); + if (scope === "turn") + expect(latestModeNotice(harness)).not.toContain("use the workflow tool to fulfill the request"); expect(harness.eventsOfType("tool_execution_start")).toEqual([]); }); + it("keeps standing consent independent of changes to the active tool profile", async () => { + const harness = await createHarness({ tools: [], extensionFactories: [workflowCapabilities] }); + harnesses.push(harness); + await harness.session.bindExtensions({}); + const requests: Array<{ tools: string[]; notices: string[] }> = []; + harness.setResponses( + Array.from({ length: 3 }, () => (context) => { + requests.push({ + tools: context.tools?.map((tool) => tool.name) ?? [], + notices: context.messages.map(getMessageText).filter((text) => text.startsWith("")), + }); + return fauxAssistantMessage("OK"); + }), + ); + harness.session.setActiveToolsByName([]); + await harness.session.prompt("/ultracode on"); + await harness.session.prompt("First task"); + harness.session.setActiveToolsByName(["workflow"]); + await harness.session.prompt("Second task"); + harness.session.setActiveToolsByName([]); + await harness.session.prompt("Third task"); + expect(requests.map((request) => request.tools.includes("workflow"))).toEqual([false, true, false]); + expect(requests.map((request) => request.notices.length)).toEqual([1, 1, 1]); + expect(requests[0]!.notices[0]).toContain("If the workflow tool is not active"); + expect(requests[1]!.notices).toEqual(requests[0]!.notices); + expect(requests[2]!.notices).toEqual(requests[0]!.notices); + }); + it("keeps the normal system-prompt refresh when active tools change", async () => { const harness = await createHarness({ tools: [], extensionFactories: [workflowCapabilities] }); harnesses.push(harness); @@ -102,9 +145,122 @@ describe("Ultracode entry through a real session", () => { expect(harness.session.systemPrompt).toContain("- workflow:"); harness.setResponses([fauxAssistantMessage("available")]); await harness.session.prompt("Explain the available modes"); - expect(latestDiscovery(harness)).toContain("Ultracode"); + expect(latestModeNotice(harness)).toBe(""); harness.session.setActiveToolsByName([]); expect(harness.session.getActiveToolNames()).toEqual([]); expect(harness.session.systemPrompt).not.toContain("- workflow:"); }); + + it("keeps per-turn authorization scoped to the user prompt", async () => { + const harness = await createHarness({ tools: [], extensionFactories: [workflowCapabilities] }); + harnesses.push(harness); + await harness.session.bindExtensions({}); + harness.setResponses([fauxAssistantMessage("OK"), fauxAssistantMessage("OK"), fauxAssistantMessage("OK")]); + await harness.session.prompt("ultracode: review this task"); + expect(latestModeNotice(harness)).toContain("use the workflow tool to fulfill the request"); + expect(latestModeNotice(harness)).not.toContain("The workflow tool is not active"); + const firstCount = harness.session.messages.length; + await harness.session.prompt("Explain one function"); + expect(harness.session.messages.slice(firstCount).some((message) => message.role === "custom")).toBe(false); + await harness.session.prompt("ultracode: review a different task"); + const notices = harness.session.messages.filter((message) => message.role === "custom"); + expect(notices).toHaveLength(2); + expect( + notices.every( + (message) => message.role === "custom" && (message.details as { source: string }).source === "turn", + ), + ).toBe(true); + }); + + it("re-announces standing consent when compaction removes the previous notice", async () => { + const harness = await createHarness({ + tools: [], + settings: { compaction: { keepRecentTokens: 1 } }, + extensionFactories: [ + workflowCapabilities, + (api) => { + api.on("session_before_compact", (event) => ({ + compaction: { + summary: "The previous tasks were discussed.", + firstKeptEntryId: event.preparation.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, + }, + })); + }, + ], + }); + harnesses.push(harness); + await harness.session.bindExtensions({}); + harness.setResponses([ + fauxAssistantMessage("First answer"), + fauxAssistantMessage("Second answer"), + fauxAssistantMessage("After compaction"), + ]); + await harness.session.prompt("/ultracode on"); + await harness.session.prompt("First question"); + await harness.session.prompt("Second question"); + await harness.session.compact(); + expect( + harness.session.messages.some( + (message) => message.role === "custom" && message.customType === "ultraloop-opt-in", + ), + ).toBe(false); + await harness.session.prompt("Continue after compaction"); + expect(latestModeNotice(harness)).toContain("Ultraloop is on for the session"); + expect( + harness.session.messages.filter( + (message) => message.role === "custom" && message.customType === "ultraloop-opt-in", + ), + ).toHaveLength(1); + }); + + it("revokes mode during streaming before the next provider call", async () => { + const harness = await createHarness({ tools: [], extensionFactories: [workflowCapabilities] }); + harnesses.push(harness); + await harness.session.bindExtensions({}); + let release!: () => void; + let entered!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const started = new Promise((resolve) => { + entered = resolve; + }); + const requests: string[][] = []; + harness.setResponses([ + async (context) => { + requests.push(context.messages.map(getMessageText)); + entered(); + await gate; + return fauxAssistantMessage("First answer"); + }, + (context) => { + requests.push(context.messages.map(getMessageText)); + return fauxAssistantMessage("Steering answer"); + }, + (context) => { + requests.push(context.messages.map(getMessageText)); + return fauxAssistantMessage("Next answer"); + }, + ]); + await harness.session.prompt("/ultracode on"); + const running = harness.session.prompt("First question"); + try { + await started; + await harness.session.prompt("/ultracode off"); + await harness.session.prompt("Additional input", { streamingBehavior: "steer" }); + release(); + await running; + expect(requests[1]!.at(-2)).toBe("Additional input"); + expect(requests[1]!.at(-1)).toContain("session mode is off"); + await harness.session.prompt("Next question"); + expect( + requests.map((messages) => messages.filter((text) => text.startsWith("")).length), + ).toEqual([1, 2, 2]); + } finally { + release(); + await harness.session.abort(); + await running; + } + }); }); 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 030b050d..4bb7bbb3 100644 --- a/packages/coding-agent/test/workflow-ultraloop-opt-in.test.ts +++ b/packages/coding-agent/test/workflow-ultraloop-opt-in.test.ts @@ -22,6 +22,7 @@ interface Recorded { notifications: Array<{ message: string; level: string }>; statuses: Map; activeTools: string[]; + contextEntries: unknown[]; api: ExtensionAPI; } @@ -32,9 +33,12 @@ function harness(): Recorded { const notifications: Array<{ message: string; level: string }> = []; const statuses = new Map(); const activeTools = ["find_tools"]; + const contextEntries: unknown[] = []; const api = { on: (event: string, handler: (event: never, ctx: ExtensionCommandContext) => unknown) => { - handlers.set(event, (payload, ctx) => handler(payload, ctx ?? makeCtx(notifications, statuses))); + handlers.set(event, (payload, ctx) => + handler(payload, ctx ?? makeCtx(notifications, statuses, contextEntries)), + ); }, registerTool: () => {}, registerCommand: (name: string, command: Command) => { @@ -47,7 +51,7 @@ function harness(): Recorded { sendMessage: () => {}, sendUserMessage: () => {}, } as unknown as ExtensionAPI; - return { entries, handlers, commands, notifications, statuses, activeTools, api }; + return { entries, handlers, commands, notifications, statuses, activeTools, contextEntries, api }; } /** Install with both gates open so tests can exercise handler behaviour without a native binding. */ @@ -58,6 +62,7 @@ function install(h: Recorded): void { function makeCtx( notifications: Array<{ message: string; level: string }>, statuses = new Map(), + contextEntries: unknown[] = [], ): ExtensionCommandContext { return { cwd: "/tmp/ultraloop", @@ -70,7 +75,11 @@ function makeCtx( else statuses.set(key, text); }, }, - sessionManager: { getEntries: () => [], getSessionId: () => "test" }, + sessionManager: { + getEntries: () => contextEntries, + buildContextEntries: () => contextEntries, + getSessionId: () => "test", + }, } as unknown as ExtensionCommandContext; } @@ -264,7 +273,7 @@ describe("createUltraloopOptInExtension", () => { expect(h.entries).toEqual([]); }); - test("/ultraloop on enables session-standing reminder every turn", () => { + test("/ultraloop on announces once while its reminder remains in the active context", () => { const h = harness(); install(h); @@ -275,21 +284,22 @@ describe("createUltraloopOptInExtension", () => { void h.commands.get("ultraloop")?.handler("on", makeCtx(h.notifications)); expect(h.notifications.at(-1)?.message).toContain("Ultracode is on for the session"); - // Any subsequent turn — even without a keyword — produces the session reminder. + // The first ordinary turn announces the new state after the user's message. const first = h.handlers.get("before_agent_start")?.({ type: "before_agent_start", prompt: "please review packages/foo", } as never) as { message?: { content: string; details: unknown } }; expect(first?.message?.content).toContain("Ultraloop is on for the session"); - expect(first?.message?.details).toEqual({ source: "session" }); + expect(first?.message?.details).toEqual({ source: "session", sessionMode: true }); + h.contextEntries.push({ type: "custom_message", ...first.message }); - // Standing reminder repeats across turns; agent_end does not reset session mode. + // The existing announcement still authorizes the session; do not duplicate it. h.handlers.get("agent_end")?.({ type: "agent_end", messages: [] } as never); const second = h.handlers.get("before_agent_start")?.({ type: "before_agent_start", prompt: "and now migrate the tests", } as never) as { message?: { content: string } }; - expect(second?.message?.content).toContain("Ultraloop is on for the session"); + expect(second).toBeUndefined(); }); test("/ultraloop off returns to per-turn opt-in", () => { @@ -308,6 +318,78 @@ describe("createUltraloopOptInExtension", () => { expect(kept?.message?.details).toEqual({ source: "turn", token: "ultraloop" }); }); + test("emits off once and preserves a simultaneous per-turn opt-in", async () => { + const h = harness(); + install(h); + await h.commands.get("ultracode")!.handler("on", makeCtx(h.notifications)); + const enabled = startOrdinaryTurn(h)!; + h.contextEntries.push({ type: "custom_message", ...enabled.message }); + await h.commands.get("ultracode")!.handler("off", makeCtx(h.notifications)); + const result = h.handlers.get("before_agent_start")!({ prompt: "ultracode: review just this task" } as never) as { + message: { content: string; details: unknown }; + }; + expect(result.message.content).toContain("session mode is off"); + expect(result.message.content).toContain('the keyword "ultracode"'); + expect(result.message.details).toEqual({ source: "turn", token: "ultracode", sessionMode: false }); + h.contextEntries.push({ type: "custom_message", ...result.message }); + expect(startOrdinaryTurn(h)).toBeUndefined(); + }); + + test("reads the selected branch rather than remembering an earlier emitted state", async () => { + const h = harness(); + install(h); + await h.commands.get("ultracode")!.handler("on", makeCtx(h.notifications)); + const first = startOrdinaryTurn(h)!; + // A failed preflight did not save the message: the next attempt must still announce. + expect(startOrdinaryTurn(h)?.message?.content).toContain("Ultraloop is on for the session"); + h.contextEntries.push({ type: "custom_message", ...first.message }); + expect(startOrdinaryTurn(h)).toBeUndefined(); + // A branch or compaction without the old entry needs the current state again. + h.contextEntries.length = 0; + expect(startOrdinaryTurn(h)?.message?.content).toContain("Ultraloop is on for the session"); + }); + + test("revokes legacy standing consent when resuming a session whose mode resets to off", () => { + const h = harness(); + install(h); + h.contextEntries.push({ + type: "custom_message", + customType: "ultraloop-opt-in", + content: buildUltraloopSessionReminder(), + details: { source: "session" }, + }); + h.handlers.get("session_start")!({ type: "session_start" } as never); + expect(startOrdinaryTurn(h)?.message?.content).toContain( + "Previous session-wide workflow authorization no longer applies", + ); + }); + + test("does not read ordinary user text as a runtime mode announcement", () => { + const h = harness(); + install(h); + h.contextEntries.push({ type: "message", message: { role: "user", content: buildUltraloopSessionReminder() } }); + expect(startOrdinaryTurn(h)).toBeUndefined(); + }); + + test("reconciles request-local state after context removal or an in-flight toggle", async () => { + const h = harness(); + install(h); + const context = h.handlers.get("context")!; + const ordinary = [{ role: "user", content: "question" }]; + expect(context({ messages: ordinary } as never)).toBeUndefined(); + await h.commands.get("ultracode")!.handler("on", makeCtx(h.notifications)); + const restored = context({ messages: ordinary } as never) as { messages: unknown[] }; + expect(restored.messages).toHaveLength(2); + expect(restored.messages[0]).toBe(ordinary[0]); + expect(restored.messages[1]).toMatchObject({ role: "custom", details: { source: "session", sessionMode: true } }); + expect(context({ messages: restored.messages } as never)).toBeUndefined(); + await h.commands.get("ultracode")!.handler("off", makeCtx(h.notifications)); + const revoked = context({ messages: restored.messages } as never) as { messages: unknown[] }; + expect(revoked.messages.at(-1)).toMatchObject({ details: { source: "session", sessionMode: false } }); + expect(context({ messages: revoked.messages } as never)).toBeUndefined(); + expect(h.contextEntries).toEqual([]); + }); + test("/ultraloop status reports the current mode without changing it", () => { const h = harness(); install(h); @@ -468,33 +550,26 @@ describe("Ultracode discovery and command aliases", () => { expect(startOrdinaryTurn(h)?.message?.customType).not.toBe("ultraloop-opt-in"); }); - test("explains mode and tool availability without granting consent or replacing the system prompt", () => { + test("ordinary turns do not inject capability tutorials or grant consent", () => { 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"); + expect(result).toBeUndefined(); + expect(startOrdinaryTurn(h)).toBeUndefined(); 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", () => { + test("keeps standing consent independent of tool availability", () => { 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"); + void h.commands.get("ultracode")!.handler("on", makeCtx(h.notifications, h.statuses)); + const content = startOrdinaryTurn(h)?.message?.content; + expect(content).toContain("If the workflow tool is not active"); h.activeTools.length = 0; - expect(startOrdinaryTurn(h)?.message?.content).toContain("not active"); - expect(startOrdinaryTurn(h)?.message?.customType).not.toBe("ultraloop-opt-in"); + expect(startOrdinaryTurn(h)?.message?.content).toBe(content); + expect(h.activeTools).toEqual([]); }); test("disabled registration advertises neither alias nor model guidance", () => {