diff --git a/packages/review-tutor/README.md b/packages/review-tutor/README.md index 9c5de17..3cd3d7c 100644 --- a/packages/review-tutor/README.md +++ b/packages/review-tutor/README.md @@ -42,11 +42,11 @@ With no argument, choose a source in the browser. The browser supports worktree, ## Model selection -The model dialog lists the session's scoped models when `--models` or the settings scope configures them. Otherwise, it lists all available models. Thinking levels are offered only for reasoning models. A scope entry with an explicit level, such as `gpt-5.6-sol:high`, pins the tutor to that level. +The model dialog lists the session's scoped models when `--models` or the settings scope configures them. Otherwise, it lists all available models. Thinking levels are offered only for reasoning models. Thinking levels are enforced by the server's model membership check. A scope entry with an explicit level, such as `gpt-5.6-sol:high`, pins the tutor to that level. ## Harness connectors -A harness connector owns model discovery, isolated invocation, and stream parsing while the shared runner owns process lifetime, bounds, and cancellation. Pi is the only registered connector today; Claude Code and Codex support is tracked in [#63](https://github.com/pickforge/pickforge-platform/issues/63). The `reviewTutorHarnessConnectors` flag defaults off. For local testing on main, set `REVIEW_TUTOR_FLAGS=reviewTutorHarnessConnectors` before starting Pi. Child processes receive only the shared environment allowlist plus keys explicitly declared by their connector, and runner failures redact common API keys, bearer credentials, and tokens before leaving the process boundary. +A harness connector owns model discovery, isolated invocation, and stream parsing while the shared runner owns process lifetime, bounds, and cancellation. Pi is registered by default; the Claude Code connector is available behind the connector flag, and Codex support is tracked in [#63](https://github.com/pickforge/pickforge-platform/issues/63). The `reviewTutorHarnessConnectors` flag defaults off. For local testing on main, set `REVIEW_TUTOR_FLAGS=reviewTutorHarnessConnectors` before starting Pi. Child processes receive only the shared environment allowlist plus keys explicitly declared by their connector, and runner failures redact common API keys, bearer credentials, and tokens before leaving the process boundary. The Claude Code connector forwards `CLAUDE_CONFIG_DIR` when present, but never forwards `ANTHROPIC_API_KEY`; users who rely on that environment key must sign in through Claude Code instead. ## Local data diff --git a/packages/review-tutor/src/connectors/claude-code.ts b/packages/review-tutor/src/connectors/claude-code.ts new file mode 100644 index 0000000..b05095f --- /dev/null +++ b/packages/review-tutor/src/connectors/claude-code.ts @@ -0,0 +1,188 @@ +import { redact } from "./redact.ts"; +import type { + ConnectorRequest, + Discovery, + DiscoveryDeps, + HarnessConnector, + ParseSink, + ParsedAnswer, + SpawnSpec, +} from "./types.ts"; +import { ConnectorError } from "./types.ts"; + +const THINKING_LEVELS = ["low", "medium", "high", "xhigh"]; +const MODELS = [ + { id: "claude-code:fable", label: "Claude Fable 5", thinkingLevels: THINKING_LEVELS }, + { id: "claude-code:opus", label: "Claude Opus 5", thinkingLevels: THINKING_LEVELS }, + { id: "claude-code:sonnet", label: "Claude Sonnet 5", thinkingLevels: THINKING_LEVELS }, +]; +const READ_ONLY_TOOLS = new Set(["Read", "Grep", "Glob"]); + +interface ClaudeEvent { + type?: unknown; + subtype?: unknown; + is_error?: unknown; + result?: unknown; + model?: unknown; + total_cost_usd?: unknown; + permissionMode?: unknown; + tools?: unknown; + usage?: unknown; + event?: { + type?: unknown; + delta?: { type?: unknown; text?: unknown }; + }; + message?: { content?: unknown }; +} + +function versionAtLeast(major: number, minor: number): boolean { + return major > 2 || (major === 2 && minor >= 1); +} + +function assistantText(content: unknown): string | undefined { + if (!Array.isArray(content)) return undefined; + return content + .filter((block): block is { type: "text"; text: string } => + block?.type === "text" && typeof block.text === "string") + .map((block) => block.text) + .join(""); +} + +function validateInit(event: ClaudeEvent): void { + if (event.type !== "system" || event.subtype !== "init") return; + const wrongMode = event.permissionMode !== undefined && event.permissionMode !== "dontAsk"; + const wrongTools = Array.isArray(event.tools) + && event.tools.some((tool) => typeof tool !== "string" || !READ_ONLY_TOOLS.has(tool)); + if (wrongMode || wrongTools) { + throw new ConnectorError( + "Claude Code did not honour the read-only tool set; refusing to continue.", + ); + } +} + +function eventDelta(event: ClaudeEvent): string | undefined { + const delta = event.event?.delta; + return event.type === "stream_event" + && event.event?.type === "content_block_delta" + && delta?.type === "text_delta" + && typeof delta.text === "string" + ? delta.text + : undefined; +} + +function eventUsage(event: ClaudeEvent): Record | undefined { + if (!event.usage || typeof event.usage !== "object") return undefined; + const usage = event.usage as Record; + const answerUsage: Record = {}; + if (typeof usage.input_tokens === "number") answerUsage.input_tokens = usage.input_tokens; + if (typeof usage.output_tokens === "number") answerUsage.output_tokens = usage.output_tokens; + const cost = typeof event.total_cost_usd === "number" ? event.total_cost_usd : usage.total_cost_usd; + if (typeof cost === "number") answerUsage.total_cost_usd = cost; + return answerUsage; +} + +export class ClaudeCodeConnector implements HarnessConnector { + readonly id = "claude-code" as const; + readonly label = "Claude Code"; + readonly envKeys = ["CLAUDE_CONFIG_DIR"] as const; + private model = "unknown"; + private sawResult = false; + + async discover(deps: DiscoveryDeps): Promise { + const output = await deps.which("claude"); + if (output === undefined) { + return { + available: false, + reason: "Claude Code is not installed (claude not found on PATH).", + }; + } + const match = output.match(/^(\d+)\.(\d+)\.(\d+)/); + if (!match) { + return { available: false, reason: "Claude Code version could not be parsed." }; + } + const version = `${match[1]}.${match[2]}.${match[3]}`; + if (!versionAtLeast(Number(match[1]), Number(match[2]))) { + return { + available: false, + reason: `Claude Code ${version} is too old; 2.1.0 or newer is required.`, + }; + } + return { available: true, version, models: MODELS.map((model) => ({ ...model })) }; + } + + spawnSpec(request: ConnectorRequest): SpawnSpec { + return { + command: "claude", + args: [ + "-p", "--output-format", "stream-json", "--verbose", "--include-partial-messages", + "--model", request.model, "--effort", request.thinking, + "--tools", "Read,Grep,Glob", "--permission-mode", "dontAsk", + "--strict-mcp-config", "--setting-sources", "", "--disable-slash-commands", + "--no-session-persistence", "--max-turns", "8", + ], + }; + } + + parseLine(line: string, sink: ParseSink): void { + let event: ClaudeEvent; + try { + event = JSON.parse(line) as ClaudeEvent; + } catch { + return; + } + + validateInit(event); + if (event.type === "system" && event.subtype === "init" && typeof event.model === "string") { + this.model = event.model; + } + const delta = eventDelta(event); + if (delta !== undefined) sink.delta(delta); + if (event.type === "assistant") { + const latest = assistantText(event.message?.content); + if (latest !== undefined) sink.final(latest); + } + if (event.type === "result") this.handleResult(event, sink); + } + + private handleResult(event: ClaudeEvent, sink: ParseSink): void { + this.sawResult = true; + if (event.is_error === true || event.subtype !== "success") { + const message = this.failureMessage(event.result); + this.sawResult = false; + this.model = "unknown"; + throw new ConnectorError(message); + } + const usage = eventUsage(event); + if (usage !== undefined) sink.usage(usage); + if (typeof event.result === "string") sink.final(event.result); + } + + finish(sink: ParseSink): ParsedAnswer { + try { + if (!this.sawResult) { + throw new ConnectorError("Claude Code exited without a result."); + } + if (!sink.answer?.trim()) { + throw new ConnectorError("Claude Code returned an empty answer."); + } + return { answer: sink.answer, ...(sink.answerUsage ? { usage: sink.answerUsage } : {}) }; + } finally { + this.sawResult = false; + this.model = "unknown"; + } + } + + private failureMessage(value: unknown): string { + const text = typeof value === "string" ? value : "Claude Code failed."; + if (/not logged in|please run \/login|authentication/i.test(text)) { + return "Claude Code is not logged in. Run `claude` once and sign in, then ask again."; + } + if (/rate limit|overloaded|429/i.test(text)) { + return "Claude Code is rate-limited right now. Try again in a few minutes."; + } + if (/unknown model|invalid model/i.test(text)) { + return `Claude Code rejected model ${this.model}.`; + } + return redact(text.slice(0, 200)); + } +} diff --git a/packages/review-tutor/src/connectors/registry.ts b/packages/review-tutor/src/connectors/registry.ts index 37bdd98..5c76411 100644 --- a/packages/review-tutor/src/connectors/registry.ts +++ b/packages/review-tutor/src/connectors/registry.ts @@ -1,4 +1,6 @@ +import { execFile } from "node:child_process"; import type { ReviewTutorFlags } from "../flags.ts"; +import { ClaudeCodeConnector } from "./claude-code.ts"; import { PiConnector } from "./pi.ts"; import type { Discovery, @@ -15,16 +17,26 @@ export interface ConnectorRegistry { discoveries(): Promise>; } +function which(command: string): Promise { + return new Promise((resolve) => { + execFile(command, ["--version"], { encoding: "utf8", shell: false }, (error, stdout) => { + resolve(error ? undefined : stdout); + }); + }); +} + export function createConnectorRegistry(options: { flags: ReviewTutorFlags; piModels: ModelChoice[]; piVersion?: string; + which?: DiscoveryDeps["which"]; }): ConnectorRegistry { const pi = new PiConnector(); - const optionalConnectors: HarnessConnector[] = []; + const optionalConnectors: HarnessConnector[] = [new ClaudeCodeConnector()]; const dependencies: DiscoveryDeps = { piModels: options.piModels, ...(options.piVersion ? { piVersion: options.piVersion } : {}), + which: options.which ?? which, }; const connectors = (): HarnessConnector[] => [ diff --git a/packages/review-tutor/src/connectors/types.ts b/packages/review-tutor/src/connectors/types.ts index 8728495..d100af5 100644 --- a/packages/review-tutor/src/connectors/types.ts +++ b/packages/review-tutor/src/connectors/types.ts @@ -13,6 +13,7 @@ export interface HarnessConnector { export interface DiscoveryDeps { piModels: ModelChoice[]; piVersion?: string; + which(command: string): Promise; } export type Discovery = diff --git a/packages/review-tutor/test/connector-claude-code.test.ts b/packages/review-tutor/test/connector-claude-code.test.ts new file mode 100644 index 0000000..f1e418f --- /dev/null +++ b/packages/review-tutor/test/connector-claude-code.test.ts @@ -0,0 +1,256 @@ +import { EventEmitter } from "node:events"; +import { readFile } from "node:fs/promises"; +import { PassThrough } from "node:stream"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it, vi } from "vitest"; +import { ClaudeCodeConnector } from "../src/connectors/claude-code.ts"; +import type { ParseSink } from "../src/connectors/types.ts"; +import { TutorRunner } from "../src/runner.ts"; + +class FakeChild extends EventEmitter { + stdout = new PassThrough(); + stderr = new PassThrough(); + stdin = new PassThrough(); + pid = 123; + kill = vi.fn(); +} + +const fixtureRoot = fileURLToPath(new URL("fixtures/claude-code/", import.meta.url)); +const request = { + model: "sonnet", + thinking: "low", + cwd: "/repo", + prompt: "Reply with the single word ok.", +}; + +function sink() { + let answer: string | undefined; + let answerUsage: Record | undefined; + return { + get answer() { return answer; }, + get answerUsage() { return answerUsage; }, + deltas: [] as string[], + usages: [] as Array>, + finals: [] as string[], + delta(text: string) { this.deltas.push(text); }, + usage(value: Record) { + answerUsage = value; + this.usages.push(value); + }, + final(next: string) { + answer = next; + this.finals.push(next); + }, + } satisfies ParseSink & { + deltas: string[]; + usages: Array>; + finals: string[]; + }; +} + +async function parseFixture(name: string) { + const connector = new ClaudeCodeConnector(); + const target = sink(); + const fixture = await readFile(`${fixtureRoot}${name}.jsonl`, "utf8"); + for (const line of fixture.split("\n")) { + if (line) connector.parseLine(line, target); + } + return { connector, target }; +} + +describe("Claude Code discovery", () => { + it("reports an absent binary", async () => { + const connector = new ClaudeCodeConnector(); + const which = vi.fn(async () => undefined); + await expect(connector.discover({ piModels: [], which })).resolves.toEqual({ + available: false, + reason: "Claude Code is not installed (claude not found on PATH).", + }); + expect(which).toHaveBeenCalledWith("claude"); + }); + + it("rejects versions before 2.1.0", async () => { + const connector = new ClaudeCodeConnector(); + await expect(connector.discover({ piModels: [], which: async () => "2.0.9 (Claude Code)" })).resolves.toEqual({ + available: false, + reason: "Claude Code 2.0.9 is too old; 2.1.0 or newer is required.", + }); + }); + + it("offers the three documented aliases and reviewer effort levels", async () => { + const connector = new ClaudeCodeConnector(); + await expect(connector.discover({ piModels: [], which: async () => "2.1.245 (Claude Code)" })).resolves.toEqual({ + available: true, + version: "2.1.245", + models: [ + { id: "claude-code:fable", label: "Claude Fable 5", thinkingLevels: ["low", "medium", "high", "xhigh"] }, + { id: "claude-code:opus", label: "Claude Opus 5", thinkingLevels: ["low", "medium", "high", "xhigh"] }, + { id: "claude-code:sonnet", label: "Claude Sonnet 5", thinkingLevels: ["low", "medium", "high", "xhigh"] }, + ], + }); + }); + + it("reports an unparseable version", async () => { + const connector = new ClaudeCodeConnector(); + await expect(connector.discover({ piModels: [], which: async () => "garbage" })).resolves.toEqual({ + available: false, + reason: "Claude Code version could not be parsed.", + }); + }); +}); + +describe("Claude Code stream parsing", () => { + it("uses partial deltas and the authoritative result", async () => { + const { connector, target } = await parseFixture("success"); + expect(target.deltas).toEqual(["o", "k"]); + expect(target.usages).toEqual([{ input_tokens: 3, output_tokens: 1, total_cost_usd: 0.01 }]); + expect(target.finals).toEqual(["partial assistant copy", "ok"]); + expect(connector.finish(target)).toEqual({ + answer: "ok", + usage: { input_tokens: 3, output_tokens: 1, total_cost_usd: 0.01 }, + }); + }); + + it("maps an authentication failure", async () => { + await expect(parseFixture("auth-error")).rejects.toThrow( + "Claude Code is not logged in. Run `claude` once and sign in, then ask again.", + ); + }); + + it.each([ + ["rate limit 429", "Claude Code is rate-limited right now. Try again in a few minutes."], + ["invalid model alias", "Claude Code rejected model sonnet."], + ])("maps result failure %j", (result, message) => { + const connector = new ClaudeCodeConnector(); + const target = sink(); + connector.parseLine(JSON.stringify({ type: "system", subtype: "init", model: "sonnet" }), target); + expect(() => connector.parseLine(JSON.stringify({ + type: "result", subtype: "error_during_execution", is_error: true, result, + }), target)).toThrow(message); + }); + + it("bounds and redacts an unmapped result failure", () => { + const connector = new ClaudeCodeConnector(); + const result = `sk-ant-fakefakefake ${"x".repeat(220)}`; + expect(() => connector.parseLine(JSON.stringify({ + type: "result", subtype: "error_during_execution", is_error: true, result, + }), sink())).toThrow(`[redacted] ${"x".repeat(180)}`); + }); + + it("rejects an empty answer", async () => { + const { connector, target } = await parseFixture("empty-answer"); + expect(() => connector.finish(target)).toThrow("Claude Code returned an empty answer."); + }); + + it("ignores malformed lines", async () => { + const { connector, target } = await parseFixture("malformed-line"); + expect(connector.finish(target)).toMatchObject({ answer: "ok" }); + }); + + it("does not report usage when the result omits it", () => { + const connector = new ClaudeCodeConnector(); + const target = sink(); + connector.parseLine(JSON.stringify({ + type: "result", subtype: "success", is_error: false, result: "ok", + }), target); + expect(target.usages).toEqual([]); + expect(connector.finish(target)).toEqual({ answer: "ok" }); + }); + + it("keeps answers isolated across sequential asks", () => { + const connector = new ClaudeCodeConnector(); + const first = sink(); + connector.parseLine(JSON.stringify({ + type: "assistant", message: { content: [{ type: "text", text: "first" }] }, + }), first); + connector.parseLine(JSON.stringify({ type: "result", subtype: "success" }), first); + expect(connector.finish(first).answer).toBe("first"); + + const second = sink(); + connector.parseLine(JSON.stringify({ + type: "assistant", message: { content: [{ type: "text", text: "second" }] }, + }), second); + connector.parseLine(JSON.stringify({ type: "result", subtype: "success" }), second); + expect(connector.finish(second).answer).toBe("second"); + expect(second.answer).not.toContain("first"); + }); + + it("clears terminal state when finishing throws", () => { + const connector = new ClaudeCodeConnector(); + const empty = sink(); + connector.parseLine(JSON.stringify({ type: "result", subtype: "success", result: "" }), empty); + expect(() => connector.finish(empty)).toThrow("Claude Code returned an empty answer."); + expect(() => connector.finish(sink())).toThrow("Claude Code exited without a result."); + }); + + it("refuses an init event with a write-capable tool", async () => { + const connector = new ClaudeCodeConnector(); + const target = sink(); + const fixture = await readFile(`${fixtureRoot}unsafe-init.jsonl`, "utf8"); + expect(() => connector.parseLine(fixture.trim(), target)).toThrow( + "Claude Code did not honour the read-only tool set; refusing to continue.", + ); + }); + + it("refuses an init event with the wrong permission mode", () => { + const connector = new ClaudeCodeConnector(); + expect(() => connector.parseLine(JSON.stringify({ + type: "system", subtype: "init", permissionMode: "default", tools: ["Read", "Grep", "Glob"], + }), sink())).toThrow("Claude Code did not honour the read-only tool set; refusing to continue."); + }); + + it("rejects a stream without a result event", async () => { + const { connector, target } = await parseFixture("missing-result"); + expect(() => connector.finish(target)).toThrow("Claude Code exited without a result."); + }); +}); + +describe("Claude Code runner integration", () => { + it("pins invocation, cwd, environment, and prompt", async () => { + const child = new FakeChild(); + const spawn = vi.fn(() => child as never); + const runner = new TutorRunner({ + spawn, + env: { PATH: "/bin", CLAUDE_CONFIG_DIR: "/config", ANTHROPIC_API_KEY: "excluded" }, + }); + let prompt = ""; + child.stdin.on("data", (chunk) => { prompt += chunk.toString(); }); + const done = runner.run({ ...request, connector: new ClaudeCodeConnector() }, () => {}); + child.stdout.end(`${JSON.stringify({ type: "result", subtype: "success", is_error: false, result: "ok", usage: {} })}\n`); + child.emit("close", 0, null); + await expect(done).resolves.toMatchObject({ answer: "ok" }); + expect(spawn).toHaveBeenCalledWith("claude", [ + "-p", "--output-format", "stream-json", "--verbose", "--include-partial-messages", + "--model", "sonnet", "--effort", "low", "--tools", "Read,Grep,Glob", + "--permission-mode", "dontAsk", "--strict-mcp-config", "--setting-sources", "", + "--disable-slash-commands", "--no-session-persistence", "--max-turns", "8", + ], expect.objectContaining({ + cwd: "/repo", + env: { PATH: "/bin", CLAUDE_CONFIG_DIR: "/config", REVIEW_TUTOR_CHILD: "1" }, + })); + expect(prompt).toBe("Reply with the single word ok."); + }); + + it("settles once when cancelled mid-stream", async () => { + const child = new FakeChild(); + const terminate = vi.fn(); + const runner = new TutorRunner({ spawn: () => child as never, terminate }); + const done = runner.run({ ...request, connector: new ClaudeCodeConnector() }, () => {}); + child.stdout.write(`${JSON.stringify({ type: "stream_event", event: { type: "content_block_delta", delta: { type: "text_delta", text: "o" } } })}\n`); + runner.cancel(); + child.emit("close", null, "SIGTERM"); + await expect(done).rejects.toThrow(/cancelled/); + expect(terminate).toHaveBeenCalledTimes(1); + }); + + it("redacts stderr before it leaves the runner", async () => { + const child = new FakeChild(); + const runner = new TutorRunner({ spawn: () => child as never }); + const done = runner.run({ ...request, connector: new ClaudeCodeConnector() }, () => {}); + child.stderr.end("sk-ant-fakefakefake"); + child.emit("close", 1, null); + const error = await done.then(() => undefined, (value: unknown) => value as Error); + expect(error?.message).toContain("[redacted]"); + expect(error?.message).not.toContain("sk-ant-fakefakefake"); + }); +}); diff --git a/packages/review-tutor/test/connectors.test.ts b/packages/review-tutor/test/connectors.test.ts index 74370cd..ac86eed 100644 --- a/packages/review-tutor/test/connectors.test.ts +++ b/packages/review-tutor/test/connectors.test.ts @@ -55,9 +55,9 @@ describe("connector registry", () => { } }); - it("keeps only the explicitly registered Pi connector with the flag off or on", () => { + it("registers Claude Code only when the connector flag is on", () => { expect(registry(false).connectors().map((connector) => connector.id)).toEqual(["pi"]); - expect(registry(true).connectors().map((connector) => connector.id)).toEqual(["pi"]); + expect(registry(true).connectors().map((connector) => connector.id)).toEqual(["pi", "claude-code"]); }); it("resolves namespaced and legacy Pi ids and rejects unknown harnesses", () => { @@ -76,7 +76,11 @@ describe("connector registry", () => { it("namespaces Pi discovery without spawning a process", async () => { const connector = new PiConnector(); - await expect(connector.discover({ piModels: models, piVersion: "1.2.3" })).resolves.toEqual({ + await expect(connector.discover({ + piModels: models, + piVersion: "1.2.3", + which: async () => undefined, + })).resolves.toEqual({ available: true, version: "1.2.3", models: [{ ...models[0], id: "pi:anthropic/model" }], diff --git a/packages/review-tutor/test/fixtures/claude-code/auth-error.jsonl b/packages/review-tutor/test/fixtures/claude-code/auth-error.jsonl new file mode 100644 index 0000000..f30479e --- /dev/null +++ b/packages/review-tutor/test/fixtures/claude-code/auth-error.jsonl @@ -0,0 +1 @@ +{"type":"result","subtype":"error_during_execution","is_error":true,"result":"Not logged in ยท Please run /login"} diff --git a/packages/review-tutor/test/fixtures/claude-code/empty-answer.jsonl b/packages/review-tutor/test/fixtures/claude-code/empty-answer.jsonl new file mode 100644 index 0000000..b5a8bb4 --- /dev/null +++ b/packages/review-tutor/test/fixtures/claude-code/empty-answer.jsonl @@ -0,0 +1 @@ +{"type":"result","subtype":"success","is_error":false,"result":"","usage":{"input_tokens":1,"output_tokens":0}} diff --git a/packages/review-tutor/test/fixtures/claude-code/malformed-line.jsonl b/packages/review-tutor/test/fixtures/claude-code/malformed-line.jsonl new file mode 100644 index 0000000..0946b55 --- /dev/null +++ b/packages/review-tutor/test/fixtures/claude-code/malformed-line.jsonl @@ -0,0 +1,3 @@ +this is not json +{"type":"assistant","message":{"content":[{"type":"text","text":"ok"}]}} +{"type":"result","subtype":"success","is_error":false,"usage":{"input_tokens":1,"output_tokens":1}} diff --git a/packages/review-tutor/test/fixtures/claude-code/missing-result.jsonl b/packages/review-tutor/test/fixtures/claude-code/missing-result.jsonl new file mode 100644 index 0000000..dd7fa9e --- /dev/null +++ b/packages/review-tutor/test/fixtures/claude-code/missing-result.jsonl @@ -0,0 +1 @@ +{"type":"assistant","message":{"content":[{"type":"text","text":"ok"}]}} diff --git a/packages/review-tutor/test/fixtures/claude-code/success.jsonl b/packages/review-tutor/test/fixtures/claude-code/success.jsonl new file mode 100644 index 0000000..9286a6f --- /dev/null +++ b/packages/review-tutor/test/fixtures/claude-code/success.jsonl @@ -0,0 +1,5 @@ +{"type":"system","subtype":"init","permissionMode":"dontAsk","tools":["Read","Grep","Glob"]} +{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"o"}}} +{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"k"}}} +{"type":"assistant","message":{"content":[{"type":"text","text":"partial assistant copy"}]}} +{"type":"result","subtype":"success","is_error":false,"result":"ok","usage":{"input_tokens":3,"output_tokens":1},"total_cost_usd":0.01} diff --git a/packages/review-tutor/test/fixtures/claude-code/unsafe-init.jsonl b/packages/review-tutor/test/fixtures/claude-code/unsafe-init.jsonl new file mode 100644 index 0000000..f4a8119 --- /dev/null +++ b/packages/review-tutor/test/fixtures/claude-code/unsafe-init.jsonl @@ -0,0 +1 @@ +{"type":"system","subtype":"init","permissionMode":"dontAsk","tools":["Bash"]} diff --git a/packages/review-tutor/test/server-extension.test.ts b/packages/review-tutor/test/server-extension.test.ts index 5a1172a..25c3a65 100644 --- a/packages/review-tutor/test/server-extension.test.ts +++ b/packages/review-tutor/test/server-extension.test.ts @@ -320,6 +320,30 @@ describe("local server security", () => { }); describe("connector protocol boundary", () => { + it("reports an unavailable Claude Code harness without offering its models when flagged on", async () => { + const connectorRegistry = createConnectorRegistry({ + flags: createReviewTutorFlags({ get: () => true, set: () => {} }), + piModels: [{ id: "provider/model", label: "Model", thinkingLevels: ["low"] }], + which: async () => undefined, + }); + const { server } = await start(new ControlledRunner(), { registry: connectorRegistry }); + try { + const state = await (await call(server.port, server.token, "/api/state")).json() as { + models: Array<{ id: string }>; + harnesses: Array<{ id: string; available: boolean; reason?: string }>; + }; + expect(state.harnesses).toContainEqual({ + id: "claude-code", + label: "Claude Code", + available: false, + reason: "Claude Code is not installed (claude not found on PATH).", + }); + expect(state.models.some((model) => model.id.startsWith("claude-code:"))).toBe(false); + } finally { + await server.close(); + } + }); + it("reports namespaced state, resolves legacy Pi asks, rejects unknown harnesses, and exports harness details", async () => { const { server, runner } = await start(); try { @@ -352,6 +376,10 @@ describe("connector protocol boundary", () => { expect(runner.calls[1]).toMatchObject({ model: "ollama/qwen3:8b" }); runner.calls[1]!.deferred.resolve({ answer: "colon answer" }); await waitFor(() => runner.completedCalls === 2); + await waitFor(async () => { + const entries = await (await call(server.port, server.token, "/api/log")).json() as unknown[]; + return entries.length === 2; + }); const unknown = await call(server.port, server.token, "/api/ask", { method: "POST",