From 487237a32c5c096470e36059e48dbb1b85bf7ebc Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Thu, 14 May 2026 20:55:00 +0200 Subject: [PATCH] fix: bound sf print mode and chat routing --- ...agent-session-custom-message-queue.test.ts | 36 ++++++ .../agent-session-print-mode-persist.test.ts | 20 +++ .../coding-agent/src/core/agent-session.ts | 22 +++- .../coding-agent/src/modes/print-mode.test.ts | 81 ++++++++++++ packages/coding-agent/src/modes/print-mode.ts | 120 ++++++++++++++++-- src/cli.ts | 4 +- src/headless-triage.ts | 62 ++++++--- .../extensions/sf/chat-command-router.js | 49 ++++++- src/resources/extensions/sf/commands/index.js | 41 ++++-- .../extensions/sf/self-feedback-drain.js | 2 +- .../sf/tests/direct-command-surface.test.mjs | 27 +++- 11 files changed, 416 insertions(+), 48 deletions(-) create mode 100644 packages/coding-agent/src/modes/print-mode.test.ts diff --git a/packages/coding-agent/src/core/agent-session-custom-message-queue.test.ts b/packages/coding-agent/src/core/agent-session-custom-message-queue.test.ts index ac93c2f9a..da0a0e5ee 100644 --- a/packages/coding-agent/src/core/agent-session-custom-message-queue.test.ts +++ b/packages/coding-agent/src/core/agent-session-custom-message-queue.test.ts @@ -145,4 +145,40 @@ describe("AgentSession custom message queueing", () => { assert.equal(commandArgs, ""); assert.equal(agentPrompted, false); }); + + it("executes_slash_command_after_input_hook_adds_route_trace_marker", async () => { + const session = await createSession(); + const agent = (session as any).agent as Agent & { + prompt: (message: AgentMessage) => Promise; + }; + let agentPrompted = false; + let commandArgs: string | undefined; + agent.prompt = async () => { + agentPrompted = true; + }; + (session as any)._extensionRunner = { + hasHandlers: (event: string) => event === "input", + emitInput: async () => ({ + action: "transform", + text: "[sf-route:chat-intent:test] /queue", + images: undefined, + }), + getCommand: (name: string) => + name === "queue" + ? { + name: "queue", + handler: async (args: string) => { + commandArgs = args; + }, + } + : undefined, + createCommandContext: () => ({}), + emitError: () => undefined, + }; + + await session.prompt("show my queue"); + + assert.equal(commandArgs, ""); + assert.equal(agentPrompted, false); + }); }); diff --git a/packages/coding-agent/src/core/agent-session-print-mode-persist.test.ts b/packages/coding-agent/src/core/agent-session-print-mode-persist.test.ts index fc786345a..7c3fc1502 100644 --- a/packages/coding-agent/src/core/agent-session-print-mode-persist.test.ts +++ b/packages/coding-agent/src/core/agent-session-print-mode-persist.test.ts @@ -179,3 +179,23 @@ test("sf src/cli.ts print-mode skips validateConfiguredModel when --model is set "reapplyValidatedModelOnFallback must be inside the same `if (!cliFlags.model)` block as validateConfiguredModel", ); }); + +test("sf src/cli.ts print-mode skips live discovery provider warmup", () => { + const registryStartupIdx = sfCliSource.indexOf( + 'markStartup("ModelRegistry")', + ); + assert.ok(registryStartupIdx >= 0, "missing model registry startup marker"); + const warmupIdx = sfCliSource.indexOf( + "warmDiscoveryBackedProviders(", + registryStartupIdx, + ); + assert.ok(warmupIdx >= 0, "missing discovery-backed provider warmup"); + const guardIdx = sfCliSource.lastIndexOf( + "if (!isPrintMode && cliFlags.listModels === undefined)", + warmupIdx, + ); + assert.ok( + guardIdx >= 0, + "print mode and static model listing must not run live provider discovery before producing one-shot output", + ); +}); diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index bd9f79671..d512b80e0 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -241,6 +241,8 @@ export interface PromptOptions { streamingBehavior?: "steer" | "followUp"; /** Source of input for extension input event handlers. Defaults to "interactive". */ source?: InputSource; + /** Whether extension input/before-agent hooks run for this prompt. Defaults to true. */ + runExtensionHooks?: boolean; } function isAgentAlreadyProcessingError(error: unknown): boolean { @@ -1195,6 +1197,7 @@ export class AgentSession { */ async prompt(text: string, options?: PromptOptions): Promise { const expandPromptTemplates = options?.expandPromptTemplates ?? true; + const runExtensionHooks = options?.runExtensionHooks ?? true; // Handle extension commands first (execute immediately, even during streaming) // Extension commands manage their own LLM interaction via pi.sendMessage() @@ -1209,7 +1212,7 @@ export class AgentSession { // Emit input event for extension interception (before skill/template expansion) let currentText = text; let currentImages = options?.images; - if (this._extensionRunner?.hasHandlers("input")) { + if (runExtensionHooks && this._extensionRunner?.hasHandlers("input")) { const inputResult = await this._extensionRunner.emitInput( currentText, currentImages, @@ -1233,6 +1236,21 @@ export class AgentSession { return; } } + if ( + expandPromptTemplates && + currentText !== text && + currentText.startsWith("[sf-route:") + ) { + const end = currentText.indexOf("]"); + const routedText = + end === -1 ? currentText : currentText.slice(end + 1).trim(); + if (routedText.startsWith("/")) { + const handled = await this._tryExecuteExtensionCommand(routedText); + if (handled) { + return; + } + } + } // Expand skill commands (/skill:name args) and prompt templates (/template args) let expandedText = currentText; @@ -1332,7 +1350,7 @@ export class AgentSession { this._pendingNextTurnMessages = []; // Emit before_agent_start extension event - if (this._extensionRunner) { + if (runExtensionHooks && this._extensionRunner) { const result = await this._extensionRunner.emitBeforeAgentStart( expandedText, currentImages, diff --git a/packages/coding-agent/src/modes/print-mode.test.ts b/packages/coding-agent/src/modes/print-mode.test.ts new file mode 100644 index 000000000..f8f6001d0 --- /dev/null +++ b/packages/coding-agent/src/modes/print-mode.test.ts @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { + PrintModeTimeoutError, + promptWithPrintTimeout, + resolvePrintModeTimeoutMs, + runPrintMode, +} from "./print-mode.js"; + +test("resolvePrintModeTimeoutMs_when_env_missing_returns_default_watchdog", () => { + assert.equal(resolvePrintModeTimeoutMs({}), 55_000); +}); + +test("resolvePrintModeTimeoutMs_when_env_zero_disables_watchdog", () => { + assert.equal(resolvePrintModeTimeoutMs({ SF_PRINT_TIMEOUT_MS: "0" }), 0); +}); + +test("promptWithPrintTimeout_when_prompt_never_settles_aborts_and_rejects", async () => { + let aborted = false; + const session = { + prompt: () => new Promise(() => {}), + abort: async () => { + aborted = true; + }, + }; + + await assert.rejects( + () => promptWithPrintTimeout(session as any, "say hi", undefined, 5), + PrintModeTimeoutError, + ); + assert.equal(aborted, true); +}); + +test("promptWithPrintTimeout_when_prompt_settles_clears_watchdog", async () => { + let aborted = false; + const session = { + prompt: async () => {}, + abort: async () => { + aborted = true; + }, + }; + + await promptWithPrintTimeout(session as any, "say hi", undefined, 5); + assert.equal(aborted, false); +}); + +test("runPrintMode_when_extension_startup_hangs_still_runs_prompt", async () => { + const logs: string[] = []; + const originalLog = console.log; + console.log = (message?: unknown) => { + logs.push(String(message)); + }; + try { + const session = { + sessionManager: { getHeader: () => null }, + bindExtensions: () => new Promise(() => {}), + subscribe: () => () => undefined, + prompt: async () => {}, + abort: async () => {}, + state: { + messages: [ + { + role: "assistant", + content: [{ type: "text", text: "hi" }], + stopReason: "stop", + }, + ], + }, + }; + + await runPrintMode(session as any, { + mode: "text", + initialMessage: "say hi", + timeoutMs: 100, + }); + + assert.deepEqual(logs, ["hi"]); + } finally { + console.log = originalLog; + } +}); diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index 88dcbac72..472a6501d 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -6,9 +6,9 @@ * - `pi --mode json "prompt"` - JSON event stream */ +import { type ChildProcess, spawn } from "node:child_process"; import type { AssistantMessage, ImageContent } from "@singularity-forge/ai"; import type { AgentSession } from "../core/agent-session.js"; -import { createDefaultCommandContextActions } from "./shared/command-context-actions.js"; /** * Options for print mode. @@ -22,6 +22,89 @@ export interface PrintModeOptions { initialMessage?: string; /** Images to attach to the initial message */ initialImages?: ImageContent[]; + /** Per-prompt timeout in milliseconds. 0 disables the watchdog. */ + timeoutMs?: number; +} + +const DEFAULT_PRINT_TIMEOUT_MS = 55_000; + +export class PrintModeTimeoutError extends Error { + constructor(timeoutMs: number) { + super( + `sf --print timed out after ${timeoutMs}ms waiting for the model response. Set SF_PRINT_TIMEOUT_MS=0 to disable or a larger millisecond value to extend.`, + ); + this.name = "PrintModeTimeoutError"; + } +} + +export function resolvePrintModeTimeoutMs( + env: NodeJS.ProcessEnv = process.env, +): number { + const raw = env.SF_PRINT_TIMEOUT_MS; + if (raw === undefined || raw.trim() === "") return DEFAULT_PRINT_TIMEOUT_MS; + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_PRINT_TIMEOUT_MS; + return parsed; +} + +export async function promptWithPrintTimeout( + session: AgentSession, + message: string, + options: { images?: ImageContent[]; runExtensionHooks?: boolean } | undefined, + timeoutMs: number, +): Promise { + if (timeoutMs <= 0) { + await session.prompt(message, options); + return; + } + + const processWatchdog = + timeoutMs >= 1_000 ? startPrintModeProcessWatchdog(timeoutMs) : null; + let timer: ReturnType | undefined; + const timeout = new PrintModeTimeoutError(timeoutMs); + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => { + void session.abort().catch(() => {}); + reject(timeout); + }, timeoutMs); + }); + + try { + await Promise.race([session.prompt(message, options), timeoutPromise]); + } finally { + if (timer) clearTimeout(timer); + stopPrintModeProcessWatchdog(processWatchdog); + } +} + +function startPrintModeProcessWatchdog(timeoutMs: number): ChildProcess | null { + const script = [ + `const timeoutMs = ${JSON.stringify(timeoutMs)};`, + `const pid = ${JSON.stringify(process.pid)};`, + "setTimeout(() => {", + " console.error('sf --print timed out after ' + timeoutMs + 'ms waiting for the model response. Set SF_PRINT_TIMEOUT_MS=0 to disable or a larger millisecond value to extend.');", + " try { process.kill(pid, 'SIGTERM'); } catch {}", + "}, timeoutMs);", + ].join("\n"); + try { + const child = spawn(process.execPath, ["-e", script], { + stdio: ["ignore", "ignore", "inherit"], + detached: false, + }); + child.unref(); + return child; + } catch { + return null; + } +} + +function stopPrintModeProcessWatchdog(child: ChildProcess | null): void { + if (!child || child.killed) return; + try { + child.kill("SIGTERM"); + } catch { + /* already exited */ + } } /** @@ -33,19 +116,16 @@ export async function runPrintMode( options: PrintModeOptions, ): Promise { const { mode, messages = [], initialMessage, initialImages } = options; + const timeoutMs = options.timeoutMs ?? resolvePrintModeTimeoutMs(); if (mode === "json") { const header = session.sessionManager.getHeader(); if (header) { console.log(JSON.stringify(header)); } } - // Set up extensions for print mode (no UI) - await session.bindExtensions({ - commandContextActions: createDefaultCommandContextActions(session), - onError: (err) => { - console.error(`Extension error (${err.extensionPath}): ${err.error}`); - }, - }); + // Print mode intentionally skips extension session_start binding. One-shot + // automation needs bounded prompt output; startup hooks are interactive/RPC + // lifecycle work and have previously blocked `sf -p` before the prompt ran. // Always subscribe to enable session persistence via _handleAgentEvent const unsubscribe = session.subscribe((event) => { @@ -83,12 +163,22 @@ export async function runPrintMode( try { // Send initial message with attachments if (initialMessage) { - await session.prompt(initialMessage, { images: initialImages }); + await promptWithPrintTimeout( + session, + initialMessage, + { images: initialImages, runExtensionHooks: false }, + timeoutMs, + ); } // Send remaining messages for (const message of messages) { - await session.prompt(message); + await promptWithPrintTimeout( + session, + message, + { runExtensionHooks: false }, + timeoutMs, + ); } // In text mode, output final response @@ -118,7 +208,14 @@ export async function runPrintMode( } } } - + } catch (err) { + if (err instanceof PrintModeTimeoutError) { + console.error(err.message); + exitCode = 124; + } else { + throw err; + } + } finally { // Ensure stdout is fully flushed before returning // This prevents race conditions where the process exits before all output is written await new Promise((resolve, reject) => { @@ -127,7 +224,6 @@ export async function runPrintMode( else resolve(); }); }); - } finally { for (const cleanup of signalCleanupHandlers) cleanup(); disposeSession(); } diff --git a/src/cli.ts b/src/cli.ts index cc78c683f..f0447faaf 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -661,7 +661,9 @@ const modelRegistry = new ModelRegistry( settingsManager, ); markStartup("ModelRegistry"); -await warmDiscoveryBackedProviders(modelRegistry); +if (!isPrintMode && cliFlags.listModels === undefined) { + await warmDiscoveryBackedProviders(modelRegistry); +} markStartup("ModelRegistry.discovery"); // Run onboarding wizard on first launch (no LLM provider configured) diff --git a/src/headless-triage.ts b/src/headless-triage.ts index 4d87a896c..7b2deb63c 100644 --- a/src/headless-triage.ts +++ b/src/headless-triage.ts @@ -419,7 +419,12 @@ async function defaultAgentRunner( return await new Promise((resolve) => { const proc = spawn(launch.command, launch.args, { cwd: options.cwd ?? process.cwd(), - env: process.env, + env: { + ...process.env, + SF_PRINT_TIMEOUT_MS: + process.env.SF_PRINT_TIMEOUT_MS ?? + String(Math.max(1_000, DEFAULT_AGENT_TIMEOUT_MS - 5_000)), + }, shell: false, stdio: ["ignore", "pipe", "pipe"], }); @@ -564,15 +569,13 @@ export async function runTriageApply( const runContextModule = (await jiti.import( sfExtensionPath("uok/run-context"), )) as { - buildUokRunContext: (opts: Record) => - | { - surface: string; - runControl: string; - permissionProfile: string; - traceId: string; - parentTrace?: string; - } - | null; + buildUokRunContext: (opts: Record) => { + surface: string; + runControl: string; + permissionProfile: string; + traceId: string; + parentTrace?: string; + } | null; }; const traceWriterModule = (await jiti.import( sfExtensionPath("uok/trace-writer"), @@ -739,10 +742,15 @@ export async function runTriageApply( // workflows, but --apply uses only the shipped review contract. if (triageDecider.source !== "builtin" || reviewCode.source !== "builtin") { const rationale = `non-builtin agents (triage-decider=${triageDecider.source}, review-code=${reviewCode.source})`; - const gateFailure = await emitRequiredTriageGate("trusted-agent-source-gate", "fail", rationale, { - triageDeciderSource: triageDecider.source, - reviewCodeSource: reviewCode.source, - }); + const gateFailure = await emitRequiredTriageGate( + "trusted-agent-source-gate", + "fail", + rationale, + { + triageDeciderSource: triageDecider.source, + reviewCodeSource: reviewCode.source, + }, + ); if (gateFailure) return gateFailure; await emit("triage-apply-failed", { reason: "untrusted-agent-source", @@ -1126,6 +1134,9 @@ export async function handleTriage( basePath: string, content: string, ) => string | null; + rankTriageModelsViaRouter: ( + candidates?: string[], + ) => Promise; }; try { drainModule = (await jiti.import( @@ -1218,11 +1229,30 @@ export async function handleTriage( } if (options.apply) { + // Pre-resolve a model via the router when no --model was supplied and + // no custom runner is injected. Without this, `defaultAgentRunner` + // would spawn `sf -p` with no `--model` flag, and that path hangs + // indefinitely during the subprocess's own model-selection step + // (see sf-mp5tuvdx-ibyk9b). The watchdog still backs this up. + let resolvedModel = options.model; + if (!resolvedModel && !options.agentRunner) { + try { + const ranked = await drainModule.rankTriageModelsViaRouter(); + resolvedModel = ranked[0]; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write( + `[triage] router pre-resolution failed; falling back to subprocess default: ${msg}\n`, + ); + } + } process.stderr.write( - "[triage] applying via triage-decider -> review-code (this can take a few minutes)…\n", + `[triage] applying via triage-decider -> review-code${ + resolvedModel ? ` (model: ${resolvedModel})` : "" + } (this can take a few minutes)…\n`, ); const result = await runTriageApply(cwd, prompt, { - model: options.model, + model: resolvedModel, agentRunner: options.agentRunner, candidateCount: candidates.length, expectedIds: candidates.map((candidate) => candidate.id), diff --git a/src/resources/extensions/sf/chat-command-router.js b/src/resources/extensions/sf/chat-command-router.js index 6bdb3e45a..b55fb2518 100644 --- a/src/resources/extensions/sf/chat-command-router.js +++ b/src/resources/extensions/sf/chat-command-router.js @@ -2,10 +2,17 @@ import { randomUUID } from "node:crypto"; import { emitJournalEvent } from "./journal.js"; import { appendTraceEvent } from "./uok/trace-writer.js"; +const ROUTE_TRACE_PREFIX = "[sf-route:"; +const ROUTE_TRACE_SUFFIX = "]"; +const MAX_PENDING_ROUTE_TRACES = 50; +const pendingRouteTraces = []; + const ROUTES = [ { command: "help", - patterns: [/\b(help|what can i do|commands?)\b/i], + patterns: [ + /^(help|show help|open help|what can i do|show commands?|list commands?)$/i, + ], }, { command: "queue", @@ -79,7 +86,10 @@ const ROUTES = [ }, { command: "pause", - patterns: [/\b(pause|pause autonomous|take a break)\b/i], + patterns: [ + /^(pause|take a break)$/i, + /\bpause\b.*\b(autonomous|sf|run|workflow)\b/i, + ], }, { command: "stop", @@ -127,7 +137,7 @@ export function routeChatCommand(text) { function emitChatIntentEvidence(basePath, decision) { if (!basePath || !decision) return; - const flowId = `chat-intent:${randomUUID()}`; + const flowId = decision.traceId ?? `chat-intent:${randomUUID()}`; const event = { input: decision.input, routedText: decision.routedText, @@ -154,15 +164,46 @@ function emitChatIntentEvidence(basePath, decision) { }); } +export function extractRouteTracePrefix(text) { + if (!text?.startsWith(ROUTE_TRACE_PREFIX)) { + return { text, routeTraceId: null }; + } + const end = text.indexOf(ROUTE_TRACE_SUFFIX); + if (end === -1) return { text, routeTraceId: null }; + const routeTraceId = text.slice(ROUTE_TRACE_PREFIX.length, end); + const stripped = text.slice(end + ROUTE_TRACE_SUFFIX.length).trimStart(); + return { text: stripped, routeTraceId }; +} + +export function consumeRouteTraceForCommand(command) { + const index = pendingRouteTraces.findIndex( + (entry) => entry.command === command, + ); + if (index === -1) return null; + const [entry] = pendingRouteTraces.splice(index, 1); + return entry; +} + export function registerChatCommandRouter(pi) { pi.on("input", (event, ctx) => { + if (event.source === "extension") return { action: "continue" }; if (event.images?.length > 0) return { action: "continue" }; const decision = classifyChatCommand(event.text); if (!decision) return { action: "continue" }; + decision.traceId = `chat-intent:${randomUUID()}`; emitChatIntentEvidence(ctx?.cwd, decision); + pendingRouteTraces.push({ + command: decision.command, + traceId: decision.traceId, + input: decision.input, + routedText: decision.routedText, + }); + if (pendingRouteTraces.length > MAX_PENDING_ROUTE_TRACES) { + pendingRouteTraces.shift(); + } return { action: "transform", - text: decision.routedText, + text: `${ROUTE_TRACE_PREFIX}${decision.traceId}${ROUTE_TRACE_SUFFIX} ${decision.routedText}`, images: event.images, }; }); diff --git a/src/resources/extensions/sf/commands/index.js b/src/resources/extensions/sf/commands/index.js index b2cf5166f..828c05920 100644 --- a/src/resources/extensions/sf/commands/index.js +++ b/src/resources/extensions/sf/commands/index.js @@ -1,6 +1,7 @@ import { spawnSync } from "node:child_process"; import { randomUUID } from "node:crypto"; import { importExtensionModule } from "@singularity-forge/coding-agent"; +import { consumeRouteTraceForCommand } from "../chat-command-router.js"; import { emitJournalEvent } from "../journal.js"; import { appendTraceEvent } from "../uok/trace-writer.js"; import { @@ -38,7 +39,7 @@ function nextActionForCommand(command, outcome) { return "continue workflow"; } -function emitCommandEvidence(basePath, traceId, evidence) { +function emitCommandEvidence(basePath, traceId, evidence, parentTrace = null) { if (!basePath) return; emitJournalEvent(basePath, { ts: new Date().toISOString(), @@ -46,10 +47,12 @@ function emitCommandEvidence(basePath, traceId, evidence) { seq: 1, eventType: "workflow-command-complete", rule: "direct-sf-command", + ...(parentTrace ? { causedBy: { flowId: parentTrace, seq: 1 } } : {}), data: evidence, }); appendTraceEvent(basePath, traceId, { type: "workflow_command_completion", + ...(parentTrace ? { parentTrace } : {}), surface: "interactive", runControl: evidence.command === "autonomous" @@ -73,7 +76,9 @@ async function dispatchDirectSFCommand(command, args, ctx, pi) { ); const previousStderrSetting = setStderrLoggingEnabled(false); const basePath = ctx?.cwd ?? process.cwd(); + const routedFrom = consumeRouteTraceForCommand(command); const traceId = `workflow-command:${command}:${randomUUID()}`; + const parentTrace = routedFrom?.traceId ?? null; const changedBefore = new Set(listChangedFiles(basePath)); const startedAt = Date.now(); let outcome = "pass"; @@ -94,16 +99,30 @@ async function dispatchDirectSFCommand(command, args, ctx, pi) { const newChangedFiles = changedAfter.filter( (file) => !changedBefore.has(file), ); - emitCommandEvidence(basePath, traceId, { - command, - args: typeof args === "string" ? args.trim() : "", - outcome, - changedFiles: changedAfter, - newChangedFiles, - blockers: blocker ? [blocker] : [], - nextAction: nextActionForCommand(command, outcome), - durationMs: Date.now() - startedAt, - }); + emitCommandEvidence( + basePath, + traceId, + { + command, + args: typeof args === "string" ? args.trim() : "", + outcome, + changedFiles: changedAfter, + newChangedFiles, + blockers: blocker ? [blocker] : [], + nextAction: nextActionForCommand(command, outcome), + durationMs: Date.now() - startedAt, + ...(routedFrom + ? { + routedFrom: { + traceId: routedFrom.traceId, + input: routedFrom.input, + routedText: routedFrom.routedText, + }, + } + : {}), + }, + parentTrace, + ); } } diff --git a/src/resources/extensions/sf/self-feedback-drain.js b/src/resources/extensions/sf/self-feedback-drain.js index 909ca7307..b461e152e 100644 --- a/src/resources/extensions/sf/self-feedback-drain.js +++ b/src/resources/extensions/sf/self-feedback-drain.js @@ -450,7 +450,7 @@ async function readOperatorTriageCandidates() { * * Consumer: runTriage (when operator doesn't pass --model). */ -async function rankTriageModelsViaRouter(candidates) { +export async function rankTriageModelsViaRouter(candidates) { const candidateList = (Array.isArray(candidates) && candidates.length > 0 ? candidates diff --git a/src/resources/extensions/sf/tests/direct-command-surface.test.mjs b/src/resources/extensions/sf/tests/direct-command-surface.test.mjs index ef7a28081..a20f846e4 100644 --- a/src/resources/extensions/sf/tests/direct-command-surface.test.mjs +++ b/src/resources/extensions/sf/tests/direct-command-surface.test.mjs @@ -6,6 +6,7 @@ import { test } from "vitest"; import guardrails from "../../guardrails/index.js"; import { classifyChatCommand, + consumeRouteTraceForCommand, registerChatCommandRouter, routeChatCommand, } from "../chat-command-router.js"; @@ -85,6 +86,9 @@ test("chat_command_router_maps_clear_chat_intent_to_public_workflow_commands", ( assert.equal(routeChatCommand("I want to see my queue"), "/queue"); assert.equal(routeChatCommand("start autonomous mode"), "/autonomous"); assert.equal(routeChatCommand("let's discuss the architecture"), "/discuss"); + assert.equal(routeChatCommand("help me fix login bug"), null); + assert.equal(routeChatCommand("add a pause button"), null); + assert.equal(routeChatCommand("pause autonomous mode"), "/pause"); assert.equal(routeChatCommand("fix the login bug"), null); assert.equal(routeChatCommand("/queue"), null); @@ -116,16 +120,37 @@ test("chat_command_router_emits_journal_evidence_for_routed_intent", async () => const entries = queryJournal(dir, { eventType: "chat-intent-routed" }); assert.equal(result.action, "transform"); - assert.equal(result.text, "/queue"); + assert.match(result.text, /^\[sf-route:chat-intent:[^\]]+\] \/queue$/); assert.equal(entries.length, 1); assert.equal(entries[0].data.command, "queue"); assert.equal(entries[0].data.outcome, "routed"); assert.equal(entries[0].data.nextAction, "execute /queue"); + + const pending = consumeRouteTraceForCommand("queue"); + assert.equal(pending.traceId, entries[0].flowId); + assert.equal(pending.input, "I want to see my queue"); + assert.equal(pending.routedText, "/queue"); } finally { rmSync(dir, { recursive: true, force: true }); } }); +test("chat_command_router_does_not_route_extension_generated_input", async () => { + let inputHandler; + registerChatCommandRouter({ + on(event, handler) { + if (event === "input") inputHandler = handler; + }, + }); + + const result = await inputHandler( + { text: "I want to see my queue", images: undefined, source: "extension" }, + { cwd: process.cwd() }, + ); + + assert.deepEqual(result, { action: "continue" }); +}); + test("direct command completions strip the already typed command name", () => { assert.deepEqual(getSfTopLevelCommandCompletions("autonomous", "--"), [ {