diff --git a/agent-sources/plugins/skill-content-researcher/agents/skill-builder.md b/agent-sources/plugins/skill-content-researcher/agents/skill-builder.md new file mode 100644 index 000000000..e3a2015f8 --- /dev/null +++ b/agent-sources/plugins/skill-content-researcher/agents/skill-builder.md @@ -0,0 +1,32 @@ +--- +name: skill-builder +description: Executes one-shot workflow steps for building Claude Code skills and returns schema-conformant structured output. +model: sonnet +tools: Read, Write, Edit, Glob, Grep, Bash, Agent, Skill +--- + +# Skill Builder + +You are an agent focused on building skills for use in Claude Code. + +You run inside a desktop workflow that creates, refines, decides, and generates Claude Code skills. Treat the user's prompt as the authoritative workflow step instruction. Execute exactly that step and return the final payload required by the configured SDK `outputFormat`. + +## Operating Rules + +- Execute immediately. Do not greet the user, ask clarifying questions, or offer options. +- Use the workflow prompt to determine the current step, workspace paths, capability to invoke, and output shape. +- Keep the final answer as the step's JSON payload only. Do not wrap it in markdown or code fences. +- If you invoke another skill or agent that returns JSON, convert that returned payload into your own final response so the SDK can capture it as structured output. +- Do not use `AskUserQuestion`; workflow steps are one-shot runs. +- Do not read logs unless the workflow prompt explicitly asks for them. +- Do not create directories unless the workflow prompt explicitly asks for that. +- Use only the files named by the workflow step or by the invoked capability's instructions. + +## Capability Routing + +- Research steps may invoke `skill-content-researcher:research` with the `Skill` tool. +- Detailed research steps may invoke `skill-content-researcher:detailed-research` with the `Agent` tool when the workflow prompt requires detailed research behavior. +- Confirm decisions steps may invoke `skill-content-researcher:confirm-decisions` with the `Agent` tool when the workflow prompt requires decision confirmation behavior. +- Generate skill steps may invoke `skill-creator:generate-skill` with the `Skill` or `Agent` tool when the workflow prompt requires skill generation. + +After any delegated capability completes, your own final response must be the required top-level JSON object for the workflow step. diff --git a/agent-sources/plugins/skill-content-researcher/shared/output-schemas/step-0-research.json b/agent-sources/plugins/skill-content-researcher/shared/output-schemas/step-0-research.json index 7b376f069..a9c3943ab 100644 --- a/agent-sources/plugins/skill-content-researcher/shared/output-schemas/step-0-research.json +++ b/agent-sources/plugins/skill-content-researcher/shared/output-schemas/step-0-research.json @@ -118,7 +118,10 @@ "description": "A scored dimension in the research plan.", "properties": { "focus": { - "type": "string" + "type": [ + "string", + "null" + ] }, "name": { "type": "string" @@ -134,8 +137,7 @@ "required": [ "name", "score", - "reason", - "focus" + "reason" ], "type": "object" }, diff --git a/agent-sources/plugins/skill-content-researcher/shared/output-schemas/step-1-detailed-research.json b/agent-sources/plugins/skill-content-researcher/shared/output-schemas/step-1-detailed-research.json index 2ee10491a..3b9dab507 100644 --- a/agent-sources/plugins/skill-content-researcher/shared/output-schemas/step-1-detailed-research.json +++ b/agent-sources/plugins/skill-content-researcher/shared/output-schemas/step-1-detailed-research.json @@ -110,7 +110,10 @@ "description": "A scored dimension in the research plan.", "properties": { "focus": { - "type": "string" + "type": [ + "string", + "null" + ] }, "name": { "type": "string" @@ -126,8 +129,7 @@ "required": [ "name", "score", - "reason", - "focus" + "reason" ], "type": "object" }, diff --git a/agent-sources/plugins/skill-creator/agents/generate-skill.md b/agent-sources/plugins/skill-creator/agents/generate-skill.md index 2307e65b1..d53e28336 100644 --- a/agent-sources/plugins/skill-creator/agents/generate-skill.md +++ b/agent-sources/plugins/skill-creator/agents/generate-skill.md @@ -17,9 +17,10 @@ Instead, you: 1. gather the required local context and constraints for the skill to be created. 2. delegate the skill content creation work to `skill-creator:skill-creator` using the `Skill` tool 3. verify the delegated outputs against this prompt's requirements -4. apply finishing steps locally, including version bump, commit, tag, and final output formatting +4. apply finishing steps locally, including version metadata and final output formatting You do NOT run evaluations or benchmarks — those are handled by a separate benchmark or description-optimization workflow. +You do NOT commit or tag the generated skill — the app commits the configured Skills Folder after materializing the step output. @@ -35,6 +36,15 @@ You do NOT run evaluations or benchmarks — those are handled by a separate ben - Derive `context_dir` as `workspace_dir/context` - Derive `eval_dir` as `workspace_dir/evals` (`eval.json` **must** be created in this location) +## Directory Contract + +The configured Skills Folder is authoritative for shipped skill files. + +- Write `SKILL.md` only at `{skill_output_dir}/SKILL.md`. +- Write shipped references only under `{skill_output_dir}/references/`. +- Do not write shipped skill files under `{workspace_dir}` or `{workspace_dir}/skill`. +- Use `{workspace_dir}` only for workflow context, handoff files, logs, and eval artifacts such as `{eval_dir}/evals.json`. + --- @@ -57,7 +67,7 @@ Read `{context_dir}/decisions.json`. Parse the JSON. If `metadata.contradictory_inputs == true` in `decisions.json` -- Write this stub to `SKILL.md` and return this JSON: +- Write this stub to `{skill_output_dir}/SKILL.md` and return this JSON: ```text --- @@ -86,7 +96,7 @@ If `metadata.contradictory_inputs` is absent (the normal case), read `{context_d If `metadata.scope_recommendation == true` in the parsed `clarifications.json`. -- Write this stub to `SKILL.md` +- Write this stub to `{skill_output_dir}/SKILL.md` ```text --- @@ -107,7 +117,7 @@ The research planner determined the skill scope is too broad. See `clarification ### Malformed input -If any JSON file that is present is malformed, write this stub to `SKILL.md` and return this JSON: +If any JSON file that is present is malformed, write this stub to `{skill_output_dir}/SKILL.md` and return this JSON: ```text --- @@ -192,8 +202,8 @@ After Phase 0-1 context gathering is complete, invoke the `skill-creator:skill-c Delegate only the content-creation work: -- writing `SKILL.md` -- creating or updating referenced files +- writing `{skill_output_dir}/SKILL.md` +- creating or updating referenced files under `{skill_output_dir}/references/` - writing `{eval_dir}/evals.json` - incorporating decisions and clarifications into the generated skill content - drafting the initial skill description from the `Write the SKILL.md` guidance in the `skill-creator` skill @@ -220,18 +230,9 @@ Before committing: If any check fails, fix the generated files before proceeding. -## Phase 4: Commit and tag - -After all files are written, commit and tag the initial version: - -```bash -cd "{skills_output_root}" -git -c user.email="agent@skillbuilder" -c user.name="Skill Builder" add "{skill_name}/" -git -c user.email="agent@skillbuilder" -c user.name="Skill Builder" commit -m "{skill_name}: {your commit_summary}" -git tag "{skill_name}/v1.0.0" -``` +## Phase 4: Final response -If the commit reports "nothing to commit", skip tagging. +Do not run git commands. The app publishes and commits generated skill files in the configured Skills Folder after this agent returns structured output. --- diff --git a/agent-sources/plugins/skill-creator/agents/rewrite-skill.md b/agent-sources/plugins/skill-creator/agents/rewrite-skill.md index a11733f4f..f42ae4185 100644 --- a/agent-sources/plugins/skill-creator/agents/rewrite-skill.md +++ b/agent-sources/plugins/skill-creator/agents/rewrite-skill.md @@ -215,8 +215,8 @@ Perform a full preservation sweep to confirm no original domain knowledge was dr After all file edits are complete, stage and commit: ```bash -git -c user.email="agent@skillbuilder" -c user.name="Skill Builder" add "{skill_name}/" -git -c user.email="agent@skillbuilder" -c user.name="Skill Builder" commit -m "{skill_name}: {your commit_summary}" +git -C "{skill_output_dir}/../.." -c user.email="agent@skillbuilder" -c user.name="Skill Builder" add "{skill_output_dir}/" +git -C "{skill_output_dir}/../.." -c user.email="agent@skillbuilder" -c user.name="Skill Builder" commit -m "{skill_name}: {your commit_summary}" ``` If the commit reports "nothing to commit", skip committing. Version tagging is handled automatically by the backend after the commit is detected. diff --git a/app/agent-tests/agent-structure.test.ts b/app/agent-tests/agent-structure.test.ts index bb20d4499..b1f9bed8e 100644 --- a/app/agent-tests/agent-structure.test.ts +++ b/app/agent-tests/agent-structure.test.ts @@ -8,6 +8,7 @@ const EXPECTED_AGENTS: string[] = []; /** Plugin-hosted agents: agent name → plugin path relative to PLUGINS_DIR */ const PLUGIN_AGENTS: Record = { + "skill-builder": "skill-content-researcher/agents/skill-builder.md", "generate-skill": "skill-creator/agents/generate-skill.md", "rewrite-skill": "skill-creator/agents/rewrite-skill.md", "detailed-research": "skill-content-researcher/agents/detailed-research.md", @@ -196,6 +197,15 @@ describe("Research scope guard contract prompts", () => { // - materialize_answer_evaluation_output_value() → answer-evaluator path describe("Agent output contracts (backend protocol alignment)", () => { + it("skill-builder is the one-shot workflow identity and returns delegated JSON as final output", () => { + const content = fs.readFileSync(resolveAgentPath("skill-builder"), "utf8"); + expect(content).toMatch(/focused on building skills for use in Claude Code/i); + expect(content).toMatch(/workflow step instruction/i); + expect(content).toMatch(/return the final payload required by the configured SDK `outputFormat`/i); + expect(content).toMatch(/convert that returned payload into your own final response/i); + expect(content).toMatch(/Do not use `AskUserQuestion`/i); + }); + it("confirm-decisions returns version/metadata/decisions shape", () => { const content = fs.readFileSync( resolveAgentPath("confirm-decisions"), diff --git a/app/e2e/evals/evals.spec.ts b/app/e2e/evals/evals.spec.ts index b4c0ece5d..e6779cf74 100644 --- a/app/e2e/evals/evals.spec.ts +++ b/app/e2e/evals/evals.spec.ts @@ -25,7 +25,7 @@ import { waitForAppReady } from "../helpers/app-helpers.js"; async function getEvalRunAgentId(page: Page): Promise { const banner = page.getByTestId("evals-run-thinking"); await banner.waitFor({ timeout: 8_000 }); - // data-agent-id is set after startAgent resolves — wait for it to be non-empty. + // data-agent-id is set after startOneShotAgent resolves — wait for it to be non-empty. await expect(banner).not.toHaveAttribute("data-agent-id", "", { timeout: 5_000 }); const agentId = await banner.getAttribute("data-agent-id"); if (!agentId) throw new Error("Could not read agent ID from evals-run-thinking banner"); diff --git a/app/sidecar/__tests__/config.test.ts b/app/sidecar/__tests__/config.test.ts index e56568fbe..5c5370229 100644 --- a/app/sidecar/__tests__/config.test.ts +++ b/app/sidecar/__tests__/config.test.ts @@ -68,6 +68,32 @@ describe("parseSidecarConfig", () => { expect(result.requiredPlugins).toEqual(["computer", "bash"]); }); + it("accepts explicit one-shot mode", () => { + const result = parseSidecarConfig({ + prompt: "hello", + apiKey: "key", + workspaceRootDir: TEST_CWD, + workspaceSkillDir: TEST_CWD, + pluginSlug: "demo", + mode: "one-shot", + }); + + expect(result.mode).toBe("one-shot"); + }); + + it("throws when mode is invalid", () => { + expect(() => + parseSidecarConfig({ + prompt: "hello", + apiKey: "key", + workspaceRootDir: TEST_CWD, + workspaceSkillDir: TEST_CWD, + pluginSlug: "demo", + mode: "interactive", + }), + ).toThrow("mode must be one of"); + }); + it("accepts empty string workspace dirs (no strict validation)", () => { // workspace dirs are type-checked but not value-validated — empty string passes. // Callers are responsible for providing valid directories. diff --git a/app/sidecar/__tests__/mock-agent.test.ts b/app/sidecar/__tests__/mock-agent.test.ts index c5f7a3b67..0c097ecda 100644 --- a/app/sidecar/__tests__/mock-agent.test.ts +++ b/app/sidecar/__tests__/mock-agent.test.ts @@ -42,6 +42,18 @@ describe("resolveStepTemplate", () => { }); it("maps workflow step agents correctly", () => { + expect(resolveStepTemplate("skill-content-researcher:skill-builder")).toBe( + "step0-research", + ); + expect(resolveStepTemplate("skill-content-researcher:skill-builder", { stepId: 1 })).toBe( + "step1-detailed-research", + ); + expect(resolveStepTemplate("skill-content-researcher:skill-builder", { stepId: 2 })).toBe( + "step2-confirm-decisions", + ); + expect(resolveStepTemplate("skill-content-researcher:skill-builder", { stepId: 3 })).toBe( + "step3-generate-skill", + ); expect(resolveStepTemplate("skill-content-researcher:detailed-research")).toBe( "step1-detailed-research", ); diff --git a/app/sidecar/__tests__/persistent-mode.test.ts b/app/sidecar/__tests__/persistent-mode.test.ts index 74b722c5b..72de994a2 100644 --- a/app/sidecar/__tests__/persistent-mode.test.ts +++ b/app/sidecar/__tests__/persistent-mode.test.ts @@ -461,6 +461,63 @@ describe("runPersistent", () => { expect(di1.item.resultStatus).toBe("success"); }); + it("rejects agent_request configs that explicitly ask for streaming mode", async () => { + const input = createInputStream([ + JSON.stringify({ + type: "agent_request", + request_id: "req_bad_mode", + config: { + mode: "streaming", + prompt: "test prompt", + apiKey: "sk-test", + workspaceRootDir: os.tmpdir(), + workspaceSkillDir: os.tmpdir(), + }, + }), + JSON.stringify({ type: "shutdown" }), + ]); + const exitFn = vi.fn(); + const capture = captureStdout(); + + try { + await runPersistent(input, exitFn); + } finally { + capture.restore(); + } + + expect(capture.lines.join("\n")).toContain("agent_request requires one-shot mode"); + expect(mockQuery).not.toHaveBeenCalled(); + }); + + it("rejects stream_start configs that explicitly ask for one-shot mode", async () => { + const input = createInputStream([ + JSON.stringify({ + type: "stream_start", + request_id: "req_bad_stream_mode", + session_id: "sess_bad_mode", + config: { + mode: "one-shot", + prompt: "test prompt", + apiKey: "sk-test", + workspaceRootDir: os.tmpdir(), + workspaceSkillDir: os.tmpdir(), + }, + }), + JSON.stringify({ type: "shutdown" }), + ]); + const exitFn = vi.fn(); + const capture = captureStdout(); + + try { + await runPersistent(input, exitFn); + } finally { + capture.restore(); + } + + expect(capture.lines.join("\n")).toContain("stream_start requires streaming mode"); + expect(mockQuery).not.toHaveBeenCalled(); + }); + it("handles SDK errors per-request without crashing", async () => { mockQuery.mockImplementation(() => { throw new Error("SDK connection failed"); diff --git a/app/sidecar/__tests__/run-agent.test.ts b/app/sidecar/__tests__/run-agent.test.ts index b4d314dca..cf2d865eb 100644 --- a/app/sidecar/__tests__/run-agent.test.ts +++ b/app/sidecar/__tests__/run-agent.test.ts @@ -62,6 +62,24 @@ describe("runAgentRequest", () => { ); }); + it("rejects one-shot requests that include AskUserQuestion", async () => { + const messages: Record[] = []; + + await runAgentRequest( + baseConfig({ mode: "one-shot", allowedTools: ["Read", "AskUserQuestion"] }), + (msg) => messages.push(msg), + ); + + const runResult = findRunResult(messages); + + expect(mockQuery).not.toHaveBeenCalled(); + expect(runResult).toBeDefined(); + expect((runResult!.event as Record).status).toBe("error"); + expect(JSON.stringify(runResult)).toContain( + "one-shot runtime requests cannot include user-question tools", + ); + }); + it("streams all messages to the onMessage callback", async () => { // Use proper SDK message shapes that MessageProcessor can process const sdkMessages = [ diff --git a/app/sidecar/__tests__/runtime-sink.test.ts b/app/sidecar/__tests__/runtime-sink.test.ts new file mode 100644 index 000000000..ae0388622 --- /dev/null +++ b/app/sidecar/__tests__/runtime-sink.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { createRecordRuntimeSink } from "../runtime/sink.js"; +import type { DisplayItem } from "../display-types.js"; + +describe("createRecordRuntimeSink", () => { + it("emits display items in the existing sidecar envelope", () => { + const messages: Record[] = []; + const sink = createRecordRuntimeSink((message) => messages.push(message)); + + const item: DisplayItem = { + id: "di-1", + type: "output", + outputText: "hello", + timestamp: 123, + }; + + sink.emitDisplayItem(item); + + expect(messages).toEqual([{ type: "display_item", item }]); + }); + + it("emits agent events in the existing sidecar envelope", () => { + const messages: Record[] = []; + const sink = createRecordRuntimeSink((message) => messages.push(message)); + + sink.emitAgentEvent({ type: "turn_complete", turn: 1, streaming: false }, 456); + + expect(messages).toEqual([ + { + type: "agent_event", + event: { type: "turn_complete", turn: 1, streaming: false }, + timestamp: 456, + }, + ]); + }); + + it("emits refine questions in the existing sidecar envelope", () => { + const messages: Record[] = []; + const sink = createRecordRuntimeSink((message) => messages.push(message)); + + sink.emitRefineQuestion({ + tool_use_id: "toolu-1", + questions: [{ id: "q1", question: "Pick one" }], + timestamp: 789, + }); + + expect(messages).toEqual([ + { + type: "refine_question", + tool_use_id: "toolu-1", + questions: [{ id: "q1", question: "Pick one" }], + timestamp: 789, + }, + ]); + }); + + it("passes raw messages through unchanged", () => { + const messages: Record[] = []; + const sink = createRecordRuntimeSink((message) => messages.push(message)); + + sink.emitRaw({ type: "system", subtype: "init_start" }); + + expect(messages).toEqual([{ type: "system", subtype: "init_start" }]); + }); +}); diff --git a/app/sidecar/__tests__/runtime-types.test.ts b/app/sidecar/__tests__/runtime-types.test.ts new file mode 100644 index 000000000..c6e152855 --- /dev/null +++ b/app/sidecar/__tests__/runtime-types.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + assertOneShotHasNoUserQuestions, + isUserQuestionToolName, + type OneShotRunRequest, + type StreamingSessionRequest, +} from "../runtime/types.js"; + +const baseContext = { + skillName: "demo-skill", + pluginSlug: "demo-plugin", + stepId: 0, + runSource: "workflow" as const, +}; + +describe("runtime request types", () => { + it("recognizes Claude and runtime-neutral user question tool names", () => { + expect(isUserQuestionToolName("AskUserQuestion")).toBe(true); + expect(isUserQuestionToolName("ask_user_question")).toBe(true); + expect(isUserQuestionToolName("Read")).toBe(false); + }); + + it("allows one-shot requests without user-question tools", () => { + const request: OneShotRunRequest = { + mode: "one-shot", + allowUserQuestions: false, + prompt: "Generate the skill.", + apiKey: "sk-test", + workspaceRootDir: "/workspace", + workspaceSkillDir: "/workspace/plugin/skill", + allowedTools: ["Read", "Write", "Edit"], + context: baseContext, + }; + + expect(() => assertOneShotHasNoUserQuestions(request)).not.toThrow(); + }); + + it("rejects one-shot requests that include AskUserQuestion", () => { + const request: OneShotRunRequest = { + mode: "one-shot", + allowUserQuestions: false, + prompt: "Ask before continuing.", + apiKey: "sk-test", + workspaceRootDir: "/workspace", + workspaceSkillDir: "/workspace/plugin/skill", + allowedTools: ["Read", "AskUserQuestion"], + context: baseContext, + }; + + expect(() => assertOneShotHasNoUserQuestions(request)).toThrow( + "one-shot runtime requests cannot include user-question tools: AskUserQuestion", + ); + }); + + it("keeps user questions valid for streaming requests", () => { + const request: StreamingSessionRequest = { + mode: "streaming", + allowUserQuestions: true, + prompt: "Refine this skill.", + apiKey: "sk-test", + workspaceRootDir: "/workspace", + workspaceSkillDir: "/workspace/plugin/skill", + allowedTools: ["Read", "AskUserQuestion"], + context: baseContext, + }; + + expect(request.allowUserQuestions).toBe(true); + expect(request.mode).toBe("streaming"); + }); +}); diff --git a/app/sidecar/__tests__/stream-session.test.ts b/app/sidecar/__tests__/stream-session.test.ts index 1975cc631..63446acf6 100644 --- a/app/sidecar/__tests__/stream-session.test.ts +++ b/app/sidecar/__tests__/stream-session.test.ts @@ -90,6 +90,22 @@ describe("StreamSession — run_result emission ordering", () => { delete process.env.MOCK_AGENTS; }); + it("exposes runtime session methods for message, answer, cancel, and close", () => { + const session = new StreamSession( + "session-runtime", + "req-start", + baseConfig(), + vi.fn(), + ); + + expect(typeof session.sendUserMessage).toBe("function"); + expect(typeof session.answerQuestion).toBe("function"); + expect(typeof session.cancel).toBe("function"); + expect(typeof session.close).toBe("function"); + + session.close(); + }); + it("emits run_result before session_exhausted on natural turn exhaustion (no result SDK message)", async () => { // SDK generator ends without emitting a result-type message async function* fakeConversation() { diff --git a/app/sidecar/__tests__/structured-output-required.test.ts b/app/sidecar/__tests__/structured-output-required.test.ts index 06b55c452..8c63a50f6 100644 --- a/app/sidecar/__tests__/structured-output-required.test.ts +++ b/app/sidecar/__tests__/structured-output-required.test.ts @@ -2,13 +2,15 @@ * Tests for VU-1015: SDK outputFormat should provide structured_output * for nested schemas (anthropics/claude-agent-sdk-typescript#277). * - * When outputFormat is configured, MessageProcessor requires SDK - * structured_output and hard-fails if it is missing. Text parsing fallback - * belongs outside this path now that the SDK canary passes for nested schemas. + * When outputFormat is configured, MessageProcessor prefers SDK + * structured_output. If a runtime returns JSON as final text instead, the + * one-shot boundary extracts that JSON and lets Rust validate the typed + * workflow contract. */ import { describe, it, expect, beforeEach } from "vitest"; import { MessageProcessor } from "../message-processor.js"; import type { DisplayItem, DisplayItemEnvelope } from "../display-types.js"; +import type { AgentEventEnvelope, RunResultEvent } from "../agent-events.js"; function extractDisplayItems(output: Record[]): DisplayItem[] { return output @@ -16,6 +18,13 @@ function extractDisplayItems(output: Record[]): DisplayItem[] { .map((o) => (o as DisplayItemEnvelope).item); } +function extractRunResult(output: Record[]): RunResultEvent | undefined { + return output + .filter((o) => o.type === "agent_event") + .map((o) => (o as AgentEventEnvelope).event) + .find((event): event is RunResultEvent => event.type === "run_result"); +} + describe("structured_output required outputFormat path (VU-1015)", () => { describe("with hasOutputFormat: true (requireStructuredOutput)", () => { let processor: MessageProcessor; @@ -42,7 +51,7 @@ describe("structured_output required outputFormat path (VU-1015)", () => { expect(result?.structuredOutput).toEqual(schema); }); - it("hard-fails when SDK omits structured_output even if result text is JSON", () => { + it("recovers JSON when SDK omits structured_output but result text is JSON", () => { const schema = { step_summary: "Research complete", artifacts: ["plan.md"] }; const raw = { type: "result", @@ -56,12 +65,14 @@ describe("structured_output required outputFormat path (VU-1015)", () => { const items = extractDisplayItems(out); const result = items.find((i) => i.type === "result"); - expect(result?.resultStatus).toBe("error"); - expect(result?.errorSubtype).toBe("structured_output_missing"); - expect(result?.structuredOutput).toBeUndefined(); + expect(result?.resultStatus).toBe("success"); + expect(result?.errorSubtype).toBeUndefined(); + expect(result?.structuredOutput).toEqual(schema); + expect(extractRunResult(out)?.status).toBe("completed"); + expect(JSON.parse(extractRunResult(out)?.resultText ?? "{}")).toEqual(schema); }); - it("hard-fails when SDK returns structured_output: null even if result text is JSON", () => { + it("recovers JSON when SDK returns structured_output: null but result text is JSON", () => { const schema = { step_summary: "Done", next_steps: ["deploy"] }; const raw = { type: "result", @@ -75,12 +86,12 @@ describe("structured_output required outputFormat path (VU-1015)", () => { const items = extractDisplayItems(out); const result = items.find((i) => i.type === "result"); - expect(result?.resultStatus).toBe("error"); - expect(result?.errorSubtype).toBe("structured_output_missing"); - expect(result?.structuredOutput).toBeUndefined(); + expect(result?.resultStatus).toBe("success"); + expect(result?.errorSubtype).toBeUndefined(); + expect(result?.structuredOutput).toEqual(schema); }); - it("hard-fails when SDK omits structured_output even if result is an object", () => { + it("recovers object result when SDK omits structured_output", () => { const raw = { type: "result", subtype: "success", @@ -92,12 +103,12 @@ describe("structured_output required outputFormat path (VU-1015)", () => { const items = extractDisplayItems(out); const result = items.find((i) => i.type === "result"); - expect(result?.resultStatus).toBe("error"); - expect(result?.errorSubtype).toBe("structured_output_missing"); - expect(result?.structuredOutput).toBeUndefined(); + expect(result?.resultStatus).toBe("success"); + expect(result?.errorSubtype).toBeUndefined(); + expect(result?.structuredOutput).toEqual({ step_summary: "Done" }); }); - it("hard-fails instead of parsing last assistant JSON text", () => { + it("recovers JSON from last assistant text when result text is not JSON", () => { const schema = { step_summary: "Done", nested: { level: 2, items: [1, 2, 3] } }; processor.process({ @@ -120,14 +131,14 @@ describe("structured_output required outputFormat path (VU-1015)", () => { const items = extractDisplayItems(out); const result = items.find((i) => i.type === "result"); - expect(result?.resultStatus).toBe("error"); - expect(result?.errorSubtype).toBe("structured_output_missing"); - expect(result?.structuredOutput).toBeUndefined(); + expect(result?.resultStatus).toBe("success"); + expect(result?.errorSubtype).toBeUndefined(); + expect(result?.structuredOutput).toEqual(schema); }); - it("hard-fails instead of parsing fenced JSON from last assistant text", () => { + it("recovers fenced JSON with leading narrative from last assistant text", () => { const schema = { step_summary: "Done", code_files: ["lib/a.ts", "lib/b.ts"] }; - const fencedJson = "```json\n" + JSON.stringify(schema) + "\n```"; + const fencedJson = "Done. Here is the payload:\n\n```json\n" + JSON.stringify(schema) + "\n```"; processor.process({ type: "assistant", @@ -149,9 +160,9 @@ describe("structured_output required outputFormat path (VU-1015)", () => { const items = extractDisplayItems(out); const result = items.find((i) => i.type === "result"); - expect(result?.resultStatus).toBe("error"); - expect(result?.errorSubtype).toBe("structured_output_missing"); - expect(result?.structuredOutput).toBeUndefined(); + expect(result?.resultStatus).toBe("success"); + expect(result?.errorSubtype).toBeUndefined(); + expect(result?.structuredOutput).toEqual(schema); }); it("hard-fails with structured_output_missing when no JSON is recoverable", () => { diff --git a/app/sidecar/config.ts b/app/sidecar/config.ts index d9a6d6d5e..b17c1dc11 100644 --- a/app/sidecar/config.ts +++ b/app/sidecar/config.ts @@ -1,4 +1,5 @@ export interface SidecarConfig { + mode?: "one-shot" | "streaming"; prompt: string; systemPrompt?: string; model?: string; @@ -108,6 +109,7 @@ export function parseSidecarConfig(raw: unknown): SidecarConfig { assertOptString(c, "pathToClaudeCodeExecutable"); // Optional enum fields + assertOptStringIn(c, "mode", ["one-shot", "streaming"]); assertOptStringIn(c, "permissionMode", ["default", "acceptEdits", "bypassPermissions", "plan"]); assertOptStringIn(c, "effort", ["low", "medium", "high", "max"]); assertOptStringIn(c, "runSource", ["workflow", "refine", "test", "gate-eval"]); diff --git a/app/sidecar/generated/contracts.ts b/app/sidecar/generated/contracts.ts index e6912acd1..7f137a588 100644 --- a/app/sidecar/generated/contracts.ts +++ b/app/sidecar/generated/contracts.ts @@ -113,7 +113,7 @@ export type DetailedResearchOutput = { status: string; refinement_count: number; /** * A scored dimension in the research plan. */ -export type DimensionScore = { name: string; score: number; reason: string; focus: string } +export type DimensionScore = { name: string; score: number; reason: string; focus?: string | null } /** * Structured output produced by the `generate-skill` agent (workflow step 3, diff --git a/app/sidecar/lib/result-extraction.ts b/app/sidecar/lib/result-extraction.ts index 961a90ad3..f29bb347d 100644 --- a/app/sidecar/lib/result-extraction.ts +++ b/app/sidecar/lib/result-extraction.ts @@ -17,3 +17,90 @@ export function extractResultMarkdown(structuredOutput: unknown): string | undef .map(([, val]) => val as string); return sections.length > 0 ? sections.join("\n\n---\n\n") : undefined; } + +/** + * Extracts a JSON value from runtime text output. + * + * Coding-agent runtimes do not all support provider-native structured output. + * The one-shot boundary therefore accepts JSON returned as plain text, including + * fenced JSON preceded by short narration. Validation stays in the typed Rust + * workflow materializers. + */ +export function extractJsonFromText(text: string): unknown | undefined { + const fullText = parseJson(text); + if (fullText !== undefined) return fullText; + + for (const candidate of fencedJsonCandidates(text)) { + const parsed = parseJson(candidate); + if (parsed !== undefined) return parsed; + } + + for (const candidate of balancedJsonCandidates(text)) { + const parsed = parseJson(candidate); + if (parsed !== undefined) return parsed; + } + + return undefined; +} + +function parseJson(text: string): unknown | undefined { + try { + return JSON.parse(text.trim()); + } catch { + return undefined; + } +} + +function fencedJsonCandidates(text: string): string[] { + const candidates: string[] = []; + const fencePattern = /```(?:json|JSON)?\s*([\s\S]*?)```/g; + let match: RegExpExecArray | null; + while ((match = fencePattern.exec(text)) !== null) { + candidates.push(match[1] ?? ""); + } + return candidates.sort((a, b) => b.length - a.length); +} + +function balancedJsonCandidates(text: string): string[] { + const candidates: string[] = []; + const openers = new Set(["{", "["]); + const matching: Record = { "{": "}", "[": "]" }; + + for (let start = 0; start < text.length; start += 1) { + const first = text[start]; + if (!openers.has(first)) continue; + + const stack: string[] = [matching[first]]; + let inString = false; + let escaped = false; + + for (let idx = start + 1; idx < text.length; idx += 1) { + const char = text[idx]; + + if (inString) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === "\"") { + inString = false; + } + continue; + } + + if (char === "\"") { + inString = true; + } else if (openers.has(char)) { + stack.push(matching[char]); + } else if (char === stack[stack.length - 1]) { + stack.pop(); + if (stack.length === 0) { + candidates.push(text.slice(start, idx + 1)); + break; + } + } + } + } + + return candidates.sort((a, b) => b.length - a.length); +} diff --git a/app/sidecar/message-processor.ts b/app/sidecar/message-processor.ts index b7fef2fee..b30ed84bc 100644 --- a/app/sidecar/message-processor.ts +++ b/app/sidecar/message-processor.ts @@ -25,7 +25,7 @@ import { truncate, computeToolSummary } from "./tool-summaries.js"; import { RESULT_ERROR_LABELS, ASSISTANT_ERROR_LABELS } from "./error-labels.js"; import { RunMetadataAccumulator } from "./run-metadata-accumulator.js"; import type { RequestContext } from "./run-metadata-accumulator.js"; -import { extractResultMarkdown } from "./lib/result-extraction.js"; +import { extractJsonFromText, extractResultMarkdown } from "./lib/result-extraction.js"; // --------------------------------------------------------------------------- // MessageProcessor @@ -655,24 +655,28 @@ export class MessageProcessor { } // Extract structured output from SDK result for artifact materialization. - // When outputFormat is configured, the SDK returns the parsed JSON in - // structured_output and a text summary in result. Fall back to result - // if it's already an object (older SDK versions). + // Prefer provider-native structured output. If a runtime returns the JSON + // contract as text instead, parse it at this one-shot boundary and let Rust + // validate the typed workflow contract. let structuredOutput: unknown = undefined; if ("structured_output" in raw && raw.structured_output != null) { structuredOutput = raw.structured_output; - } else if (!this.requireStructuredOutput && "result" in raw && raw.result != null && typeof raw.result !== "string") { + } else if ("result" in raw && raw.result != null && typeof raw.result !== "string") { structuredOutput = raw.result; + } else if (this.requireStructuredOutput) { + const resultJson = typeof raw.result === "string" + ? extractJsonFromText(raw.result) + : undefined; + structuredOutput = resultJson ?? (this.lastOutputText ? extractJsonFromText(this.lastOutputText) : undefined); } - // outputFormat is an SDK contract: when configured, the SDK must provide - // structured_output. If it is absent, fail clearly instead of recovering - // from display text. + // outputFormat is the one-shot structured contract. If neither the + // provider nor the final text produced JSON, fail clearly. if (structuredOutput == null && this.requireStructuredOutput) { resultStatus = "error"; errorSubtype = "structured_output_missing"; - outputText = "Structured output missing — the agent did not return JSON matching the required schema. Retry the step."; + outputText = "Structured output missing — the agent did not return parseable JSON matching the required schema. Retry the step."; process.stderr.write( `[message-processor] event=structured_output_missing subtype=${subtype ?? "none"} ` + `result_type=${typeof raw.result} result_length=${typeof raw.result === "string" ? raw.result.length : 0}\n` @@ -699,8 +703,18 @@ export class MessageProcessor { `[message-processor] event=emit_display_item item_type=result id=${item.id} status=${resultStatus} subtype=${subtype ?? "none"} orphaned_tools=${orphanedItems.length}\n`, ); + const summaryRaw = errorSubtype === "structured_output_missing" + ? { + ...raw, + subtype: errorSubtype, + is_error: true, + errors: [outputText], + stop_reason: "error", + } + : raw; + // Build run_result from accumulated state. - const runSummary = this.accumulator.buildRunSummary(raw); + const runSummary = this.accumulator.buildRunSummary(summaryRaw); // Attach the agent's final output text so Rust can parse structured results // (e.g. step_id=-12 generate-skill-description-evals) without a second IPC round-trip. if (structuredOutput != null) { diff --git a/app/sidecar/mock-agent.ts b/app/sidecar/mock-agent.ts index eb1993e3d..2e130c259 100644 --- a/app/sidecar/mock-agent.ts +++ b/app/sidecar/mock-agent.ts @@ -23,7 +23,7 @@ const MOCK_SCENARIO = process.env.MOCK_SCENARIO ?? "default"; /** @internal Exported for testing only. */ export function resolveStepTemplate( agentName: string | undefined, - config?: { skillName?: string; runSource?: string }, + config?: { skillName?: string; runSource?: string; stepId?: number }, ): string | null { if (!agentName) { // Eval generator: invoked without a plugin agentName; identified by skillName. @@ -36,6 +36,12 @@ export function resolveStepTemplate( } // Exact matches first + if (agentName === "skill-content-researcher:skill-builder") { + if (config?.stepId === 1) return "step1-detailed-research"; + if (config?.stepId === 2) return "step2-confirm-decisions"; + if (config?.stepId === 3) return "step3-generate-skill"; + return "step0-research"; + } if (agentName === "skill-content-researcher:detailed-research") return "step1-detailed-research"; if (agentName === "skill-content-researcher:confirm-decisions") return "step2-confirm-decisions"; if (agentName === "skill-creator:generate-skill") return "step3-generate-skill"; diff --git a/app/sidecar/package-lock.json b/app/sidecar/package-lock.json index 3f679b25f..4e6fe0b7b 100644 --- a/app/sidecar/package-lock.json +++ b/app/sidecar/package-lock.json @@ -8,7 +8,7 @@ "name": "skill-builder-sidecar", "version": "0.1.0", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.121" + "@anthropic-ai/claude-agent-sdk": "^0.2.123" }, "devDependencies": { "@types/node": "^22", @@ -18,9 +18,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.2.121", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.121.tgz", - "integrity": "sha512-hwZNYTkGLKVixd/V/OCJwfH/SdfxZXGV0m6wvy5EBq6qfB+lvJTRz/MSOSa7dHqo4/F7zJY68crEEca68Wrxpw==", + "version": "0.2.123", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.123.tgz", + "integrity": "sha512-a4TysYoR9DBdkM9Uwh4J5ub7TwKmRPe5hFiWh4En+IKC+qkk5UFkxFM22c//cZjYZKynHX0ah2t6LUqb+najYA==", "license": "SEE LICENSE IN README.md", "dependencies": { "@anthropic-ai/sdk": "^0.81.0", @@ -30,23 +30,23 @@ "node": ">=18.0.0" }, "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.121", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.121", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.121", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.121", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.121", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.121", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.121", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.121" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.123", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.123", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.123", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.123", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.123", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.123", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.123", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.123" }, "peerDependencies": { "zod": "^4.0.0" } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.2.121", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.2.121.tgz", - "integrity": "sha512-zVHcXvx6Hl/glDcOCH+EyNx4KPE9cMGLk42eEBSZe014tAN5W8bwM/By08iM6dxijnpH0NQRNNEAW+BryWzuDg==", + "version": "0.2.123", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.2.123.tgz", + "integrity": "sha512-tYAXCjlXZQklsUs0J//gip3fZQRzhlH5OCgvNXV70qe7A1iiwHqO2KPGvEHV1L+deEKQoMZmTaCOrQpN6zju3w==", "cpu": [ "arm64" ], @@ -57,9 +57,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.2.121", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.2.121.tgz", - "integrity": "sha512-lIXdqKj+bpfDxCk/eU1F1TXNqsIsLTRrkUG/wx19WIGZ8gLUmmVSveUKGlNegTs7S6evMvuezprJzDJT4TcvPA==", + "version": "0.2.123", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.2.123.tgz", + "integrity": "sha512-AcUC6sTon6z6HculP87KsAOeTMRLBwpovdhcXUTjXUpo/8nplJ7lBEzWjZCHt8FF1KuN/WBy1Z4bDg/59TQDmA==", "cpu": [ "x64" ], @@ -70,15 +70,12 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.2.121", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.2.121.tgz", - "integrity": "sha512-AQSnJzaiFvQpUPfO1tWLvsHgb6KNar4QYEQ/5/sk1itfgr3Fx9gxTreq43wX7AXSvkBX1QlDaP1aR1sfM/g/lQ==", + "version": "0.2.123", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.2.123.tgz", + "integrity": "sha512-7+GnbcF3/aZ8RJ1WmU/ogtPsOpknBAoUPer90MvZuFYBLPT9iI/U7f24gjrOHuYdcbDA5n7jFlhcfIO26F5DJQ==", "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -86,15 +83,12 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.2.121", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.2.121.tgz", - "integrity": "sha512-4XaGK+dRBYy7krln7BrDG0WsdE6ejUSgHjWHlUGXoubFfZUvls4GSahLcYjJBArLi4dLnxKw8zEuiQguPAIbrw==", + "version": "0.2.123", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.2.123.tgz", + "integrity": "sha512-bYgRiaf2q+yVbGAoUluuhqrEW1zexL34+3HDmK9DneKXa2K2EJpw4M6Sq4XoBD/JezGaemoAP78Xv/M/QUS1OQ==", "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -102,15 +96,12 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.2.121", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.2.121.tgz", - "integrity": "sha512-DJUgpm7au086WaQV/S7BGOt2M8D90spGZRizT3twYsacf1BxzK1qsXqB/Pw1lUjPy6pI107pml/TaPzWuS/Vzg==", + "version": "0.2.123", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.2.123.tgz", + "integrity": "sha512-Xi+Rwk8uP5vWEnawJOlsk179fr0ATLl5J90MlbLj+puKaX5svEq8ljS+P3zq6zHTJeKh9GKLzPf7bc5YJKwcew==", "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -118,15 +109,12 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.2.121", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.2.121.tgz", - "integrity": "sha512-sQoGIgzLlBRrwizxsCV/lbaEuxXom/cfOwlDtQ2HnS1IzDDSjSf5d5pugpWItkOyXBWcHzMUu731WTTutvd/BQ==", + "version": "0.2.123", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.2.123.tgz", + "integrity": "sha512-IX95lFKhmmndY/YPfWPsVV+C3rLYJmuuq5wCS53p6jYIkCMxH1iGfhBGF1EUWcXO4Uc8yqXFmQ3aaxMzOOPrwA==", "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -134,9 +122,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.2.121", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.2.121.tgz", - "integrity": "sha512-6n/NHkHxs0/lCJX3XPADjo1EFzXBf0IwYz/nyzJGBCDJjGKmgTe0i8eYBr/hviwt1/OPeK7dmVzVSVl6EL9Azg==", + "version": "0.2.123", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.2.123.tgz", + "integrity": "sha512-WDZmAQG1rOiqNLZlSXaCjSWmqJvLk2io+vFQWWqSy2b5HCk9pa3PadLiaLztiihyk81wPhH9Q/44kOxdyfEGMw==", "cpu": [ "arm64" ], @@ -147,9 +135,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.2.121", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.2.121.tgz", - "integrity": "sha512-v2/R918/t94cCwc6rmbxk+UYeQPtF2oBLtQAk+cT0M60hvqmCZO2noyZx5uTp8TQncOlG4MkINIeNY2yfmWSoQ==", + "version": "0.2.123", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.2.123.tgz", + "integrity": "sha512-588xrd1i6d4kXQ6FqwL+cgBiN4evRQSi5DCtPa02CZ3VEbuVQBeFlyPlD8tfWtNNeGZ4NM8kjPNNzZz5omezPA==", "cpu": [ "x64" ], @@ -845,9 +833,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -865,9 +850,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -885,9 +867,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -905,9 +884,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -925,9 +901,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -945,9 +918,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2074,9 +2044,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2098,9 +2065,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2122,9 +2086,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2146,9 +2107,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/app/sidecar/package.json b/app/sidecar/package.json index b6177a58c..03735b1a8 100644 --- a/app/sidecar/package.json +++ b/app/sidecar/package.json @@ -9,7 +9,7 @@ "test": "vitest run" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.121" + "@anthropic-ai/claude-agent-sdk": "^0.2.123" }, "devDependencies": { "@types/node": "^22", diff --git a/app/sidecar/persistent-mode.ts b/app/sidecar/persistent-mode.ts index 0d4d5f15d..05d897d0d 100644 --- a/app/sidecar/persistent-mode.ts +++ b/app/sidecar/persistent-mode.ts @@ -1,7 +1,8 @@ import { createInterface, type Interface } from "node:readline"; import { type SidecarConfig, parseSidecarConfig } from "./config.js"; -import { runAgentRequest } from "./run-agent.js"; import { StreamSession } from "./stream-session.js"; +import { ClaudeRuntime, toOneShotRunRequest } from "./runtime/claude-runtime.js"; +import { createRecordRuntimeSink } from "./runtime/sink.js"; /** Incoming request envelope: run an agent. */ interface AgentRequest { @@ -236,6 +237,7 @@ export async function runPersistent( // Active streaming sessions (refine chat uses these for multi-turn conversations) const activeSessions = new Map(); + const runtime = new ClaudeRuntime(); for await (const line of rl) { const message = parseIncomingMessage(line); @@ -307,6 +309,16 @@ export async function runPersistent( `[sidecar] Stream start: session=${session_id} request=${request_id}\n`, ); + if (config.mode === "one-shot") { + writeLine( + wrapWithRequestId(request_id, { + type: "error", + message: "stream_start requires streaming mode", + }), + ); + continue; + } + if (activeSessions.has(session_id)) { writeLine( wrapWithRequestId(request_id, { @@ -427,6 +439,17 @@ export async function runPersistent( const { request_id, config } = message; process.stderr.write(`[sidecar] Agent request: ${request_id}\n`); + + if (config.mode === "streaming") { + writeLine( + wrapWithRequestId(request_id, { + type: "error", + message: "agent_request requires one-shot mode", + }), + ); + continue; + } + const abortController = new AbortController(); currentAbort = abortController; currentRequestId = request_id; @@ -435,9 +458,13 @@ export async function runPersistent( // This lets ping/shutdown messages be processed while the agent runs. const requestPromise = (async () => { try { - await runAgentRequest(config, (msg) => { - writeLine(wrapWithRequestId(request_id, msg)); - }, abortController.signal); + await runtime.runOnce( + toOneShotRunRequest({ ...config, mode: "one-shot" }), + createRecordRuntimeSink((msg) => { + writeLine(wrapWithRequestId(request_id, msg)); + }), + abortController.signal, + ); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); diff --git a/app/sidecar/run-agent.ts b/app/sidecar/run-agent.ts index 6ba047fd8..4411f1f44 100644 --- a/app/sidecar/run-agent.ts +++ b/app/sidecar/run-agent.ts @@ -1,223 +1,71 @@ -import { query } from "@anthropic-ai/claude-agent-sdk"; import type { SidecarConfig } from "./config.js"; -import { runMockAgent } from "./mock-agent.js"; -import { buildQueryOptions } from "./options.js"; -import { createAbortState, linkExternalSignal } from "./shutdown.js"; -import { MessageProcessor } from "./message-processor.js"; -import { ResultGate } from "./result-gate.js"; -import * as fs from "node:fs/promises"; -import * as path from "node:path"; - -/** - * Discover all installed plugins under /.claude/plugins/. - * Returns an absolute path for each subdirectory found there. - */ -export async function discoverInstalledPlugins(rootDir: string): Promise { - const pluginsDir = path.join(rootDir, ".claude", "plugins"); - try { - const entries = await fs.readdir(pluginsDir, { withFileTypes: true }); - return entries - .filter((entry) => entry.isDirectory()) - .map((entry) => path.join(pluginsDir, entry.name)); - } catch { - return []; - } -} - -/** - * Filter discovered plugin directories to the explicit required set. - * No fallback to "all plugins" is allowed; callers must request plugins intentionally. - */ -export function selectPluginPaths( - discoveredPluginPaths: string[], - requiredPlugins?: string[], -): string[] { - if (!requiredPlugins || requiredPlugins.length === 0) { - return []; - } - - const discoveredByName = new Map( - discoveredPluginPaths.map((pluginPath) => [path.basename(pluginPath), pluginPath] as const), - ); - - return requiredPlugins - .map((pluginName) => discoveredByName.get(pluginName)) - .filter((pluginPath): pluginPath is string => typeof pluginPath === "string"); -} - -/** - * Emit a system-level progress event (not an SDK message). - * These events let the UI show granular status during initialization. - */ -export function emitSystemEvent( - onMessage: (message: Record) => void, - subtype: string, -): void { - onMessage({ type: "system", subtype, timestamp: Date.now() }); +import type { RunResultEvent } from "./agent-events.js"; +import { + ClaudeRuntime, + discoverInstalledPlugins, + emitSystemEvent, + selectPluginPaths, + toOneShotRunRequest, +} from "./runtime/claude-runtime.js"; +import { createRecordRuntimeSink } from "./runtime/sink.js"; + +export { discoverInstalledPlugins, emitSystemEvent, selectPluginPaths }; + +function buildRuntimeValidationResult( + config: SidecarConfig, + message: string, +): RunResultEvent { + return { + type: "run_result", + skillName: config.skillName ?? "unknown", + stepId: config.stepId ?? -1, + workflowSessionId: config.workflowSessionId, + usageSessionId: config.usageSessionId, + runSource: config.runSource, + sessionId: null, + model: config.model ?? "unknown", + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalCostUsd: 0, + modelUsageBreakdown: [], + contextWindow: 0, + resultSubtype: "runtime_validation", + resultErrors: [message], + stopReason: null, + numTurns: 0, + durationMs: 0, + durationApiMs: 0, + toolUseCount: 0, + compactionCount: 0, + status: "error", + resultText: message, + workspacePath: config.workspaceSkillDir, + pluginSlug: config.pluginSlug ?? "unknown", + }; } /** - * Run a single agent request using the SDK. - * - * Streams each SDK message to the provided `onMessage` callback. - * The callback receives raw SDK message objects (the caller is responsible - * for any wrapping, e.g., adding `request_id`). + * Compatibility entry point for existing one-shot sidecar callers. * - * @param config The sidecar config for this request - * @param onMessage Called for each message from the SDK conversation - * @param externalSignal Optional AbortSignal to cancel from outside (e.g., when persistent-mode - * aborts a stuck request to start a new one) + * New runtime code should use ClaudeRuntime directly. This wrapper preserves + * the historic function signature while routing through the one-shot runtime + * boundary. */ export async function runAgentRequest( config: SidecarConfig, onMessage: (message: Record) => void, externalSignal?: AbortSignal, ): Promise { - if (process.env.MOCK_AGENTS === "true") { - process.stderr.write("[sidecar] Mock agent mode\n"); - return runMockAgent(config, onMessage, externalSignal); - } - - const state = createAbortState(); - if (externalSignal) { - linkExternalSignal(state, externalSignal); - } - - // Discover plugins from the workspace root where .claude/plugins/ lives. - const discoveredPluginPaths = await discoverInstalledPlugins(config.workspaceRootDir); - const pluginPaths = selectPluginPaths(discoveredPluginPaths, config.requiredPlugins); - - // Route SDK subprocess stderr through onMessage so it gets wrapped with - // request_id and written to the JSONL transcript (not the app log). - const stderrHandler = (data: string) => { - onMessage({ type: "system", subtype: "sdk_stderr", data: data.trimEnd(), timestamp: Date.now() }); - }; - - // Ref for the Stop hook — assigned after processor creation. - const processorRef: { current: MessageProcessor | null } = { current: null }; - - - // Process raw SDK messages through MessageProcessor for structured display items - const processor = new MessageProcessor({ - skillName: config.skillName, - stepId: config.stepId, - workflowSessionId: config.workflowSessionId, - usageSessionId: config.usageSessionId, - runSource: config.runSource, - workspaceSkillDir: config.workspaceSkillDir, - pluginSlug: config.pluginSlug, - hasOutputFormat: config.outputFormat != null, - }); - processorRef.current = processor; - - const options = buildQueryOptions(config, state.abortController, pluginPaths, stderrHandler, processorRef); - - // Gate run_result emission until all subagents/background tasks finish. - const gate = new ResultGate(processor); - - // Emit plugins passed to the SDK as a system event so it appears in the JSONL transcript. - const pluginsToLog = (options as Record).plugins as unknown[] | undefined; - onMessage({ - type: "system", - subtype: "sdk_plugins_debug", - plugins: pluginsToLog ?? [], - timestamp: Date.now(), - }); - - // Notify the UI that we're about to initialize the SDK - emitSystemEvent(onMessage, "init_start"); + const runtime = new ClaudeRuntime(); + const sink = createRecordRuntimeSink(onMessage); try { - process.stderr.write("[sidecar] Starting SDK query\n"); - const conversation = query({ - prompt: config.prompt, - options, - }); - - let sdkReadyEmitted = false; - for await (const message of conversation) { - if (state.abortController.signal.aborted) break; - - // Emit sdk_ready on first actual message from the SDK so the - // "Connecting to API..." progress reflects real connection state. - if (!sdkReadyEmitted) { - emitSystemEvent(onMessage, "sdk_ready"); - sdkReadyEmitted = true; - } - - const raw = message as Record; - - // Forward prompt suggestions directly. - if (raw.type === "prompt_suggestion" && typeof raw.suggestion === "string") { - onMessage({ - type: "agent_event", - event: { - type: "prompt_suggestion", - suggestion: raw.suggestion, - }, - timestamp: Date.now(), - }); - continue; - } - - // Process into display items + pass-through messages - // Task events (task_started/task_progress/task_notification) are routed - // through the "task" classifier category → processTaskEvent. - const items = processor.process(raw); - for (const item of items) { - gate.emit(item as Record, onMessage); - } - gate.tryFlush(onMessage); - } - - // Safety net: emit any deferred run_result when the SDK loop exits. - gate.flush(onMessage); + await runtime.runOnce(toOneShotRunRequest(config), sink, externalSignal); } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (state.abortController.signal.aborted) { - process.stderr.write("[sidecar] Query stream aborted during iteration\n"); - } else { - process.stderr.write(`[sidecar] Query stream failed: ${errorMessage}\n`); - - const errorItems = processor.process({ - type: "error", - message: errorMessage, - }); - for (const item of errorItems) { - onMessage(item as Record); - } - - const [errorSummary, orphanedErr] = processor.buildExecutionErrorSummary(errorMessage); - for (const item of orphanedErr) { - onMessage(item as Record); - } - onMessage({ type: "agent_event", event: errorSummary, timestamp: Date.now() } as Record); - return; - } - } - - // Emit a shutdown run_result for aborted runs so Rust can persist partial data - if (state.abortController.signal.aborted) { - process.stderr.write("[sidecar] Run aborted — emitting shutdown run_result\n"); - const [shutdownSummary, orphanedAbort] = processor.buildShutdownSummary(); - for (const item of orphanedAbort) { - onMessage(item as Record); - } - onMessage({ type: "agent_event", event: shutdownSummary, timestamp: Date.now() } as Record); - } - - // Guard: if the SDK iterator completed without emitting a result message - // (e.g. auth failure where the SDK yields error-bearing messages then exits - // without a result), emit an error run_result so Rust fires agent-exit and - // the frontend transitions out of "Running" state. - if (!processor.hasEmittedResult() && !state.abortController.signal.aborted) { - process.stderr.write("[sidecar] SDK completed without result — emitting error run_result\n"); - const [errorSummary, orphanedNoResult] = processor.buildExecutionErrorSummary( - "Agent ended without producing a result", - ); - for (const item of orphanedNoResult) { - onMessage(item as Record); - } - onMessage({ type: "agent_event", event: errorSummary, timestamp: Date.now() } as Record); + const message = error instanceof Error ? error.message : String(error); + sink.emitRaw({ type: "error", message }); + sink.emitAgentEvent(buildRuntimeValidationResult(config, message)); } } diff --git a/app/sidecar/runtime/claude-runtime.ts b/app/sidecar/runtime/claude-runtime.ts new file mode 100644 index 000000000..d0d926187 --- /dev/null +++ b/app/sidecar/runtime/claude-runtime.ts @@ -0,0 +1,262 @@ +import { query } from "@anthropic-ai/claude-agent-sdk"; +import type { SidecarConfig } from "../config.js"; +import { runMockAgent } from "../mock-agent.js"; +import { buildQueryOptions } from "../options.js"; +import { createAbortState, linkExternalSignal } from "../shutdown.js"; +import { MessageProcessor } from "../message-processor.js"; +import { ResultGate } from "../result-gate.js"; +import { + assertOneShotHasNoUserQuestions, + type AgentRuntime, + type OneShotRunRequest, + type RuntimeSession, + type RuntimeSink, + type StreamingSessionRequest, +} from "./types.js"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +export async function discoverInstalledPlugins(rootDir: string): Promise { + const pluginsDir = path.join(rootDir, ".claude", "plugins"); + try { + const entries = await fs.readdir(pluginsDir, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(pluginsDir, entry.name)); + } catch { + return []; + } +} + +export function selectPluginPaths( + discoveredPluginPaths: string[], + requiredPlugins?: string[], +): string[] { + if (!requiredPlugins || requiredPlugins.length === 0) { + return []; + } + + const discoveredByName = new Map( + discoveredPluginPaths.map((pluginPath) => [path.basename(pluginPath), pluginPath] as const), + ); + + return requiredPlugins + .map((pluginName) => discoveredByName.get(pluginName)) + .filter((pluginPath): pluginPath is string => typeof pluginPath === "string"); +} + +export function emitSystemEvent( + onMessage: (message: Record) => void, + subtype: string, +): void { + onMessage({ type: "system", subtype, timestamp: Date.now() }); +} + +export function toOneShotRunRequest(config: SidecarConfig): OneShotRunRequest { + return { + mode: "one-shot", + allowUserQuestions: false, + prompt: config.prompt, + systemPrompt: config.systemPrompt, + model: config.model, + agentName: config.agentName, + apiKey: config.apiKey, + workspaceRootDir: config.workspaceRootDir, + workspaceSkillDir: config.workspaceSkillDir, + requiredPlugins: config.requiredPlugins, + allowedTools: config.allowedTools, + settingSources: config.settingSources, + maxTurns: config.maxTurns, + permissionMode: config.permissionMode, + betas: config.betas, + thinking: config.thinking, + effort: config.effort, + fallbackModel: config.fallbackModel, + outputFormat: config.outputFormat, + promptSuggestions: config.promptSuggestions, + pathToClaudeCodeExecutable: config.pathToClaudeCodeExecutable, + context: { + skillName: config.skillName, + stepId: config.stepId, + workflowSessionId: config.workflowSessionId, + usageSessionId: config.usageSessionId, + runSource: config.runSource, + workspaceSkillDir: config.workspaceSkillDir, + pluginSlug: config.pluginSlug ?? "unknown", + }, + }; +} + +export function toClaudeSidecarConfig(request: OneShotRunRequest | StreamingSessionRequest): SidecarConfig { + return { + mode: request.mode, + prompt: request.prompt, + systemPrompt: request.systemPrompt, + model: request.model, + agentName: request.agentName, + apiKey: request.apiKey, + workspaceRootDir: request.workspaceRootDir, + workspaceSkillDir: request.workspaceSkillDir, + requiredPlugins: request.requiredPlugins, + allowedTools: request.allowedTools, + settingSources: request.settingSources, + maxTurns: request.maxTurns, + permissionMode: request.permissionMode, + betas: request.betas, + thinking: request.thinking, + effort: request.effort, + fallbackModel: request.fallbackModel, + outputFormat: request.outputFormat, + promptSuggestions: request.promptSuggestions, + pathToClaudeCodeExecutable: request.pathToClaudeCodeExecutable, + skillName: request.context.skillName, + stepId: request.context.stepId, + workflowSessionId: request.context.workflowSessionId, + usageSessionId: request.context.usageSessionId, + runSource: request.context.runSource, + pluginSlug: request.context.pluginSlug, + }; +} + +export class ClaudeRuntime implements AgentRuntime { + async runOnce( + request: OneShotRunRequest, + sink: RuntimeSink, + externalSignal?: AbortSignal, + ): Promise { + assertOneShotHasNoUserQuestions(request); + const config = toClaudeSidecarConfig(request); + + if (process.env.MOCK_AGENTS === "true") { + process.stderr.write("[sidecar] Mock agent mode\n"); + return runMockAgent(config, (message) => sink.emitRaw(message), externalSignal); + } + + const state = createAbortState(); + if (externalSignal) { + linkExternalSignal(state, externalSignal); + } + + const discoveredPluginPaths = await discoverInstalledPlugins(config.workspaceRootDir); + const pluginPaths = selectPluginPaths(discoveredPluginPaths, config.requiredPlugins); + + const stderrHandler = (data: string) => { + sink.emitRaw({ type: "system", subtype: "sdk_stderr", data: data.trimEnd(), timestamp: Date.now() }); + }; + + const processorRef: { current: MessageProcessor | null } = { current: null }; + const processor = new MessageProcessor({ + skillName: config.skillName, + stepId: config.stepId, + workflowSessionId: config.workflowSessionId, + usageSessionId: config.usageSessionId, + runSource: config.runSource, + workspaceSkillDir: config.workspaceSkillDir, + pluginSlug: config.pluginSlug, + hasOutputFormat: config.outputFormat != null, + }); + processorRef.current = processor; + + const options = buildQueryOptions(config, state.abortController, pluginPaths, stderrHandler, processorRef); + const gate = new ResultGate(processor); + + const pluginsToLog = (options as Record).plugins as unknown[] | undefined; + sink.emitRaw({ + type: "system", + subtype: "sdk_plugins_debug", + plugins: pluginsToLog ?? [], + timestamp: Date.now(), + }); + + emitSystemEvent((message) => sink.emitRaw(message), "init_start"); + + try { + process.stderr.write("[sidecar] Starting SDK query\n"); + const conversation = query({ + prompt: config.prompt, + options, + }); + + let sdkReadyEmitted = false; + for await (const message of conversation) { + if (state.abortController.signal.aborted) break; + + if (!sdkReadyEmitted) { + emitSystemEvent((msg) => sink.emitRaw(msg), "sdk_ready"); + sdkReadyEmitted = true; + } + + const raw = message as Record; + + if (raw.type === "prompt_suggestion" && typeof raw.suggestion === "string") { + sink.emitRaw({ + type: "agent_event", + event: { + type: "prompt_suggestion", + suggestion: raw.suggestion, + }, + timestamp: Date.now(), + }); + continue; + } + + const items = processor.process(raw); + for (const item of items) { + gate.emit(item as Record, (msg) => sink.emitRaw(msg)); + } + gate.tryFlush((msg) => sink.emitRaw(msg)); + } + + gate.flush((msg) => sink.emitRaw(msg)); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (state.abortController.signal.aborted) { + process.stderr.write("[sidecar] Query stream aborted during iteration\n"); + } else { + process.stderr.write(`[sidecar] Query stream failed: ${errorMessage}\n`); + + const errorItems = processor.process({ + type: "error", + message: errorMessage, + }); + for (const item of errorItems) { + sink.emitRaw(item as Record); + } + + const [errorSummary, orphanedErr] = processor.buildExecutionErrorSummary(errorMessage); + for (const item of orphanedErr) { + sink.emitRaw(item as Record); + } + sink.emitAgentEvent(errorSummary); + return; + } + } + + if (state.abortController.signal.aborted) { + process.stderr.write("[sidecar] Run aborted — emitting shutdown run_result\n"); + const [shutdownSummary, orphanedAbort] = processor.buildShutdownSummary(); + for (const item of orphanedAbort) { + sink.emitRaw(item as Record); + } + sink.emitAgentEvent(shutdownSummary); + } + + if (!processor.hasEmittedResult() && !state.abortController.signal.aborted) { + process.stderr.write("[sidecar] SDK completed without result — emitting error run_result\n"); + const [errorSummary, orphanedNoResult] = processor.buildExecutionErrorSummary( + "Agent ended without producing a result", + ); + for (const item of orphanedNoResult) { + sink.emitRaw(item as Record); + } + sink.emitAgentEvent(errorSummary); + } + } + + startStreamingSession( + _request: StreamingSessionRequest, + _sink: RuntimeSink, + ): RuntimeSession { + throw new Error("Claude streaming sessions are wired through StreamSession"); + } +} diff --git a/app/sidecar/runtime/sink.ts b/app/sidecar/runtime/sink.ts new file mode 100644 index 000000000..462a067dd --- /dev/null +++ b/app/sidecar/runtime/sink.ts @@ -0,0 +1,34 @@ +import type { AgentEvent } from "../agent-events.js"; +import type { DisplayItem } from "../display-types.js"; +import type { RefineQuestion, RuntimeSink } from "./types.js"; + +export function createRecordRuntimeSink( + emit: (message: Record) => void, +): RuntimeSink { + return { + emit(message) { + emit(message); + }, + + emitDisplayItem(item: DisplayItem) { + emit({ type: "display_item", item }); + }, + + emitAgentEvent(event: AgentEvent, timestamp = Date.now()) { + emit({ type: "agent_event", event, timestamp }); + }, + + emitRefineQuestion(question: RefineQuestion) { + emit({ + type: "refine_question", + tool_use_id: question.tool_use_id, + questions: question.questions, + timestamp: question.timestamp, + }); + }, + + emitRaw(message: Record) { + emit(message); + }, + }; +} diff --git a/app/sidecar/runtime/types.ts b/app/sidecar/runtime/types.ts new file mode 100644 index 000000000..174214c38 --- /dev/null +++ b/app/sidecar/runtime/types.ts @@ -0,0 +1,110 @@ +import type { AgentEvent } from "../agent-events.js"; +import type { DisplayItem } from "../display-types.js"; + +export type RuntimeMode = "one-shot" | "streaming"; + +export interface RunPersistenceContext { + skillName?: string; + stepId?: number; + workflowSessionId?: string; + usageSessionId?: string; + runSource?: "workflow" | "refine" | "test" | "gate-eval"; + workspaceSkillDir?: string; + pluginSlug: string; +} + +export interface RuntimeRequestBase { + prompt: string; + systemPrompt?: string; + model?: string; + agentName?: string; + apiKey: string; + workspaceRootDir: string; + workspaceSkillDir: string; + requiredPlugins?: string[]; + allowedTools?: string[]; + settingSources?: ("user" | "project")[]; + maxTurns?: number; + permissionMode?: string; + betas?: string[]; + thinking?: { type: "disabled" | "adaptive" | "enabled"; budgetTokens?: number }; + effort?: "low" | "medium" | "high" | "max"; + fallbackModel?: string; + outputFormat?: { + type: "json_schema"; + schema: Record; + }; + promptSuggestions?: boolean; + pathToClaudeCodeExecutable?: string; + context: RunPersistenceContext; +} + +export interface OneShotRunRequest extends RuntimeRequestBase { + mode: "one-shot"; + allowUserQuestions: false; +} + +export interface StreamingSessionRequest extends RuntimeRequestBase { + mode: "streaming"; + allowUserQuestions: true; +} + +export type RuntimeRequest = OneShotRunRequest | StreamingSessionRequest; + +export interface RefineQuestion { + tool_use_id: string; + questions: unknown[]; + timestamp: number; +} + +export interface RuntimeSink { + emit(message: Record): void; + emitDisplayItem(item: DisplayItem): void; + emitAgentEvent(event: AgentEvent, timestamp?: number): void; + emitRefineQuestion(question: RefineQuestion): void; + emitRaw(message: Record): void; +} + +export interface RuntimeSession { + readonly queryDone: Promise; + sendUserMessage(requestId: string, message: string): Promise | void; + answerQuestion( + requestId: string, + toolUseId: string, + questions: unknown[], + answers: Record, + ): Promise | void; + cancel(): Promise | void; + close(): Promise | void; +} + +export interface AgentRuntime { + runOnce( + request: OneShotRunRequest, + sink: RuntimeSink, + signal?: AbortSignal, + ): Promise; + + startStreamingSession( + request: StreamingSessionRequest, + sink: RuntimeSink, + ): RuntimeSession; +} + +const USER_QUESTION_TOOL_NAMES = new Set([ + "AskUserQuestion", + "ask_user_question", +]); + +export function isUserQuestionToolName(toolName: string): boolean { + return USER_QUESTION_TOOL_NAMES.has(toolName); +} + +export function assertOneShotHasNoUserQuestions(request: OneShotRunRequest): void { + const forbiddenTools = (request.allowedTools ?? []).filter(isUserQuestionToolName); + if (forbiddenTools.length > 0) { + throw new Error( + `one-shot runtime requests cannot include user-question tools: ${forbiddenTools.join(", ")}`, + ); + } +} diff --git a/app/sidecar/stream-session.ts b/app/sidecar/stream-session.ts index a044b6a65..3333bfa44 100644 --- a/app/sidecar/stream-session.ts +++ b/app/sidecar/stream-session.ts @@ -9,6 +9,7 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { MessageProcessor } from "./message-processor.js"; import { ResultGate } from "./result-gate.js"; +import type { RuntimeSession } from "./runtime/types.js"; /** Sentinel used to close the async generator cleanly. */ const CLOSE_SENTINEL = Symbol("close"); @@ -32,7 +33,7 @@ interface PendingQuestion { * stopping the active turn. The session stays alive. The next `pushMessage()` * restarts `query()` with `resume: sdkSessionId` to continue the conversation. */ -export class StreamSession { +export class StreamSession implements RuntimeSession { private currentRequestId: string; private pendingResolve: ((value: string | typeof CLOSE_SENTINEL) => void) | null = null; private messageQueue: string[] = []; @@ -119,6 +120,10 @@ export class StreamSession { } } + sendUserMessage(requestId: string, message: string): void { + this.pushMessage(requestId, message); + } + /** * Interrupt the current turn without closing the session. * Aborts the AbortController so the SDK stops the active turn. @@ -179,6 +184,10 @@ export class StreamSession { } } + cancel(): void { + this.cancelTurn(); + } + answerQuestion( requestId: string, toolUseId: string, diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 9d7ba13d4..f27515c0e 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -308,6 +308,21 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -731,9 +746,9 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "core-graphics" -version = "0.24.0" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.10.0", "core-foundation 0.10.1", @@ -837,6 +852,19 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + [[package]] name = "cssparser-macros" version = "0.6.1" @@ -849,14 +877,20 @@ dependencies = [ [[package]] name = "ctor" -version = "0.2.9" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" dependencies = [ - "quote", - "syn 2.0.114", + "ctor-proc-macro", + "dtor", ] +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + [[package]] name = "ctutils" version = "0.4.2" @@ -901,6 +935,17 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + [[package]] name = "deflate64" version = "0.1.10" @@ -930,6 +975,27 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.114", +] + [[package]] name = "digest" version = "0.10.7" @@ -974,12 +1040,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "dispatch" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" - [[package]] name = "dispatch2" version = "0.3.0" @@ -1026,6 +1086,21 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser 0.36.0", + "foldhash 0.2.0", + "html5ever 0.38.0", + "precomputed-hash", + "selectors 0.36.1", + "tendril 0.5.0", +] + [[package]] name = "dpi" version = "0.1.2" @@ -1050,6 +1125,21 @@ dependencies = [ "dtoa", ] +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + [[package]] name = "dunce" version = "1.0.5" @@ -2029,7 +2119,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" dependencies = [ "byteorder", - "png", + "png 0.17.16", ] [[package]] @@ -2372,18 +2462,12 @@ version = "0.8.8-speedreader" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" dependencies = [ - "cssparser", + "cssparser 0.29.6", "html5ever 0.29.1", "indexmap 2.13.0", - "selectors", + "selectors 0.24.0", ] -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "leb128fmt" version = "0.1.0" @@ -2426,6 +2510,15 @@ version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + [[package]] name = "libgit2-sys" version = "0.18.3+1.9.2" @@ -2662,7 +2755,7 @@ dependencies = [ "hyper-util", "log", "pin-project-lite", - "rand 0.9.3", + "rand 0.9.4", "regex", "serde_json", "serde_urlencoded", @@ -2672,9 +2765,9 @@ dependencies = [ [[package]] name = "muda" -version = "0.17.1" +version = "0.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" +checksum = "0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb" dependencies = [ "crossbeam-channel", "dpi", @@ -2685,10 +2778,10 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation", "once_cell", - "png", + "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2706,12 +2799,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "ndk-context" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -2820,9 +2907,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", "objc2-exception-helper", @@ -2836,17 +2923,9 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.10.0", "block2", - "libc", "objc2", - "objc2-cloud-kit", - "objc2-core-data", "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-image", - "objc2-core-text", - "objc2-core-video", "objc2-foundation", - "objc2-quartz-core", ] [[package]] @@ -2866,7 +2945,6 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ - "bitflags 2.10.0", "objc2", "objc2-foundation", ] @@ -2906,28 +2984,25 @@ dependencies = [ ] [[package]] -name = "objc2-core-text" +name = "objc2-core-location" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" dependencies = [ - "bitflags 2.10.0", "objc2", - "objc2-core-foundation", - "objc2-core-graphics", + "objc2-foundation", ] [[package]] -name = "objc2-core-video" +name = "objc2-core-text" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ "bitflags 2.10.0", "objc2", "objc2-core-foundation", "objc2-core-graphics", - "objc2-io-surface", ] [[package]] @@ -2969,16 +3044,6 @@ dependencies = [ "objc2-core-foundation", ] -[[package]] -name = "objc2-javascript-core" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586" -dependencies = [ - "objc2", - "objc2-core-foundation", -] - [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2992,25 +3057,33 @@ dependencies = [ ] [[package]] -name = "objc2-security" +name = "objc2-ui-kit" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.10.0", + "block2", "objc2", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", ] [[package]] -name = "objc2-ui-kit" +name = "objc2-user-notifications" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" dependencies = [ - "bitflags 2.10.0", "objc2", - "objc2-core-foundation", "objc2-foundation", ] @@ -3026,8 +3099,6 @@ dependencies = [ "objc2-app-kit", "objc2-core-foundation", "objc2-foundation", - "objc2-javascript-core", - "objc2-security", ] [[package]] @@ -3406,6 +3477,19 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.10.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -3593,7 +3677,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.3", + "rand 0.9.4", "ring", "rustc-hash", "rustls", @@ -3673,9 +3757,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -3842,9 +3926,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" dependencies = [ "base64 0.22.1", "bytes", @@ -4231,14 +4315,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" dependencies = [ "bitflags 1.3.2", - "cssparser", - "derive_more", + "cssparser 0.29.6", + "derive_more 0.99.20", "fxhash", "log", "phf 0.8.0", "phf_codegen 0.8.0", "precomputed-hash", - "servo_arc", + "servo_arc 0.2.0", + "smallvec", +] + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.10.0", + "cssparser 0.36.0", + "derive_more 2.1.1", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen 0.13.1", + "precomputed-hash", + "rustc-hash", + "servo_arc 0.4.3", "smallvec", ] @@ -4422,6 +4525,15 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "sha1" version = "0.11.0" @@ -4504,7 +4616,7 @@ dependencies = [ "mockito", "nix", "notify", - "rand 0.9.3", + "rand 0.9.4", "reqwest", "rusqlite", "schemars 1.2.1", @@ -4809,35 +4921,35 @@ dependencies = [ [[package]] name = "tao" -version = "0.34.5" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" +checksum = "1cf65722394c2ac443e80120064987f8914ee1d4e4e36e63cdf10f2990f01159" dependencies = [ "bitflags 2.10.0", "block2", "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", - "dispatch", + "dbus", + "dispatch2", "dlopen2", "dpi", "gdkwayland-sys", "gdkx11-sys", "gtk", "jni", - "lazy_static", "libc", "log", "ndk", - "ndk-context", "ndk-sys", "objc2", "objc2-app-kit", "objc2-foundation", + "objc2-ui-kit", "once_cell", "parking_lot", + "percent-encoding", "raw-window-handle", - "scopeguard", "tao-macros", "unicode-segmentation", "url", @@ -4872,9 +4984,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.10.2" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "463ae8677aa6d0f063a900b9c41ecd4ac2b7ca82f0b058cc4491540e55b20129" +checksum = "d059f2527558d9dba6f186dec4772610e1aecfd3f94002397613e7e648752b66" dependencies = [ "anyhow", "bytes", @@ -4923,9 +5035,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.5.5" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca7bd893329425df750813e95bd2b643d5369d929438da96d5bbb7cc2c918f74" +checksum = "be9aa8c59a894f76c29a002501c589de5eb4987a5913d62a6e0a47f320901988" dependencies = [ "anyhow", "cargo_toml", @@ -4939,22 +5051,21 @@ dependencies = [ "serde_json", "tauri-utils", "tauri-winres", - "toml 0.9.11+spec-1.1.0", "walkdir", ] [[package]] name = "tauri-codegen" -version = "2.5.4" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aac423e5859d9f9ccdd32e3cf6a5866a15bedbf25aa6630bcb2acde9468f6ae3" +checksum = "d3e4e8230d565106aa19dfbaa01a7ed01abf78047fe0577a83377224bd1bf20e" dependencies = [ "base64 0.22.1", "brotli", "ico", "json-patch", "plist", - "png", + "png 0.17.16", "proc-macro2", "quote", "semver", @@ -4972,9 +5083,9 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.5.4" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b6a1bd2861ff0c8766b1d38b32a6a410f6dc6532d4ef534c47cfb2236092f59" +checksum = "bc8de2cddbbc33dbdf4c84f170121886595efdbcc9cb4b3d76342b79d082cedc" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -5089,9 +5200,9 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b885ffeac82b00f1f6fd292b6e5aabfa7435d537cef57d11e38a489956535651" +checksum = "1e42bbcb76237351fbaa02f08d808c537dc12eb5a6eabbf3e517b50056334d95" dependencies = [ "cookie", "dpi", @@ -5114,9 +5225,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5204682391625e867d16584fedc83fc292fb998814c9f7918605c789cd876314" +checksum = "2cadb13dad0c681e1e0a2c49ae488f0e2906ded3d57e7a0017f4aaf46e387117" dependencies = [ "gtk", "http", @@ -5124,7 +5235,6 @@ dependencies = [ "log", "objc2", "objc2-app-kit", - "objc2-foundation", "once_cell", "percent-encoding", "raw-window-handle", @@ -5141,14 +5251,15 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.8.2" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcd169fccdff05eff2c1033210b9b94acd07a47e6fa9a3431cf09cfd4f01c87e" +checksum = "55f61d2bf7188fbcf2b0ed095b67a6bc498f713c939314bb19eb700118a573b7" dependencies = [ "anyhow", "brotli", "cargo_metadata", "ctor", + "dom_query", "dunce", "glob", "html5ever 0.29.1", @@ -5159,6 +5270,7 @@ dependencies = [ "log", "memchr", "phf 0.11.3", + "plist", "proc-macro2", "quote", "regex", @@ -5323,9 +5435,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.51.1" +version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ "bytes", "libc", @@ -5546,9 +5658,9 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.21.3" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" dependencies = [ "crossbeam-channel", "dirs", @@ -5560,10 +5672,10 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation", "once_cell", - "png", + "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5711,9 +5823,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.23.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -6610,24 +6722,23 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "wry" -version = "0.54.1" +version = "0.55.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ed1a195b0375491dd15a7066a10251be217ce743cf4bbbbdcf5391d6473bee0" +checksum = "3013fd6116aac351dd2e18f349b28b2cfef3a5ff3253a9d0ce2d7193bb1b4429" dependencies = [ "base64 0.22.1", "block2", "cookie", "crossbeam-channel", "dirs", + "dom_query", "dpi", "dunce", "gdkx11", "gtk", - "html5ever 0.29.1", "http", "javascriptcore-rs", "jni", - "kuchikiki", "libc", "ndk", "objc2", diff --git a/app/src-tauri/src/agents/sidecar.rs b/app/src-tauri/src/agents/sidecar.rs index ed2e9dc9d..9c7bae5cc 100644 --- a/app/src-tauri/src/agents/sidecar.rs +++ b/app/src-tauri/src/agents/sidecar.rs @@ -4,6 +4,8 @@ use crate::types::SecretString; #[derive(Clone, Serialize, Deserialize)] pub struct SidecarConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub mode: Option, pub prompt: String, #[serde(rename = "systemPrompt", skip_serializing_if = "Option::is_none")] pub system_prompt: Option, @@ -82,6 +84,7 @@ pub struct SidecarConfig { impl std::fmt::Debug for SidecarConfig { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("SidecarConfig") + .field("mode", &self.mode) .field("prompt", &self.prompt) .field("model", &self.model) .field("api_key", &"[redacted]") @@ -205,6 +208,7 @@ mod tests { #[test] fn test_sidecar_config_serialization() { let config = SidecarConfig { + mode: Some("one-shot".to_string()), prompt: "Analyze this codebase".to_string(), system_prompt: None, model: Some("sonnet".to_string()), @@ -243,6 +247,7 @@ mod tests { assert_eq!(parsed["maxTurns"], 25); assert_eq!(parsed["permissionMode"], "bypassPermissions"); assert_eq!(parsed["model"], "sonnet"); + assert_eq!(parsed["mode"], "one-shot"); assert_eq!(parsed["agentName"], "research-entities"); // betas is None + skip_serializing_if — should be absent assert!(parsed.get("betas").is_none()); @@ -253,6 +258,7 @@ mod tests { #[test] fn test_sidecar_config_serialization_with_thinking() { let config = SidecarConfig { + mode: None, prompt: "Reason about this".to_string(), system_prompt: None, model: Some("opus".to_string()), @@ -297,6 +303,7 @@ mod tests { // skill_name must serialize as "skillName" so the sidecar's // mock discriminator (config.skillName) receives the value correctly. let config = SidecarConfig { + mode: None, prompt: "test".to_string(), system_prompt: None, model: None, @@ -341,6 +348,7 @@ mod tests { fn test_sidecar_config_skill_name_absent_when_none() { // When skill_name is None, it must be omitted (skip_serializing_if = "Option::is_none"). let config = SidecarConfig { + mode: None, prompt: "test".to_string(), system_prompt: None, model: None, diff --git a/app/src-tauri/src/agents/sidecar_pool/dispatch.rs b/app/src-tauri/src/agents/sidecar_pool/dispatch.rs index ed53b7203..009011549 100644 --- a/app/src-tauri/src/agents/sidecar_pool/dispatch.rs +++ b/app/src-tauri/src/agents/sidecar_pool/dispatch.rs @@ -1217,6 +1217,7 @@ mod tests { fn sample_config() -> SidecarConfig { SidecarConfig { + mode: Some("streaming".to_string()), prompt: "Top secret prompt".to_string(), system_prompt: None, model: Some("claude-sonnet-4".to_string()), diff --git a/app/src-tauri/src/commands/agent.rs b/app/src-tauri/src/commands/agent.rs index f7a29fb30..3e3a4672a 100644 --- a/app/src-tauri/src/commands/agent.rs +++ b/app/src-tauri/src/commands/agent.rs @@ -194,6 +194,7 @@ pub async fn start_agent( let setting_sources = derive_setting_sources(agent_name.as_deref()); let config = SidecarConfig { + mode: Some("one-shot".to_string()), prompt, system_prompt, model: model_for_config, diff --git a/app/src-tauri/src/commands/description/eval.rs b/app/src-tauri/src/commands/description/eval.rs index 6d311ce8f..b66fb869e 100644 --- a/app/src-tauri/src/commands/description/eval.rs +++ b/app/src-tauri/src/commands/description/eval.rs @@ -229,6 +229,7 @@ async fn run_single_eval_query( let transcript_dir_str = transcript_log_dir.to_string_lossy().into_owned(); let config = SidecarConfig { + mode: Some("one-shot".to_string()), prompt: query.clone(), system_prompt: None, model: Some(model.to_string()), diff --git a/app/src-tauri/src/commands/description/mod.rs b/app/src-tauri/src/commands/description/mod.rs index cc3f68384..7311e7e00 100644 --- a/app/src-tauri/src/commands/description/mod.rs +++ b/app/src-tauri/src/commands/description/mod.rs @@ -190,9 +190,18 @@ pub async fn apply_description( log::info!("[apply_description] skill={} plugin={}", skill_name, plugin_slug); let skills_path = super::refine::resolve_skills_path(&db)?; - let skills_root = Path::new(&skills_path); + apply_description_inner(&skill_name, &plugin_slug, &skills_path, &description) +} + +fn apply_description_inner( + skill_name: &str, + plugin_slug: &str, + skills_path: &str, + description: &str, +) -> Result { + let skills_root = Path::new(skills_path); let skill_md_path = crate::skill_paths::resolve_skill_dir( - skills_root, &plugin_slug, &skill_name, + skills_root, plugin_slug, skill_name, ).join("SKILL.md"); let content = std::fs::read_to_string(&skill_md_path).map_err(|e| { @@ -200,7 +209,15 @@ pub async fn apply_description( format!("Failed to read SKILL.md: {}", e) })?; - let updated = update_skill_description(&content, &description)?; + let current_version = crate::git::latest_skill_semver(skills_root, plugin_slug, skill_name) + .unwrap_or_else(|_| "0.0.0".to_string()); + let current_description = crate::commands::imported_skills::parse_frontmatter_full(&content).description; + if current_description.as_deref() == Some(description) { + log::info!("[apply_description] description unchanged for skill={}", skill_name); + return Ok(current_version); + } + + let updated = update_skill_description(&content, description)?; std::fs::write(&skill_md_path, updated).map_err(|e| { log::error!("[apply_description] failed to write SKILL.md: {}", e); @@ -209,25 +226,29 @@ pub async fn apply_description( // Commit the description change so it appears in the git version history. let commit_msg = format!("Update {} description via optimization", skill_name); - match crate::git::commit_all(skills_root, &commit_msg) { + let relative_skill_path = Path::new(plugin_slug).join(skill_name); + match crate::git::commit_path(skills_root, &relative_skill_path, &commit_msg) { Ok(Some(sha)) => { log::info!("[apply_description] committed skill={} sha={}", skill_name, &sha[..8.min(sha.len())]); } Ok(None) => { log::info!("[apply_description] nothing to commit for skill={}", skill_name); + return Ok(current_version); } Err(e) => { - log::warn!("[apply_description] commit failed skill={} error={}", skill_name, e); + log::error!("[apply_description] commit failed skill={} error={}", skill_name, e); + return Err(format!("Failed to commit description update: {}", e)); } } // Tag the new commit with the next patch version. - let current_version = crate::git::latest_skill_semver(skills_root, &plugin_slug, &skill_name) - .unwrap_or_else(|_| "0.0.0".to_string()); let new_version = crate::git::bump_patch(¤t_version); - match crate::git::create_skill_version_tag(skills_root, &plugin_slug, &skill_name, &new_version) { + match crate::git::create_skill_version_tag(skills_root, plugin_slug, skill_name, &new_version) { Ok(tag) => log::info!("[apply_description] tagged skill={} tag={}", skill_name, tag), - Err(e) => log::warn!("[apply_description] tag failed skill={} error={}", skill_name, e), + Err(e) => { + log::error!("[apply_description] tag failed skill={} error={}", skill_name, e); + return Err(format!("Failed to tag description update: {}", e)); + } } Ok(new_version) @@ -421,6 +442,7 @@ pub async fn start_generate_desc_evals( })); let config = SidecarConfig { + mode: Some("one-shot".to_string()), prompt: user_prompt, system_prompt: Some(system_prompt), model: Some(model.clone()), @@ -626,4 +648,67 @@ mod tests { ); assert!(result.contains("author: dev"), "author field preserved"); } + + #[test] + fn apply_description_commits_skill_path_and_tags_new_version() { + let dir = TempDir::new().unwrap(); + let plugin = crate::skill_paths::DEFAULT_PLUGIN_SLUG; + crate::git::ensure_repo(dir.path()).unwrap(); + + let skill_dir = dir.path().join(plugin).join("desc-skill"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write( + skill_dir.join("SKILL.md"), + "---\nname: desc-skill\ndescription: old\n---\n# Body\n", + ).unwrap(); + crate::git::commit_all(dir.path(), "desc-skill: initial").unwrap(); + crate::git::create_skill_version_tag(dir.path(), plugin, "desc-skill", "1.0.0").unwrap(); + + let new_version = apply_description_inner( + "desc-skill", + plugin, + dir.path().to_str().unwrap(), + "new optimized description", + ).unwrap(); + + assert_eq!(new_version, "1.0.1"); + let content = std::fs::read_to_string(skill_dir.join("SKILL.md")).unwrap(); + assert!(content.contains("description: \"new optimized description\"")); + assert_eq!( + crate::git::latest_skill_semver(dir.path(), plugin, "desc-skill").unwrap(), + "1.0.1" + ); + + let history = crate::git::get_history(dir.path(), "desc-skill", plugin, 10).unwrap(); + assert_eq!(history[0].version.as_deref(), Some("1.0.1")); + assert_eq!(history[0].message, "Update desc-skill description via optimization"); + } + + #[test] + fn apply_description_does_not_tag_when_description_is_unchanged() { + let dir = TempDir::new().unwrap(); + let plugin = crate::skill_paths::DEFAULT_PLUGIN_SLUG; + crate::git::ensure_repo(dir.path()).unwrap(); + + let skill_dir = dir.path().join(plugin).join("same-desc"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write( + skill_dir.join("SKILL.md"), + "---\nname: same-desc\ndescription: same\n---\n# Body\n", + ).unwrap(); + crate::git::commit_all(dir.path(), "same-desc: initial").unwrap(); + crate::git::create_skill_version_tag(dir.path(), plugin, "same-desc", "1.0.0").unwrap(); + + let new_version = apply_description_inner( + "same-desc", + plugin, + dir.path().to_str().unwrap(), + "same", + ).unwrap(); + + assert_eq!(new_version, "1.0.0"); + let history = crate::git::get_history(dir.path(), "same-desc", plugin, 10).unwrap(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].version.as_deref(), Some("1.0.0")); + } } diff --git a/app/src-tauri/src/commands/git.rs b/app/src-tauri/src/commands/git.rs index a88036c74..9df02b274 100644 --- a/app/src-tauri/src/commands/git.rs +++ b/app/src-tauri/src/commands/git.rs @@ -109,18 +109,6 @@ mod tests { Db(Mutex::new(conn)) } - /// Build a temp git repo with one commit containing `skill_name/SKILL.md` at the repo root. - /// Used by restore tests that exercise `restore_version` directly. - fn init_skill_repo(dir: &std::path::Path, skill_name: &str, content: &str) -> String { - crate::git::ensure_repo(dir).unwrap(); - let skill_dir = dir.join(skill_name); - std::fs::create_dir_all(&skill_dir).unwrap(); - std::fs::write(skill_dir.join("SKILL.md"), content).unwrap(); - crate::git::commit_all(dir, &format!("{}: initial", skill_name)) - .unwrap() - .unwrap() - } - /// Build a temp git repo with one commit at the plugin-aware path layout. /// Used by history tests that exercise `get_history` with a plugin_slug. fn init_skill_repo_plugin( diff --git a/app/src-tauri/src/commands/refine/output.rs b/app/src-tauri/src/commands/refine/output.rs index 8fd06cac0..0a1f0e3fe 100644 --- a/app/src-tauri/src/commands/refine/output.rs +++ b/app/src-tauri/src/commands/refine/output.rs @@ -266,8 +266,9 @@ pub(crate) fn finalize_refine_run_inner_for_plugin( // Clean up any stale skill snapshot left by a prior rewrite→benchmark cycle cleanup_skill_snapshot(&workspace_skill_root); - // Agent now handles commit+tag via shell git; read HEAD for the commit SHA - let commit_sha = { + // Read HEAD for the commit SHA. The rewrite agent is instructed to commit, + // but finalize also commits scoped skill changes if the agent only edited files. + let mut commit_sha = { let repo = git2::Repository::open(Path::new(skills_path)) .map_err(|e| format!("Failed to open repo: {}", e))?; repo.head() @@ -278,10 +279,36 @@ pub(crate) fn finalize_refine_run_inner_for_plugin( // If HEAD hasn't changed since the session started, the agent made no commits. // Return files with an empty diff instead of diffing a stale prior commit. - let head_unchanged = match (&commit_sha, pre_run_sha) { + let mut head_unchanged = match (&commit_sha, pre_run_sha) { (Some(current), Some(pre)) => current == pre, _ => false, }; + if head_unchanged { + let relative_skill_path = Path::new(plugin_slug).join(skill_name); + match crate::git::commit_path( + Path::new(skills_path), + &relative_skill_path, + &format!("{}: refine update", skill_name), + ) { + Ok(Some(new_sha)) => { + log::info!( + "[finalize_refine_run] backend committed refine changes skill={} sha={}", + skill_name, + &new_sha[..8.min(new_sha.len())] + ); + commit_sha = Some(new_sha); + head_unchanged = false; + } + Ok(None) => {} + Err(e) => { + log::warn!( + "[finalize_refine_run] backend scoped commit failed skill={}: {}", + skill_name, + e + ); + } + } + } if head_unchanged { log::info!( "[finalize_refine_run] HEAD unchanged skill={} — no agent commit, skipping diff", diff --git a/app/src-tauri/src/commands/refine/protocol.rs b/app/src-tauri/src/commands/refine/protocol.rs index e724c99f6..976a5fa70 100644 --- a/app/src-tauri/src/commands/refine/protocol.rs +++ b/app/src-tauri/src/commands/refine/protocol.rs @@ -173,6 +173,7 @@ pub(super) fn build_refine_config( let effective_fallback = fallback_model.filter(|fm| fm != &model); let config = SidecarConfig { + mode: Some("streaming".to_string()), prompt, system_prompt: None, betas: crate::commands::workflow::build_betas( diff --git a/app/src-tauri/src/commands/refine/tests.rs b/app/src-tauri/src/commands/refine/tests.rs index 97f443614..b15c03b23 100644 --- a/app/src-tauri/src/commands/refine/tests.rs +++ b/app/src-tauri/src/commands/refine/tests.rs @@ -634,7 +634,11 @@ fn test_refine_prompt_includes_all_three_paths() { let skills_fwd = skills.replace('\\', "/"); assert!(system_prompt .contains(&format!("The workspace directory is: {}/skills/my-skill", ws_fwd))); - assert!(system_prompt.contains("The skill directory is:")); + assert!(system_prompt.contains(&format!( + "The skill directory is: {}/{}", + skills_fwd, + crate::skill_paths::DEFAULT_PLUGIN_SLUG + ))); assert!(system_prompt.contains("The context directory is:")); } @@ -1145,6 +1149,42 @@ fn test_finalize_refine_no_tag_when_head_unchanged() { assert_eq!(version, "1.0.0", "no-op refine should not create a new tag"); } +#[test] +fn test_finalize_refine_commits_dirty_skill_path_and_tags() { + let dir = tempdir().unwrap(); + let workspace_dir = tempdir().unwrap(); + let plugin = crate::skill_paths::DEFAULT_PLUGIN_SLUG; + crate::git::ensure_repo(dir.path()).unwrap(); + + let skill_dir = dir.path().join(plugin).join("dirty-skill"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write(skill_dir.join("SKILL.md"), "# V1\n").unwrap(); + let initial_sha = crate::git::commit_all(dir.path(), "dirty-skill: initial").unwrap().unwrap(); + crate::git::create_skill_version_tag(dir.path(), plugin, "dirty-skill", "1.0.0").unwrap(); + + // Simulate rewrite-skill editing the configured skill directory but failing to commit. + std::fs::write(skill_dir.join("SKILL.md"), "# V1 refined\n").unwrap(); + + let result = finalize_refine_run_inner_for_plugin( + "dirty-skill", + dir.path().to_str().unwrap(), + workspace_dir.path().to_str().unwrap(), + plugin, + None, + Some(&initial_sha), + ) + .unwrap(); + + let new_sha = result.commit_sha.expect("finalize should commit dirty skill changes"); + assert_ne!(new_sha, initial_sha); + let version = crate::git::latest_skill_semver(dir.path(), plugin, "dirty-skill").unwrap(); + assert_eq!(version, "1.0.1", "backend refine commit should bump patch version"); + assert!( + result.diff.files.iter().any(|file| file.path == "skills/dirty-skill/SKILL.md"), + "refine diff should include the backend-committed skill file" + ); +} + // ===== Protected frontmatter field tests ===== use super::output::update_skill_name; diff --git a/app/src-tauri/src/commands/workflow/output_format.rs b/app/src-tauri/src/commands/workflow/output_format.rs index bfae2a240..9ae6f4f1e 100644 --- a/app/src-tauri/src/commands/workflow/output_format.rs +++ b/app/src-tauri/src/commands/workflow/output_format.rs @@ -1,4 +1,4 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; use crate::commands::workflow_artifacts::{ AnswerEvaluationOutput, DecisionsOutput, DetailedResearchOutput, GenerateSkillOutput, @@ -28,14 +28,15 @@ pub(crate) fn materialize_workflow_step_output_value( "[materialize_step] step_id={} skill_root={} output_keys={:?}", step_id, skill_root.display(), - structured_output.as_object().map(|o| o.keys().collect::>()) + structured_output + .as_object() + .map(|o| o.keys().collect::>()) ); match step_id { 0 => { - let parsed = - serde_json::from_value::(structured_output.clone()) - .map_err(|e| format!("invalid research step output: {}", e))?; + let parsed = serde_json::from_value::(structured_output.clone()) + .map_err(|e| format!("invalid research step output: {}", e))?; if parsed.status != "research_complete" { return Err(format!( @@ -83,8 +84,9 @@ pub(crate) fn materialize_workflow_step_output_value( // Typed deserialization into ClarificationsFile already validated structure. - let clarifications_pretty = serde_json::to_string_pretty(&parsed.clarifications_json) - .map_err(|e| format!("Failed to serialize clarifications_json: {}", e))?; + let clarifications_pretty = + serde_json::to_string_pretty(&parsed.clarifications_json) + .map_err(|e| format!("Failed to serialize clarifications_json: {}", e))?; let clarifications_path = context_dir.join("clarifications.json"); std::fs::write(&clarifications_path, clarifications_pretty).map_err(|e| { @@ -249,6 +251,56 @@ pub(crate) fn materialize_workflow_step_output_value( } } +pub(crate) fn publish_generated_skill_output( + workspace_skill_root: &Path, + skills_path: &Path, + plugin_slug: &str, + skill_name: &str, +) -> Result { + let generated_dir = workspace_skill_root.join("skill"); + let generated_skill_md = generated_dir.join("SKILL.md"); + let published_dir = crate::skill_paths::resolve_skill_dir(skills_path, plugin_slug, skill_name); + let published_skill_md = published_dir.join("SKILL.md"); + + if !generated_skill_md.is_file() { + if published_skill_md.is_file() { + log::info!( + "[publish_generated_skill_output] skill={} plugin={} already published at {}", + skill_name, + plugin_slug, + published_skill_md.display() + ); + return Ok(published_dir); + } + + return Err(format!( + "Generated skill output missing: expected '{}' or '{}'", + generated_skill_md.display(), + published_skill_md.display() + )); + } + + if let Some(parent) = published_dir.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + format!( + "Failed to create plugin directory '{}': {}", + parent.display(), + e + ) + })?; + } + + crate::fs_utils::copy_dir_recursive(&generated_dir, &published_dir)?; + log::info!( + "[publish_generated_skill_output] skill={} plugin={} source={} target={}", + skill_name, + plugin_slug, + generated_dir.display(), + published_dir.display() + ); + Ok(published_dir) +} + #[tauri::command] pub fn materialize_workflow_step_output( skill_name: String, @@ -273,18 +325,20 @@ pub fn materialize_workflow_step_output( &plugin_slug, &skill_name, ); - materialize_workflow_step_output_value(&skill_root, step_id, &structured_output).map_err(|e| { - log::error!( - "[materialize_workflow_step_output] skill={} step={} step_id={} failed: {}", - skill_name, - super::evaluation::workflow_step_log_name(step_id as i32), - step_id, + materialize_workflow_step_output_value(&skill_root, step_id, &structured_output).map_err( + |e| { + log::error!( + "[materialize_workflow_step_output] skill={} step={} step_id={} failed: {}", + skill_name, + super::evaluation::workflow_step_log_name(step_id as i32), + step_id, + e + ); e - ); - e - })?; + }, + )?; - // After successful generate materialization, commit and tag the skill. + // After successful generate materialization, publish and commit the skill. // Benchmark output does not trigger a commit (benchmark data is in workspace, not git). // Rewrite/refine commit+tag is handled by finalize_refine_run. if step_id == 3 { @@ -305,16 +359,37 @@ pub fn materialize_workflow_step_output( .unwrap_or_else(|| workspace_path.clone()); drop(conn); - // Agent now handles commit+tag via shell git; read HEAD for logging let skills_dir = Path::new(&skills_path); + publish_generated_skill_output(&skill_root, skills_dir, &plugin_slug, &skill_name)?; + + let commit_message = format!("{}: generated skill", skill_name); + match crate::git::commit_all(skills_dir, &commit_message) { + Ok(Some(sha)) => log::info!( + "[materialize_workflow_step_output] committed generated skill={} sha={}", + skill_name, + &sha[..8.min(sha.len())] + ), + Ok(None) => log::info!( + "[materialize_workflow_step_output] no generated skill changes to commit skill={}", + skill_name + ), + Err(e) => log::warn!( + "[materialize_workflow_step_output] generated skill publish commit failed skill={}: {}", + skill_name, + e + ), + } + match git2::Repository::open(skills_dir) { Ok(repo) => { - if let Some(sha) = repo.head().ok() + if let Some(sha) = repo + .head() + .ok() .and_then(|h| h.peel_to_commit().ok()) .map(|c| c.id().to_string()) { log::info!( - "[materialize_workflow_step_output] agent committed skill={} sha={}", + "[materialize_workflow_step_output] generated skill repo head skill={} sha={}", skill_name, &sha[..8.min(sha.len())] ); } @@ -322,7 +397,8 @@ pub fn materialize_workflow_step_output( Err(e) => { log::warn!( "[materialize_workflow_step_output] could not open repo for skill={}: {}", - skill_name, e + skill_name, + e ); } } @@ -336,10 +412,9 @@ pub fn materialize_workflow_step_output( /// /// Uses the generated schema from `contracts::workflow_outputs::AnswerEvaluationOutput`. pub(crate) fn answer_evaluator_output_format() -> serde_json::Value { - let schema: serde_json::Value = serde_json::from_str( - crate::generated::schemas::ANSWER_EVALUATION_SCHEMA, - ) - .expect("generated ANSWER_EVALUATION_SCHEMA must be valid JSON"); + let schema: serde_json::Value = + serde_json::from_str(crate::generated::schemas::ANSWER_EVALUATION_SCHEMA) + .expect("generated ANSWER_EVALUATION_SCHEMA must be valid JSON"); serde_json::json!({ "type": "json_schema", "schema": schema @@ -372,8 +447,14 @@ pub(crate) fn validate_answer_evaluation_json( // Semantic validation: vague/contradictory entries must have a reason for (idx, entry) in parsed.per_question.iter().enumerate() { - if !["clear", "needs_refinement", "not_answered", "vague", "contradictory"] - .contains(&entry.verdict.as_str()) + if ![ + "clear", + "needs_refinement", + "not_answered", + "vague", + "contradictory", + ] + .contains(&entry.verdict.as_str()) { return Err(format!( "answer_evaluation.per_question[{}].verdict is invalid", @@ -468,3 +549,90 @@ pub fn materialize_answer_evaluation_output( e }) } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn publish_generated_skill_output_copies_workspace_skill_to_library_layout() { + let workspace = tempfile::tempdir().unwrap(); + let skills = tempfile::tempdir().unwrap(); + let workspace_skill_root = workspace.path().join("skills").join("lead-routing"); + let generated_refs = workspace_skill_root.join("skill").join("references"); + fs::create_dir_all(&generated_refs).unwrap(); + fs::write( + workspace_skill_root.join("skill").join("SKILL.md"), + "---\nname: lead-routing\n---\n# Lead Routing\n", + ) + .unwrap(); + fs::write(generated_refs.join("terms.md"), "# Terms\n").unwrap(); + + let published_dir = publish_generated_skill_output( + &workspace_skill_root, + skills.path(), + "skills", + "lead-routing", + ) + .unwrap(); + + assert_eq!( + published_dir, + crate::skill_paths::resolve_skill_dir(skills.path(), "skills", "lead-routing") + ); + assert_eq!( + fs::read_to_string(published_dir.join("SKILL.md")).unwrap(), + "---\nname: lead-routing\n---\n# Lead Routing\n" + ); + assert_eq!( + fs::read_to_string(published_dir.join("references").join("terms.md")).unwrap(), + "# Terms\n" + ); + } + + #[test] + fn publish_generated_skill_output_errors_when_neither_workspace_nor_library_has_skill_md() { + let workspace = tempfile::tempdir().unwrap(); + let skills = tempfile::tempdir().unwrap(); + let workspace_skill_root = workspace.path().join("skills").join("missing-skill"); + + let err = publish_generated_skill_output( + &workspace_skill_root, + skills.path(), + "skills", + "missing-skill", + ) + .unwrap_err(); + + assert!( + err.contains("Generated skill output missing"), + "unexpected error: {err}" + ); + } + + #[test] + fn publish_generated_skill_output_accepts_directly_published_skill() { + let workspace = tempfile::tempdir().unwrap(); + let skills = tempfile::tempdir().unwrap(); + let workspace_skill_root = workspace.path().join("skills").join("direct-skill"); + let published_dir = + crate::skill_paths::resolve_skill_dir(skills.path(), "skills", "direct-skill"); + fs::create_dir_all(&published_dir).unwrap(); + fs::write(published_dir.join("SKILL.md"), "# Direct\n").unwrap(); + + let result = publish_generated_skill_output( + &workspace_skill_root, + skills.path(), + "skills", + "direct-skill", + ) + .unwrap(); + + assert_eq!(result, published_dir); + assert_eq!( + fs::read_to_string(result.join("SKILL.md")).unwrap(), + "# Direct\n" + ); + } +} diff --git a/app/src-tauri/src/commands/workflow/prompt.rs b/app/src-tauri/src/commands/workflow/prompt.rs index 8aa972e8e..27ab2444c 100644 --- a/app/src-tauri/src/commands/workflow/prompt.rs +++ b/app/src-tauri/src/commands/workflow/prompt.rs @@ -42,6 +42,8 @@ pub(crate) fn build_prompt(p: &PromptParams<'_>) -> String { Follow your agent instructions and produce structured JSON output.{} \ The skill name is: {}. The workspace directory is: {}. \ The skill output directory (SKILL.md and references/) is: {}. \ + This skill output directory is the configured Settings Skills Folder target for the shipped skill. \ + Workflow context files live in the workspace directory; shipped skill files must be written only to the skill output directory, never to the workspace directory or a workspace skill/ subdirectory. \ The user context file is at: {}/user-context.md. \ The context directory is: {}/context. \ All directories already exist — never create directories with mkdir or any other method. Never list directories with ls. Read only the specific files named in your instructions and write files directly.", @@ -107,6 +109,7 @@ pub(crate) fn build_step0_prompt( "EXECUTE IMMEDIATELY — do not ask questions, do not greet the user, do not offer options. \ Your ONLY task: invoke the Skill tool with exactly `skill-content-researcher:research` to produce clarification questions. \ Do NOT use `detailed-research` or any other agent/skill — ONLY `skill-content-researcher:research`. \ + After the skill returns, return its payload as your own final response. Your final response MUST be ONLY a raw JSON object — no markdown, no code fences, no explanation. \ Context for the skill invocation: \ The skill name is: {}. The workspace directory is: {}. \ The user context file is at: {}/user-context.md. \ diff --git a/app/src-tauri/src/commands/workflow/runtime.rs b/app/src-tauri/src/commands/workflow/runtime.rs index dab8bb8f3..258236228 100644 --- a/app/src-tauri/src/commands/workflow/runtime.rs +++ b/app/src-tauri/src/commands/workflow/runtime.rs @@ -18,24 +18,23 @@ use super::settings::{read_workflow_settings, WorkflowSettings}; use super::output_format::answer_evaluator_output_format; use super::step_config::{ build_betas, get_step_config, resolve_model_id, thinking_budget_for_step, - tools_for_agent, workflow_output_format_for_agent, + tools_for_agent, workflow_output_format_for_agent, WORKFLOW_AGENT_IDENTITY, }; use super::user_context::write_user_context_file; // ─── Session management ────────────────────────────────────────────────────── -/// In-memory state for a single workflow step streaming session. -/// Keyed by agent_id in WorkflowStepSessionManager. -pub struct WorkflowStepSession { +/// In-memory state for a single workflow one-shot run. +/// Keyed by agent_id in WorkflowStepRunManager. +pub struct WorkflowStepRun { pub skill_name: String, - pub session_id: String, } -/// Manages active workflow step streaming sessions. Registered as Tauri managed state. -/// Allows `answer_workflow_step_question` to look up the session for a given agent. -pub struct WorkflowStepSessionManager(pub Mutex>); +/// Manages active workflow step one-shot runs. Registered as Tauri managed state. +/// Allows `cancel_workflow_step` to look up the skill sidecar for a given agent. +pub struct WorkflowStepRunManager(pub Mutex>); -impl WorkflowStepSessionManager { +impl WorkflowStepRunManager { pub fn new() -> Self { Self(Mutex::new(HashMap::new())) } @@ -43,16 +42,14 @@ impl WorkflowStepSessionManager { // ─── run_workflow_step_inner ───────────────────────────────────────────────── -/// Core logic for launching a single workflow step via streaming. Builds the prompt, -/// constructs the sidecar config, and starts a streaming session. Returns the agent_id. -/// -/// Each step gets its own streaming session so AskUserQuestion callbacks can be -/// routed back to the correct sidecar via send_stream_question_answer. +/// Core logic for launching a single workflow step via one-shot execution. Builds +/// the prompt, constructs the sidecar config, and sends an agent_request. Returns +/// the agent_id, which is also the one-shot request_id. #[allow(clippy::too_many_arguments)] async fn run_workflow_step_inner( app: &tauri::AppHandle, pool: &SidecarPool, - sessions: &WorkflowStepSessionManager, + runs: &WorkflowStepRunManager, skill_name: &str, step_id: u32, workspace_path: &str, @@ -88,12 +85,18 @@ async fn run_workflow_step_inner( const JSON_ONLY: &str = "Your final response MUST be ONLY a raw JSON object — no markdown, no explanation, no wrapping."; - // Steps 1–2 run the agent directly (no subagent relay) so outputFormat is - // enforced on the agent that actually produces the data. Step 3 still - // uses the prompt-directive approach (subagent relay) until proven stable. + // The SDK always runs the generic skill-builder agent identity. Step prompts + // still carry the existing workflow-specific instructions and, when needed, + // tell the agent which capability to invoke before returning structured JSON. let subagent_directive: Option = match step_id { - // Steps 1–2: agent runs directly — no subagent directive needed. - 1 | 2 => None, + 1 => Some(format!( + "Invoke the `skill-content-researcher:detailed-research` agent to perform detailed research. \ + Then return that payload as your own final response. {JSON_ONLY}" + )), + 2 => Some(format!( + "Invoke the `skill-content-researcher:confirm-decisions` agent to confirm decisions. \ + Then return that payload as your own final response. {JSON_ONLY}" + )), 3 => Some(format!( "Launch the `skill-creator:generate-skill` subagent to generate the skill. \ {JSON_ONLY} Required fields: \ @@ -141,42 +144,23 @@ async fn run_workflow_step_inner( required_plugins, ); - // Steps 1–2 use the agent directly (agentName set) so the SDK's outputFormat - // constrains the agent that produces the data — no relay layer. - // Steps 0, 3 use the prompt-directive approach: model is set explicitly - // and the prompt instructs the model to launch a named subagent. - let direct_agent = match step_id { - 1 | 2 => Some(agent_name.clone()), - _ => None, - }; + let sdk_agent_identity = WORKFLOW_AGENT_IDENTITY.to_string(); - // Inject output schema inline into system prompt for steps 0–2 so the model - // always sees the contract it must produce, regardless of whether - // structured_output is enforced by the SDK. - let system_prompt_for_step: Option = match step_id { - 0 => Some(format!( - "Your output MUST be a JSON object that strictly conforms to the following schema:\n\n{}", - crate::generated::schemas::RESEARCH_STEP_INLINE_SCHEMA - )), - 1 => Some(format!( - "Your output MUST be a JSON object that strictly conforms to the following schema:\n\n{}", - crate::generated::schemas::DETAILED_RESEARCH_INLINE_SCHEMA - )), - 2 => Some(format!( - "Your output MUST be a JSON object that strictly conforms to the following schema:\n\n{}", - crate::generated::schemas::DECISIONS_INLINE_SCHEMA - )), - _ => None, - }; log::debug!( - "[run_workflow_step] system_prompt schema injected={} for step_id={}", - system_prompt_for_step.is_some(), + "[run_workflow_step] sdk_agent_identity={} output_format_configured={} step_id={}", + sdk_agent_identity, + workflow_output_format_for_agent(&agent_name).is_some(), step_id ); let mut config = SidecarConfig { + mode: Some("one-shot".to_string()), prompt, - system_prompt: system_prompt_for_step, + // Do not set a string systemPrompt for workflow steps. The SDK treats + // that as a custom system prompt, which can replace the configured + // agent identity's instructions. Structured contracts are enforced via + // output_format below. + system_prompt: None, model: Some(settings.preferred_model.clone()), api_key: settings.api_key.clone(), workspace_root_dir: workspace_path.replace('\\', "/"), @@ -203,7 +187,7 @@ async fn run_workflow_step_inner( output_format: workflow_output_format_for_agent(&agent_name), prompt_suggestions: None, path_to_claude_code_executable: None, - agent_name: direct_agent, + agent_name: Some(sdk_agent_identity), required_plugins: Some(required_plugins), setting_sources: None, conversation_history: None, @@ -232,32 +216,37 @@ async fn run_workflow_step_inner( } } - let session_id = uuid::Uuid::new_v4().to_string(); log::debug!( - "[run_workflow_step] starting stream session=[REDACTED] agent={} workspace_skill_dir={}", + "[run_workflow_step] starting one-shot request agent={} workspace_skill_dir={}", agent_id, config.workspace_skill_dir, ); - pool.send_stream_start(skill_name, &session_id, &agent_id, config, app) + let transcript_log_dir = config.transcript_log_dir.clone(); + pool.send_request( + skill_name, + &agent_id, + config, + app, + transcript_log_dir.as_deref(), + ) .await .map_err(|e| { log::error!( - "[run_workflow_step] Failed to start stream for agent={}: {}", + "[run_workflow_step] Failed to start one-shot request for agent={}: {}", agent_id, e ); e })?; - // Register session so answer_workflow_step_question can route answers + // Register active one-shot run so cancel_workflow_step can route cancel. { - let mut map = sessions.0.lock().map_err(|e| e.to_string())?; + let mut map = runs.0.lock().map_err(|e| e.to_string())?; map.insert( agent_id.clone(), - WorkflowStepSession { + WorkflowStepRun { skill_name: skill_name.to_string(), - session_id, }, ); } @@ -271,7 +260,7 @@ pub async fn run_workflow_step( app: tauri::AppHandle, pool: tauri::State<'_, SidecarPool>, db: tauri::State<'_, Db>, - sessions: tauri::State<'_, WorkflowStepSessionManager>, + runs: tauri::State<'_, WorkflowStepRunManager>, skill_name: String, step_id: u32, workspace_path: String, @@ -290,29 +279,26 @@ pub async fn run_workflow_step( &workspace_path, )?; - // Close any stale streaming sessions for this skill before starting a new step. - // Uses stream_end (not stream_cancel) so the sidecar removes the session from - // activeSessions and releases SDK resources (including any cli.js child process). - // Without this, a reset + re-run hangs because the old session's SDK resources - // are still held when the new stream_start arrives. - let stale_sessions: Vec<(String, String)> = { - let map = sessions.0.lock().map_err(|e| e.to_string())?; + // Cancel any stale one-shot workflow requests for this skill before starting + // a new step. The sidecar treats agent_id as request_id for agent_request. + let stale_runs: Vec = { + let map = runs.0.lock().map_err(|e| e.to_string())?; map.iter() .filter(|(_, s)| s.skill_name == skill_name) - .map(|(agent_id, s)| (agent_id.clone(), s.session_id.clone())) + .map(|(agent_id, _)| agent_id.clone()) .collect() }; - for (stale_agent_id, stale_session_id) in &stale_sessions { + for stale_agent_id in &stale_runs { log::info!( - "[run_workflow_step] closing stale session agent={} before starting step_id={}", + "[run_workflow_step] canceling stale one-shot run agent={} before starting step_id={}", stale_agent_id, step_id, ); - let _ = pool.send_stream_end(&skill_name, stale_session_id).await; + let _ = pool.send_cancel(&skill_name, stale_agent_id).await; } - if !stale_sessions.is_empty() { - let mut map = sessions.0.lock().map_err(|e| e.to_string())?; - for (stale_agent_id, _) in &stale_sessions { + if !stale_runs.is_empty() { + let mut map = runs.0.lock().map_err(|e| e.to_string())?; + for stale_agent_id in &stale_runs { map.remove(stale_agent_id); } } @@ -414,7 +400,7 @@ pub async fn run_workflow_step( run_workflow_step_inner( &app, pool.inner(), - sessions.inner(), + runs.inner(), &skill_name, step_id, &workspace_path, @@ -436,71 +422,27 @@ pub async fn run_workflow_step( // ─── answer_workflow_step_question ─────────────────────────────────────────── -/// Route an AskUserQuestion answer back to the active workflow step streaming session. -/// -/// The frontend calls this when the user submits an answer to a question posed -/// by a workflow step agent (step 0–3). The session is looked up by agent_id -/// and the answer is forwarded to the sidecar via send_stream_question_answer. +/// Workflow steps are one-shot runs. They do not support AskUserQuestion. #[tauri::command] pub async fn answer_workflow_step_question( agent_id: String, tool_use_id: String, questions: serde_json::Value, answers: serde_json::Value, - sessions: tauri::State<'_, WorkflowStepSessionManager>, + runs: tauri::State<'_, WorkflowStepRunManager>, pool: tauri::State<'_, SidecarPool>, ) -> Result<(), String> { - log::info!( - "[answer_workflow_step_question] agent={} tool={}", - agent_id, - tool_use_id + let _ = (tool_use_id, questions, answers, runs, pool); + log::warn!( + "[answer_workflow_step_question] rejected for one-shot workflow agent={}", + agent_id ); - - let (skill_name, session_id) = { - let map = sessions.0.lock().map_err(|e| { - log::error!( - "[answer_workflow_step_question] Failed to acquire session lock: {}", - e - ); - e.to_string() - })?; - let session = map.get(&agent_id).ok_or_else(|| { - let msg = format!( - "No workflow step session found for agent_id={}", - agent_id - ); - log::error!("[answer_workflow_step_question] {}", msg); - msg - })?; - (session.skill_name.clone(), session.session_id.clone()) - }; - - pool.send_stream_question_answer( - &skill_name, - &session_id, - &agent_id, - &tool_use_id, - questions, - answers, - ) - .await - .map_err(|e| { - log::error!( - "[answer_workflow_step_question] Failed to send answer for agent={}: {}", - agent_id, - e - ); - e - }) + Err("Workflow steps run in one-shot mode and cannot ask user questions".to_string()) } // ─── run_answer_evaluator ──────────────────────────────────────────────────── -/// Run the answer-evaluator agent (Haiku) as a full streaming session. -/// -/// The agent resolves contradictions and asks the gate decision question via -/// AskUserQuestion. AskUserQuestion callbacks are routed back via -/// `answer_workflow_step_question` using the registered WorkflowStepSession. +/// Run the answer-evaluator agent as a one-shot request. /// /// Returns the agent ID for the frontend to subscribe to completion events. #[tauri::command] @@ -508,7 +450,7 @@ pub async fn run_answer_evaluator( app: tauri::AppHandle, pool: tauri::State<'_, SidecarPool>, db: tauri::State<'_, Db>, - sessions: tauri::State<'_, WorkflowStepSessionManager>, + runs: tauri::State<'_, WorkflowStepRunManager>, skill_name: String, workspace_path: String, ) -> Result { @@ -587,6 +529,7 @@ pub async fn run_answer_evaluator( let agent_id = make_agent_id(&skill_name, "gate-eval"); let mut config = SidecarConfig { + mode: Some("one-shot".to_string()), prompt, system_prompt: None, // Answer evaluator uses Sonnet for reliable skill invocation. @@ -636,33 +579,37 @@ pub async fn run_answer_evaluator( } } - let session_id = uuid::Uuid::new_v4().to_string(); - log::debug!( - "[run_answer_evaluator] starting stream session=[REDACTED] agent={} workspace_skill_dir={}", + "[run_answer_evaluator] starting one-shot request agent={} workspace_skill_dir={}", agent_id, config.workspace_skill_dir, ); - pool.send_stream_start(&skill_name, &session_id, &agent_id, config, &app) + let transcript_log_dir = config.transcript_log_dir.clone(); + pool.send_request( + &skill_name, + &agent_id, + config, + &app, + transcript_log_dir.as_deref(), + ) .await .map_err(|e| { log::error!( - "[run_answer_evaluator] Failed to start stream for agent={}: {}", + "[run_answer_evaluator] Failed to start one-shot request for agent={}: {}", agent_id, e ); e })?; - // Register session so answer_workflow_step_question can route AskUserQuestion answers back. + // Register active one-shot run so cancel_workflow_step can route cancel. { - let mut map = sessions.0.lock().map_err(|e| e.to_string())?; + let mut map = runs.0.lock().map_err(|e| e.to_string())?; map.insert( agent_id.clone(), - WorkflowStepSession { + WorkflowStepRun { skill_name, - session_id, }, ); } @@ -670,20 +617,19 @@ pub async fn run_answer_evaluator( Ok(agent_id) } -/// Cancel a running workflow step streaming session by agent_id. +/// Cancel a running workflow step one-shot request by agent_id. /// -/// Looks up the session_id from WorkflowStepSessionManager and sends -/// a stream_cancel message to the sidecar so the AbortController fires. -/// This is the correct cancel path for streaming workflow steps (VU-729+). +/// Looks up the sidecar skill key and sends a cancel message to the sidecar so +/// the current one-shot request AbortController fires. #[tauri::command] pub async fn cancel_workflow_step( agent_id: String, - sessions: tauri::State<'_, WorkflowStepSessionManager>, + runs: tauri::State<'_, WorkflowStepRunManager>, pool: tauri::State<'_, SidecarPool>, ) -> Result<(), String> { log::info!("[cancel_workflow_step] agent={}", agent_id); - let (skill_name, session_id) = { - let map = sessions.0.lock().map_err(|e| { + let skill_name = { + let map = runs.0.lock().map_err(|e| { log::error!("[cancel_workflow_step] Failed to acquire session lock: {}", e); e.to_string() })?; @@ -692,13 +638,13 @@ pub async fn cancel_workflow_step( log::warn!("[cancel_workflow_step] {}", msg); msg })?; - (session.skill_name.clone(), session.session_id.clone()) + session.skill_name.clone() }; - pool.send_stream_end(&skill_name, &session_id) + pool.send_cancel(&skill_name, &agent_id) .await .map_err(|e| { log::warn!( - "[cancel_workflow_step] Failed to send stream_end for agent={}: {}", + "[cancel_workflow_step] Failed to send cancel for agent={}: {}", agent_id, e ); diff --git a/app/src-tauri/src/commands/workflow/step_config.rs b/app/src-tauri/src/commands/workflow/step_config.rs index 3e4df4e6b..d06d533cc 100644 --- a/app/src-tauri/src/commands/workflow/step_config.rs +++ b/app/src-tauri/src/commands/workflow/step_config.rs @@ -1,14 +1,17 @@ use crate::types::StepConfig; +pub(crate) const WORKFLOW_AGENT_IDENTITY: &str = "skill-content-researcher:skill-builder"; + /// Canonical allowed-tools lookup keyed by agent name. /// Values must match the `tools:` frontmatter in the corresponding agent `.md` file. pub fn tools_for_agent(agent_name: &str) -> Vec { let tools: &[&str] = match agent_name { + WORKFLOW_AGENT_IDENTITY => &["Read", "Write", "Edit", "Glob", "Grep", "Bash", "Agent", "Skill"], "skill-content-researcher:research-orchestrator" => &["Read", "Skill", "AskUserQuestion"], "skill-content-researcher:detailed-research" => &["Read", "Agent", "AskUserQuestion"], - "skill-content-researcher:confirm-decisions" => &["Read", "AskUserQuestion"], + "skill-content-researcher:confirm-decisions" => &["Read", "Agent", "AskUserQuestion"], "answer-evaluator" => &["Read", "Skill"], - "skill-creator:generate-skill" => &["Read", "Write", "Edit", "Glob", "Grep", "Bash", "Skill", "AskUserQuestion"], + "skill-creator:generate-skill" => &["Read", "Write", "Edit", "Glob", "Grep", "Bash", "Agent", "Skill", "AskUserQuestion"], "skill-creator:rewrite-skill" => &["Read", "Write", "Edit", "Glob", "Grep", "Bash", "Agent", "Skill", "AskUserQuestion"], "skill-creator:generate-skill-description-evals" => &["Read", "Skill"], _ => &["Read", "Glob", "Grep", "Agent", "Skill"], @@ -16,6 +19,13 @@ pub fn tools_for_agent(agent_name: &str) -> Vec { tools.iter().map(|s| s.to_string()).collect() } +fn one_shot_tools_for_agent(agent_name: &str) -> Vec { + tools_for_agent(agent_name) + .into_iter() + .filter(|tool| tool != "AskUserQuestion") + .collect() +} + pub fn resolve_model_id(shorthand: &str) -> String { match shorthand { "sonnet" => "claude-sonnet-4-6".to_string(), @@ -27,12 +37,15 @@ pub fn resolve_model_id(shorthand: &str) -> String { /// Canonical step configuration table. /// -/// | Step | Agent | Plugins | Source | -/// |------|-------|---------|--------| -/// | 0 | skill-content-researcher:research-orchestrator | skill-content-researcher | plugin agents/ | -/// | 1 | skill-content-researcher:detailed-research | skill-content-researcher | plugin agents/ | -/// | 2 | skill-content-researcher:confirm-decisions | skill-content-researcher | plugin agents/ | -/// | 3 | skill-creator:generate-skill | skill-creator | plugin agents/ | +/// `agent_name` identifies the step capability used for tools, output schema, +/// and logging. The SDK agent identity is `WORKFLOW_AGENT_IDENTITY`. +/// +/// | Step | Capability | Plugins | +/// |------|------------|---------| +/// | 0 | skill-content-researcher:research-orchestrator | skill-content-researcher | +/// | 1 | skill-content-researcher:detailed-research | skill-content-researcher | +/// | 2 | skill-content-researcher:confirm-decisions | skill-content-researcher | +/// | 3 | skill-creator:generate-skill | skill-content-researcher, skill-creator | pub(crate) fn get_step_config(step_id: u32) -> Result { match step_id { 0 => { @@ -42,7 +55,7 @@ pub(crate) fn get_step_config(step_id: u32) -> Result { name: "Research".to_string(), prompt_template: "research-orchestrator.md".to_string(), output_file: "context/clarifications.json".to_string(), - allowed_tools: tools_for_agent(agent), + allowed_tools: one_shot_tools_for_agent(agent), max_turns: 50, agent_name: agent.to_string(), required_plugins: vec!["skill-content-researcher".to_string()], @@ -55,7 +68,7 @@ pub(crate) fn get_step_config(step_id: u32) -> Result { name: "Detailed Research".to_string(), prompt_template: "detailed-research.md".to_string(), output_file: "context/clarifications.json".to_string(), - allowed_tools: tools_for_agent(agent), + allowed_tools: one_shot_tools_for_agent(agent), max_turns: 50, agent_name: agent.to_string(), required_plugins: vec!["skill-content-researcher".to_string()], @@ -68,7 +81,7 @@ pub(crate) fn get_step_config(step_id: u32) -> Result { name: "Confirm Decisions".to_string(), prompt_template: "confirm-decisions.md".to_string(), output_file: "context/decisions.json".to_string(), - allowed_tools: tools_for_agent(agent), + allowed_tools: one_shot_tools_for_agent(agent), max_turns: 100, agent_name: agent.to_string(), required_plugins: vec!["skill-content-researcher".to_string()], @@ -81,10 +94,13 @@ pub(crate) fn get_step_config(step_id: u32) -> Result { name: "Generate Skill".to_string(), prompt_template: "generate-skill.md".to_string(), output_file: "skill/SKILL.md".to_string(), - allowed_tools: tools_for_agent(agent), + allowed_tools: one_shot_tools_for_agent(agent), max_turns: 500, agent_name: agent.to_string(), - required_plugins: vec!["skill-creator".to_string()], + required_plugins: vec![ + "skill-content-researcher".to_string(), + "skill-creator".to_string(), + ], }) } _ => Err(format!("Unknown step_id {}. Valid steps are 0-3.", step_id)), diff --git a/app/src-tauri/src/commands/workflow/tests.rs b/app/src-tauri/src/commands/workflow/tests.rs index b95bf3096..36afa5f7e 100644 --- a/app/src-tauri/src/commands/workflow/tests.rs +++ b/app/src-tauri/src/commands/workflow/tests.rs @@ -17,6 +17,7 @@ use super::prompt::{build_prompt, PromptParams}; use super::user_context::{format_user_context, write_user_context_file}; use super::step_config::{ build_betas, get_step_config, thinking_budget_for_step, workflow_output_format_for_agent, + WORKFLOW_AGENT_IDENTITY, }; use super::evaluation::get_step_output_files; @@ -98,6 +99,7 @@ fn test_get_step_output_files_unknown_step() { #[test] fn test_step_config_canonical_agent_names() { + assert_eq!(WORKFLOW_AGENT_IDENTITY, "skill-content-researcher:skill-builder"); assert_eq!(get_step_config(0).unwrap().agent_name, "skill-content-researcher:research-orchestrator"); assert_eq!(get_step_config(1).unwrap().agent_name, "skill-content-researcher:detailed-research"); assert_eq!(get_step_config(2).unwrap().agent_name, "skill-content-researcher:confirm-decisions"); @@ -120,10 +122,24 @@ fn test_step_config_canonical_required_plugins() { ); assert_eq!( get_step_config(3).unwrap().required_plugins, - vec!["skill-creator"] + vec!["skill-content-researcher", "skill-creator"] ); } +#[test] +fn test_workflow_step_tools_are_one_shot_safe() { + for step_id in 0..=3 { + let config = get_step_config(step_id).unwrap(); + assert!( + !config + .allowed_tools + .iter() + .any(|tool| tool == "AskUserQuestion"), + "workflow step {step_id} must not allow AskUserQuestion in one-shot mode" + ); + } +} + #[test] fn test_workflow_output_format_is_set_for_json_contract_workflow_agents() { assert!(workflow_output_format_for_agent("skill-content-researcher:research-orchestrator").is_some()); @@ -442,6 +458,61 @@ fn test_materialize_step0_writes_research_and_clarifications() { assert!(!skill_root.join("context/research-plan.md").exists()); } +#[test] +fn test_materialize_step0_accepts_null_focus_in_unselected_dimension_scores() { + let tmp = tempfile::tempdir().unwrap(); + let skill_root = tmp.path().join("my-skill"); + let payload = serde_json::json!({ + "status": "research_complete", + "dimensions_selected": 1, + "question_count": 0, + "research_output": { + "version": "1", + "metadata": { + "title": "Test", + "question_count": 0, + "section_count": 0, + "refinement_count": 0, + "must_answer_count": 0, + "priority_questions": [], + "research_plan": { + "purpose": "Analyze leads", + "domain": "Cloud services", + "topic_relevance": "High", + "dimensions_evaluated": 2, + "dimensions_selected": 1, + "dimension_scores": [ + { + "name": "entities", + "score": 5.0, + "reason": "Critical custom relationships.", + "focus": "Lead to opportunity conversion" + }, + { + "name": "modeling-patterns", + "score": 3.0, + "reason": "Mostly standard.", + "focus": null + } + ], + "selected_dimensions": [ + { + "name": "entities", + "focus": "Lead to opportunity conversion" + } + ] + } + }, + "sections": [], + "notes": [] + } + }); + + materialize_workflow_step_output_value(&skill_root, 0, &payload).unwrap(); + let written = std::fs::read_to_string(skill_root.join("context/clarifications.json")).unwrap(); + assert!(written.contains("\"modeling-patterns\"")); +} + #[test] fn test_materialize_step0_empty_metadata_defaults_to_zeros() { let tmp = tempfile::tempdir().unwrap(); @@ -1011,6 +1082,8 @@ fn test_build_prompt_all_three_paths() { assert!(prompt .contains("The workspace directory is: /home/user/.vibedata/skill-builder/skills/my-skill")); assert!(prompt.contains("The skill output directory (SKILL.md and references/) is: /home/user/my-skills/skills/my-skill")); + assert!(prompt.contains("This skill output directory is the configured Settings Skills Folder target for the shipped skill")); + assert!(prompt.contains("shipped skill files must be written only to the skill output directory, never to the workspace directory or a workspace skill/ subdirectory")); assert!(prompt.contains("The user context file is at: /home/user/.vibedata/skill-builder/skills/my-skill/user-context.md")); assert!(prompt.contains("The context directory is: /home/user/.vibedata/skill-builder/skills/my-skill/context")); } @@ -1066,8 +1139,8 @@ fn test_build_prompt_without_author_info() { #[test] fn test_build_prompt_does_not_include_schema_file_path() { - // Schema file paths are no longer injected into the prompt — they are - // injected into config.system_prompt instead (VU-1049). + // Schema file paths are no longer injected into the prompt. Workflow steps + // use SDK outputFormat for structured contracts. for step_id in [1u32, 2, 3] { let prompt = build_prompt(&PromptParams { skill_name: "s", workspace_path: "/ws", plugin_slug: DEFAULT_PLUGIN_SLUG, @@ -1092,40 +1165,23 @@ fn test_build_prompt_does_not_include_schema_file_path() { } #[test] -fn test_system_prompt_injects_correct_inline_schema_per_step() { +fn test_output_format_contains_correct_inline_schema_per_workflow_step() { use crate::generated::schemas; - // Steps 0–2 must each produce a system_prompt containing the matching inline schema. - let cases: &[(u32, &str)] = &[ - (0, schemas::RESEARCH_STEP_INLINE_SCHEMA), - (1, schemas::DETAILED_RESEARCH_INLINE_SCHEMA), - (2, schemas::DECISIONS_INLINE_SCHEMA), + let cases: &[(&str, &str)] = &[ + ("skill-content-researcher:research-orchestrator", schemas::RESEARCH_STEP_INLINE_SCHEMA), + ("skill-content-researcher:detailed-research", schemas::DETAILED_RESEARCH_INLINE_SCHEMA), + ("skill-content-researcher:confirm-decisions", schemas::DECISIONS_INLINE_SCHEMA), ]; - for (step_id, expected_schema) in cases { - let system_prompt: Option = match step_id { - 0 => Some(format!( - "Your output MUST be a JSON object that strictly conforms to the following schema:\n\n{}", - schemas::RESEARCH_STEP_INLINE_SCHEMA - )), - 1 => Some(format!( - "Your output MUST be a JSON object that strictly conforms to the following schema:\n\n{}", - schemas::DETAILED_RESEARCH_INLINE_SCHEMA - )), - 2 => Some(format!( - "Your output MUST be a JSON object that strictly conforms to the following schema:\n\n{}", - schemas::DECISIONS_INLINE_SCHEMA - )), - _ => None, - }; - let sp = system_prompt.expect(&format!("step {step_id} must have a system_prompt")); - assert!(sp.contains(expected_schema), "step {step_id}: system_prompt must contain the inline schema"); - assert!(sp.contains("strictly conforms to the following schema"), "step {step_id}: system_prompt must include conformance directive"); + for (agent, expected_schema) in cases { + let format = workflow_output_format_for_agent(agent) + .unwrap_or_else(|| panic!("{agent} must have workflow outputFormat")); + let actual_schema = format + .get("schema") + .expect("outputFormat must contain schema"); + let expected_schema: serde_json::Value = + serde_json::from_str(expected_schema).expect("generated schema must parse"); + assert_eq!(*actual_schema, expected_schema, "{agent}: outputFormat must use the inline schema"); } - // Step 3 must produce no system_prompt. - let step3: Option = match 3u32 { - 0 | 1 | 2 => Some("would not happen".to_string()), - _ => None, - }; - assert!(step3.is_none(), "step 3 must not have a system_prompt"); } #[test] diff --git a/app/src-tauri/src/contracts/clarifications.rs b/app/src-tauri/src/contracts/clarifications.rs index c564705db..5bdca5ec5 100644 --- a/app/src-tauri/src/contracts/clarifications.rs +++ b/app/src-tauri/src/contracts/clarifications.rs @@ -85,7 +85,8 @@ pub struct DimensionScore { pub name: String, pub score: f64, pub reason: String, - pub focus: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub focus: Option, } /// A dimension selected for deeper research. @@ -407,4 +408,17 @@ mod tests { let roundtrip: ClarificationsFile = serde_json::from_str(&reserialized).expect("roundtrip"); assert_eq!(roundtrip.metadata.title, file.metadata.title); } + + #[test] + fn test_dimension_score_focus_accepts_null_for_unselected_dimensions() { + let json = serde_json::json!({ + "name": "segmentation-and-periods", + "score": 3.0, + "reason": "Useful but mostly standard.", + "focus": null + }); + + let score: DimensionScore = serde_json::from_value(json).unwrap(); + assert!(score.focus.is_none()); + } } diff --git a/app/src-tauri/src/generated/schemas.rs b/app/src-tauri/src/generated/schemas.rs index 925cdefe5..9257effa3 100644 --- a/app/src-tauri/src/generated/schemas.rs +++ b/app/src-tauri/src/generated/schemas.rs @@ -344,7 +344,10 @@ pub const RESEARCH_STEP_INLINE_SCHEMA: &str = r###"{ "description": "A scored dimension in the research plan.", "properties": { "focus": { - "type": "string" + "type": [ + "string", + "null" + ] }, "name": { "type": "string" @@ -360,8 +363,7 @@ pub const RESEARCH_STEP_INLINE_SCHEMA: &str = r###"{ "required": [ "name", "score", - "reason", - "focus" + "reason" ], "type": "object" }, @@ -749,7 +751,10 @@ pub const DETAILED_RESEARCH_INLINE_SCHEMA: &str = r###"{ "description": "A scored dimension in the research plan.", "properties": { "focus": { - "type": "string" + "type": [ + "string", + "null" + ] }, "name": { "type": "string" @@ -765,8 +770,7 @@ pub const DETAILED_RESEARCH_INLINE_SCHEMA: &str = r###"{ "required": [ "name", "score", - "reason", - "focus" + "reason" ], "type": "object" }, diff --git a/app/src-tauri/src/git.rs b/app/src-tauri/src/git.rs index e35829a03..1b208386c 100644 --- a/app/src-tauri/src/git.rs +++ b/app/src-tauri/src/git.rs @@ -133,6 +133,83 @@ pub fn commit_all(path: &Path, message: &str) -> Result, String> Ok(Some(oid.to_string())) } +/// Stage and commit only one repo-relative path. Returns the commit SHA, or +/// Ok(None) if that path has no changes. +pub fn commit_path(path: &Path, relative_path: &Path, message: &str) -> Result, String> { + let relative = relative_path + .to_string_lossy() + .replace(std::path::MAIN_SEPARATOR, "/") + .trim_matches('/') + .to_string(); + if relative.is_empty() { + return Err("Refusing to commit empty relative path".to_string()); + } + + log::debug!("[git] commit_path at {} path={} — \"{}\"", path.display(), relative, message); + let repo = ensure_repo(path)?; + + ensure_no_unrelated_staged_changes(&repo, &relative)?; + + let mut scoped_opts = StatusOptions::new(); + scoped_opts.include_untracked(true).recurse_untracked_dirs(true).pathspec(&relative); + let scoped_statuses = repo + .statuses(Some(&mut scoped_opts)) + .map_err(|e| format!("Failed to get statuses for '{}': {}", relative, e))?; + if scoped_statuses.is_empty() { + log::debug!("[git] No changes under '{}' — skipping", relative); + return Ok(None); + } + + let mut index = repo + .index() + .map_err(|e| format!("Failed to get index: {}", e))?; + index + .add_all([relative.as_str()].iter(), git2::IndexAddOption::DEFAULT, None) + .map_err(|e| format!("Failed to stage '{}': {}", relative, e))?; + + for entry in scoped_statuses.iter() { + if entry.status().contains(git2::Status::WT_DELETED) { + if let Some(p) = entry.path() { + let _ = index.remove_path(Path::new(p)); + } + } + } + + index + .write() + .map_err(|e| format!("Failed to write index: {}", e))?; + + let tree_id = index + .write_tree() + .map_err(|e| format!("Failed to write tree: {}", e))?; + let tree = repo + .find_tree(tree_id) + .map_err(|e| format!("Failed to find tree: {}", e))?; + + let head_commit = repo.head().ok().and_then(|h| h.peel_to_commit().ok()); + if let Some(ref parent) = head_commit { + let parent_tree = parent + .tree() + .map_err(|e| format!("Failed to get parent tree: {}", e))?; + let diff = repo + .diff_tree_to_tree(Some(&parent_tree), Some(&tree), None) + .map_err(|e| format!("Failed to compute diff: {}", e))?; + if diff.deltas().count() == 0 { + log::debug!("[git] No changes to commit — skipping"); + return Ok(None); + } + } + + let sig = default_signature(&repo)?; + let parents: Vec<&git2::Commit> = head_commit.as_ref().into_iter().collect(); + let oid = repo + .commit(Some("HEAD"), &sig, &sig, message, &tree, &parents) + .map_err(|e| format!("Failed to commit '{}': {}", relative, e))?; + + log::info!("[git] Committed {}: {} ({})", relative, message, &oid.to_string()[..8]); + Ok(Some(oid.to_string())) +} + // --- Tag name helpers (templates from plugin-paths.json via skill_paths) --- pub fn skill_version_tag_name(plugin_slug: &str, skill_name: &str, version: &str) -> String { @@ -846,6 +923,40 @@ fn default_signature(repo: &Repository) -> Result, String> { .map_err(|e| format!("Failed to create signature: {}", e)) } +fn ensure_no_unrelated_staged_changes(repo: &Repository, relative: &str) -> Result<(), String> { + let prefix = format!("{}/", relative.trim_end_matches('/')); + let mut opts = StatusOptions::new(); + opts.include_untracked(true).recurse_untracked_dirs(true); + let statuses = repo + .statuses(Some(&mut opts)) + .map_err(|e| format!("Failed to inspect staged changes: {}", e))?; + + for entry in statuses.iter() { + let Some(path) = entry.path() else { + continue; + }; + if path == relative || path.starts_with(&prefix) { + continue; + } + + let status = entry.status(); + let staged = status.intersects( + git2::Status::INDEX_NEW + | git2::Status::INDEX_MODIFIED + | git2::Status::INDEX_DELETED + | git2::Status::INDEX_RENAMED + | git2::Status::INDEX_TYPECHANGE, + ); + if staged { + return Err(format!( + "Refusing scoped commit for '{}' because unrelated staged change exists at '{}'", + relative, path + )); + } + } + Ok(()) +} + /// Check if a commit touches any file under the given path prefix. fn commit_touches_path( repo: &Repository, diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 5b2bdb252..7a0049155 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -285,7 +285,7 @@ pub fn run() { .manage(agents::sidecar_pool::SidecarPool::new()) .manage(CloseGuardState::default()) .manage(commands::refine::RefineSessionManager::new()) - .manage(commands::workflow::runtime::WorkflowStepSessionManager::new()) + .manage(commands::workflow::runtime::WorkflowStepRunManager::new()) .manage(commands::description::DescriptionProcessState::new()) .invoke_handler(tauri::generate_handler![ commands::agent::start_agent, diff --git a/app/src-tauri/src/types/mod.rs b/app/src-tauri/src/types/mod.rs index 8ad4c8815..f1ed4a323 100644 --- a/app/src-tauri/src/types/mod.rs +++ b/app/src-tauri/src/types/mod.rs @@ -138,6 +138,7 @@ mod tests { #[test] fn test_sidecar_config_serde() { let config = crate::agents::sidecar::SidecarConfig { + mode: Some("one-shot".to_string()), prompt: "test prompt".to_string(), system_prompt: None, model: Some("sonnet".to_string()), diff --git a/app/src/__tests__/components/feedback-dialog.test.tsx b/app/src/__tests__/components/feedback-dialog.test.tsx index b8e66f58c..6e754916b 100644 --- a/app/src/__tests__/components/feedback-dialog.test.tsx +++ b/app/src/__tests__/components/feedback-dialog.test.tsx @@ -37,7 +37,7 @@ const { mockStartAgent, mockGetWorkspacePath, mockCreateGithubIssue } = vi.hoist })); vi.mock("@/lib/tauri", () => ({ - startAgent: mockStartAgent, + startOneShotAgent: mockStartAgent, getWorkspacePath: mockGetWorkspacePath, createGithubIssue: mockCreateGithubIssue, githubGetUser: vi.fn(() => Promise.resolve(null)), @@ -213,7 +213,7 @@ describe("FeedbackDialog", () => { expect(analyzeBtn).toBeDisabled(); }); - it("clicking Analyze calls startAgent with enrichment prompt (model: haiku)", async () => { + it("clicking Analyze calls startOneShotAgent with enrichment prompt (model: haiku)", async () => { const user = userEvent.setup(); render(); diff --git a/app/src/__tests__/components/refine/chat-input-bar.test.tsx b/app/src/__tests__/components/refine/chat-input-bar.test.tsx index 3b48c5655..0e824239a 100644 --- a/app/src/__tests__/components/refine/chat-input-bar.test.tsx +++ b/app/src/__tests__/components/refine/chat-input-bar.test.tsx @@ -15,6 +15,7 @@ const defaultProps = { onSend: vi.fn(), onCancel: vi.fn(), isRunning: false, + waitingForQuestion: false, availableFiles: ["SKILL.md", "references/glossary.md"], availableAgents: ["skill-creator:rewrite-skill", "skill-creator:generate-skill"], }; @@ -55,6 +56,12 @@ describe("ChatInputBar", () => { expect(defaultProps.onSend).not.toHaveBeenCalled(); }); + it("shows pending question status while the agent is waiting for an answer", () => { + renderBar({ isRunning: true, waitingForQuestion: true }); + + expect(screen.getByText("Answer the pending question above to continue.")).toBeInTheDocument(); + }); + it("does not show command buttons", () => { renderBar(); diff --git a/app/src/__tests__/components/refine/chat-message-list.test.tsx b/app/src/__tests__/components/refine/chat-message-list.test.tsx index 95d172c2d..f5f3af520 100644 --- a/app/src/__tests__/components/refine/chat-message-list.test.tsx +++ b/app/src/__tests__/components/refine/chat-message-list.test.tsx @@ -80,6 +80,47 @@ describe("ChatMessageList", () => { expect(onQuestionSubmit).toHaveBeenCalled(); }); + it("submits multi-select question answers as a comma-separated string", async () => { + const user = userEvent.setup(); + const onQuestionSubmit = vi.fn().mockResolvedValue(undefined); + const messages: RefineMessage[] = [ + { + id: "q1", + role: "question", + agentId: "agent-1", + toolUseId: "toolu_1", + pending: true, + questions: [{ + header: "Deal Type", + question: "Which deal types need more detail?", + multiSelect: true, + options: [ + { label: "Staffing", description: "Short-cycle services." }, + { label: "Transformation", description: "Long-cycle projects." }, + { label: "Managed Services", description: "Recurring services." }, + ], + }], + timestamp: 1, + }, + ]; + + render(); + + await user.click(screen.getByRole("button", { name: /staffing/i })); + await user.click(screen.getByRole("button", { name: /managed services/i })); + await user.click(screen.getByTestId("refine-question-submit")); + + expect(onQuestionSubmit).toHaveBeenCalledWith( + expect.objectContaining({ id: "q1" }), + expect.objectContaining({ + answers: { + "Which deal types need more detail?": "Staffing, Managed Services", + }, + selectedLabels: ["Staffing", "Managed Services"], + }), + ); + }); + it("renders multi-question as wizard with step-through navigation", async () => { const user = userEvent.setup(); const onQuestionSubmit = vi.fn().mockResolvedValue(undefined); diff --git a/app/src/__tests__/components/workspace/workspace-overview.test.tsx b/app/src/__tests__/components/workspace/workspace-overview.test.tsx index 9c87b707b..10fab5a3f 100644 --- a/app/src/__tests__/components/workspace/workspace-overview.test.tsx +++ b/app/src/__tests__/components/workspace/workspace-overview.test.tsx @@ -21,6 +21,7 @@ vi.mock("@/components/skill-dialog", () => ({ })); import { WorkspaceOverview } from "@/components/workspace/workspace-overview"; +import { getSkillHistory } from "@/lib/tauri"; const baseSkill: SkillSummary = { name: "sales-pipeline", @@ -123,4 +124,33 @@ describe("WorkspaceOverview", () => { expect(dialog).toBeInTheDocument(); expect(dialog).toHaveAttribute("data-open", "true"); }); + + it("renders version history without selectable checkboxes", async () => { + vi.mocked(getSkillHistory).mockResolvedValueOnce([ + { + sha: "1b66e7d8", + message: "Update analyzing-leads description via optimization", + timestamp: new Date().toISOString(), + version: "0.0.3", + }, + { + sha: "0ae7c6ee", + message: "generated skill", + timestamp: new Date(Date.now() - 60_000).toISOString(), + version: null, + }, + ]); + + render( + , + ); + + expect(await screen.findByText("v0.0.3")).toBeInTheDocument(); + expect(screen.getByText("generated skill")).toBeInTheDocument(); + expect(screen.queryAllByRole("checkbox")).toHaveLength(0); + expect(screen.queryByRole("button", { name: /compare/i })).not.toBeInTheDocument(); + }); }); diff --git a/app/src/__tests__/components/workspace/workspace-refine.test.tsx b/app/src/__tests__/components/workspace/workspace-refine.test.tsx index 24ea6a3b4..82ee3d2e8 100644 --- a/app/src/__tests__/components/workspace/workspace-refine.test.tsx +++ b/app/src/__tests__/components/workspace/workspace-refine.test.tsx @@ -11,7 +11,7 @@ const tauriMocks = vi.hoisted(() => ({ closeRefineSession: vi.fn().mockResolvedValue(undefined), cleanupSkillSidecar: vi.fn().mockResolvedValue(undefined), getSkillContentForRefine: vi.fn().mockResolvedValue([]), - sendRefineMessage: vi.fn().mockResolvedValue("agent-1"), + sendStreamingRefineMessage: vi.fn().mockResolvedValue("agent-1"), cancelRefineTurn: vi.fn().mockResolvedValue(undefined), finalizeRefineRun: vi.fn().mockResolvedValue({ files: [], diff: null }), })); diff --git a/app/src/__tests__/components/workspace/workspace-shell.test.tsx b/app/src/__tests__/components/workspace/workspace-shell.test.tsx index dd60b3849..dfb39fbe8 100644 --- a/app/src/__tests__/components/workspace/workspace-shell.test.tsx +++ b/app/src/__tests__/components/workspace/workspace-shell.test.tsx @@ -66,7 +66,7 @@ vi.mock("@/lib/tauri", () => ({ cleanupSkillSidecar: vi.fn().mockResolvedValue(undefined), cancelDescriptionOptimization: vi.fn().mockResolvedValue(undefined), getSkillContentForRefine: vi.fn().mockResolvedValue([]), - sendRefineMessage: vi.fn().mockResolvedValue("agent-1"), + sendStreamingRefineMessage: vi.fn().mockResolvedValue("agent-1"), finalizeRefineRun: vi.fn().mockResolvedValue({ files: [], diff: null }), getSkillHistory: vi.fn().mockResolvedValue([]), readLatestBenchmark: vi.fn().mockResolvedValue(null), diff --git a/app/src/__tests__/hooks/use-agent-stream.test.ts b/app/src/__tests__/hooks/use-agent-stream.test.ts index 04b4b2b88..9783c2bef 100644 --- a/app/src/__tests__/hooks/use-agent-stream.test.ts +++ b/app/src/__tests__/hooks/use-agent-stream.test.ts @@ -105,6 +105,31 @@ describe("initAgentStream", () => { expect(question.questions).toHaveLength(1); }); + it("drops question messages for active one-shot workflow agents", async () => { + useAgentStore.getState().setActiveAgent("workflow-agent"); + await initAgentStream(); + + listeners["agent-message"]({ + payload: { + agent_id: "workflow-agent", + message: { + type: "refine_question", + tool_use_id: "toolu_workflow", + questions: [ + { + header: "Workflow Question", + question: "This should not be shown", + options: [{ label: "Continue", description: "Continue." }], + }, + ], + }, + }, + }); + + expect(useWorkflowStore.getState().pendingQuestion).toBeNull(); + expect(useRefineStore.getState().messages).toHaveLength(0); + }); + it("updates run init via agent-run-init event", async () => { useAgentStore.getState().startRun("agent-1", "sonnet"); await initAgentStream(); diff --git a/app/src/__tests__/hooks/use-workflow-state-machine.test.ts b/app/src/__tests__/hooks/use-workflow-state-machine.test.ts index 0cb4311d4..e6c4bf9de 100644 --- a/app/src/__tests__/hooks/use-workflow-state-machine.test.ts +++ b/app/src/__tests__/hooks/use-workflow-state-machine.test.ts @@ -267,7 +267,37 @@ describe("useWorkflowStateMachine", () => { }; mockActiveAgentId = "agent-finish-1"; mockRuns = { - "agent-finish-1": { status: "completed", displayItems: [], totalCost: 0 }, + "agent-finish-1": { + status: "completed", + displayItems: [ + { + id: "result-agent-finish-1", + type: "result", + timestamp: Date.now(), + outputText_result: "Agent completed", + structuredOutput: { + status: "research_complete", + dimensions_selected: 1, + question_count: 1, + research_plan_markdown: "# Research Plan", + clarifications_json: { + version: "1", + metadata: { + question_count: 0, + section_count: 0, + refinement_count: 0, + must_answer_count: 0, + priority_questions: [], + }, + sections: [], + notes: [], + }, + }, + resultStatus: "success", + }, + ], + totalCost: 0, + }, }; // verifyStepOutput resolves true — step output exists mockVerifyStepOutput.mockResolvedValueOnce(true); diff --git a/app/src/__tests__/lib/gate-feedback.test.ts b/app/src/__tests__/lib/gate-feedback.test.ts index b70f8b8ac..0de0ab893 100644 --- a/app/src/__tests__/lib/gate-feedback.test.ts +++ b/app/src/__tests__/lib/gate-feedback.test.ts @@ -55,6 +55,20 @@ describe("buildGateFeedbackNotes", () => { expect(notes[0].body).toContain("more concrete detail"); }); + it("maps contradictory verdict with evaluator summary", () => { + const eval_ = { + ...makeEvaluation([ + { question_id: "Q10", verdict: "contradictory", reason: "This answer conflicts with Q11." }, + ]), + reasoning: "Q10 and Q11 form a contradiction cluster.", + }; + const notes = buildGateFeedbackNotes(eval_); + expect(notes).toHaveLength(1); + expect(notes[0].title).toBe("Contradictory answer: Q10"); + expect(notes[0].body).toContain("This answer conflicts with Q11."); + expect(notes[0].body).toContain("Evaluator summary: Q10 and Q11 form a contradiction cluster."); + }); + it("uses custom reason when provided", () => { const eval_ = makeEvaluation([ { question_id: "q1", verdict: "vague", reason: "Be more specific about the data model." }, @@ -78,13 +92,15 @@ describe("buildGateFeedbackNotes", () => { { question_id: "q3", verdict: "clear" }, { question_id: "q4", verdict: "not_answered" }, { question_id: "q5", verdict: "needs_refinement" }, + { question_id: "q6", verdict: "contradictory", reason: "Conflicts with q2." }, ]); const notes = buildGateFeedbackNotes(eval_); - expect(notes).toHaveLength(3); + expect(notes).toHaveLength(4); expect(notes.map((n) => n.title)).toEqual([ "Vague answer: q2", "Not answered: q4", "Needs refinement: q5", + "Contradictory answer: q6", ]); }); diff --git a/app/src/__tests__/lib/runtime-api-contract.test.ts b/app/src/__tests__/lib/runtime-api-contract.test.ts new file mode 100644 index 000000000..0748d9bbf --- /dev/null +++ b/app/src/__tests__/lib/runtime-api-contract.test.ts @@ -0,0 +1,50 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const SRC_ROOT = resolve(__dirname, "../../"); + +function readSource(relativePath: string) { + return readFileSync(resolve(SRC_ROOT, relativePath), "utf8"); +} + +describe("runtime API contract", () => { + it("exposes explicit one-shot and streaming agent APIs", () => { + const source = readSource("lib/tauri.ts"); + + expect(source).toContain("export const startOneShotAgent"); + expect(source).toContain("export const sendStreamingRefineMessage"); + expect(source).not.toContain("export const startAgent"); + expect(source).not.toContain("export const answerWorkflowStepQuestion"); + }); + + it("keeps non-refine UI surfaces on the one-shot agent API", () => { + const nonRefineSources = [ + "components/feedback-dialog.tsx", + "components/workspace/workspace-evals.tsx", + ]; + + for (const relativePath of nonRefineSources) { + const source = readSource(relativePath); + + expect(source, relativePath).toContain("startOneShotAgent"); + expect(source, relativePath).not.toContain("startAgent("); + expect(source, relativePath).not.toContain("sendStreamingRefineMessage"); + } + }); + + it("keeps refine UI on the streaming refine API", () => { + const source = readSource("components/workspace/workspace-refine.tsx"); + + expect(source).toContain("sendStreamingRefineMessage"); + expect(source).toContain("answerStreamingRefineQuestion"); + expect(source).not.toContain("startOneShotAgent"); + }); + + it("does not expose workflow AskUserQuestion UI for one-shot runs", () => { + const source = readSource("components/agent-output-panel.tsx"); + + expect(source).not.toContain("answerWorkflowStepQuestion"); + expect(source).not.toContain("RefineQuestionInline"); + }); +}); diff --git a/app/src/__tests__/pages/workflow.test.tsx b/app/src/__tests__/pages/workflow.test.tsx index cb81da5b3..6c8494711 100644 --- a/app/src/__tests__/pages/workflow.test.tsx +++ b/app/src/__tests__/pages/workflow.test.tsx @@ -235,6 +235,31 @@ describe("WorkflowPage — agent completion lifecycle", () => { // Agent completes — should stay on step 0 completion screen (clarifications editable) act(() => { + useAgentStore.getState().addDisplayItem("agent-1", { + id: "result-agent-1", + type: "result", + timestamp: Date.now(), + outputText_result: "Agent completed", + structuredOutput: { + status: "research_complete", + dimensions_selected: 1, + question_count: 1, + research_plan_markdown: "# Research Plan", + clarifications_json: { + version: "1", + metadata: { + question_count: 0, + section_count: 0, + refinement_count: 0, + must_answer_count: 0, + priority_questions: [], + }, + sections: [], + notes: [], + }, + }, + resultStatus: "success", + }); useAgentStore.getState().completeRun("agent-1", true); }); @@ -682,6 +707,31 @@ describe("WorkflowPage — editable clarifications on completed agent step", () // Agent completes step 0 act(() => { + useAgentStore.getState().addDisplayItem("agent-1", { + id: "result-agent-1", + type: "result", + timestamp: Date.now(), + outputText_result: "Agent completed", + structuredOutput: { + status: "research_complete", + dimensions_selected: 1, + question_count: 1, + research_plan_markdown: "# Research Plan", + clarifications_json: { + version: "1", + metadata: { + question_count: 0, + section_count: 0, + refinement_count: 0, + must_answer_count: 0, + priority_questions: [], + }, + sections: [], + notes: [], + }, + }, + resultStatus: "success", + }); useAgentStore.getState().completeRun("agent-1", true); }); @@ -903,7 +953,7 @@ describe("WorkflowPage — editable clarifications on completed agent step", () expect(mockToast.error).toHaveBeenCalled(); }); - it("step 0 continues when structured output payload is missing", async () => { + it("step 0 errors when structured output payload is missing", async () => { useWorkflowStore.getState().initWorkflow("test-skill", "test domain"); useWorkflowStore.getState().setHydrated(true); useWorkflowStore.getState().updateStepStatus(0, "in_progress"); @@ -917,9 +967,13 @@ describe("WorkflowPage — editable clarifications on completed agent step", () }); await waitFor(() => { - expect(useWorkflowStore.getState().steps[0].status).toBe("completed"); + expect(useWorkflowStore.getState().steps[0].status).toBe("error"); }); expect(vi.mocked(materializeWorkflowStepOutput)).not.toHaveBeenCalled(); + expect(mockToast.error).toHaveBeenCalledWith( + "Step 1 completed but produced no structured output", + { duration: Infinity }, + ); }); it("step 0 errors when structured output fails backend materialization", async () => { @@ -1166,7 +1220,9 @@ describe("WorkflowPage — editable clarifications on completed agent step", () const parsed = JSON.parse(serialized); expect(Array.isArray(parsed.answer_evaluator_notes)).toBe(true); expect(parsed.answer_evaluator_notes.some((n: { title: string }) => n.title === "Vague answer: Q1")).toBe(true); - // contradictory verdicts are resolved by the agent inline and not written to notes + expect(parsed.answer_evaluator_notes.some((n: { title: string; body: string }) => + n.title === "Contradictory answer: Q2" && n.body.includes("One vague and one contradictory answer.") + )).toBe(true); }); it("gate falls back when structured gate payload is missing", async () => { @@ -3274,20 +3330,20 @@ describe("WorkflowPage — gate handler isolated paths (TF-02)", () => { expect(useWorkflowStore.getState().gateLoading).toBe(false); }); - it("buildGateFeedbackNotes maps three actionable verdict types with reasons", async () => { - // contradictory is resolved inline by the agent and is not written to notes + it("buildGateFeedbackNotes maps actionable verdict types with reasons", async () => { const evaluation = { verdict: "mixed", answered_count: 0, empty_count: 1, vague_count: 1, - contradictory_count: 0, - total_count: 3, + contradictory_count: 1, + total_count: 4, reasoning: "Multiple issues.", per_question: [ { question_id: "Q1", verdict: "vague", reason: "Too general." }, { question_id: "Q3", verdict: "not_answered", reason: "Skipped entirely." }, { question_id: "Q4", verdict: "needs_refinement", reason: "Needs more constraints." }, + { question_id: "Q5", verdict: "contradictory", reason: "Conflicts with Q1." }, ], }; @@ -3337,10 +3393,11 @@ describe("WorkflowPage — gate handler isolated paths (TF-02)", () => { const parsed = JSON.parse(serialized); const notes = parsed.answer_evaluator_notes as Array<{ type: string; title: string; body: string }>; - // Three actionable verdict types with custom reasons + // Actionable verdict types with custom reasons expect(notes.some((n) => n.title === "Vague answer: Q1" && n.body === "Too general.")).toBe(true); expect(notes.some((n) => n.title === "Not answered: Q3" && n.body === "Skipped entirely.")).toBe(true); expect(notes.some((n) => n.title === "Needs refinement: Q4" && n.body === "Needs more constraints.")).toBe(true); + expect(notes.some((n) => n.title === "Contradictory answer: Q5" && n.body.includes("Conflicts with Q1."))).toBe(true); // All should have type "answer_feedback" expect(notes.every((n) => n.type === "answer_feedback")).toBe(true); @@ -3407,7 +3464,7 @@ describe("WorkflowPage — gate handler isolated paths (TF-02)", () => { const parsed = JSON.parse(serialized); const notes = parsed.answer_evaluator_notes as Array<{ title: string; body: string }>; - // Fallback messages (contradictory is resolved by agent and not written to notes) + // Fallback messages expect(notes.some((n) => n.title === "Vague answer: Q1" && n.body === "Answer is too general and needs specific details.")).toBe(true); expect(notes.some((n) => n.title === "Not answered: Q3" && n.body.includes("still unanswered"))).toBe(true); expect(notes.some((n) => n.title === "Needs refinement: Q4" && n.body.includes("more concrete detail"))).toBe(true); @@ -3518,7 +3575,7 @@ describe("WorkflowPage — step-completion error paths (TF-03)", () => { vi.mocked(WorkflowStepComplete).mockImplementation(() =>
); }); - it("step errors when verifyStepOutput returns false (no output files)", async () => { + it("step errors before file verification when structured output is missing", async () => { vi.mocked(verifyStepOutput).mockResolvedValue(false); useWorkflowStore.getState().initWorkflow("test-skill", "test domain"); @@ -3529,7 +3586,7 @@ describe("WorkflowPage — step-completion error paths (TF-03)", () => { render(); - // Agent completes with structured output (step 0 does not require it, so null is OK) + // Agent completes without structured output; step 0 now requires structured output. act(() => { useAgentStore.getState().completeRun("agent-no-output", true); }); @@ -3540,7 +3597,7 @@ describe("WorkflowPage — step-completion error paths (TF-03)", () => { expect(useWorkflowStore.getState().isRunning).toBe(false); expect(mockToast.error).toHaveBeenCalledWith( - "Step 1 completed but produced no output files", + "Step 1 completed but produced no structured output", { duration: Infinity }, ); }); @@ -3701,6 +3758,31 @@ describe("WorkflowPage — step-completion error paths (TF-03)", () => { render(); act(() => { + useAgentStore.getState().addDisplayItem("agent-verify-throw", { + id: "result-verify-throw", + type: "result", + timestamp: Date.now(), + outputText_result: "Agent completed", + structuredOutput: { + status: "research_complete", + dimensions_selected: 1, + question_count: 1, + research_plan_markdown: "# Research Plan", + clarifications_json: { + version: "1", + metadata: { + question_count: 0, + section_count: 0, + refinement_count: 0, + must_answer_count: 0, + priority_questions: [], + }, + sections: [], + notes: [], + }, + }, + resultStatus: "success", + }); useAgentStore.getState().completeRun("agent-verify-throw", true); }); diff --git a/app/src/components/agent-output-panel.tsx b/app/src/components/agent-output-panel.tsx index 3930a9118..4060463ef 100644 --- a/app/src/components/agent-output-panel.tsx +++ b/app/src/components/agent-output-panel.tsx @@ -2,12 +2,8 @@ import { useEffect, useRef, useCallback } from "react"; import { Card, CardContent } from "@/components/ui/card"; import { ScrollArea } from "@/components/ui/scroll-area"; import { useAgentStore } from "@/stores/agent-store"; -import { useWorkflowStore } from "@/stores/workflow-store"; import { AgentRunFooter } from "@/components/agent-run-footer"; import { DisplayItemList } from "@/components/agent-items/display-item-list"; -import { RefineQuestionInline } from "@/components/refine/refine-question-inline"; -import { answerWorkflowStepQuestion } from "@/lib/tauri"; -import type { RefineMessage, RefineQuestionResponse } from "@/stores/refine-store"; interface AgentOutputPanelProps { agentId: string; @@ -16,33 +12,16 @@ interface AgentOutputPanelProps { export function AgentOutputPanel({ agentId }: AgentOutputPanelProps) { const displayItems = useAgentStore((s) => s.runs[agentId]?.displayItems); const hasRun = useAgentStore((s) => agentId in s.runs); - const activeAgentId = useAgentStore((s) => s.activeAgentId); - const pendingQuestion = useWorkflowStore((s) => s.pendingQuestion); - const clearPendingQuestion = useWorkflowStore((s) => s.clearPendingQuestion); const scrollRef = useRef(null); const bottomRef = useRef(null); - // Only show the pending question for the currently active workflow step agent - const stepQuestion = activeAgentId === agentId ? pendingQuestion : null; - const scrollToBottom = useCallback(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, []); useEffect(() => { scrollToBottom(); - }, [displayItems?.length, stepQuestion, scrollToBottom]); - - const handleQuestionSubmit = useCallback(async (message: RefineMessage, response: RefineQuestionResponse) => { - if (!message.toolUseId || !message.questions) return; - await answerWorkflowStepQuestion( - agentId, - message.toolUseId, - message.questions, - response.answers as Record, - ); - clearPendingQuestion(); - }, [agentId, clearPendingQuestion]); + }, [displayItems?.length, scrollToBottom]); if (!hasRun) { return ( @@ -54,32 +33,11 @@ export function AgentOutputPanel({ agentId }: AgentOutputPanelProps) { ); } - // Build a RefineMessage from the pending question to pass to RefineQuestionInline - const questionMessage: RefineMessage | null = stepQuestion - ? { - id: stepQuestion.toolUseId, - role: "question", - agentId, - toolUseId: stepQuestion.toolUseId, - questions: stepQuestion.questions, - pending: true, - timestamp: Date.now(), - } - : null; - return (
- {questionMessage && ( -
- -
- )}
diff --git a/app/src/components/feedback-dialog.tsx b/app/src/components/feedback-dialog.tsx index 22e9d00f7..ef965d12e 100644 --- a/app/src/components/feedback-dialog.tsx +++ b/app/src/components/feedback-dialog.tsx @@ -21,7 +21,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" import { ScrollArea } from "@/components/ui/scroll-area" import { Separator } from "@/components/ui/separator" import { Textarea } from "@/components/ui/textarea" -import { startAgent, getWorkspacePath, createGithubIssue } from "@/lib/tauri" +import { startOneShotAgent, getWorkspacePath, createGithubIssue } from "@/lib/tauri" import { useAgentStore } from "@/stores/agent-store" import { useAuthStore } from "@/stores/auth-store" import { GitHubLoginDialog } from "@/components/github-login-dialog" @@ -214,7 +214,7 @@ export function FeedbackDialog() { try { const cwd = await getWorkspacePath() - await startAgent( + await startOneShotAgent( agentId, prompt, "haiku", diff --git a/app/src/components/refine/chat-input-bar.tsx b/app/src/components/refine/chat-input-bar.tsx index 18cfac598..030abf2b8 100644 --- a/app/src/components/refine/chat-input-bar.tsx +++ b/app/src/components/refine/chat-input-bar.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { SendHorizontal, Square, X, Bot, FileText } from "lucide-react"; +import { SendHorizontal, Square, X, Bot, FileText, HelpCircle } from "lucide-react"; import { useEditor, EditorContent } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import Mention from "@tiptap/extension-mention"; @@ -260,6 +260,7 @@ interface ChatInputBarProps { onSend: (text: string, targetFiles?: string[]) => void; onCancel?: () => void; isRunning: boolean; + waitingForQuestion?: boolean; availableFiles: string[]; availableAgents: string[]; prefilledValue?: string; @@ -269,6 +270,7 @@ export function ChatInputBar({ onSend, onCancel, isRunning, + waitingForQuestion = false, availableFiles, availableAgents, prefilledValue, @@ -412,6 +414,12 @@ export function ChatInputBar({ return (
+ {waitingForQuestion && ( +
+ + Answer the pending question above to continue. +
+ )} {hasBadges && (
{targetFiles.map((f) => ( diff --git a/app/src/components/refine/chat-message-list.tsx b/app/src/components/refine/chat-message-list.tsx index 766ccda38..0070c6c09 100644 --- a/app/src/components/refine/chat-message-list.tsx +++ b/app/src/components/refine/chat-message-list.tsx @@ -42,7 +42,7 @@ export function ChatMessageList({ if (questionRef.current) { questionRef.current.scrollIntoView({ behavior: "smooth", - block: "nearest", + block: "center", inline: "nearest", }); } else { diff --git a/app/src/components/refine/chat-panel.tsx b/app/src/components/refine/chat-panel.tsx index 2abb98a83..e12e89555 100644 --- a/app/src/components/refine/chat-panel.tsx +++ b/app/src/components/refine/chat-panel.tsx @@ -29,6 +29,7 @@ export function ChatPanel({ const messages = useRefineStore((s) => s.messages); const sessionExhausted = useRefineStore((s) => s.sessionExhausted); const pendingInitialMessage = useRefineStore((s) => s.pendingInitialMessage); + const waitingForQuestion = messages.some((message) => message.role === "question" && message.pending); // Suggestion chip click — inject text into the input via the store's // pendingInitialMessage mechanism (same as cross-page navigation). @@ -68,6 +69,7 @@ export function ChatPanel({ onSend={onSend} onCancel={onCancel} isRunning={isRunning || sessionExhausted || !!scopeBlocked} + waitingForQuestion={waitingForQuestion} availableFiles={availableFiles} availableAgents={availableAgents} prefilledValue={pendingInitialMessage ?? undefined} diff --git a/app/src/components/refine/refine-question-inline.tsx b/app/src/components/refine/refine-question-inline.tsx index f30ecf848..da6060812 100644 --- a/app/src/components/refine/refine-question-inline.tsx +++ b/app/src/components/refine/refine-question-inline.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react"; -import { CheckCircle2, ChevronLeft, ChevronRight, HelpCircle } from "lucide-react"; +import { Check, CheckCircle2, ChevronLeft, ChevronRight, HelpCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import type { RefineMessage, RefineQuestionPrompt, RefineQuestionResponse } from "@/stores/refine-store"; @@ -15,11 +15,11 @@ function isClarifyLabel(label: string): boolean { function QuestionStep({ question, - selectedAnswer, + selectedAnswers, onSelect, }: { question: RefineQuestionPrompt; - selectedAnswer: string | undefined; + selectedAnswers: string[]; onSelect: (label: string) => void; }) { return ( @@ -34,7 +34,7 @@ function QuestionStep({
{question.options.map((option) => { - const selected = selectedAnswer === option.label; + const selected = selectedAnswers.includes(option.label); return ( ); @@ -57,7 +69,7 @@ function QuestionStep({ } export function RefineQuestionInline({ message, onSubmit }: RefineQuestionInlineProps) { - const [selectedAnswers, setSelectedAnswers] = useState>({}); + const [selectedAnswers, setSelectedAnswers] = useState>({}); const [clarificationText, setClarificationText] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const [currentStep, setCurrentStep] = useState(0); @@ -65,13 +77,13 @@ export function RefineQuestionInline({ message, onSubmit }: RefineQuestionInline const questions = message.questions ?? []; const isWizard = questions.length > 1; const clarifySelected = useMemo( - () => Object.values(selectedAnswers).some((answer) => isClarifyLabel(answer)), + () => Object.values(selectedAnswers).some((answers) => answers.some(isClarifyLabel)), [selectedAnswers], ); const handleSubmit = async () => { if (questions.length === 0) return; - const unanswered = questions.some((question) => !selectedAnswers[question.question]); + const unanswered = questions.some((question) => (selectedAnswers[question.question] ?? []).length === 0); if (unanswered) return; const customText = clarifySelected ? clarificationText.trim() : undefined; @@ -82,16 +94,18 @@ export function RefineQuestionInline({ message, onSubmit }: RefineQuestionInline await onSubmit(message, { answers: Object.fromEntries( questions.map((question) => { - const selected = selectedAnswers[question.question]; + const selected = selectedAnswers[question.question] ?? []; + const answerValues = selected.map((label) => + isClarifyLabel(label) && customText ? customText : label, + ); return [ question.question, - isClarifyLabel(selected) && customText ? customText : selected, + answerValues.join(", "), ]; }), ), selectedLabels: questions - .map((question) => selectedAnswers[question.question]) - .filter((answer): answer is string => typeof answer === "string"), + .flatMap((question) => selectedAnswers[question.question] ?? []), customText, }); } finally { @@ -128,10 +142,26 @@ export function RefineQuestionInline({ message, onSubmit }: RefineQuestionInline const currentQuestion = questions[currentStep]; const isLastStep = currentStep === questions.length - 1; - const currentAnswer = currentQuestion ? selectedAnswers[currentQuestion.question] : undefined; - const currentAnswered = !!currentAnswer; - const currentNeedsClarifyText = currentAnswered && isClarifyLabel(currentAnswer!) && clarificationText.trim().length === 0; - const allAnswered = questions.every((q) => !!selectedAnswers[q.question]); + const currentAnswers = currentQuestion ? (selectedAnswers[currentQuestion.question] ?? []) : []; + const currentAnswered = currentAnswers.length > 0; + const currentNeedsClarifyText = + currentAnswered && currentAnswers.some(isClarifyLabel) && clarificationText.trim().length === 0; + const allAnswered = questions.every((q) => (selectedAnswers[q.question] ?? []).length > 0); + + const updateSelectedAnswers = (question: RefineQuestionPrompt, label: string) => { + setSelectedAnswers((current) => { + const existing = current[question.question] ?? []; + const next = question.multiSelect + ? (existing.includes(label) + ? existing.filter((answer) => answer !== label) + : [...existing, label]) + : [label]; + return { + ...current, + [question.question]: next, + }; + }); + }; return (
- setSelectedAnswers((current) => ({ - ...current, - [currentQuestion.question]: label, - })) - } + selectedAnswers={selectedAnswers[currentQuestion.question] ?? []} + onSelect={(label) => updateSelectedAnswers(currentQuestion, label)} /> ) : ( questions.map((question: RefineQuestionPrompt) => ( - setSelectedAnswers((current) => ({ - ...current, - [question.question]: label, - })) - } + selectedAnswers={selectedAnswers[question.question] ?? []} + onSelect={(label) => updateSelectedAnswers(question, label)} /> )) )} @@ -235,7 +255,7 @@ export function RefineQuestionInline({ message, onSubmit }: RefineQuestionInline data-testid="refine-question-submit" disabled={ isSubmitting - || questions.some((question) => !selectedAnswers[question.question]) + || questions.some((question) => (selectedAnswers[question.question] ?? []).length === 0) || (clarifySelected && clarificationText.trim().length === 0) } onClick={() => void handleSubmit()} diff --git a/app/src/components/workspace/workspace-evals.tsx b/app/src/components/workspace/workspace-evals.tsx index a41e4e6e5..309d5bddf 100644 --- a/app/src/components/workspace/workspace-evals.tsx +++ b/app/src/components/workspace/workspace-evals.tsx @@ -38,7 +38,7 @@ import { readPendingEval, readSkillContextForEvalGen, saveTestCase, - startAgent, + startOneShotAgent, } from "@/lib/tauri"; import type { EvalBenchmark, IterationMeta, PendingEval, SkillSummary, ImportedSkill, TestCase } from "@/lib/types"; import { @@ -303,7 +303,7 @@ export function WorkspaceEvals({ skill, workspacePath, onNavigateToRefine, onRun const agentId = crypto.randomUUID(); const cwd = skillWorkspace; - await startAgent( + await startOneShotAgent( agentId, genUserPrompt, preferredModel, @@ -391,7 +391,7 @@ export function WorkspaceEvals({ skill, workspacePath, onNavigateToRefine, onRun const agentId = crypto.randomUUID(); const cwd = skillWorkspace; - await startAgent( + await startOneShotAgent( agentId, regenUserPrompt, preferredModel, @@ -540,7 +540,7 @@ export function WorkspaceEvals({ skill, workspacePath, onNavigateToRefine, onRun ); try { - await startAgent( + await startOneShotAgent( agentId, evalUserPrompt, preferredModel, diff --git a/app/src/components/workspace/workspace-overview.tsx b/app/src/components/workspace/workspace-overview.tsx index f6a061f42..79ef4fc2c 100644 --- a/app/src/components/workspace/workspace-overview.tsx +++ b/app/src/components/workspace/workspace-overview.tsx @@ -4,7 +4,6 @@ import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; import SkillDialog from "@/components/skill-dialog"; import { BenchmarkOverviewCard } from "@/components/workspace/benchmark-overview-card"; -import { VersionDiffDialog } from "@/components/workspace/version-diff-dialog"; import { useSettingsStore } from "@/stores/settings-store"; import { getSkillHistory, listSkills, readLatestBenchmark } from "@/lib/tauri"; import { useSkillStore } from "@/stores/skill-store"; @@ -53,8 +52,6 @@ export function WorkspaceOverview({ skill, skillType, isLoading }: WorkspaceOver const [showAllCommits, setShowAllCommits] = useState(false); const [benchmarkData, setBenchmarkData] = useState(null); const [benchmarkIteration, setBenchmarkIteration] = useState(null); - const [selectedShas, setSelectedShas] = useState([]); - const [diffDialogOpen, setDiffDialogOpen] = useState(false); const workspacePath = useSettingsStore((s) => s.workspacePath); const latestVersion = useSkillStore((s) => s.latestVersion); @@ -218,62 +215,31 @@ export function WorkspaceOverview({ skill, skillType, isLoading }: WorkspaceOver {/* Version History card */}
-
-

Version History

- {selectedShas.length === 2 && ( - - )} -
+

Version History

{commits.length === 0 ? (

No version history yet

) : (
- {visibleCommits.map((commit) => { - const isSelected = selectedShas.includes(commit.sha); - return ( -
- { - setSelectedShas((prev) => { - if (prev.includes(commit.sha)) { - return prev.filter((s) => s !== commit.sha); - } - if (prev.length >= 2) { - return [prev[1], commit.sha]; - } - return [...prev, commit.sha]; - }); + {visibleCommits.map((commit) => ( +
+ + {commit.sha.slice(0, 7)} + + {commit.version && ( + - - {commit.sha.slice(0, 7)} + > + v{commit.version} - {commit.version && ( - - v{commit.version} - - )} - {formatCommitMessage(commit.message)} - {formatRelativeDate(commit.timestamp)} -
- ); - })} + )} + {formatCommitMessage(commit.message)} + {formatRelativeDate(commit.timestamp)} +
+ ))} {commits.length > 5 && !showAllCommits && (
); } diff --git a/app/src/components/workspace/workspace-refine.tsx b/app/src/components/workspace/workspace-refine.tsx index 770da99b2..c56b36d66 100644 --- a/app/src/components/workspace/workspace-refine.tsx +++ b/app/src/components/workspace/workspace-refine.tsx @@ -18,8 +18,8 @@ import { useAgentStore, formatTokenCount } from "@/stores/agent-store"; import { getSkillContentForRefine, startRefineSession, - sendRefineMessage, - answerRefineQuestion, + sendStreamingRefineMessage, + answerStreamingRefineQuestion, cancelRefineTurn, closeRefineSession, finalizeRefineRun, @@ -247,7 +247,7 @@ export function WorkspaceRefine({ skill }: WorkspaceRefineProps) { store.setRunning(true); try { - const agentId = await sendRefineMessage( + const agentId = await sendStreamingRefineMessage( sessionId, text, workspacePath, @@ -342,7 +342,7 @@ export function WorkspaceRefine({ skill }: WorkspaceRefineProps) { store.setPendingRedirect(null); } - await answerRefineQuestion( + await answerStreamingRefineQuestion( sessionId, agentId, message.toolUseId, diff --git a/app/src/generated/contracts.ts b/app/src/generated/contracts.ts index e6912acd1..7f137a588 100644 --- a/app/src/generated/contracts.ts +++ b/app/src/generated/contracts.ts @@ -113,7 +113,7 @@ export type DetailedResearchOutput = { status: string; refinement_count: number; /** * A scored dimension in the research plan. */ -export type DimensionScore = { name: string; score: number; reason: string; focus: string } +export type DimensionScore = { name: string; score: number; reason: string; focus?: string | null } /** * Structured output produced by the `generate-skill` agent (workflow step 3, diff --git a/app/src/hooks/use-agent-stream.ts b/app/src/hooks/use-agent-stream.ts index 5bae7d8a5..c3c6a4aee 100644 --- a/app/src/hooks/use-agent-stream.ts +++ b/app/src/hooks/use-agent-stream.ts @@ -187,11 +187,12 @@ export async function initAgentStream() { const toolUseId = typeof message.tool_use_id === "string" ? message.tool_use_id : ""; const questions = Array.isArray(message.questions) ? message.questions : []; if (toolUseId && questions.length > 0) { - // Route to workflow store when this agent is the active workflow step agent, - // otherwise route to refine store (activeAgentId is only set for workflow steps). const activeAgentId = useAgentStore.getState().activeAgentId; if (activeAgentId === agent_id) { - useWorkflowStore.getState().setPendingQuestion({ agentId: agent_id, toolUseId, questions }); + console.warn( + "[use-agent-stream] dropping question from one-shot workflow agent agent_id=%s", + agent_id, + ); } else { useRefineStore.getState().addQuestionMessage(agent_id, toolUseId, questions); } diff --git a/app/src/lib/gate-feedback.ts b/app/src/lib/gate-feedback.ts index aeed7e5a2..6397f0356 100644 --- a/app/src/lib/gate-feedback.ts +++ b/app/src/lib/gate-feedback.ts @@ -3,10 +3,9 @@ import type { AnswerEvaluationOutput } from "@/lib/types"; /** * Build feedback notes from an answer evaluation's per-question verdicts. - * Filters to actionable verdicts (vague, not_answered, needs_refinement) + * Filters to actionable verdicts (vague, not_answered, needs_refinement, + * contradictory) * and maps each to a typed Note for the clarifications editor. - * Contradictions are resolved inline by the agent before returning, - * so they are never present in the final evaluation output. * * Extracted from use-workflow-state-machine.ts for independent testability. */ @@ -22,9 +21,19 @@ export function buildGateFeedbackNotes(evaluation: AnswerEvaluationOutput): Note (q) => q.verdict === "vague" || q.verdict === "not_answered" || - q.verdict === "needs_refinement" + q.verdict === "needs_refinement" || + q.verdict === "contradictory" ) .map((q) => { + if (q.verdict === "contradictory") { + const reason = optionalReason(q) || "This answer conflicts with another answer and must be resolved before continuing."; + const summary = evaluation.reasoning?.trim(); + return { + type: "answer_feedback", + title: `Contradictory answer: ${q.question_id}`, + body: summary ? `${reason}\n\nEvaluator summary: ${summary}` : reason, + }; + } if (q.verdict === "not_answered") { return { type: "answer_feedback", diff --git a/app/src/lib/tauri.ts b/app/src/lib/tauri.ts index ca74b3c99..1b29d9bdc 100644 --- a/app/src/lib/tauri.ts +++ b/app/src/lib/tauri.ts @@ -137,7 +137,7 @@ export const reviewSkillScope = ( // --- Agent --- -export const startAgent = ( +export const startOneShotAgent = ( agentId: string, prompt: string, model: string, @@ -489,7 +489,7 @@ export const cancelAgentRun = (skillName: string, agentId: string) => export const cancelWorkflowStep = (agentId: string) => invoke("cancel_workflow_step", { agentId }) -export const answerRefineQuestion = ( +export const answerStreamingRefineQuestion = ( sessionId: string, agentId: string, toolUseId: string, @@ -503,19 +503,7 @@ export const answerRefineQuestion = ( answers, }) -export const answerWorkflowStepQuestion = ( - agentId: string, - toolUseId: string, - questions: unknown, - answers: Record, -) => invoke("answer_workflow_step_question", { - agentId, - toolUseId, - questions, - answers, -}) - -export const sendRefineMessage = ( +export const sendStreamingRefineMessage = ( sessionId: string, userMessage: string, workspacePath: string, diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 7acff0152..5516655ca 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -521,8 +521,9 @@ export type WorkflowStepStructuredOutput = /** Per-question verdict entry within an {@link AnswerEvaluationOutput}. Matches `PerQuestionEntry` in `workflow_artifacts.rs`. */ export interface PerQuestionEntry { question_id: string - verdict: "clear" | "needs_refinement" | "not_answered" | "vague" + verdict: "clear" | "needs_refinement" | "not_answered" | "vague" | "contradictory" reason?: string | null + contradicts?: string | null } /** Structured output produced by the answer-evaluator agent. Matches `AnswerEvaluationOutput` in `workflow_artifacts.rs`. */ diff --git a/app/src/lib/workflow-step-configs.ts b/app/src/lib/workflow-step-configs.ts index c68c1ede7..7738516e9 100644 --- a/app/src/lib/workflow-step-configs.ts +++ b/app/src/lib/workflow-step-configs.ts @@ -10,7 +10,13 @@ export interface StepConfig { } export const STEP_CONFIGS: Record = { - 0: { type: "agent", outputFiles: ["context/research-plan.md", "context/clarifications.json"], model: "sonnet", clarificationsEditable: true }, + 0: { + type: "agent", + outputFiles: ["context/research-plan.md", "context/clarifications.json"], + model: "sonnet", + clarificationsEditable: true, + requiresStructuredOutput: true, + }, 1: { type: "agent", outputFiles: ["context/clarifications.json"], model: "sonnet", clarificationsEditable: true, requiresStructuredOutput: true }, 2: { type: "reasoning", outputFiles: ["context/decisions.json"], model: "opus" }, 3: { type: "agent", outputFiles: ["skill/SKILL.md", "skill/references/"], model: "sonnet", requiresStructuredOutput: true }, diff --git a/docs/design/README.md b/docs/design/README.md index 0e1fd6785..1b4a7a9ce 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -3,6 +3,7 @@ | Directory | What it covers | |---|---| | [agent-specs/](agent-specs/README.md) | Agent layer architecture: workflow steps, artifact contracts, infrastructure files, storage layout | +| [agent-runtime-boundary/](agent-runtime-boundary/README.md) | Agent runtime boundary: one-shot runs, streaming sessions, runtime adapters, and OpenHands migration shape | | [backend-design/](backend-design/README.md) | Tauri/Rust backend: DB schema, API surface, key data flows, agent sidecar integration — see [database.md](backend-design/database.md) for full schema | | [clarifications-rendering/](clarifications-rendering/README.md) | Design exploration for the clarifications Q&A screen (VD-799/817) | | [skills/](skills/README.md) | Bundled skills: purpose slots, research skill, skill-test skill | diff --git a/docs/design/agent-runtime-boundary/README.md b/docs/design/agent-runtime-boundary/README.md new file mode 100644 index 000000000..ecaf12423 --- /dev/null +++ b/docs/design/agent-runtime-boundary/README.md @@ -0,0 +1,306 @@ +# Agent Runtime Boundary + +> **Status:** Draft + +## Overview + +Skill Builder currently talks directly to the Claude Agent SDK from the Node +sidecar. The intended design is to make the sidecar expose a Skill +Builder-owned runtime boundary first, keep Claude behind that boundary, and +then add an OpenHands implementation behind the same contract. + +The boundary separates app behavior from provider/runtime behavior. React and +Rust should continue to consume Skill Builder protocol messages such as +`display_item`, `agent_event`, `refine_question`, and `run_result`; runtime +adapters are responsible for converting SDK-specific events into that protocol. + +## Design Scope + +**Covers** + +- One-shot run and streaming-session runtime contracts. +- The invariant that one-shot runs cannot ask the user questions. +- How the existing Claude SDK implementation moves behind the new boundary. +- How OpenHands can be introduced after the boundary exists. +- Runtime-facing configuration and event mapping responsibilities. + +**Does not cover** + +- Rewriting the React/Tauri application shell. +- Changing skill artifact formats such as `clarifications.json` or + `decisions.json`. +- Replacing the app's existing persistence schema. +- A detailed task-by-task implementation plan. + +## Key Decisions + +| Decision | Rationale | +|---|---| +| Add the runtime boundary before introducing OpenHands. | This keeps the current Claude behavior available while the app-facing contract is clarified. | +| Model one-shot runs and streaming sessions as separate runtime methods. | Callers must choose whether a run is autonomous or interactive instead of relying on sidecar inference. | +| Forbid user questions in one-shot runs. | A one-shot run has no app-owned pause/resume loop, so `AskUserQuestion` semantics are incompatible by definition. | +| Keep `AskUserQuestion` as a Skill Builder interaction contract. | The UX belongs to this app; Claude currently triggers it, but OpenHands can trigger the same app behavior through a custom tool. | +| Keep the app-facing protocol stable during the boundary refactor. | Rust persistence, frontend stores, and tests already depend on normalized sidecar messages. | +| Treat Claude as the first runtime adapter. | Wrapping the current implementation creates a working reference adapter for OpenHands parity checks. | + +## Runtime Contract + +The sidecar owns the runtime abstraction. The app-facing shape is intentionally +Skill Builder-specific instead of a generic agent framework wrapper. + +```ts +interface AgentRuntime { + runOnce( + request: OneShotRunRequest, + sink: RuntimeSink, + signal?: AbortSignal, + ): Promise; + + startStreamingSession( + request: StreamingSessionRequest, + sink: RuntimeSink, + ): RuntimeSession; +} + +interface RuntimeSession { + sendUserMessage(message: string): Promise; + answerQuestion(toolUseId: string, answer: unknown): Promise; + cancel(): Promise; + close(): Promise; +} +``` + +Shared request fields belong in a base request: + +```ts +interface RuntimeRequestBase { + prompt: string; + systemPrompt?: string; + model?: string; + agentName?: string; + cwd: string; + workspaceRootDir: string; + workspaceSkillDir: string; + requiredPlugins?: string[]; + maxTurns?: number; + outputFormat?: { + type: "json_schema"; + schema: Record; + }; + context: RunPersistenceContext; +} +``` + +Mode-specific request fields express the app invariant: + +```ts +interface OneShotRunRequest extends RuntimeRequestBase { + mode: "one-shot"; + allowUserQuestions: false; +} + +interface StreamingSessionRequest extends RuntimeRequestBase { + mode: "streaming"; + allowUserQuestions: true; +} +``` + +The boundary should reject invalid configurations before a runtime adapter is +called: + +- `OneShotRunRequest` must not include `AskUserQuestion` or its OpenHands + equivalent in the tool set. +- `StreamingSessionRequest` may expose the app-owned question tool. +- If a runtime emits a user-question event during a one-shot run, the adapter + emits an error `run_result` and closes the run. + +## Runtime Sink + +The sink is the only way a runtime adapter reports app-visible progress. + +```ts +interface RuntimeSink { + emitDisplayItem(item: DisplayItem): void; + emitAgentEvent(event: AgentEvent): void; + emitRefineQuestion(question: RefineQuestion): void; + emitRaw(message: Record): void; +} +``` + +Adapters can keep internal SDK event shapes, but they must normalize before +emitting to the sink. The Rust event router and React stores should not need to +know whether a message came from Claude or OpenHands. + +## One-Shot Runs + +A one-shot run is an autonomous request: + +1. The caller submits one request. +2. The runtime streams progress and display events. +3. The runtime emits exactly one terminal `run_result`. +4. The runtime is done. + +One-shot runs are appropriate for workflow steps, skill tests, evaluation +subruns, description optimization subruns, and background analysis. Workflow +steps are one-shot by contract and must not expose `AskUserQuestion` or require +mid-run user input. + +The existing `runAgentRequest` path is the closest current implementation of +this mode. It should become the Claude-backed `runOnce` implementation first. + +## Streaming Sessions + +A streaming session is an app-owned interactive loop: + +1. The caller starts a session. +2. The UI can send more user messages. +3. The agent can request app-owned user input. +4. The app can answer, cancel, resume, or close the session. +5. The session emits terminal run summaries for persistence. + +Streaming sessions are appropriate for refine chat and other app-owned +interactive loops that may ask the user structured questions. The existing +`StreamSession` class is the closest current implementation of this mode. + +## AskUserQuestion Contract + +`AskUserQuestion` is not a Claude-specific product feature. It is the app-owned +interaction contract for structured mid-run user input. + +In the Claude adapter, this contract is implemented with the Claude SDK +`canUseTool` callback for the `AskUserQuestion` tool. + +In the OpenHands adapter, the same contract should be implemented as a custom +tool or action: + +```text +OpenHands agent requests app user input + -> OpenHands runtime adapter emits refine_question + -> Tauri/React renders the existing UX + -> user submits an answer + -> runtime adapter returns an observation to OpenHands + -> agent continues +``` + +The app-level semantics stay the same across adapters: + +- only one pending question per streaming session unless the contract is + deliberately expanded; +- cancellation rejects or resolves any pending question consistently; +- submitted answers are correlated by tool-use ID; +- unanswered questions cannot leave the session permanently stuck. + +## Claude Adapter + +The first adapter should wrap existing behavior rather than rewriting it. + +Responsibilities: + +- move `query()` calls behind `ClaudeRuntime.runOnce` and + `ClaudeRuntime.startStreamingSession`; +- keep Claude option construction in a Claude-specific module; +- keep Claude plugin discovery and `.claude/plugins` wiring inside the Claude + adapter until plugin layout is generalized; +- keep `MessageProcessor` and `RunMetadataAccumulator` behavior stable unless + a boundary test proves a change is required; +- keep `MOCK_AGENTS=true` behavior available for tests. + +This adapter is the parity reference for the OpenHands port. + +## OpenHands Adapter + +OpenHands should be introduced only after the Claude adapter passes behind the +runtime boundary. + +Responsibilities: + +- start or connect to the OpenHands runtime required by the SDK; +- translate Skill Builder runtime requests into OpenHands session/run inputs; +- map OpenHands events, actions, observations, tool calls, usage data, and + terminal status into the existing sink; +- implement the app-owned user-question tool for streaming sessions; +- provide cancellation and close behavior compatible with the sidecar pool; +- report enough model/session metadata for usage and run-history persistence. + +The OpenHands adapter should not force React or Rust to consume OpenHands event +shapes directly. + +## Configuration + +`SidecarConfig` currently mixes app context, Claude SDK options, and persistence +context. The boundary should split those concerns: + +- app request context: prompt, cwd, workspace paths, skill and plugin identity; +- runtime selection: Claude or OpenHands; +- model/provider config: model ID, provider credentials, base URL or runtime + endpoint as needed; +- mode-specific controls: one-shot versus streaming, question capability, + prompt suggestions, max turns; +- persistence context: skill name, step ID, workflow session ID, usage session + ID, run source, transcript log directory. + +Claude-only fields such as `pathToClaudeCodeExecutable`, Claude betas, and +Claude-specific permission modes belong in the Claude adapter config, not in +the common request shape. + +## State And Transitions + +One-shot run states: + +```text +created -> running -> completed +created -> running -> failed +created -> running -> canceled +``` + +Streaming session states: + +```text +created -> active -> waiting_for_user -> active +created -> active -> completed +created -> active -> canceled +created -> active -> failed +created -> active -> closed +``` + +`waiting_for_user` is valid only for streaming sessions. + +## Relationship To Existing Design Specs + +| Spec | Relationship | +|---|---| +| `docs/design/sdk-agent-options/README.md` | Describes current Claude SDK option wiring that should move behind the Claude adapter. | +| `docs/design/backend-design/agent-event-contracts.md` | Defines the app-facing event contract that runtime adapters should preserve. | +| `docs/design/agent-specs/README.md` | Describes workflow step and artifact contracts that runtime migration should not change. | +| `docs/design/write-eval-test-refine-loop/README.md` | Contains flows that should explicitly choose one-shot or streaming runtime mode. | +| `docs/design/workflow-state/README.md` | Contains workflow state behavior that should remain app-owned, not runtime-owned. | + +## Key Source Files + +| File | Purpose | +|---|---| +| `app/sidecar/runtime/types.ts` | Runtime request, sink, and session boundary types plus one-shot user-question guards. | +| `app/sidecar/runtime/sink.ts` | Adapter from runtime sink methods to the existing JSONL sidecar message envelopes. | +| `app/sidecar/runtime/claude-runtime.ts` | Claude runtime adapter for one-shot execution behind the boundary. | +| `app/sidecar/run-agent.ts` | Compatibility wrapper for existing one-shot callers. | +| `app/sidecar/stream-session.ts` | Streaming Claude SDK session path, runtime session methods, and `AskUserQuestion` bridge. | +| `app/sidecar/options.ts` | Claude SDK option builder. | +| `app/sidecar/config.ts` | Sidecar request validation shape, including optional runtime mode. | +| `app/sidecar/message-processor.ts` | Current SDK-message to app-protocol mapper. | +| `app/sidecar/run-metadata-accumulator.ts` | Current `run_result` summary construction. | +| `app/sidecar/persistent-mode.ts` | Sidecar request demultiplexer that rejects mode mismatches and routes one-shot requests through the runtime boundary. | +| `app/src-tauri/src/agents/sidecar.rs` | Rust `SidecarConfig`, runtime mode serialization, and sidecar spawn path. | +| `app/src-tauri/src/agents/sidecar_pool/dispatch.rs` | Rust request dispatch, streaming, shutdown, and answer routing. | +| `app/src-tauri/src/commands/workflow/runtime.rs` | Workflow one-shot request dispatch, cancellation, and question rejection. | +| `app/src-tauri/src/commands/refine/protocol.rs` | Refine chat protocol that should remain streaming. | +| `app/src-tauri/src/commands/workflow/step_config.rs` | Workflow step tool configuration with one-shot-safe tool sets. | +| `app/src/hooks/use-agent-stream.ts` | Frontend listener for normalized agent events. | +| `app/src/stores/workflow-store.ts` | Frontend pending-question state. | + +## Open Questions + +1. Should runtime selection be a hidden developer setting during the OpenHands + port, or should Settings expose it once both adapters exist? +2. Should Claude plugin layout remain the canonical skill layout after + OpenHands lands, or should the app introduce a runtime-neutral skill layout + as a later migration? diff --git a/docs/superpowers/plans/2026-05-01-agent-runtime-boundary.md b/docs/superpowers/plans/2026-05-01-agent-runtime-boundary.md new file mode 100644 index 000000000..033832c37 --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-agent-runtime-boundary.md @@ -0,0 +1,1600 @@ +# Agent Runtime Boundary Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a Skill Builder-owned sidecar runtime boundary with explicit one-shot and streaming modes while keeping the current Claude Agent SDK behavior unchanged. + +**Architecture:** Introduce focused runtime types under `app/sidecar/runtime/`, wrap the existing Claude one-shot path behind `ClaudeRuntime.runOnce`, and make the existing streaming session satisfy the runtime session contract. Keep the sidecar JSONL protocol stable so Rust and React continue consuming `display_item`, `agent_event`, `refine_question`, and `run_result` without OpenHands-specific changes. + +**Tech Stack:** TypeScript sidecar, Vitest, Claude Agent SDK, existing Node sidecar JSONL protocol, Tauri/Rust callers. + +--- + +## Scope + +This plan implements the first migration milestone from +`docs/design/agent-runtime-boundary/README.md`: + +- add the runtime boundary; +- keep Claude behind the boundary; +- enforce that one-shot runs cannot use `AskUserQuestion`; +- make callers and tests distinguish one-shot requests from streaming sessions. + +This plan does not add the OpenHands SDK. OpenHands should be implemented after +this plan lands and the Claude adapter provides a passing parity baseline. + +## File Structure + +- Create `app/sidecar/runtime/types.ts` + - Runtime request/session/sink interfaces. + - `isUserQuestionToolName` and `assertOneShotHasNoUserQuestions` helpers. +- Create `app/sidecar/runtime/sink.ts` + - Converts existing `onMessage(record)` callbacks into a typed `RuntimeSink`. +- Create `app/sidecar/runtime/claude-runtime.ts` + - Claude runtime adapter that owns `runOnce`. + - Reuses current `runAgentRequest` internals with minimal movement. +- Modify `app/sidecar/run-agent.ts` + - Keep the existing exported `runAgentRequest` compatibility function. + - Delegate to `ClaudeRuntime.runOnce`. + - Keep plugin discovery helpers exported for streaming until they move later. +- Modify `app/sidecar/stream-session.ts` + - Implement the runtime `RuntimeSession` interface. + - Keep `AskUserQuestion` only in the streaming path. +- Modify `app/sidecar/persistent-mode.ts` + - Continue routing `agent_request` to one-shot and `stream_*` messages to streaming. + - This file should not call Claude SDK APIs directly. +- Modify `app/sidecar/config.ts` + - Add optional `mode` validation for explicit sidecar configs. + - Preserve compatibility when Rust does not send `mode` yet. +- Add/modify tests under `app/sidecar/__tests__/` + - `runtime-types.test.ts` + - `runtime-sink.test.ts` + - update `run-agent.test.ts` + - update `stream-session.test.ts` + - update `persistent-mode.test.ts` + - update `config.test.ts` +- Update docs if the implemented shape differs from + `docs/design/agent-runtime-boundary/README.md`. + +## Task 1: Runtime Types And One-Shot Guard + +**Files:** + +- Create: `app/sidecar/runtime/types.ts` +- Create: `app/sidecar/__tests__/runtime-types.test.ts` +- Modify: `app/sidecar/config.ts` +- Test: `app/sidecar/__tests__/runtime-types.test.ts` +- Test: `app/sidecar/__tests__/config.test.ts` + +- [ ] **Step 1: Write failing tests for runtime request invariants** + +Create `app/sidecar/__tests__/runtime-types.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { + assertOneShotHasNoUserQuestions, + isUserQuestionToolName, + type OneShotRunRequest, + type StreamingSessionRequest, +} from "../runtime/types.js"; + +const baseContext = { + skillName: "demo-skill", + pluginSlug: "demo-plugin", + stepId: 0, + runSource: "workflow" as const, +}; + +describe("runtime request types", () => { + it("recognizes Claude and runtime-neutral user question tool names", () => { + expect(isUserQuestionToolName("AskUserQuestion")).toBe(true); + expect(isUserQuestionToolName("ask_user_question")).toBe(true); + expect(isUserQuestionToolName("Read")).toBe(false); + }); + + it("allows one-shot requests without user-question tools", () => { + const request: OneShotRunRequest = { + mode: "one-shot", + allowUserQuestions: false, + prompt: "Generate the skill.", + apiKey: "sk-test", + workspaceRootDir: "/workspace", + workspaceSkillDir: "/workspace/plugin/skill", + allowedTools: ["Read", "Write", "Edit"], + context: baseContext, + }; + + expect(() => assertOneShotHasNoUserQuestions(request)).not.toThrow(); + }); + + it("rejects one-shot requests that include AskUserQuestion", () => { + const request: OneShotRunRequest = { + mode: "one-shot", + allowUserQuestions: false, + prompt: "Ask before continuing.", + apiKey: "sk-test", + workspaceRootDir: "/workspace", + workspaceSkillDir: "/workspace/plugin/skill", + allowedTools: ["Read", "AskUserQuestion"], + context: baseContext, + }; + + expect(() => assertOneShotHasNoUserQuestions(request)).toThrow( + "one-shot runtime requests cannot include user-question tools: AskUserQuestion", + ); + }); + + it("keeps user questions valid for streaming requests", () => { + const request: StreamingSessionRequest = { + mode: "streaming", + allowUserQuestions: true, + prompt: "Refine this skill.", + apiKey: "sk-test", + workspaceRootDir: "/workspace", + workspaceSkillDir: "/workspace/plugin/skill", + allowedTools: ["Read", "AskUserQuestion"], + context: baseContext, + }; + + expect(request.allowUserQuestions).toBe(true); + expect(request.mode).toBe("streaming"); + }); +}); +``` + +- [ ] **Step 2: Run the new test and verify it fails** + +Run: + +```bash +cd app/sidecar && npx vitest run __tests__/runtime-types.test.ts +``` + +Expected: FAIL because `../runtime/types.js` does not exist. + +- [ ] **Step 3: Add runtime type definitions and guard** + +Create `app/sidecar/runtime/types.ts`: + +```ts +import type { AgentEvent } from "../agent-events.js"; +import type { DisplayItem } from "../display-types.js"; + +export type RuntimeMode = "one-shot" | "streaming"; + +export interface RunPersistenceContext { + skillName?: string; + stepId?: number; + workflowSessionId?: string; + usageSessionId?: string; + runSource?: "workflow" | "refine" | "test" | "gate-eval"; + workspaceSkillDir?: string; + pluginSlug: string; +} + +export interface RuntimeRequestBase { + prompt: string; + systemPrompt?: string; + model?: string; + agentName?: string; + apiKey: string; + workspaceRootDir: string; + workspaceSkillDir: string; + requiredPlugins?: string[]; + allowedTools?: string[]; + settingSources?: ("user" | "project")[]; + maxTurns?: number; + outputFormat?: { + type: "json_schema"; + schema: Record; + }; + promptSuggestions?: boolean; + context: RunPersistenceContext; +} + +export interface OneShotRunRequest extends RuntimeRequestBase { + mode: "one-shot"; + allowUserQuestions: false; +} + +export interface StreamingSessionRequest extends RuntimeRequestBase { + mode: "streaming"; + allowUserQuestions: true; +} + +export type RuntimeRequest = OneShotRunRequest | StreamingSessionRequest; + +export interface RefineQuestion { + tool_use_id: string; + questions: unknown[]; + timestamp: number; +} + +export interface RuntimeSink { + emit(message: Record): void; + emitDisplayItem(item: DisplayItem): void; + emitAgentEvent(event: AgentEvent, timestamp?: number): void; + emitRefineQuestion(question: RefineQuestion): void; + emitRaw(message: Record): void; +} + +export interface RuntimeSession { + readonly queryDone: Promise; + sendUserMessage(requestId: string, message: string): Promise | void; + answerQuestion( + requestId: string, + toolUseId: string, + questions: unknown[], + answers: Record, + ): Promise | void; + cancel(): Promise | void; + close(): Promise | void; +} + +export interface AgentRuntime { + runOnce( + request: OneShotRunRequest, + sink: RuntimeSink, + signal?: AbortSignal, + ): Promise; + + startStreamingSession( + request: StreamingSessionRequest, + sink: RuntimeSink, + ): RuntimeSession; +} + +const USER_QUESTION_TOOL_NAMES = new Set([ + "AskUserQuestion", + "ask_user_question", +]); + +export function isUserQuestionToolName(toolName: string): boolean { + return USER_QUESTION_TOOL_NAMES.has(toolName); +} + +export function assertOneShotHasNoUserQuestions(request: OneShotRunRequest): void { + const forbiddenTools = (request.allowedTools ?? []).filter(isUserQuestionToolName); + if (forbiddenTools.length > 0) { + throw new Error( + `one-shot runtime requests cannot include user-question tools: ${forbiddenTools.join(", ")}`, + ); + } +} +``` + +- [ ] **Step 4: Extend `SidecarConfig` mode validation without requiring Rust changes** + +Modify `app/sidecar/config.ts`: + +```ts +export interface SidecarConfig { + mode?: "one-shot" | "streaming"; + prompt: string; + systemPrompt?: string; + model?: string; + agentName?: string; + apiKey: string; + workspaceRootDir: string; + workspaceSkillDir: string; + requiredPlugins?: string[]; + allowedTools?: string[]; + settingSources?: ('user' | 'project')[]; + maxTurns?: number; + permissionMode?: string; + betas?: string[]; + thinking?: { type: "disabled" | "adaptive" | "enabled"; budgetTokens?: number }; + effort?: "low" | "medium" | "high" | "max"; + fallbackModel?: string; + outputFormat?: { + type: "json_schema"; + schema: Record; + }; + promptSuggestions?: boolean; + pathToClaudeCodeExecutable?: string; + skillName?: string; + stepId?: number; + workflowSessionId?: string; + usageSessionId?: string; + runSource?: "workflow" | "refine" | "test" | "gate-eval"; + pluginSlug: string; +} +``` + +In `parseSidecarConfig`, add this enum validation beside the existing enum +fields: + +```ts + assertOptStringIn(c, "mode", ["one-shot", "streaming"]); +``` + +- [ ] **Step 5: Add config validation tests for mode** + +In `app/sidecar/__tests__/config.test.ts`, add: + +```ts + it("accepts explicit one-shot mode", () => { + const result = parseSidecarConfig({ + prompt: "hello", + apiKey: "key", + workspaceRootDir: TEST_CWD, + workspaceSkillDir: TEST_CWD, + pluginSlug: "demo", + mode: "one-shot", + }); + + expect(result.mode).toBe("one-shot"); + }); + + it("throws when mode is invalid", () => { + expect(() => + parseSidecarConfig({ + prompt: "hello", + apiKey: "key", + workspaceRootDir: TEST_CWD, + workspaceSkillDir: TEST_CWD, + pluginSlug: "demo", + mode: "interactive", + }), + ).toThrow("mode must be one of"); + }); +``` + +- [ ] **Step 6: Run tests for Task 1** + +Run: + +```bash +cd app/sidecar && npx vitest run __tests__/runtime-types.test.ts __tests__/config.test.ts +``` + +Expected: PASS. + +- [ ] **Step 7: Commit Task 1** + +Run: + +```bash +git add app/sidecar/runtime/types.ts app/sidecar/__tests__/runtime-types.test.ts app/sidecar/config.ts app/sidecar/__tests__/config.test.ts +git commit -m "Add sidecar runtime request types" +``` + +## Task 2: Runtime Sink Adapter + +**Files:** + +- Create: `app/sidecar/runtime/sink.ts` +- Create: `app/sidecar/__tests__/runtime-sink.test.ts` +- Test: `app/sidecar/__tests__/runtime-sink.test.ts` + +- [ ] **Step 1: Write failing tests for record sink behavior** + +Create `app/sidecar/__tests__/runtime-sink.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { createRecordRuntimeSink } from "../runtime/sink.js"; +import type { DisplayItem } from "../display-types.js"; + +describe("createRecordRuntimeSink", () => { + it("emits display items in the existing sidecar envelope", () => { + const messages: Record[] = []; + const sink = createRecordRuntimeSink((message) => messages.push(message)); + + const item: DisplayItem = { + id: "di-1", + type: "output", + content: "hello", + timestamp: 123, + }; + + sink.emitDisplayItem(item); + + expect(messages).toEqual([{ type: "display_item", item }]); + }); + + it("emits agent events in the existing sidecar envelope", () => { + const messages: Record[] = []; + const sink = createRecordRuntimeSink((message) => messages.push(message)); + + sink.emitAgentEvent({ type: "turn_complete", turn: 1, streaming: false }, 456); + + expect(messages).toEqual([ + { + type: "agent_event", + event: { type: "turn_complete", turn: 1, streaming: false }, + timestamp: 456, + }, + ]); + }); + + it("emits refine questions in the existing sidecar envelope", () => { + const messages: Record[] = []; + const sink = createRecordRuntimeSink((message) => messages.push(message)); + + sink.emitRefineQuestion({ + tool_use_id: "toolu-1", + questions: [{ id: "q1", question: "Pick one" }], + timestamp: 789, + }); + + expect(messages).toEqual([ + { + type: "refine_question", + tool_use_id: "toolu-1", + questions: [{ id: "q1", question: "Pick one" }], + timestamp: 789, + }, + ]); + }); + + it("passes raw messages through unchanged", () => { + const messages: Record[] = []; + const sink = createRecordRuntimeSink((message) => messages.push(message)); + + sink.emitRaw({ type: "system", subtype: "init_start" }); + + expect(messages).toEqual([{ type: "system", subtype: "init_start" }]); + }); +}); +``` + +- [ ] **Step 2: Run the new test and verify it fails** + +Run: + +```bash +cd app/sidecar && npx vitest run __tests__/runtime-sink.test.ts +``` + +Expected: FAIL because `../runtime/sink.js` does not exist. + +- [ ] **Step 3: Implement the sink adapter** + +Create `app/sidecar/runtime/sink.ts`: + +```ts +import type { AgentEvent } from "../agent-events.js"; +import type { DisplayItem } from "../display-types.js"; +import type { RefineQuestion, RuntimeSink } from "./types.js"; + +export function createRecordRuntimeSink( + emit: (message: Record) => void, +): RuntimeSink { + return { + emit(message) { + emit(message); + }, + + emitDisplayItem(item: DisplayItem) { + emit({ type: "display_item", item }); + }, + + emitAgentEvent(event: AgentEvent, timestamp = Date.now()) { + emit({ type: "agent_event", event, timestamp }); + }, + + emitRefineQuestion(question: RefineQuestion) { + emit({ + type: "refine_question", + tool_use_id: question.tool_use_id, + questions: question.questions, + timestamp: question.timestamp, + }); + }, + + emitRaw(message: Record) { + emit(message); + }, + }; +} +``` + +- [ ] **Step 4: Run tests for Task 2** + +Run: + +```bash +cd app/sidecar && npx vitest run __tests__/runtime-sink.test.ts __tests__/runtime-types.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit Task 2** + +Run: + +```bash +git add app/sidecar/runtime/sink.ts app/sidecar/__tests__/runtime-sink.test.ts +git commit -m "Add sidecar runtime sink adapter" +``` + +## Task 3: Claude One-Shot Runtime Adapter + +**Files:** + +- Create: `app/sidecar/runtime/claude-runtime.ts` +- Modify: `app/sidecar/run-agent.ts` +- Modify: `app/sidecar/__tests__/run-agent.test.ts` +- Create or modify: `app/sidecar/__tests__/claude-runtime.test.ts` +- Test: `app/sidecar/__tests__/run-agent.test.ts` +- Test: `app/sidecar/__tests__/claude-runtime.test.ts` + +- [ ] **Step 1: Write failing tests for one-shot guard through `runAgentRequest`** + +Add to `app/sidecar/__tests__/run-agent.test.ts`: + +```ts + it("rejects one-shot requests that include AskUserQuestion", async () => { + const messages: Record[] = []; + + await runAgentRequest( + { + ...baseConfig, + mode: "one-shot", + allowedTools: ["Read", "AskUserQuestion"], + }, + (message) => messages.push(message), + ); + + const runResult = messages.find( + (message) => + message.type === "agent_event" && + (message.event as Record | undefined)?.type === "run_result", + ); + + expect(query).not.toHaveBeenCalled(); + expect(runResult).toBeDefined(); + expect((runResult!.event as Record).status).toBe("error"); + expect(JSON.stringify(runResult)).toContain( + "one-shot runtime requests cannot include user-question tools", + ); + }); +``` + +If `baseConfig` is not visible in the test file, create it near the other test +fixtures with the same fields existing tests already use: + +```ts +const baseConfig: SidecarConfig = { + prompt: "hello", + apiKey: "sk-test", + workspaceRootDir: "/workspace", + workspaceSkillDir: "/workspace/demo-plugin/demo-skill", + pluginSlug: "demo-plugin", +}; +``` + +- [ ] **Step 2: Run the one-shot guard test and verify it fails** + +Run: + +```bash +cd app/sidecar && npx vitest run __tests__/run-agent.test.ts -t "rejects one-shot requests" +``` + +Expected: FAIL because `runAgentRequest` does not enforce the new runtime +boundary invariant. + +- [ ] **Step 3: Add `ClaudeRuntime.runOnce`** + +Create `app/sidecar/runtime/claude-runtime.ts` by moving the current body of +`runAgentRequest` into a runtime adapter. Keep imports exactly focused on the +Claude implementation: + +```ts +import { query } from "@anthropic-ai/claude-agent-sdk"; +import type { SidecarConfig } from "../config.js"; +import { runMockAgent } from "../mock-agent.js"; +import { buildQueryOptions } from "../options.js"; +import { createAbortState, linkExternalSignal } from "../shutdown.js"; +import { MessageProcessor } from "../message-processor.js"; +import { ResultGate } from "../result-gate.js"; +import { + assertOneShotHasNoUserQuestions, + type AgentRuntime, + type OneShotRunRequest, + type RuntimeSink, + type StreamingSessionRequest, + type RuntimeSession, +} from "./types.js"; +import { StreamSession } from "../stream-session.js"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +export async function discoverInstalledPlugins(rootDir: string): Promise { + const pluginsDir = path.join(rootDir, ".claude", "plugins"); + try { + const entries = await fs.readdir(pluginsDir, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(pluginsDir, entry.name)); + } catch { + return []; + } +} + +export function selectPluginPaths( + discoveredPluginPaths: string[], + requiredPlugins?: string[], +): string[] { + if (!requiredPlugins || requiredPlugins.length === 0) { + return []; + } + + const discoveredByName = new Map( + discoveredPluginPaths.map((pluginPath) => [path.basename(pluginPath), pluginPath] as const), + ); + + return requiredPlugins + .map((pluginName) => discoveredByName.get(pluginName)) + .filter((pluginPath): pluginPath is string => typeof pluginPath === "string"); +} + +export function emitSystemEvent( + sink: RuntimeSink, + subtype: string, +): void { + sink.emitRaw({ type: "system", subtype, timestamp: Date.now() }); +} + +export function toOneShotRunRequest(config: SidecarConfig): OneShotRunRequest { + return { + mode: "one-shot", + allowUserQuestions: false, + prompt: config.prompt, + systemPrompt: config.systemPrompt, + model: config.model, + agentName: config.agentName, + apiKey: config.apiKey, + workspaceRootDir: config.workspaceRootDir, + workspaceSkillDir: config.workspaceSkillDir, + requiredPlugins: config.requiredPlugins, + allowedTools: config.allowedTools, + settingSources: config.settingSources, + maxTurns: config.maxTurns, + outputFormat: config.outputFormat, + promptSuggestions: config.promptSuggestions, + context: { + skillName: config.skillName, + stepId: config.stepId, + workflowSessionId: config.workflowSessionId, + usageSessionId: config.usageSessionId, + runSource: config.runSource, + workspaceSkillDir: config.workspaceSkillDir, + pluginSlug: config.pluginSlug, + }, + }; +} + +export function toClaudeSidecarConfig(request: OneShotRunRequest | StreamingSessionRequest): SidecarConfig { + return { + prompt: request.prompt, + systemPrompt: request.systemPrompt, + model: request.model, + agentName: request.agentName, + apiKey: request.apiKey, + workspaceRootDir: request.workspaceRootDir, + workspaceSkillDir: request.workspaceSkillDir, + requiredPlugins: request.requiredPlugins, + allowedTools: request.allowedTools, + settingSources: request.settingSources, + maxTurns: request.maxTurns, + outputFormat: request.outputFormat, + promptSuggestions: request.promptSuggestions, + skillName: request.context.skillName, + stepId: request.context.stepId, + workflowSessionId: request.context.workflowSessionId, + usageSessionId: request.context.usageSessionId, + runSource: request.context.runSource, + pluginSlug: request.context.pluginSlug, + }; +} + +export class ClaudeRuntime implements AgentRuntime { + async runOnce( + request: OneShotRunRequest, + sink: RuntimeSink, + externalSignal?: AbortSignal, + ): Promise { + assertOneShotHasNoUserQuestions(request); + const config = toClaudeSidecarConfig(request); + + if (process.env.MOCK_AGENTS === "true") { + process.stderr.write("[sidecar] Mock agent mode\n"); + return runMockAgent(config, (message) => sink.emitRaw(message), externalSignal); + } + + const state = createAbortState(); + if (externalSignal) { + linkExternalSignal(state, externalSignal); + } + + const discoveredPluginPaths = await discoverInstalledPlugins(config.workspaceRootDir); + const pluginPaths = selectPluginPaths(discoveredPluginPaths, config.requiredPlugins); + + const stderrHandler = (data: string) => { + sink.emitRaw({ + type: "system", + subtype: "sdk_stderr", + data: data.trimEnd(), + timestamp: Date.now(), + }); + }; + + const processorRef: { current: MessageProcessor | null } = { current: null }; + const processor = new MessageProcessor({ + skillName: config.skillName, + stepId: config.stepId, + workflowSessionId: config.workflowSessionId, + usageSessionId: config.usageSessionId, + runSource: config.runSource, + workspaceSkillDir: config.workspaceSkillDir, + pluginSlug: config.pluginSlug, + hasOutputFormat: config.outputFormat != null, + }); + processorRef.current = processor; + + const options = buildQueryOptions(config, state.abortController, pluginPaths, stderrHandler, processorRef); + const gate = new ResultGate(processor); + + const pluginsToLog = (options as Record).plugins as unknown[] | undefined; + sink.emitRaw({ + type: "system", + subtype: "sdk_plugins_debug", + plugins: pluginsToLog ?? [], + timestamp: Date.now(), + }); + + emitSystemEvent(sink, "init_start"); + + try { + process.stderr.write("[sidecar] Starting SDK query\n"); + const conversation = query({ + prompt: config.prompt, + options, + }); + + let sdkReadyEmitted = false; + for await (const message of conversation) { + if (state.abortController.signal.aborted) break; + + if (!sdkReadyEmitted) { + emitSystemEvent(sink, "sdk_ready"); + sdkReadyEmitted = true; + } + + const raw = message as Record; + + if (raw.type === "prompt_suggestion" && typeof raw.suggestion === "string") { + sink.emitAgentEvent({ + type: "prompt_suggestion", + suggestion: raw.suggestion, + }); + continue; + } + + const items = processor.process(raw); + for (const item of items) { + gate.emit(item as Record, (message) => sink.emitRaw(message)); + } + gate.tryFlush((message) => sink.emitRaw(message)); + } + + gate.flush((message) => sink.emitRaw(message)); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (state.abortController.signal.aborted) { + process.stderr.write("[sidecar] Query stream aborted during iteration\n"); + } else { + process.stderr.write(`[sidecar] Query stream failed: ${errorMessage}\n`); + + const errorItems = processor.process({ + type: "error", + message: errorMessage, + }); + for (const item of errorItems) { + sink.emitRaw(item as Record); + } + + const [errorSummary, orphanedErr] = processor.buildExecutionErrorSummary(errorMessage); + for (const item of orphanedErr) { + sink.emitRaw(item as Record); + } + sink.emitAgentEvent(errorSummary); + return; + } + } + + if (state.abortController.signal.aborted) { + process.stderr.write("[sidecar] Run aborted — emitting shutdown run_result\n"); + const [shutdownSummary, orphanedAbort] = processor.buildShutdownSummary(); + for (const item of orphanedAbort) { + sink.emitRaw(item as Record); + } + sink.emitAgentEvent(shutdownSummary); + } + + if (!processor.hasEmittedResult() && !state.abortController.signal.aborted) { + process.stderr.write("[sidecar] SDK completed without result — emitting error run_result\n"); + const [errorSummary, orphanedNoResult] = processor.buildExecutionErrorSummary( + "Agent ended without producing a result", + ); + for (const item of orphanedNoResult) { + sink.emitRaw(item as Record); + } + sink.emitAgentEvent(errorSummary); + } + } + + startStreamingSession( + request: StreamingSessionRequest, + sink: RuntimeSink, + ): RuntimeSession { + const config = toClaudeSidecarConfig(request); + return new StreamSession( + request.context.workflowSessionId ?? "stream-session", + "stream-start", + config, + (_requestId, message) => sink.emitRaw(message), + ); + } +} +``` + +After adding this file, run TypeScript once before continuing. If the +`startStreamingSession` constructor mismatch causes a compile error, leave the +method unimplemented for this task: + +```ts + startStreamingSession(): RuntimeSession { + throw new Error("Claude streaming sessions are wired through StreamSession until Task 4"); + } +``` + +Task 4 wires streaming cleanly. + +- [ ] **Step 4: Make `runAgentRequest` delegate to `ClaudeRuntime.runOnce`** + +Replace the implementation of `app/sidecar/run-agent.ts` with a thin +compatibility wrapper: + +```ts +import type { SidecarConfig } from "./config.js"; +import { + ClaudeRuntime, + discoverInstalledPlugins, + emitSystemEvent, + selectPluginPaths, + toOneShotRunRequest, +} from "./runtime/claude-runtime.js"; +import { createRecordRuntimeSink } from "./runtime/sink.js"; + +export { discoverInstalledPlugins, emitSystemEvent, selectPluginPaths }; + +export async function runAgentRequest( + config: SidecarConfig, + onMessage: (message: Record) => void, + externalSignal?: AbortSignal, +): Promise { + const runtime = new ClaudeRuntime(); + const sink = createRecordRuntimeSink(onMessage); + + try { + await runtime.runOnce(toOneShotRunRequest(config), sink, externalSignal); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + sink.emitRaw({ type: "error", message }); + sink.emitAgentEvent({ + type: "run_result", + status: "error", + subtype: "execution_error", + errorSubtype: "runtime_validation", + errors: [message], + result: "", + sessionId: "unknown", + model: config.model ?? "unknown", + totalCostUsd: 0, + durationMs: 0, + durationApiMs: 0, + numTurns: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + toolUseCount: 0, + compactionCount: 0, + contextWindow: 0, + modelUsageBreakdown: [], + }); + } +} +``` + +If `RunResultEvent` has different required fields in +`app/sidecar/agent-events.ts`, use that type as the source of truth and include +all required fields in the synthetic error event. + +- [ ] **Step 5: Run one-shot tests** + +Run: + +```bash +cd app/sidecar && npx vitest run __tests__/run-agent.test.ts __tests__/runtime-types.test.ts __tests__/runtime-sink.test.ts +``` + +Expected: PASS. + +- [ ] **Step 6: Run all sidecar tests** + +Run: + +```bash +cd app/sidecar && npx vitest run +``` + +Expected: PASS. + +- [ ] **Step 7: Commit Task 3** + +Run: + +```bash +git add app/sidecar/runtime/claude-runtime.ts app/sidecar/run-agent.ts app/sidecar/__tests__/run-agent.test.ts +git commit -m "Wrap Claude one-shot runtime" +``` + +## Task 4: Streaming Session Runtime Contract + +**Files:** + +- Modify: `app/sidecar/stream-session.ts` +- Modify: `app/sidecar/runtime/claude-runtime.ts` +- Modify: `app/sidecar/__tests__/stream-session.test.ts` +- Test: `app/sidecar/__tests__/stream-session.test.ts` +- Test: `app/sidecar/__tests__/persistent-mode.test.ts` + +- [ ] **Step 1: Write failing test for runtime-session method aliases** + +Add to `app/sidecar/__tests__/stream-session.test.ts`: + +```ts + it("exposes runtime session methods for message, answer, cancel, and close", () => { + const session = new StreamSession( + "session-runtime", + "req-start", + baseConfig, + vi.fn(), + ); + + expect(typeof session.sendUserMessage).toBe("function"); + expect(typeof session.answerQuestion).toBe("function"); + expect(typeof session.cancel).toBe("function"); + expect(typeof session.close).toBe("function"); + + session.close(); + }); +``` + +If `baseConfig` does not exist in the file, define it using the same shape as +the existing stream-session fixtures: + +```ts +const baseConfig: SidecarConfig = { + prompt: "hello", + apiKey: "sk-test", + workspaceRootDir: "/workspace", + workspaceSkillDir: "/workspace/demo-plugin/demo-skill", + pluginSlug: "demo-plugin", +}; +``` + +- [ ] **Step 2: Run the test and verify it fails** + +Run: + +```bash +cd app/sidecar && npx vitest run __tests__/stream-session.test.ts -t "exposes runtime session methods" +``` + +Expected: FAIL because `sendUserMessage` and `cancel` do not exist. + +- [ ] **Step 3: Make `StreamSession` implement `RuntimeSession`** + +Modify the imports and class declaration in `app/sidecar/stream-session.ts`: + +```ts +import type { RuntimeSession } from "./runtime/types.js"; + +export class StreamSession implements RuntimeSession { +``` + +Add method aliases inside the class: + +```ts + sendUserMessage(requestId: string, message: string): void { + this.pushMessage(requestId, message); + } + + cancel(): void { + this.cancelTurn(); + } +``` + +Keep the existing `answerQuestion` and `close` methods unchanged. They already +match the runtime-session behavior. + +- [ ] **Step 4: Add a clean streaming factory to `ClaudeRuntime`** + +In `app/sidecar/runtime/claude-runtime.ts`, replace any temporary +`startStreamingSession` implementation with: + +```ts +export function toStreamingSessionRequest(config: SidecarConfig): StreamingSessionRequest { + return { + mode: "streaming", + allowUserQuestions: true, + prompt: config.prompt, + systemPrompt: config.systemPrompt, + model: config.model, + agentName: config.agentName, + apiKey: config.apiKey, + workspaceRootDir: config.workspaceRootDir, + workspaceSkillDir: config.workspaceSkillDir, + requiredPlugins: config.requiredPlugins, + allowedTools: config.allowedTools, + settingSources: config.settingSources, + maxTurns: config.maxTurns, + outputFormat: config.outputFormat, + promptSuggestions: config.promptSuggestions, + context: { + skillName: config.skillName, + stepId: config.stepId, + workflowSessionId: config.workflowSessionId, + usageSessionId: config.usageSessionId, + runSource: config.runSource, + workspaceSkillDir: config.workspaceSkillDir, + pluginSlug: config.pluginSlug, + }, + }; +} + + startStreamingSession( + request: StreamingSessionRequest, + sink: RuntimeSink, + sessionId = request.context.workflowSessionId ?? "stream-session", + firstRequestId = "stream-start", + ): RuntimeSession { + const config = toClaudeSidecarConfig(request); + return new StreamSession( + sessionId, + firstRequestId, + config, + (_requestId, message) => sink.emitRaw(message), + ); + } +``` + +If the TypeScript class method cannot accept optional `sessionId` and +`firstRequestId` while satisfying `AgentRuntime`, add a second method instead: + +```ts + createStreamingSession( + request: StreamingSessionRequest, + sink: RuntimeSink, + sessionId: string, + firstRequestId: string, + ): RuntimeSession { + const config = toClaudeSidecarConfig(request); + return new StreamSession( + sessionId, + firstRequestId, + config, + (_requestId, message) => sink.emitRaw(message), + ); + } +``` + +Use `createStreamingSession` from `persistent-mode.ts` in Task 5. + +- [ ] **Step 5: Run streaming tests** + +Run: + +```bash +cd app/sidecar && npx vitest run __tests__/stream-session.test.ts +``` + +Expected: PASS. + +- [ ] **Step 6: Commit Task 4** + +Run: + +```bash +git add app/sidecar/stream-session.ts app/sidecar/runtime/claude-runtime.ts app/sidecar/__tests__/stream-session.test.ts +git commit -m "Expose streaming runtime session contract" +``` + +## Task 5: Persistent Mode Chooses Runtime Mode Explicitly + +**Files:** + +- Modify: `app/sidecar/persistent-mode.ts` +- Modify: `app/sidecar/__tests__/persistent-mode.test.ts` +- Test: `app/sidecar/__tests__/persistent-mode.test.ts` + +- [ ] **Step 1: Write failing tests for explicit mode defaults** + +Add to `app/sidecar/__tests__/persistent-mode.test.ts`: + +```ts + it("treats agent_request as one-shot mode when mode is omitted", async () => { + const input = makeInput([ + { + type: "agent_request", + request_id: "req-one-shot", + config: { + prompt: "hello", + apiKey: "sk-test", + workspaceRootDir: "/workspace", + workspaceSkillDir: "/workspace/demo-plugin/demo-skill", + pluginSlug: "demo-plugin", + }, + }, + { type: "shutdown" }, + ]); + + const output = await runPersistentForTest(input); + expect(output.some((line) => line.includes("req-one-shot"))).toBe(true); + }); + + it("rejects agent_request configs that explicitly ask for streaming mode", async () => { + const input = makeInput([ + { + type: "agent_request", + request_id: "req-bad-mode", + config: { + mode: "streaming", + prompt: "hello", + apiKey: "sk-test", + workspaceRootDir: "/workspace", + workspaceSkillDir: "/workspace/demo-plugin/demo-skill", + pluginSlug: "demo-plugin", + }, + }, + { type: "shutdown" }, + ]); + + const output = await runPersistentForTest(input); + expect(output.join("\n")).toContain("agent_request requires one-shot mode"); + }); + + it("rejects stream_start configs that explicitly ask for one-shot mode", async () => { + const input = makeInput([ + { + type: "stream_start", + request_id: "req-bad-stream-mode", + session_id: "session-1", + config: { + mode: "one-shot", + prompt: "hello", + apiKey: "sk-test", + workspaceRootDir: "/workspace", + workspaceSkillDir: "/workspace/demo-plugin/demo-skill", + pluginSlug: "demo-plugin", + }, + }, + { type: "shutdown" }, + ]); + + const output = await runPersistentForTest(input); + expect(output.join("\n")).toContain("stream_start requires streaming mode"); + }); +``` + +If `makeInput` or `runPersistentForTest` helpers have different local names, +use the existing helper names in the file and keep the assertion strings the +same. + +- [ ] **Step 2: Run tests and verify new mode tests fail** + +Run: + +```bash +cd app/sidecar && npx vitest run __tests__/persistent-mode.test.ts -t "mode" +``` + +Expected: at least the explicit-mode rejection tests FAIL because +`persistent-mode.ts` does not enforce mode-specific request types yet. + +- [ ] **Step 3: Route through `ClaudeRuntime` in persistent mode** + +Modify imports in `app/sidecar/persistent-mode.ts`: + +```ts +import { type SidecarConfig, parseSidecarConfig } from "./config.js"; +import { + ClaudeRuntime, + toOneShotRunRequest, + toStreamingSessionRequest, +} from "./runtime/claude-runtime.js"; +import { createRecordRuntimeSink } from "./runtime/sink.js"; +import { StreamSession } from "./stream-session.js"; +``` + +Add a runtime instance near the active request state: + +```ts + const runtime = new ClaudeRuntime(); +``` + +In the `stream_start` branch, before creating a session: + +```ts + if (config.mode === "one-shot") { + writeLine( + wrapWithRequestId(request_id, { + type: "error", + message: "stream_start requires streaming mode", + }), + ); + continue; + } +``` + +Keep the direct `new StreamSession(...)` for this task if the runtime streaming +factory would make the diff too large. The important invariant is that stream +requests are streaming-mode requests. + +In the `agent_request` branch, before starting the request promise: + +```ts + if (config.mode === "streaming") { + writeLine( + wrapWithRequestId(request_id, { + type: "error", + message: "agent_request requires one-shot mode", + }), + ); + continue; + } +``` + +Then replace the `runAgentRequest` call inside the request promise: + +```ts + await runtime.runOnce( + toOneShotRunRequest({ ...config, mode: "one-shot" }), + createRecordRuntimeSink((msg) => { + writeLine(wrapWithRequestId(request_id, msg)); + }), + abortController.signal, + ); +``` + +Remove the `runAgentRequest` import after this compiles. + +- [ ] **Step 4: Run persistent-mode tests** + +Run: + +```bash +cd app/sidecar && npx vitest run __tests__/persistent-mode.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Run sidecar tests** + +Run: + +```bash +cd app/sidecar && npx vitest run +``` + +Expected: PASS. + +- [ ] **Step 6: Commit Task 5** + +Run: + +```bash +git add app/sidecar/persistent-mode.ts app/sidecar/__tests__/persistent-mode.test.ts +git commit -m "Route persistent mode through runtime boundary" +``` + +## Task 6: Rust Config Carries Runtime Mode Deliberately + +**Files:** + +- Modify: `app/src-tauri/src/agents/sidecar.rs` +- Modify: `app/src-tauri/src/commands/workflow/runtime.rs` +- Modify: `app/src-tauri/src/commands/refine/protocol.rs` +- Modify if needed: `app/src-tauri/src/commands/agent.rs` +- Test: Rust tests for `agents::sidecar`, `commands::workflow`, and `commands::refine` + +- [ ] **Step 1: Write failing Rust serialization test for `mode`** + +In `app/src-tauri/src/agents/sidecar.rs`, add or update a test so a serialized +one-shot config includes `"mode":"one-shot"`: + +```rust +#[test] +fn serializes_sidecar_config_mode() { + let config = SidecarConfig { + mode: Some("one-shot".to_string()), + prompt: "hello".to_string(), + system_prompt: None, + model: Some("claude-sonnet-4-6".to_string()), + api_key: SecretString::new("sk-test".to_string()), + workspace_root_dir: "/workspace".to_string(), + workspace_skill_dir: "/workspace/demo-plugin/demo-skill".to_string(), + allowed_tools: Some(vec!["Read".to_string()]), + max_turns: Some(10), + permission_mode: Some("bypassPermissions".to_string()), + betas: None, + thinking: None, + fallback_model: None, + effort: None, + output_format: None, + prompt_suggestions: None, + path_to_claude_code_executable: None, + agent_name: None, + required_plugins: None, + setting_sources: None, + conversation_history: None, + skill_name: Some("demo-skill".to_string()), + step_id: Some(0), + workflow_session_id: Some("workflow-1".to_string()), + usage_session_id: None, + run_source: Some("workflow".to_string()), + transcript_log_dir: None, + plugin_slug: "demo-plugin".to_string(), + }; + + let json = serde_json::to_value(&config).unwrap(); + assert_eq!(json["mode"], "one-shot"); +} +``` + +If `SecretString::new` has a different constructor, use the constructor already +used by nearby tests in `sidecar.rs`. + +- [ ] **Step 2: Run the Rust sidecar test and verify it fails** + +Run: + +```bash +cd app/src-tauri && cargo test agents::sidecar::serializes_sidecar_config_mode +``` + +Expected: FAIL because `SidecarConfig` does not have `mode`. + +- [ ] **Step 3: Add `mode` to Rust `SidecarConfig`** + +In `app/src-tauri/src/agents/sidecar.rs`, add: + +```rust + #[serde(skip_serializing_if = "Option::is_none")] + pub mode: Option, +``` + +Place it near `prompt` so JSON config readers see mode before runtime-specific +fields. + +Update all `SidecarConfig` construction sites touched by compiler errors: + +```rust +mode: Some("one-shot".to_string()), +``` + +for `agent_request` flows, and: + +```rust +mode: Some("streaming".to_string()), +``` + +for `stream_start` flows. + +Expected locations include: + +- `app/src-tauri/src/commands/workflow/runtime.rs` +- `app/src-tauri/src/commands/refine/protocol.rs` +- `app/src-tauri/src/commands/agent.rs` +- `app/src-tauri/src/commands/description/eval.rs` +- tests that construct `SidecarConfig` directly. + +- [ ] **Step 4: Classify workflow and refine calls** + +Apply these mode rules: + +- `stream_start` calls in workflow runtime: `mode: Some("streaming".to_string())` +- refine chat calls: `mode: Some("streaming".to_string())` +- direct `start_agent` calls: `mode: Some("one-shot".to_string())` +- description eval/improve subruns: `mode: Some("one-shot".to_string())` + +Do not remove `AskUserQuestion` from workflow streaming step tool lists in this +task. That behavior remains valid for streaming. + +- [ ] **Step 5: Run Rust tests for changed command areas** + +Run: + +```bash +cd app/src-tauri && cargo test agents::sidecar commands::workflow commands::refine commands::agent commands::description +``` + +Expected: PASS. + +- [ ] **Step 6: Commit Task 6** + +Run: + +```bash +git add app/src-tauri/src/agents/sidecar.rs app/src-tauri/src/commands/workflow/runtime.rs app/src-tauri/src/commands/refine/protocol.rs app/src-tauri/src/commands/agent.rs app/src-tauri/src/commands/description +git commit -m "Send explicit runtime modes from Rust" +``` + +## Task 7: Boundary Documentation And Repo Map Audit + +**Files:** + +- Modify: `docs/design/agent-runtime-boundary/README.md` +- Modify if needed: `docs/design/sdk-agent-options/README.md` +- Modify if needed: `repo-map.json` +- Test: markdownlint and repo-map audit checks from `AGENTS.md` + +- [ ] **Step 1: Update design doc if implementation names differ** + +If the implemented files use different names than the design doc, update +`docs/design/agent-runtime-boundary/README.md` so the `Key Source Files` +section names the real files. For example, keep this entry if the plan is +followed: + +```markdown +| `app/sidecar/runtime/claude-runtime.ts` | Claude runtime adapter for one-shot and streaming-session boundary methods. | +``` + +- [ ] **Step 2: Update `repo-map.json` if the sidecar module description is stale** + +If `repo-map.json` still describes the sidecar as directly calling SDK +`query()` from `run-agent.ts`, update the `app/sidecar` description to include: + +```json +"runtime/ (runtime boundary types, sink adapter, and Claude runtime adapter)" +``` + +Keep the update surgical. Do not rewrite unrelated repo-map sections. + +- [ ] **Step 3: Run markdownlint for changed docs** + +Run: + +```bash +npx markdownlint docs/design/agent-runtime-boundary/README.md docs/design/sdk-agent-options/README.md +``` + +Expected: PASS. If `sdk-agent-options/README.md` was not modified, omit it +from the command. + +- [ ] **Step 4: Run repo-map audit commands from AGENTS.md** + +Run: + +```bash +find app/src-tauri/src/commands -maxdepth 1 -type f -name '*.rs' -print | sort +find app/src-tauri/src/commands/workflow -maxdepth 1 -type f -name '*.rs' -print | sort +find app/src-tauri/src/commands/imported_skills -maxdepth 1 -type f -name '*.rs' -print | sort +find app/src-tauri/src/commands/github_import -maxdepth 1 -type f -name '*.rs' -print | sort +find app/src/stores -maxdepth 1 -type f -name '*.ts' ! -name 'index.ts' -print | sort +find app/src/pages -maxdepth 1 -type f -print | sort +``` + +Expected: The listed files match `repo-map.json`. If they do not, update only +the stale entries caused or revealed by this work. + +- [ ] **Step 5: Commit Task 7** + +Run: + +```bash +git add docs/design/agent-runtime-boundary/README.md docs/design/sdk-agent-options/README.md repo-map.json +git commit -m "Document sidecar runtime boundary implementation" +``` + +If only one or two files changed, stage only those paths. + +## Task 8: Final Verification + +**Files:** + +- No new source files. +- Verify the whole boundary refactor. + +- [ ] **Step 1: Run all sidecar tests** + +Run: + +```bash +cd app/sidecar && npx vitest run +``` + +Expected: PASS. + +- [ ] **Step 2: Run structural agent tests because sidecar runtime behavior changed** + +Run: + +```bash +cd app && npm run test:agents:structural +``` + +Expected: PASS. + +- [ ] **Step 3: Run frontend unit tests required by AGENTS.md for `app/src/**` only if frontend files changed** + +If no `app/src/**` files changed, skip this step and record that it was not +applicable. If frontend files changed, run: + +```bash +cd app && npm run test:unit +``` + +Expected: PASS. + +- [ ] **Step 4: Run Rust tests for changed Rust modules** + +Run: + +```bash +cd app/src-tauri && cargo test agents::sidecar commands::workflow commands::refine commands::agent commands::description +``` + +Expected: PASS. + +- [ ] **Step 5: Build the sidecar** + +Run: + +```bash +cd app && npm run sidecar:build +``` + +Expected: PASS and `app/sidecar/dist/` is rebuilt. + +- [ ] **Step 6: Confirm no live API smoke test was run** + +Do not run: + +```bash +cd app && npm run test:agents:smoke +``` + +Expected: The final handoff tells the user that live agent smoke tests were not +run because repo guidance says not to run them autonomously. + +- [ ] **Step 7: Check for verification-only changes** + +Run: + +```bash +git status --short +``` + +Expected: no uncommitted changes. If verification produced changes, stop and +inspect them before creating a follow-up commit with exact file paths. + +## Follow-Up Plan Boundary + +After this plan lands, write a separate OpenHands implementation plan that uses +the new `AgentRuntime` boundary. That follow-up should cover: + +- adding the OpenHands SDK dependency; +- creating `app/sidecar/runtime/openhands-runtime.ts`; +- mapping OpenHands events into `RuntimeSink`; +- implementing the app-owned question tool for streaming sessions; +- replacing Claude-only settings and packaging behavior. + +Do not add those changes to this first boundary refactor. diff --git a/repo-map.json b/repo-map.json index ff75a7436..84554101d 100644 --- a/repo-map.json +++ b/repo-map.json @@ -120,7 +120,7 @@ }, "sidecar_runner": { "path": "app/sidecar/agent-runner.ts", - "description": "Entry point: reads stdin JSON config, calls SDK query(), streams JSONL events to stdout" + "description": "Entry point: reads stdin JSON config, starts persistent sidecar mode, streams JSONL events to stdout" }, "sidecar_events": { "path": "app/sidecar/agent-events.ts", @@ -128,7 +128,7 @@ }, "sidecar_core": { "path": "app/sidecar/", - "description": "Supporting sidecar modules: persistent-mode.ts (message demultiplexer, routes one-shot vs streaming requests), run-agent.ts (executes a single agent request), stream-session.ts (async generator push pattern for multi-turn streaming), message-processor.ts (transforms SDK messages into DisplayItems and AgentEvents), message-classifier.ts (classifies SDK message types), run-metadata-accumulator.ts (accumulates run-level metadata), tool-summaries.ts (context-aware tool call summaries), display-types.ts (DisplayItem types), config.ts + options.ts (SDK config builders), error-labels.ts (user-facing error strings), shutdown.ts (graceful shutdown handler)" + "description": "Supporting sidecar modules: persistent-mode.ts (message demultiplexer, routes one-shot vs streaming requests), runtime/ (runtime boundary types, sink adapter, and Claude runtime adapter), run-agent.ts (one-shot compatibility wrapper), stream-session.ts (async generator push pattern for multi-turn streaming), message-processor.ts (transforms SDK messages into DisplayItems and AgentEvents), message-classifier.ts (classifies SDK message types), run-metadata-accumulator.ts (accumulates run-level metadata), tool-summaries.ts (context-aware tool call summaries), display-types.ts (DisplayItem types), config.ts + options.ts (sidecar config validation and Claude SDK option builder), error-labels.ts (user-facing error strings), shutdown.ts (graceful shutdown handler)" }, "sidecar_mock": { "path": "app/sidecar/mock-agent.ts", @@ -136,7 +136,7 @@ }, "agent_prompts": { "path": "agent-sources/agents/", - "description": "Flat directory of .md agent prompts: answer-evaluator. Plugin-hosted agents: generate-skill and rewrite-skill under agent-sources/plugins/skill-creator/agents/; research-orchestrator, detailed-research, and confirm-decisions under agent-sources/plugins/skill-content-researcher/agents/. Plugin-hosted skills: skill-evaluator and skill-validator under agent-sources/plugins/skill-creator/skills/" + "description": "Flat directory of .md agent prompts: answer-evaluator. Plugin-hosted agents: generate-skill and rewrite-skill under agent-sources/plugins/skill-creator/agents/; skill-builder, research-orchestrator, detailed-research, and confirm-decisions under agent-sources/plugins/skill-content-researcher/agents/. Plugin-hosted skills: skill-evaluator and skill-validator under agent-sources/plugins/skill-creator/skills/" }, "agent_plugins": { "path": "agent-sources/plugins/", @@ -185,7 +185,7 @@ "dependencies_internal": { "frontend → rust": "Via Tauri IPC (`invoke()` from @tauri-apps/api/core)", "rust → sidecar": "Node.js child process, stdin/stdout JSONL protocol", - "sidecar → anthropic": "Via @anthropic-ai/claude-agent-sdk (SDK query())", + "sidecar → anthropic": "Via runtime/claude-runtime.ts using @anthropic-ai/claude-agent-sdk (SDK query())", "frontend → sidecar_events": "Typed via agent-events.ts contract, received as Tauri events" }, "dependencies_external": {