diff --git a/src/agent/__tests__/skills.test.ts b/src/agent/__tests__/skills.test.ts new file mode 100644 index 0000000..44f4f9e --- /dev/null +++ b/src/agent/__tests__/skills.test.ts @@ -0,0 +1,108 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +import { listSkills } from "../skills/list-skills"; +import { readSkillFrontMatter } from "../skills/skill-reader"; + +describe("readSkillFrontMatter", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "helixent-skill-reader-")); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + test("reads frontmatter from a SKILL.md file", async () => { + const skillPath = join(tempDir, "SKILL.md"); + await writeFile(skillPath, `--- +name: my-skill +description: A test skill +--- + +# My Skill + +Some content here. +`); + const result = await readSkillFrontMatter(skillPath); + expect(result.name).toBe("my-skill"); + expect(result.description).toBe("A test skill"); + expect(result.path).toBe(skillPath); + }); + + test("throws when file does not exist", async () => { + await expect(readSkillFrontMatter(join(tempDir, "nonexistent.md"))).rejects.toThrow("does not exist"); + }); + + test("handles SKILL.md with no frontmatter", async () => { + const skillPath = join(tempDir, "SKILL.md"); + await writeFile(skillPath, "Just plain content, no frontmatter."); + const result = await readSkillFrontMatter(skillPath); + expect(result.name).toBeUndefined(); + expect(result.description).toBeUndefined(); + expect(result.path).toBe(skillPath); + }); +}); + +describe("listSkills", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "helixent-list-skills-")); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + test("discovers skills from a directory with SKILL.md subfolders", async () => { + const skillDir = join(tempDir, "skills"); + await mkdir(join(skillDir, "skill-a"), { recursive: true }); + await writeFile( + join(skillDir, "skill-a", "SKILL.md"), + `---\nname: skill-a\ndescription: Skill A\n---\n`, + ); + await mkdir(join(skillDir, "skill-b"), { recursive: true }); + await writeFile( + join(skillDir, "skill-b", "SKILL.md"), + `---\nname: skill-b\ndescription: Skill B\n---\n`, + ); + + const skills = await listSkills([skillDir]); + expect(skills).toHaveLength(2); + const names = skills.map((s) => s.name); + expect(names).toContain("skill-a"); + expect(names).toContain("skill-b"); + }); + + test("skips directories without SKILL.md", async () => { + const skillDir = join(tempDir, "skills"); + await mkdir(join(skillDir, "no-skill"), { recursive: true }); + // No SKILL.md created + + const skills = await listSkills([skillDir]); + expect(skills).toHaveLength(0); + }); + + test("skips non-directory entries", async () => { + const skillDir = join(tempDir, "skills"); + await mkdir(skillDir); + await writeFile(join(skillDir, "readme.txt"), "not a directory"); + + const skills = await listSkills([skillDir]); + expect(skills).toHaveLength(0); + }); + + test("returns empty array for non-existent directory", async () => { + const skills = await listSkills([join(tempDir, "does-not-exist")]); + expect(skills).toHaveLength(0); + }); + + +}); diff --git a/src/agent/__tests__/todos.test.ts b/src/agent/__tests__/todos.test.ts new file mode 100644 index 0000000..89d7d4a --- /dev/null +++ b/src/agent/__tests__/todos.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test"; + +import { createTodoSystem } from "../todos/todos"; + +const mockContext = { prompt: "", messages: [], tools: [] } as never; + +describe("createTodoSystem", () => { + describe("tool invocation", () => { + test("replaces list when merge is false", async () => { + const { tool } = createTodoSystem(); + const result = await tool.invoke({ + todos: [ + { id: "1", content: "Task A", status: "pending" }, + { id: "2", content: "Task B", status: "in_progress" }, + ], + merge: false, + }); + expect(result).toContain("2 items"); + expect(result).toContain("1 pending"); + expect(result).toContain("1 in_progress"); + }); + + test("merges by id when merge is true", async () => { + const { tool } = createTodoSystem(); + await tool.invoke({ + todos: [ + { id: "1", content: "Task A", status: "pending" }, + { id: "2", content: "Task B", status: "pending" }, + ], + merge: false, + }); + + const result = await tool.invoke({ + todos: [{ id: "1", content: "Task A updated", status: "completed" }], + merge: true, + }); + expect(result).toContain("2 items"); + expect(result).toContain("1 completed"); + expect(result).toContain("1 pending"); + }); + + test("appends new items when merging with new ids", async () => { + const { tool } = createTodoSystem(); + await tool.invoke({ + todos: [{ id: "1", content: "Task A", status: "pending" }], + merge: false, + }); + + const result = await tool.invoke({ + todos: [{ id: "2", content: "Task B", status: "pending" }], + merge: true, + }); + expect(result).toContain("2 items"); + expect(result).toContain("2 pending"); + }); + + test("handles empty todo list", async () => { + const { tool } = createTodoSystem(); + const result = await tool.invoke({ todos: [], merge: false }); + expect(result).toContain("0 items"); + }); + + test("counts all status types correctly", async () => { + const { tool } = createTodoSystem(); + const result = await tool.invoke({ + todos: [ + { id: "1", content: "A", status: "pending" }, + { id: "2", content: "B", status: "in_progress" }, + { id: "3", content: "C", status: "completed" }, + { id: "4", content: "D", status: "cancelled" }, + ], + merge: false, + }); + expect(result).toContain("4 items"); + expect(result).toContain("1 pending"); + expect(result).toContain("1 in_progress"); + expect(result).toContain("1 completed"); + expect(result).toContain("1 cancelled"); + }); + }); + + describe("middleware", () => { + test("beforeModel does nothing when store is empty", async () => { + const { middleware } = createTodoSystem(); + const result = await middleware.beforeModel?.({ + modelContext: { prompt: "hello", messages: [] }, + agentContext: mockContext, + }); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/src/agent/__tests__/tool-result-summary.test.ts b/src/agent/__tests__/tool-result-summary.test.ts new file mode 100644 index 0000000..731ef5d --- /dev/null +++ b/src/agent/__tests__/tool-result-summary.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "bun:test"; + +import { summarizeToolResultText } from "../tool-result-summary"; + +describe("summarizeToolResultText", () => { + describe("error prefix passthrough", () => { + test("returns content as-is when it starts with 'Error:'", () => { + expect(summarizeToolResultText("Error: something went wrong")).toBe("Error: something went wrong"); + }); + + test("returns content as-is for 'Error:' with no additional text", () => { + expect(summarizeToolResultText("Error:")).toBe("Error:"); + }); + }); + + describe("JSON success path", () => { + test("returns summary when ok is true and summary is a string", () => { + expect(summarizeToolResultText(JSON.stringify({ ok: true, summary: "3 files found" }))).toBe("3 files found"); + }); + + test("returns null when ok is true but summary is not a string", () => { + expect(summarizeToolResultText(JSON.stringify({ ok: true, summary: 42 }))).toBeNull(); + }); + + test("returns null when ok is true but summary is missing", () => { + expect(summarizeToolResultText(JSON.stringify({ ok: true }))).toBeNull(); + }); + }); + + describe("JSON error path", () => { + test("returns formatted error with code when ok is false", () => { + expect(summarizeToolResultText(JSON.stringify({ ok: false, error: "not found", code: "FILE_NOT_FOUND" }))).toBe( + "Error [FILE_NOT_FOUND]: not found", + ); + }); + + test("prefers summary over error for the message when ok is false", () => { + expect( + summarizeToolResultText(JSON.stringify({ ok: false, summary: "custom msg", error: "raw error", code: "E1" })), + ).toBe("Error [E1]: custom msg"); + }); + + test("falls back to error string when summary is not a string and ok is false", () => { + expect(summarizeToolResultText(JSON.stringify({ ok: false, error: "fail", code: "E2" }))).toBe( + "Error [E2]: fail", + ); + }); + + test("returns formatted error without code when ok is false and code is missing", () => { + expect(summarizeToolResultText(JSON.stringify({ ok: false, summary: "bad input" }))).toBe("Error: bad input"); + }); + + test("returns formatted error using raw content when neither summary nor error is a string", () => { + const raw = JSON.stringify({ ok: false, summary: 123, error: 456 }); + expect(summarizeToolResultText(raw)).toBe(`Error: ${raw}`); + }); + }); + + describe("non-JSON content", () => { + test("returns null for plain text that is not an error", () => { + expect(summarizeToolResultText("some plain text output")).toBeNull(); + }); + + test("returns null for empty string", () => { + expect(summarizeToolResultText("")).toBeNull(); + }); + + test("returns null for invalid JSON", () => { + expect(summarizeToolResultText("{not valid json}")).toBeNull(); + }); + + test("returns null for JSON with neither ok true nor ok false", () => { + expect(summarizeToolResultText(JSON.stringify({ data: "stuff" }))).toBeNull(); + }); + }); +}); diff --git a/src/agent/agent-middleware.ts b/src/agent/agent-middleware.ts index 77e2a9b..386f230 100644 --- a/src/agent/agent-middleware.ts +++ b/src/agent/agent-middleware.ts @@ -126,7 +126,10 @@ export interface AgentMiddleware { * @param params - Hook parameters. * @returns Optional context updates to merge into `context`, or a skip instruction to bypass tool execution. */ - beforeToolUse?: (params: BeforeToolUseParams) => Promise; + beforeToolUse?: (params: { + agentContext: AgentContext; + toolUse: ToolUseContent>; + }) => Promise; /** * Runs immediately after a tool invocation resolves. * @param params - Hook parameters. diff --git a/src/cli/config/__tests__/schema.test.ts b/src/cli/config/__tests__/schema.test.ts new file mode 100644 index 0000000..de9be50 --- /dev/null +++ b/src/cli/config/__tests__/schema.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, test } from "bun:test"; + +import { helixentConfigSchema, modelEntrySchema } from "../schema"; + +describe("modelEntrySchema", () => { + test("accepts valid model entry with required fields", () => { + const result = modelEntrySchema.safeParse({ + name: "gpt-4", + baseURL: "https://api.openai.com/v1", + APIKey: "sk-xxx", + }); + expect(result.success).toBe(true); + }); + + test("accepts model entry with explicit openai provider", () => { + const result = modelEntrySchema.safeParse({ + name: "gpt-4", + baseURL: "https://api.openai.com/v1", + APIKey: "sk-xxx", + provider: "openai", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.provider).toBe("openai"); + } + }); + + test("accepts model entry with anthropic provider", () => { + const result = modelEntrySchema.safeParse({ + name: "claude-3", + baseURL: "https://api.anthropic.com", + APIKey: "sk-ant-xxx", + provider: "anthropic", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.provider).toBe("anthropic"); + } + }); + + test("defaults provider to openai when not specified", () => { + const result = modelEntrySchema.safeParse({ + name: "gpt-4", + baseURL: "https://api.openai.com/v1", + APIKey: "sk-xxx", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.provider).toBe("openai"); + } + }); + + test("rejects empty name", () => { + const result = modelEntrySchema.safeParse({ + name: "", + baseURL: "https://api.openai.com/v1", + APIKey: "sk-xxx", + }); + expect(result.success).toBe(false); + }); + + test("rejects empty baseURL", () => { + const result = modelEntrySchema.safeParse({ + name: "gpt-4", + baseURL: "", + APIKey: "sk-xxx", + }); + expect(result.success).toBe(false); + }); + + test("rejects empty APIKey", () => { + const result = modelEntrySchema.safeParse({ + name: "gpt-4", + baseURL: "https://api.openai.com/v1", + APIKey: "", + }); + expect(result.success).toBe(false); + }); + + test("rejects invalid provider", () => { + const result = modelEntrySchema.safeParse({ + name: "gpt-4", + baseURL: "https://api.openai.com/v1", + APIKey: "sk-xxx", + provider: "invalid", + }); + expect(result.success).toBe(false); + }); +}); + +describe("helixentConfigSchema", () => { + test("accepts valid config with models", () => { + const result = helixentConfigSchema.safeParse({ + models: [ + { name: "gpt-4", baseURL: "https://api.openai.com/v1", APIKey: "sk-xxx" }, + ], + }); + expect(result.success).toBe(true); + }); + + test("accepts config with defaultModel matching a model name", () => { + const result = helixentConfigSchema.safeParse({ + models: [ + { name: "gpt-4", baseURL: "https://api.openai.com/v1", APIKey: "sk-xxx" }, + ], + defaultModel: "gpt-4", + }); + expect(result.success).toBe(true); + }); + + test("rejects empty models array", () => { + const result = helixentConfigSchema.safeParse({ models: [] }); + expect(result.success).toBe(false); + }); + + test("rejects defaultModel that does not match any model name", () => { + const result = helixentConfigSchema.safeParse({ + models: [ + { name: "gpt-4", baseURL: "https://api.openai.com/v1", APIKey: "sk-xxx" }, + ], + defaultModel: "nonexistent", + }); + expect(result.success).toBe(false); + }); + + test("accepts multiple models", () => { + const result = helixentConfigSchema.safeParse({ + models: [ + { name: "gpt-4", baseURL: "https://api.openai.com/v1", APIKey: "sk-xxx" }, + { name: "claude-3", baseURL: "https://api.anthropic.com", APIKey: "sk-ant-xxx", provider: "anthropic" }, + ], + defaultModel: "claude-3", + }); + expect(result.success).toBe(true); + }); +}); diff --git a/src/cli/settings/__tests__/settings.test.ts b/src/cli/settings/__tests__/settings.test.ts new file mode 100644 index 0000000..c341cc1 --- /dev/null +++ b/src/cli/settings/__tests__/settings.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from "bun:test"; + +import { appendToolToAllowList, settingsSchema } from "../settings"; + +describe("settingsSchema", () => { + test("accepts valid settings with permissions", () => { + const result = settingsSchema.safeParse({ + permissions: { allow: ["bash", "write_file"] }, + }); + expect(result.success).toBe(true); + }); + + test("accepts empty object", () => { + const result = settingsSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + test("accepts unknown additional fields (passthrough)", () => { + const result = settingsSchema.safeParse({ + permissions: { allow: ["bash"] }, + someOtherField: "value", + }); + expect(result.success).toBe(true); + }); + + test("accepts permissions with additional fields (passthrough)", () => { + const result = settingsSchema.safeParse({ + permissions: { allow: ["bash"], deny: ["dangerous"] }, + }); + expect(result.success).toBe(true); + }); +}); + +describe("appendToolToAllowList", () => { + test("adds tool to empty allow list", () => { + const result = appendToolToAllowList({}, "bash"); + expect(result).toMatchObject({ + permissions: { allow: ["bash"] }, + }); + }); + + test("appends tool to existing allow list", () => { + const result = appendToolToAllowList( + { permissions: { allow: ["bash"] } }, + "write_file", + ); + expect(result).toMatchObject({ + permissions: { allow: ["bash", "write_file"] }, + }); + }); + + test("does not duplicate tool in allow list", () => { + const result = appendToolToAllowList( + { permissions: { allow: ["bash", "write_file"] } }, + "bash", + ); + expect(result).toMatchObject({ + permissions: { allow: ["bash", "write_file"] }, + }); + }); + + test("creates permissions object when it does not exist", () => { + const result = appendToolToAllowList({ otherKey: "value" }, "bash"); + expect(result).toMatchObject({ + otherKey: "value", + permissions: { allow: ["bash"] }, + }); + }); + + test("handles non-object permissions gracefully", () => { + const result = appendToolToAllowList({ permissions: "invalid" }, "bash"); + expect(result).toMatchObject({ + permissions: { allow: ["bash"] }, + }); + }); + + test("handles array permissions gracefully", () => { + const result = appendToolToAllowList({ permissions: ["not", "an", "object"] }, "bash"); + expect(result).toMatchObject({ + permissions: { allow: ["bash"] }, + }); + }); + + test("filters non-string entries from existing allow list", () => { + const result = appendToolToAllowList( + { permissions: { allow: ["bash", 42, null, "write_file"] } }, + "str_replace", + ); + expect(result).toMatchObject({ + permissions: { allow: ["bash", "write_file", "str_replace"] }, + }); + }); + + test("preserves other fields in the document", () => { + const result = appendToolToAllowList( + { theme: "dark", version: 2, permissions: { allow: ["bash"] } }, + "write_file", + ); + expect(result).toMatchObject({ + theme: "dark", + version: 2, + permissions: { allow: ["bash", "write_file"] }, + }); + }); +}); diff --git a/src/cli/tui/message-text.ts b/src/cli/tui/message-text.ts index 3a89644..424f2ac 100644 --- a/src/cli/tui/message-text.ts +++ b/src/cli/tui/message-text.ts @@ -1,5 +1,4 @@ -import { summarizeToolResultText } from "@/agent/tool-result-summary"; -import type { AssistantMessage, NonSystemMessage, ToolMessage, ToolUseContent, UserMessage } from "@/foundation"; +import type { AssistantMessage, NonSystemMessage, ToolUseContent, UserMessage } from "@/foundation"; const ESC = "\x1b["; const RESET = `${ESC}0m`; @@ -76,13 +75,4 @@ function toolUseText(content: ToolUseContent): string { } } -function toolMessageText(message: ToolMessage): string | null { - const parts: string[] = []; - for (const content of message.content) { - const summary = summarizeToolResultText(content.content); - if (summary) { - parts.push(`${dim("✓")} ${dim(summary)}`); - } - } - return parts.length > 0 ? parts.join("\n") : null; -} + diff --git a/src/coding/permissions/__tests__/approval-manager.test.ts b/src/coding/permissions/__tests__/approval-manager.test.ts new file mode 100644 index 0000000..dcc9a00 --- /dev/null +++ b/src/coding/permissions/__tests__/approval-manager.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "bun:test"; + +import type { ToolUseContent } from "@/foundation"; + +import { ApprovalManager } from "../approval-manager"; +import type { ApprovalDecision } from "../approval-types"; + +function makeToolUse(name: string): ToolUseContent { + return { type: "tool_use", id: "tc_1", name, input: {} }; +} + +describe("ApprovalManager", () => { + test("askUser queues a request and subscriber receives it", async () => { + const manager = new ApprovalManager(); + const toolUse = makeToolUse("bash"); + + const received: ToolUseContent[] = []; + manager.subscribe((req) => { + if (req) received.push(req.toolUse); + }); + + const promise = manager.askUser(toolUse); + expect(received).toHaveLength(1); + expect(received[0]!.name).toBe("bash"); + + // Resolve so the promise doesn't hang + manager.respond("allow_once"); + const decision = await promise; + expect(decision).toBe("allow_once"); + }); + + test("respond resolves the pending request with the decision", async () => { + const manager = new ApprovalManager(); + const toolUse = makeToolUse("write_file"); + + const promise = manager.askUser(toolUse); + manager.respond("deny"); + + const decision = await promise; + expect(decision).toBe("deny"); + }); + + test("respond does nothing when no request is pending", () => { + const manager = new ApprovalManager(); + expect(() => manager.respond("allow_once")).not.toThrow(); + }); + + test("processes queued requests sequentially", async () => { + const manager = new ApprovalManager(); + const decisions: ApprovalDecision[] = []; + + const p1 = manager.askUser(makeToolUse("bash")); + const p2 = manager.askUser(makeToolUse("write_file")); + + // Only the first should be active + manager.respond("allow_once"); + decisions.push(await p1); + + // After resolving first, second becomes active + manager.respond("deny"); + decisions.push(await p2); + + expect(decisions).toEqual(["allow_once", "deny"]); + }); + + test("subscriber receives null when queue empties", async () => { + const manager = new ApprovalManager(); + const events: (ToolUseContent | null)[] = []; + + manager.subscribe((req) => { + events.push(req?.toolUse ?? null); + }); + + const promise = manager.askUser(makeToolUse("bash")); + manager.respond("allow_once"); + await promise; + + // After resolving, subscriber should get null + expect(events).toContain(null); + }); + + test("subscribe returns unsubscribe function", async () => { + const manager = new ApprovalManager(); + const events: (ToolUseContent | null)[] = []; + + const unsubscribe = manager.subscribe((req) => { + events.push(req?.toolUse ?? null); + }); + + const promise = manager.askUser(makeToolUse("bash")); + manager.respond("allow_once"); + await promise; + + expect(events.length).toBeGreaterThan(0); + const countBefore = events.length; + + // After unsubscribing, new requests should not trigger callback + unsubscribe(); + const promise2 = manager.askUser(makeToolUse("write_file")); + manager.respond("deny"); + await promise2; + + // No new events after unsubscribe (the null from queue empty may have fired) + expect(events.length).toBe(countBefore); + }); +}); diff --git a/src/coding/permissions/__tests__/coding-approval-middleware.test.ts b/src/coding/permissions/__tests__/coding-approval-middleware.test.ts new file mode 100644 index 0000000..1df049d --- /dev/null +++ b/src/coding/permissions/__tests__/coding-approval-middleware.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, test } from "bun:test"; + +import type { AgentContext } from "@/agent"; +import type { ToolUseContent } from "@/foundation"; + +import type { ApprovalDecision } from "../approval-types"; +import { createCodingApprovalMiddleware } from "../coding-approval-middleware"; + +function makeToolUse(name: string): ToolUseContent { + return { type: "tool_use", id: "tc_1", name, input: {} }; +} + +const mockAgentContext: AgentContext = { prompt: "", messages: [], tools: [] }; + +describe("createCodingApprovalMiddleware", () => { + test("allows tools not in the requiresApproval list", async () => { + const middleware = createCodingApprovalMiddleware({ + cwd: "/tmp", + requiresApproval: ["bash", "write_file"], + askUser: async () => "deny" as ApprovalDecision, + }); + + const result = await middleware.beforeToolUse?.({ + agentContext: mockAgentContext, + toolUse: makeToolUse("read_file"), + }); + + expect(result).toBeUndefined(); + }); + + test("asks user for tools in the requiresApproval list", async () => { + let asked = false; + const middleware = createCodingApprovalMiddleware({ + cwd: "/tmp", + requiresApproval: ["bash"], + askUser: async () => { + asked = true; + return "allow_once" as ApprovalDecision; + }, + }); + + const result = await middleware.beforeToolUse?.({ + agentContext: mockAgentContext, + toolUse: makeToolUse("bash"), + }); + + expect(asked).toBe(true); + expect(result).toBeUndefined(); + }); + + test("skips approval when tool is in the allow list", async () => { + let asked = false; + const middleware = createCodingApprovalMiddleware({ + cwd: "/tmp", + requiresApproval: ["bash"], + askUser: async () => { + asked = true; + return "allow_once" as ApprovalDecision; + }, + approvalPersistence: { + loadAllowList: async () => new Set(["bash"]), + persistAllowedTool: async () => {}, + }, + }); + + const result = await middleware.beforeToolUse?.({ + agentContext: mockAgentContext, + toolUse: makeToolUse("bash"), + }); + + expect(asked).toBe(false); + expect(result).toBeUndefined(); + }); + + test("returns skip result when user denies", async () => { + const middleware = createCodingApprovalMiddleware({ + cwd: "/tmp", + requiresApproval: ["bash"], + askUser: async () => "deny" as ApprovalDecision, + }); + + const result = await middleware.beforeToolUse?.({ + agentContext: mockAgentContext, + toolUse: makeToolUse("bash"), + }); + + expect(result).toMatchObject({ + __skip: true, + result: expect.stringContaining("User denied execution of tool: bash"), + }); + }); + + test("persists tool when user allows always for project", async () => { + let persistedTool: string | undefined; + const middleware = createCodingApprovalMiddleware({ + cwd: "/tmp", + requiresApproval: ["bash"], + askUser: async () => "allow_always_project" as ApprovalDecision, + approvalPersistence: { + loadAllowList: async () => new Set(), + persistAllowedTool: async (_cwd, toolName) => { + persistedTool = toolName; + }, + }, + }); + + await middleware.beforeToolUse?.({ + agentContext: mockAgentContext, + toolUse: makeToolUse("bash"), + }); + + expect(persistedTool).toBe("bash"); + }); + + test("does not throw when persistence fails on allow_always_project", async () => { + const middleware = createCodingApprovalMiddleware({ + cwd: "/tmp", + requiresApproval: ["bash"], + askUser: async () => "allow_always_project" as ApprovalDecision, + approvalPersistence: { + loadAllowList: async () => new Set(), + persistAllowedTool: async () => { + throw new Error("disk full"); + }, + }, + }); + + // Should not throw despite persistence error + const result = await middleware.beforeToolUse?.({ + agentContext: mockAgentContext, + toolUse: makeToolUse("bash"), + }); + + expect(result).toBeUndefined(); + }); + + test("works without approvalPersistence", async () => { + const middleware = createCodingApprovalMiddleware({ + cwd: "/tmp", + requiresApproval: ["bash"], + askUser: async () => "allow_always_project" as ApprovalDecision, + }); + + const result = await middleware.beforeToolUse?.({ + agentContext: mockAgentContext, + toolUse: makeToolUse("bash"), + }); + + expect(result).toBeUndefined(); + }); +}); diff --git a/src/coding/permissions/__tests__/requires-approval.test.ts b/src/coding/permissions/__tests__/requires-approval.test.ts new file mode 100644 index 0000000..d9f70a2 --- /dev/null +++ b/src/coding/permissions/__tests__/requires-approval.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; + +import { CODING_TOOLS_REQUIRING_APPROVAL } from "../requires-approval"; + +describe("CODING_TOOLS_REQUIRING_APPROVAL", () => { + test("contains expected tool names", () => { + expect(CODING_TOOLS_REQUIRING_APPROVAL).toContain("bash"); + expect(CODING_TOOLS_REQUIRING_APPROVAL).toContain("write_file"); + expect(CODING_TOOLS_REQUIRING_APPROVAL).toContain("str_replace"); + expect(CODING_TOOLS_REQUIRING_APPROVAL).toContain("apply_patch"); + expect(CODING_TOOLS_REQUIRING_APPROVAL).toContain("mkdir"); + expect(CODING_TOOLS_REQUIRING_APPROVAL).toContain("move_path"); + }); + + test("is a non-empty array", () => { + expect(Array.isArray(CODING_TOOLS_REQUIRING_APPROVAL)).toBe(true); + expect(CODING_TOOLS_REQUIRING_APPROVAL.length).toBeGreaterThan(0); + }); + + test("has no duplicate entries", () => { + const unique = new Set(CODING_TOOLS_REQUIRING_APPROVAL); + expect(unique.size).toBe(CODING_TOOLS_REQUIRING_APPROVAL.length); + }); +}); diff --git a/src/coding/tools/__tests__/ask-user-question.test.ts b/src/coding/tools/__tests__/ask-user-question.test.ts new file mode 100644 index 0000000..180ecdd --- /dev/null +++ b/src/coding/tools/__tests__/ask-user-question.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, test } from "bun:test"; + +import { createAskUserQuestionTool, askUserQuestionParametersSchema } from "../ask-user-question"; +import type { AskUserQuestionParameters, AskUserQuestionResult } from "../ask-user-question"; + +describe("askUserQuestionParametersSchema", () => { + test("accepts valid single-select question", () => { + const result = askUserQuestionParametersSchema.safeParse({ + questions: [ + { + question: "Which language?", + header: "Language", + options: [ + { label: "TypeScript", description: "A typed superset of JS" }, + { label: "Python", description: "A dynamic language" }, + ], + multi_select: false, + }, + ], + }); + expect(result.success).toBe(true); + }); + + test("accepts valid multi-select question", () => { + const result = askUserQuestionParametersSchema.safeParse({ + questions: [ + { + question: "Which features?", + header: "Features", + options: [ + { label: "A", description: "Feature A" }, + { label: "B", description: "Feature B" }, + { label: "C", description: "Feature C" }, + ], + multi_select: true, + }, + ], + }); + expect(result.success).toBe(true); + }); + + test("rejects empty questions array", () => { + const result = askUserQuestionParametersSchema.safeParse({ questions: [] }); + expect(result.success).toBe(false); + }); + + test("rejects more than 4 questions", () => { + const q = { + question: "Q?", + header: "H", + options: [ + { label: "A", description: "A" }, + { label: "B", description: "B" }, + ], + multi_select: false, + }; + const result = askUserQuestionParametersSchema.safeParse({ questions: [q, q, q, q, q] }); + expect(result.success).toBe(false); + }); + + test("rejects question with fewer than 2 options", () => { + const result = askUserQuestionParametersSchema.safeParse({ + questions: [ + { + question: "Only one?", + header: "H", + options: [{ label: "A", description: "Only option" }], + multi_select: false, + }, + ], + }); + expect(result.success).toBe(false); + }); + + test("rejects question with more than 4 options", () => { + const result = askUserQuestionParametersSchema.safeParse({ + questions: [ + { + question: "Too many?", + header: "H", + options: [ + { label: "A", description: "A" }, + { label: "B", description: "B" }, + { label: "C", description: "C" }, + { label: "D", description: "D" }, + { label: "E", description: "E" }, + ], + multi_select: false, + }, + ], + }); + expect(result.success).toBe(false); + }); + + test("rejects header longer than 12 characters", () => { + const result = askUserQuestionParametersSchema.safeParse({ + questions: [ + { + question: "Q?", + header: "WayTooLongHeader", + options: [ + { label: "A", description: "A" }, + { label: "B", description: "B" }, + ], + multi_select: false, + }, + ], + }); + expect(result.success).toBe(false); + }); +}); + +describe("createAskUserQuestionTool", () => { + test("returns result from callback as JSON string", async () => { + const params: AskUserQuestionParameters = { + questions: [ + { + question: "Pick?", + header: "Pick", + options: [ + { label: "A", description: "Option A" }, + { label: "B", description: "Option B" }, + ], + multi_select: false, + }, + ], + }; + const expectedResult: AskUserQuestionResult = { + answers: [{ question_index: 0, selected_labels: ["A"] }], + }; + + const tool = createAskUserQuestionTool(async () => expectedResult); + const result = await tool.invoke(params); + expect(JSON.parse(result as string)).toEqual(expectedResult); + }); + + test("throws on abort signal before callback", async () => { + const tool = createAskUserQuestionTool(async () => ({ + answers: [{ question_index: 0, selected_labels: ["A"] }], + })); + + const ac = new AbortController(); + ac.abort(); + + await expect(tool.invoke( + { + questions: [ + { + question: "Q?", + header: "Q", + options: [ + { label: "A", description: "A" }, + { label: "B", description: "B" }, + ], + multi_select: false, + }, + ], + }, + ac.signal, + )).rejects.toThrow("Aborted"); + }); + + test("throws when answer count does not match question count", async () => { + const tool = createAskUserQuestionTool(async () => ({ + answers: [], // Missing answer + })); + + await expect( + tool.invoke({ + questions: [ + { + question: "Q?", + header: "Q", + options: [ + { label: "A", description: "A" }, + { label: "B", description: "B" }, + ], + multi_select: false, + }, + ], + }), + ).rejects.toThrow("expected 1 answers, got 0"); + }); + + test("throws when single-select question has multiple selections", async () => { + const tool = createAskUserQuestionTool(async () => ({ + answers: [{ question_index: 0, selected_labels: ["A", "B"] }], + })); + + await expect( + tool.invoke({ + questions: [ + { + question: "Q?", + header: "Q", + options: [ + { label: "A", description: "A" }, + { label: "B", description: "B" }, + ], + multi_select: false, + }, + ], + }), + ).rejects.toThrow("requires exactly one selection"); + }); + + test("throws when answer contains unknown label", async () => { + const tool = createAskUserQuestionTool(async () => ({ + answers: [{ question_index: 0, selected_labels: ["UNKNOWN"] }], + })); + + await expect( + tool.invoke({ + questions: [ + { + question: "Q?", + header: "Q", + options: [ + { label: "A", description: "A" }, + { label: "B", description: "B" }, + ], + multi_select: false, + }, + ], + }), + ).rejects.toThrow('unknown label "UNKNOWN"'); + }); + + test("throws when multi-select question has no selections", async () => { + const tool = createAskUserQuestionTool(async () => ({ + answers: [{ question_index: 0, selected_labels: [] }], + })); + + await expect( + tool.invoke({ + questions: [ + { + question: "Q?", + header: "Q", + options: [ + { label: "A", description: "A" }, + { label: "B", description: "B" }, + ], + multi_select: true, + }, + ], + }), + ).rejects.toThrow("requires at least one selection"); + }); + + test("throws when answer count does not match question count", async () => { + const tool = createAskUserQuestionTool(async () => ({ + answers: [{ question_index: 1, selected_labels: ["A"] }], // Only 1 answer for 2 questions + })); + + await expect( + tool.invoke({ + questions: [ + { + question: "Q1?", + header: "Q1", + options: [ + { label: "A", description: "A" }, + { label: "B", description: "B" }, + ], + multi_select: false, + }, + { + question: "Q2?", + header: "Q2", + options: [ + { label: "A", description: "A" }, + { label: "B", description: "B" }, + ], + multi_select: false, + }, + ], + }), + ).rejects.toThrow("expected 2 answers, got 1"); + }); +}); diff --git a/src/coding/tools/__tests__/tool-result.test.ts b/src/coding/tools/__tests__/tool-result.test.ts new file mode 100644 index 0000000..c1554cb --- /dev/null +++ b/src/coding/tools/__tests__/tool-result.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test"; + +import { errorToolResult, okToolResult } from "../tool-result"; + +describe("okToolResult", () => { + test("returns success result with data", () => { + const result = okToolResult("done", { value: 42 }); + expect(result).toEqual({ + ok: true, + summary: "done", + data: { value: 42 }, + }); + }); + + test("returns success result without data when data is undefined", () => { + const result = okToolResult("done", undefined); + expect(result).toEqual({ + ok: true, + summary: "done", + data: undefined, + }); + }); +}); + +describe("errorToolResult", () => { + test("returns error result with code and details", () => { + const result = errorToolResult("file not found", "FILE_NOT_FOUND", { path: "/tmp/x" }); + expect(result).toEqual({ + ok: false, + summary: "file not found", + error: "file not found", + code: "FILE_NOT_FOUND", + details: { path: "/tmp/x" }, + }); + }); + + test("returns error result without code", () => { + const result = errorToolResult("something failed"); + expect(result).toEqual({ + ok: false, + summary: "something failed", + error: "something failed", + }); + expect(result).not.toHaveProperty("code"); + expect(result).not.toHaveProperty("details"); + }); + + test("returns error result with code but no details", () => { + const result = errorToolResult("denied", "PERMISSION_DENIED"); + expect(result).toEqual({ + ok: false, + summary: "denied", + error: "denied", + code: "PERMISSION_DENIED", + }); + expect(result).not.toHaveProperty("details"); + }); +}); diff --git a/src/coding/tools/read-file.ts b/src/coding/tools/read-file.ts index 6b16f90..10ce084 100644 --- a/src/coding/tools/read-file.ts +++ b/src/coding/tools/read-file.ts @@ -2,7 +2,7 @@ import z from "zod"; import { defineTool } from "@/foundation"; -import { errorToolResult, okToolResult } from "./tool-result"; +import { errorToolResult } from "./tool-result"; import { ensureAbsolutePath, truncateText } from "./tool-utils"; const DEFAULT_MAX_CHARS = 12000; diff --git a/src/community/anthropic/__tests__/stream-utils.test.ts b/src/community/anthropic/__tests__/stream-utils.test.ts new file mode 100644 index 0000000..9b16f81 --- /dev/null +++ b/src/community/anthropic/__tests__/stream-utils.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, test } from "bun:test"; + +import { StreamAccumulator } from "../stream-utils"; + +describe("StreamAccumulator (Anthropic)", () => { + test("accumulates text from content_block_start and delta", () => { + const acc = new StreamAccumulator(); + acc.push({ + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "Hello" }, + } as never); + acc.push({ + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: " world" }, + } as never); + + const snapshot = acc.snapshot(); + expect(snapshot.content).toHaveLength(1); + expect(snapshot.content[0]).toMatchObject({ type: "text", text: "Hello world" }); + expect(snapshot.streaming).toBe(true); + }); + + test("accumulates thinking content", () => { + const acc = new StreamAccumulator(); + acc.push({ + type: "content_block_start", + index: 0, + content_block: { type: "thinking", thinking: "Let me " }, + } as never); + acc.push({ + type: "content_block_delta", + index: 0, + delta: { type: "thinking_delta", thinking: "think..." }, + } as never); + + const snapshot = acc.snapshot(); + expect(snapshot.content).toHaveLength(1); + const thinking = snapshot.content[0] as unknown as { type: string; thinking: string; _anthropicSignature?: string }; + expect(thinking.type).toBe("thinking"); + expect(thinking.thinking).toBe("Let me think..."); + }); + + test("preserves thinking signature", () => { + const acc = new StreamAccumulator(); + acc.push({ + type: "content_block_start", + index: 0, + content_block: { type: "thinking", thinking: "hmm", signature: "sig_abc" }, + } as never); + acc.push({ + type: "content_block_delta", + index: 0, + delta: { type: "signature_delta", signature: "sig_updated" }, + } as never); + + const snapshot = acc.snapshot(); + const thinking = snapshot.content[0] as unknown as { type: string; thinking: string; _anthropicSignature?: string }; + expect(thinking._anthropicSignature).toBe("sig_updated"); + }); + + test("accumulates tool_use input JSON progressively", () => { + const acc = new StreamAccumulator(); + acc.push({ + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "tu_1", name: "bash" }, + } as never); + acc.push({ + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: '{"command"' }, + } as never); + acc.push({ + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: ':"ls"}' }, + } as never); + + const snapshot = acc.snapshot(); + expect(snapshot.content).toHaveLength(1); + expect(snapshot.content[0]).toMatchObject({ + type: "tool_use", + id: "tu_1", + name: "bash", + input: { command: "ls" }, + }); + }); + + test("returns empty input for incomplete tool_use JSON", () => { + const acc = new StreamAccumulator(); + acc.push({ + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "tu_1", name: "bash" }, + } as never); + acc.push({ + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: '{"command"' }, + } as never); + + const snapshot = acc.snapshot(); + expect(snapshot.content).toHaveLength(1); + expect(snapshot.content[0]).toMatchObject({ + type: "tool_use", + id: "tu_1", + name: "bash", + input: {}, + }); + }); + + test("captures usage from message_start and message_delta", () => { + const acc = new StreamAccumulator(); + acc.push({ + type: "message_start", + message: { + id: "msg_1", + type: "message", + role: "assistant", + content: [], + model: "claude-3", + usage: { input_tokens: 20, output_tokens: 0 }, + }, + } as never); + acc.push({ + type: "message_delta", + delta: { stop_reason: "end_turn" }, + usage: { output_tokens: 10 }, + } as never); + + const snapshot = acc.snapshot(); + expect(snapshot.usage).toEqual({ promptTokens: 20, completionTokens: 10, totalTokens: 30 }); + expect(snapshot.streaming).toBeUndefined(); + }); + + test("handles multiple blocks in order", () => { + const acc = new StreamAccumulator(); + acc.push({ + type: "content_block_start", + index: 0, + content_block: { type: "thinking", thinking: "hmm" }, + } as never); + acc.push({ + type: "content_block_start", + index: 1, + content_block: { type: "text", text: "Answer" }, + } as never); + acc.push({ + type: "content_block_start", + index: 2, + content_block: { type: "tool_use", id: "tu_1", name: "bash" }, + } as never); + acc.push({ + type: "content_block_delta", + index: 2, + delta: { type: "input_json_delta", partial_json: '{"cmd":"x"}' }, + } as never); + + const snapshot = acc.snapshot(); + expect(snapshot.content).toHaveLength(3); + expect(snapshot.content[0]).toMatchObject({ type: "thinking" }); + expect(snapshot.content[1]).toMatchObject({ type: "text", text: "Answer" }); + expect(snapshot.content[2]).toMatchObject({ type: "tool_use", name: "bash" }); + }); + + test("ignores unknown event types", () => { + const acc = new StreamAccumulator(); + acc.push({ + type: "content_block_stop", + index: 0, + } as never); + acc.push({ + type: "message_stop", + } as never); + + const snapshot = acc.snapshot(); + expect(snapshot.content).toHaveLength(0); + }); + + test("returns empty content for text block with no text", () => { + const acc = new StreamAccumulator(); + acc.push({ + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "" }, + } as never); + + const snapshot = acc.snapshot(); + // Empty text blocks are filtered out + expect(snapshot.content).toHaveLength(0); + }); +}); diff --git a/src/community/anthropic/__tests__/utils.test.ts b/src/community/anthropic/__tests__/utils.test.ts new file mode 100644 index 0000000..47f41be --- /dev/null +++ b/src/community/anthropic/__tests__/utils.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, test } from "bun:test"; + +import type { Message } from "@/foundation"; + +import { + extractSystemPrompt, + convertToAnthropicMessages, + parseAssistantMessage, + convertToAnthropicTools, +} from "../utils"; + +describe("extractSystemPrompt", () => { + test("returns undefined when no system messages exist", () => { + expect(extractSystemPrompt([])).toBeUndefined(); + }); + + test("extracts text from a single system message", () => { + const messages: Message[] = [ + { role: "system", content: [{ type: "text", text: "You are helpful." }] }, + ]; + expect(extractSystemPrompt(messages)).toBe("You are helpful."); + }); + + test("joins multiple system messages with double newline", () => { + const messages: Message[] = [ + { role: "system", content: [{ type: "text", text: "Rule 1" }] }, + { role: "system", content: [{ type: "text", text: "Rule 2" }] }, + ]; + expect(extractSystemPrompt(messages)).toBe("Rule 1\n\nRule 2"); + }); + + test("joins multiple text blocks within a system message", () => { + const messages: Message[] = [ + { role: "system", content: [{ type: "text", text: "Part A" }, { type: "text", text: "Part B" }] }, + ]; + expect(extractSystemPrompt(messages)).toBe("Part A\n\nPart B"); + }); +}); + +describe("convertToAnthropicMessages", () => { + test("excludes system messages", () => { + const messages: Message[] = [ + { role: "system", content: [{ type: "text", text: "System prompt" }] }, + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ]; + const result = convertToAnthropicMessages(messages); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ role: "user" }); + }); + + test("converts user message with text", () => { + const messages: Message[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ]; + const result = convertToAnthropicMessages(messages); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ role: "user", content: [{ type: "text", text: "Hello" }] }); + }); + + test("converts user message with image_url", () => { + const messages: Message[] = [ + { + role: "user", + content: [{ type: "image_url", image_url: { url: "https://example.com/img.png" } }], + }, + ]; + const result = convertToAnthropicMessages(messages); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + role: "user", + content: [{ type: "image", source: { type: "url", url: "https://example.com/img.png" } }], + }); + }); + + test("converts assistant message with text and tool_use", () => { + const messages: Message[] = [ + { + role: "assistant", + content: [ + { type: "text", text: "I'll check." }, + { type: "tool_use", id: "tu_1", name: "bash", input: { command: "ls" } }, + ], + }, + ]; + const result = convertToAnthropicMessages(messages); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + role: "assistant", + content: [ + { type: "text", text: "I'll check." }, + { type: "tool_use", id: "tu_1", name: "bash", input: { command: "ls" } }, + ], + }); + }); + + test("converts assistant message with thinking content", () => { + const messages: Message[] = [ + { + role: "assistant", + content: [ + { type: "thinking", thinking: "Let me think..." } as unknown as { type: "text"; text: string }, + ], + }, + ]; + const result = convertToAnthropicMessages(messages); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + role: "assistant", + content: [{ type: "thinking", thinking: "Let me think..." }], + }); + }); + + test("converts tool messages as user messages with tool_result", () => { + const messages: Message[] = [ + { + role: "tool", + content: [{ type: "tool_result", tool_use_id: "tu_1", content: "file contents" }], + }, + ]; + const result = convertToAnthropicMessages(messages); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + role: "user", + content: [{ type: "tool_result", tool_use_id: "tu_1", content: "file contents" }], + }); + }); +}); + +describe("parseAssistantMessage (Anthropic)", () => { + test("parses text blocks", () => { + const result = parseAssistantMessage({ + id: "msg_1", + type: "message", + role: "assistant", + content: [{ type: "text", text: "Hello!" }], + model: "claude-3", + stop_reason: "end_turn", + usage: { input_tokens: 10, output_tokens: 5 }, + } as never); + expect(result.content).toHaveLength(1); + expect(result.content[0]).toMatchObject({ type: "text", text: "Hello!" }); + }); + + test("parses thinking blocks with signature", () => { + const result = parseAssistantMessage({ + id: "msg_1", + type: "message", + role: "assistant", + content: [{ type: "thinking", thinking: "hmm", signature: "sig_123" }], + model: "claude-3", + stop_reason: "end_turn", + usage: { input_tokens: 10, output_tokens: 5 }, + } as never); + expect(result.content).toHaveLength(1); + const thinking = result.content[0] as unknown as { type: string; thinking: string; _anthropicSignature?: string }; + expect(thinking.type).toBe("thinking"); + expect(thinking.thinking).toBe("hmm"); + expect(thinking._anthropicSignature).toBe("sig_123"); + }); + + test("parses tool_use blocks", () => { + const result = parseAssistantMessage({ + id: "msg_1", + type: "message", + role: "assistant", + content: [{ type: "tool_use", id: "tu_1", name: "bash", input: { command: "ls" } }], + model: "claude-3", + stop_reason: "tool_use", + usage: { input_tokens: 10, output_tokens: 5 }, + } as never); + expect(result.content).toHaveLength(1); + expect(result.content[0]).toMatchObject({ + type: "tool_use", + id: "tu_1", + name: "bash", + input: { command: "ls" }, + }); + }); + + test("includes usage when provided", () => { + const result = parseAssistantMessage( + { + id: "msg_1", + type: "message", + role: "assistant", + content: [], + model: "claude-3", + stop_reason: "end_turn", + usage: { input_tokens: 20, output_tokens: 10 }, + } as never, + { promptTokens: 20, completionTokens: 10, totalTokens: 30 }, + ); + expect(result.usage).toEqual({ promptTokens: 20, completionTokens: 10, totalTokens: 30 }); + }); +}); + +describe("convertToAnthropicTools", () => { + test("converts tools to Anthropic format", () => { + const tools = [ + { + name: "bash", + description: "Run a command", + parameters: { + toJSONSchema: () => ({ + type: "object", + properties: { command: { type: "string" } }, + required: ["command"], + }), + }, + }, + ]; + const result = convertToAnthropicTools(tools as never); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + name: "bash", + description: "Run a command", + input_schema: { + type: "object", + properties: { command: { type: "string" } }, + required: ["command"], + }, + }); + }); + + test("returns empty array for no tools", () => { + expect(convertToAnthropicTools([] as never)).toEqual([]); + }); +}); diff --git a/src/community/anthropic/stream-utils.ts b/src/community/anthropic/stream-utils.ts index dba42ba..1518df2 100644 --- a/src/community/anthropic/stream-utils.ts +++ b/src/community/anthropic/stream-utils.ts @@ -140,7 +140,7 @@ function blockToContent(block: BlockState): AssistantMessageContent[number] | nu // Preserve the signature so it can be sent back in multi-turn conversations. thinkingContent._anthropicSignature = block.signature; } - return thinkingContent as { type: "thinking"; thinking: string }; + return thinkingContent as never; } // tool_use return { type: "tool_use", id: block.id, name: block.name, input: parseToolInput(block.partialJson) }; diff --git a/src/community/anthropic/utils.ts b/src/community/anthropic/utils.ts index c54d8e8..89aee28 100644 --- a/src/community/anthropic/utils.ts +++ b/src/community/anthropic/utils.ts @@ -118,7 +118,7 @@ export function parseAssistantMessage( for (const block of response.content) { if (block.type === "text") { - result.content.push({ type: "text", text: block.text }); + result.content.push({ type: "text", text: block.text } as never); } else if (block.type === "thinking") { // Preserve the signature so it can be sent back in multi-turn conversations. // The signature is stored as an extra runtime property on the content object. @@ -129,14 +129,14 @@ export function parseAssistantMessage( if (block.signature) { thinkingContent._anthropicSignature = block.signature; } - result.content.push(thinkingContent as { type: "thinking"; thinking: string }); + result.content.push(thinkingContent as never); } else if (block.type === "tool_use") { result.content.push({ type: "tool_use", id: block.id, name: block.name, - input: block.input as Record, - }); + input: block.input, + } as never); } } diff --git a/src/community/openai/__tests__/stream-utils.test.ts b/src/community/openai/__tests__/stream-utils.test.ts new file mode 100644 index 0000000..9482dc0 --- /dev/null +++ b/src/community/openai/__tests__/stream-utils.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, test } from "bun:test"; + +import { StreamAccumulator } from "../stream-utils"; + +describe("StreamAccumulator (OpenAI)", () => { + test("accumulates text content from chunks", () => { + const acc = new StreamAccumulator(); + acc.push({ + id: "chatcmpl-1", + object: "chat.completion.chunk", + created: 0, + model: "gpt-4", + choices: [{ index: 0, delta: { content: "Hello" }, finish_reason: null }], + }); + acc.push({ + id: "chatcmpl-1", + object: "chat.completion.chunk", + created: 0, + model: "gpt-4", + choices: [{ index: 0, delta: { content: " world" }, finish_reason: null }], + }); + + const snapshot = acc.snapshot(); + expect(snapshot.content).toHaveLength(1); + expect(snapshot.content[0]).toMatchObject({ type: "text", text: "Hello world" }); + expect(snapshot.streaming).toBe(true); + }); + + test("accumulates reasoning_content", () => { + const acc = new StreamAccumulator(); + acc.push({ + id: "chatcmpl-1", + object: "chat.completion.chunk", + created: 0, + model: "gpt-4", + choices: [ + { index: 0, delta: { reasoning_content: "thinking" } as never, finish_reason: null }, + ], + }); + + const snapshot = acc.snapshot(); + expect(snapshot.content).toHaveLength(1); + expect(snapshot.content[0]).toMatchObject({ type: "thinking", thinking: "thinking" }); + }); + + test("accumulates tool calls across multiple chunks", () => { + const acc = new StreamAccumulator(); + acc.push({ + id: "chatcmpl-1", + object: "chat.completion.chunk", + created: 0, + model: "gpt-4", + choices: [ + { + index: 0, + delta: { tool_calls: [{ index: 0, id: "call_1", function: { name: "bash", arguments: "" } }] }, + finish_reason: null, + }, + ], + }); + acc.push({ + id: "chatcmpl-1", + object: "chat.completion.chunk", + created: 0, + model: "gpt-4", + choices: [ + { + index: 0, + delta: { tool_calls: [{ index: 0, function: { arguments: '{"command"' } }] }, + finish_reason: null, + }, + ], + }); + acc.push({ + id: "chatcmpl-1", + object: "chat.completion.chunk", + created: 0, + model: "gpt-4", + choices: [ + { + index: 0, + delta: { tool_calls: [{ index: 0, function: { arguments: ':"ls"}' } }] }, + finish_reason: null, + }, + ], + }); + + const snapshot = acc.snapshot(); + expect(snapshot.content).toHaveLength(1); + expect(snapshot.content[0]).toMatchObject({ + type: "tool_use", + id: "call_1", + name: "bash", + input: { command: "ls" }, + }); + }); + + test("withholds incomplete tool_use during streaming", () => { + const acc = new StreamAccumulator(); + acc.push({ + id: "chatcmpl-1", + object: "chat.completion.chunk", + created: 0, + model: "gpt-4", + choices: [ + { + index: 0, + delta: { tool_calls: [{ index: 0, id: "call_1", function: { name: "bash", arguments: '{"command' } }] }, + finish_reason: null, + }, + ], + }); + + // Arguments are incomplete JSON — tool_use should be withheld + const snapshot = acc.snapshot(); + expect(snapshot.content).toHaveLength(0); + }); + + test("includes tool_use with empty input on final snapshot even if JSON is incomplete", () => { + const acc = new StreamAccumulator(); + acc.push({ + id: "chatcmpl-1", + object: "chat.completion.chunk", + created: 0, + model: "gpt-4", + choices: [ + { + index: 0, + delta: { tool_calls: [{ index: 0, id: "call_1", function: { name: "bash", arguments: '{"command' } }] }, + finish_reason: null, + }, + ], + }); + // Final chunk with usage + acc.push({ + id: "chatcmpl-1", + object: "chat.completion.chunk", + created: 0, + model: "gpt-4", + choices: [], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }); + + const snapshot = acc.snapshot(); + expect(snapshot.content).toHaveLength(1); + expect(snapshot.content[0]).toMatchObject({ + type: "tool_use", + id: "call_1", + name: "bash", + input: {}, + }); + expect(snapshot.streaming).toBeUndefined(); + }); + + test("captures usage from final chunk", () => { + const acc = new StreamAccumulator(); + acc.push({ + id: "chatcmpl-1", + object: "chat.completion.chunk", + created: 0, + model: "gpt-4", + choices: [{ index: 0, delta: { content: "Hi" }, finish_reason: null }], + }); + acc.push({ + id: "chatcmpl-1", + object: "chat.completion.chunk", + created: 0, + model: "gpt-4", + choices: [], + usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 }, + }); + + const snapshot = acc.snapshot(); + expect(snapshot.usage).toEqual({ promptTokens: 10, completionTokens: 2, totalTokens: 12 }); + expect(snapshot.streaming).toBeUndefined(); + }); + + test("handles multiple tool calls in order", () => { + const acc = new StreamAccumulator(); + // First tool call + acc.push({ + id: "chatcmpl-1", + object: "chat.completion.chunk", + created: 0, + model: "gpt-4", + choices: [ + { + index: 0, + delta: { tool_calls: [{ index: 0, id: "call_1", function: { name: "bash", arguments: '{"cmd":"a"}' } }] }, + finish_reason: null, + }, + ], + }); + // Second tool call + acc.push({ + id: "chatcmpl-1", + object: "chat.completion.chunk", + created: 0, + model: "gpt-4", + choices: [ + { + index: 0, + delta: { tool_calls: [{ index: 1, id: "call_2", function: { name: "read_file", arguments: '{"path":"b"}' } }] }, + finish_reason: null, + }, + ], + }); + + const snapshot = acc.snapshot(); + expect(snapshot.content).toHaveLength(2); + expect(snapshot.content[0]).toMatchObject({ type: "tool_use", id: "call_1", name: "bash" }); + expect(snapshot.content[1]).toMatchObject({ type: "tool_use", id: "call_2", name: "read_file" }); + }); + + test("handles empty chunk gracefully", () => { + const acc = new StreamAccumulator(); + acc.push({ + id: "chatcmpl-1", + object: "chat.completion.chunk", + created: 0, + model: "gpt-4", + choices: [], + }); + + const snapshot = acc.snapshot(); + expect(snapshot.content).toHaveLength(0); + }); +}); diff --git a/src/community/openai/__tests__/utils.test.ts b/src/community/openai/__tests__/utils.test.ts new file mode 100644 index 0000000..9c50991 --- /dev/null +++ b/src/community/openai/__tests__/utils.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, test } from "bun:test"; + +import type { Message } from "@/foundation"; + +import { convertToOpenAIMessages, parseAssistantMessage, convertToOpenAITools } from "../utils"; + +describe("convertToOpenAIMessages", () => { + test("passes system messages through unchanged", () => { + const messages: Message[] = [ + { role: "system", content: [{ type: "text", text: "You are helpful." }] }, + ]; + const result = convertToOpenAIMessages(messages); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ role: "system", content: [{ type: "text", text: "You are helpful." }] }); + }); + + test("passes user messages through unchanged", () => { + const messages: Message[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ]; + const result = convertToOpenAIMessages(messages); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ role: "user", content: [{ type: "text", text: "Hello" }] }); + }); + + test("converts assistant message with text content", () => { + const messages: Message[] = [ + { role: "assistant", content: [{ type: "text", text: "Hi there" }] }, + ]; + const result = convertToOpenAIMessages(messages); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ role: "assistant" }); + expect((result[0] as { content: unknown[] }).content).toContainEqual({ type: "text", text: "Hi there" }); + }); + + test("converts assistant message with tool_use content", () => { + const messages: Message[] = [ + { + role: "assistant", + content: [ + { type: "tool_use", id: "call_1", name: "bash", input: { command: "ls" } }, + ], + }, + ]; + const result = convertToOpenAIMessages(messages); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + role: "assistant", + content: "", + tool_calls: [ + { + type: "function", + id: "call_1", + function: { name: "bash", arguments: '{"command":"ls"}' }, + }, + ], + }); + }); + + test("skips thinking content in assistant messages", () => { + const messages: Message[] = [ + { + role: "assistant", + content: [ + { type: "thinking", thinking: "Let me think..." }, + { type: "text", text: "The answer is 42." }, + ], + }, + ]; + const result = convertToOpenAIMessages(messages); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ role: "assistant" }); + expect((result[0] as { content: unknown[] }).content).toContainEqual({ type: "text", text: "The answer is 42." }); + expect((result[0] as { content: unknown[] }).content).not.toContainEqual( + expect.objectContaining({ type: "thinking" }), + ); + }); + + test("converts tool messages into separate tool role messages", () => { + const messages: Message[] = [ + { + role: "tool", + content: [ + { type: "tool_result", tool_use_id: "call_1", content: "output" }, + { type: "tool_result", tool_use_id: "call_2", content: "output2" }, + ], + }, + ]; + const result = convertToOpenAIMessages(messages); + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ role: "tool", tool_call_id: "call_1", content: "output" }); + expect(result[1]).toMatchObject({ role: "tool", tool_call_id: "call_2", content: "output2" }); + }); + + test("handles mixed assistant content (text + tool_use)", () => { + const messages: Message[] = [ + { + role: "assistant", + content: [ + { type: "text", text: "I'll run that." }, + { type: "tool_use", id: "call_1", name: "bash", input: { command: "echo hi" } }, + ], + }, + ]; + const result = convertToOpenAIMessages(messages); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + role: "assistant", + tool_calls: [{ type: "function", id: "call_1" }], + }); + expect((result[0] as { content: unknown[] }).content).toContainEqual({ type: "text", text: "I'll run that." }); + }); +}); + +describe("parseAssistantMessage", () => { + test("parses text content", () => { + const result = parseAssistantMessage({ + role: "assistant", + content: "Hello!", + } as never); + expect(result).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: "Hello!" }], + }); + }); + + test("parses reasoning_content as thinking", () => { + const result = parseAssistantMessage({ + role: "assistant", + content: "The answer", + reasoning_content: "Let me reason...", + } as never); + expect(result.content).toHaveLength(2); + expect(result.content[0]).toMatchObject({ type: "thinking", thinking: "Let me reason..." }); + expect(result.content[1]).toMatchObject({ type: "text", text: "The answer" }); + }); + + test("parses tool_calls", () => { + const result = parseAssistantMessage({ + role: "assistant", + content: null, + tool_calls: [ + { + type: "function", + id: "call_1", + function: { name: "bash", arguments: '{"command":"ls"}' }, + }, + ], + } as never); + expect(result.content).toHaveLength(1); + expect(result.content[0]).toMatchObject({ + type: "tool_use", + id: "call_1", + name: "bash", + input: { command: "ls" }, + }); + }); + + test("includes usage when provided", () => { + const result = parseAssistantMessage( + { role: "assistant", content: "Hi" } as never, + { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, + ); + expect(result.usage).toEqual({ promptTokens: 10, completionTokens: 5, totalTokens: 15 }); + }); + + test("handles empty content string", () => { + const result = parseAssistantMessage({ + role: "assistant", + content: "", + } as never); + expect(result.content).toHaveLength(1); + expect(result.content[0]).toMatchObject({ type: "text", text: "" }); + }); +}); + +describe("convertToOpenAITools", () => { + test("converts tools to OpenAI format", () => { + const tools = [ + { + name: "bash", + description: "Run a command", + parameters: { + toJSONSchema: () => ({ + type: "object", + properties: { command: { type: "string" } }, + required: ["command"], + }), + }, + }, + ]; + const result = convertToOpenAITools(tools as never); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: "function", + function: { + name: "bash", + description: "Run a command", + parameters: { + type: "object", + properties: { command: { type: "string" } }, + required: ["command"], + }, + }, + }); + }); + + test("returns empty array for no tools", () => { + expect(convertToOpenAITools([] as never)).toEqual([]); + }); +}); diff --git a/src/foundation/__tests__/tools.test.ts b/src/foundation/__tests__/tools.test.ts new file mode 100644 index 0000000..e2cb78c --- /dev/null +++ b/src/foundation/__tests__/tools.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "bun:test"; + +import { defineTool } from "../tools/function-tool"; +import type { StructuredToolResult } from "../tools/structured-tool-result"; + +describe("defineTool", () => { + test("creates a tool with the given name, description, and parameters", () => { + const tool = defineTool({ + name: "test_tool", + description: "A test tool", + parameters: { parse: () => ({}) } as never, + invoke: async () => "result", + }); + + expect(tool.name).toBe("test_tool"); + expect(tool.description).toBe("A test tool"); + }); + + test("invoke calls the provided function with input", async () => { + const tool = defineTool({ + name: "echo", + description: "Echoes input", + parameters: { parse: () => ({}) } as never, + invoke: async (input) => JSON.stringify(input), + }); + + const result = await tool.invoke({ message: "hello" } as never); + expect(result).toBe('{"message":"hello"}'); + }); + + test("invoke passes abort signal when provided", async () => { + const ac = new AbortController(); + ac.abort(); + + const tool = defineTool({ + name: "slow", + description: "Slow tool", + parameters: { parse: () => ({}) } as never, + invoke: async (_input, signal) => { + if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); + return "done"; + }, + }); + + await expect(tool.invoke({} as never, ac.signal)).rejects.toThrow("Aborted"); + }); +}); + +describe("StructuredToolResult types", () => { + test("success result shape is valid", () => { + const result: StructuredToolResult = { + ok: true, + summary: "done", + data: "payload", + }; + expect(result.ok).toBe(true); + expect(result.summary).toBe("done"); + expect(result.data).toBe("payload"); + }); + + test("error result shape is valid", () => { + const result: StructuredToolResult = { + ok: false, + summary: "failed", + error: "failed", + code: "ERR", + details: { path: "/tmp" }, + }; + expect(result.ok).toBe(false); + expect(result.code).toBe("ERR"); + expect(result.details).toEqual({ path: "/tmp" }); + }); + + test("error result without optional fields is valid", () => { + const result: StructuredToolResult = { + ok: false, + summary: "oops", + error: "oops", + }; + expect(result.ok).toBe(false); + expect(result.code).toBeUndefined(); + expect(result.details).toBeUndefined(); + }); +}); diff --git a/test.log b/test.log new file mode 100644 index 0000000..23d885f --- /dev/null +++ b/test.log @@ -0,0 +1,615 @@ +    ~/Desktop/helixent    test/complete-unit-tests !7 ▓▒░ bun test ░▒▓ ✔  22:21:33  +bun test v1.3.11 (af24e281) + +src/foundation/__tests__/tools.test.ts: +✓ defineTool > creates a tool with the given name, description, and parameters +✓ defineTool > invoke calls the provided function with input [0.20ms] +✓ defineTool > invoke passes abort signal when provided [0.11ms] +✓ StructuredToolResult types > success result shape is valid [0.16ms] +✓ StructuredToolResult types > error result shape is valid [0.02ms] +✓ StructuredToolResult types > error result without optional fields is valid [0.02ms] + +src/agent/__tests__/tool-result-policy.test.ts: +✓ getToolResultPolicy > returns summary-first policy for search and filesystem inspection tools [0.06ms] +✓ getToolResultPolicy > returns data-carrying policy for read_file [0.04ms] +✓ getToolResultPolicy > returns default policy for unknown tools + +src/agent/__tests__/skills.test.ts: +✓ readSkillFrontMatter > reads frontmatter from a SKILL.md file [3.29ms] +✓ readSkillFrontMatter > throws when file does not exist [0.38ms] +✓ readSkillFrontMatter > handles SKILL.md with no frontmatter [0.62ms] +✓ listSkills > discovers skills from a directory with SKILL.md subfolders [1.88ms] +✓ listSkills > skips directories without SKILL.md [0.72ms] +✓ listSkills > skips non-directory entries [0.71ms] +✓ listSkills > returns empty array for non-existent directory [0.19ms] + +src/agent/__tests__/todos.test.ts: +✓ createTodoSystem > tool invocation > replaces list when merge is false [1.41ms] +✓ createTodoSystem > tool invocation > merges by id when merge is true [0.37ms] +✓ createTodoSystem > tool invocation > appends new items when merging with new ids [0.17ms] +✓ createTodoSystem > tool invocation > handles empty todo list [0.14ms] +✓ createTodoSystem > tool invocation > counts all status types correctly [0.38ms] +✓ createTodoSystem > middleware > beforeModel does nothing when store is empty [0.34ms] + +src/agent/__tests__/tool-result-summary.test.ts: +✓ summarizeToolResultText > error prefix passthrough > returns content as-is when it starts with 'Error:' +✓ summarizeToolResultText > error prefix passthrough > returns content as-is for 'Error:' with no additional text +✓ summarizeToolResultText > JSON success path > returns summary when ok is true and summary is a string [0.11ms] +✓ summarizeToolResultText > JSON success path > returns null when ok is true but summary is not a string +✓ summarizeToolResultText > JSON success path > returns null when ok is true but summary is missing +✓ summarizeToolResultText > JSON error path > returns formatted error with code when ok is false +✓ summarizeToolResultText > JSON error path > prefers summary over error for the message when ok is false +✓ summarizeToolResultText > JSON error path > falls back to error string when summary is not a string and ok is false [0.08ms] +✓ summarizeToolResultText > JSON error path > returns formatted error without code when ok is false and code is missing +✓ summarizeToolResultText > JSON error path > returns formatted error using raw content when neither summary nor error is a string [0.02ms] +✓ summarizeToolResultText > non-JSON content > returns null for plain text that is not an error [0.06ms] +✓ summarizeToolResultText > non-JSON content > returns null for empty string +✓ summarizeToolResultText > non-JSON content > returns null for invalid JSON +✓ summarizeToolResultText > non-JSON content > returns null for JSON with neither ok true nor ok false + +src/agent/__tests__/tool-result-runtime.test.ts: +✓ inferToolErrorKind > maps common tool error code families +✓ normalizeToolResult > preserves structured success results [0.24ms] +✓ normalizeToolResult > preserves structured errors and infers error kind +✓ normalizeToolResult > normalizes legacy string errors +✓ normalizeToolResult > normalizes plain success strings +✓ formatToolResultForMessage > omits data for summary-first tools [0.17ms] +✓ formatToolResultForMessage > preserves data for content-carrying tools [0.10ms] +✓ formatToolResultForMessage > passes through raw read_file text results +✓ formatToolResultForMessage > passes through read_file text that starts with Error: verbatim [0.06ms] +✓ formatToolResultForMessage > formats errors with stable structured shape +✓ formatToolResultForMessage > always returns valid json when payload exceeds limits + +src/cli/settings/__tests__/settings-loader.test.ts: +✓ SettingsLoader > loadAllowList unions permissions.allow from user, project, and local files [3.08ms] +[helixent] Could not read /var/folders/qy/qgkbp2gd5f9g5398v2qw8zgr0000gn/T/helixent-settings-bhnoGA/helixent-home/settings.json; skipping settings layer. +✓ SettingsLoader > ignores invalid user layer and still merges project and local [1.39ms] +✓ SettingsLoader > last layer wins for non-allow keys under permissions [1.39ms] +✓ SettingsWriter > appendAllowedTool writes only to project settings.local.json [1.39ms] + +src/cli/settings/__tests__/settings.test.ts: +✓ settingsSchema > accepts valid settings with permissions [1.01ms] +✓ settingsSchema > accepts empty object [0.10ms] +✓ settingsSchema > accepts unknown additional fields (passthrough) [0.05ms] +✓ settingsSchema > accepts permissions with additional fields (passthrough) [0.03ms] +✓ appendToolToAllowList > adds tool to empty allow list [0.05ms] +✓ appendToolToAllowList > appends tool to existing allow list [0.16ms] +✓ appendToolToAllowList > does not duplicate tool in allow list [0.04ms] +✓ appendToolToAllowList > creates permissions object when it does not exist [0.05ms] +✓ appendToolToAllowList > handles non-object permissions gracefully [0.03ms] +✓ appendToolToAllowList > handles array permissions gracefully [0.03ms] +✓ appendToolToAllowList > filters non-string entries from existing allow list +✓ appendToolToAllowList > preserves other fields in the document [0.06ms] + +src/cli/config/__tests__/schema.test.ts: +✓ modelEntrySchema > accepts valid model entry with required fields [0.68ms] +✓ modelEntrySchema > accepts model entry with explicit openai provider [0.07ms] +✓ modelEntrySchema > accepts model entry with anthropic provider [0.02ms] +✓ modelEntrySchema > defaults provider to openai when not specified [0.03ms] +✓ modelEntrySchema > rejects empty name [0.25ms] +✓ modelEntrySchema > rejects empty baseURL [0.12ms] +✓ modelEntrySchema > rejects empty APIKey +✓ modelEntrySchema > rejects invalid provider [0.16ms] +✓ helixentConfigSchema > accepts valid config with models [0.15ms] +✓ helixentConfigSchema > accepts config with defaultModel matching a model name [0.05ms] +✓ helixentConfigSchema > rejects empty models array [0.04ms] +✓ helixentConfigSchema > rejects defaultModel that does not match any model name [0.10ms] +✓ helixentConfigSchema > accepts multiple models [0.05ms] + +src/cli/tui/__tests__/command-registry.test.ts: +✓ resolveBuiltinCommand > resolves a bare builtin [0.31ms] +✓ resolveBuiltinCommand > captures trailing args after a builtin [0.06ms] +✓ resolveBuiltinCommand > treats input with no leading slash the same way +✓ resolveBuiltinCommand > returns null for unknown commands and empty input [0.03ms] +✓ formatHelp > lists builtins and skills when called with no target [0.10ms] +✓ formatHelp > renders details for a single command [0.04ms] +✓ formatHelp > tolerates a leading slash and case in target [0.02ms] +✓ formatHelp > returns an error message for unknown targets [0.01ms] + +src/coding/tools/__tests__/list-files.test.ts: +✓ listFilesTool > lists directory entries recursively [2.23ms] +✓ listFilesTool > returns structured error for missing directory [0.51ms] + +src/coding/tools/__tests__/ask-user-question-manager.test.ts: +✓ AskUserQuestionManager > resolves requests in FIFO order [0.36ms] +✓ AskUserQuestionManager > notifies subscriber with current request [0.06ms] + +src/coding/tools/__tests__/ask-user-question.test.ts: +✓ askUserQuestionParametersSchema > accepts valid single-select question [0.59ms] +✓ askUserQuestionParametersSchema > accepts valid multi-select question [0.01ms] +✓ askUserQuestionParametersSchema > rejects empty questions array [0.08ms] +✓ askUserQuestionParametersSchema > rejects more than 4 questions [0.04ms] +✓ askUserQuestionParametersSchema > rejects question with fewer than 2 options [0.09ms] +✓ askUserQuestionParametersSchema > rejects question with more than 4 options [0.07ms] +✓ askUserQuestionParametersSchema > rejects header longer than 12 characters [0.01ms] +✓ createAskUserQuestionTool > returns result from callback as JSON string [0.24ms] +✓ createAskUserQuestionTool > throws on abort signal before callback [0.13ms] +✓ createAskUserQuestionTool > throws when answer count does not match question count [0.06ms] +✓ createAskUserQuestionTool > throws when single-select question has multiple selections +✓ createAskUserQuestionTool > throws when answer contains unknown label [0.14ms] +✓ createAskUserQuestionTool > throws when multi-select question has no selections [0.09ms] +✓ createAskUserQuestionTool > throws when answer count does not match question count [0.07ms] + +src/coding/tools/__tests__/str-replace.test.ts: +✓ strReplaceTool > replaces all occurrences when count is omitted [2.53ms] +✓ strReplaceTool > replaces at most count occurrences [1.08ms] +✓ strReplaceTool > returns unchanged when count is zero [0.59ms] +✓ strReplaceTool > returns error when file is missing [0.33ms] +✓ strReplaceTool > returns error when old is empty [0.53ms] +✓ strReplaceTool > returns error when old is not found [1.10ms] +✓ strReplaceTool > returns error for relative path [0.34ms] + +src/coding/tools/__tests__/apply-patch.test.ts: +✓ applyPatchTool > applies a simple patch to an existing file [1.63ms] +✓ applyPatchTool > rejects file deletion patches [0.72ms] +✓ applyPatchTool > fails when hunk counts do not match contents [0.82ms] + +src/coding/tools/__tests__/tool-utils.test.ts: +✓ tool-result helpers > okToolResult returns stable success shape [0.70ms] +✓ tool-result helpers > errorToolResult returns stable error shape [0.08ms] +✓ truncateText > does not truncate short text [0.01ms] +✓ truncateText > truncates long text with suffix [0.03ms] + +src/coding/tools/__tests__/write-file.test.ts: +✓ writeFileTool > writes content to an absolute path [1.00ms] +✓ writeFileTool > overwrites an existing file [0.74ms] +✓ writeFileTool > writes into an existing subdirectory [0.90ms] +✓ writeFileTool > creates parent directories when they do not exist [1.32ms] +✓ writeFileTool > returns error for relative path [0.59ms] + +src/coding/tools/__tests__/mkdir.test.ts: +✓ mkdirTool > creates a directory recursively [0.94ms] +✓ mkdirTool > returns structured error for relative path [0.27ms] + +src/coding/tools/__tests__/grep-search.test.ts: +✓ grepSearchTool > returns structured error for invalid directory [0.82ms] +✓ grepSearchTool > finds matches with ripgrep when available [1.25ms] + +src/coding/tools/__tests__/tool-result.test.ts: +✓ okToolResult > returns success result with data [0.04ms] +✓ okToolResult > returns success result without data when data is undefined [0.01ms] +✓ errorToolResult > returns error result with code and details [0.02ms] +✓ errorToolResult > returns error result without code [0.03ms] +✓ errorToolResult > returns error result with code but no details + +src/coding/tools/__tests__/move-path.test.ts: +✓ movePathTool > moves a file to a new path [1.15ms] +✓ movePathTool > returns structured error for relative source path [0.24ms] + +src/coding/tools/__tests__/glob-search.test.ts: +✓ globSearchTool > finds files matching a glob pattern [1.70ms] +✓ globSearchTool > returns structured error for invalid directory [0.37ms] + +src/coding/tools/__tests__/file-info.test.ts: +✓ fileInfoTool > returns metadata for a file [1.14ms] +✓ fileInfoTool > returns structured error for relative path [0.30ms] + +src/coding/tools/__tests__/bash.test.ts: +✓ bashTool > returns stdout for a successful command [10.38ms] +✓ bashTool > returns an error string when the command fails [5.07ms] + +src/coding/tools/__tests__/read-file.test.ts: +✓ readFileTool > returns raw content for whole-file reads [1.14ms] +✓ readFileTool > returns numbered lines for ranged reads [0.76ms] +✓ readFileTool > returns structured error for invalid range [0.53ms] +✓ readFileTool > returns structured error when file is missing [0.30ms] + +src/coding/permissions/__tests__/coding-approval-middleware.test.ts: +✓ createCodingApprovalMiddleware > allows tools not in the requiresApproval list [0.23ms] +✓ createCodingApprovalMiddleware > asks user for tools in the requiresApproval list +✓ createCodingApprovalMiddleware > skips approval when tool is in the allow list [0.10ms] +✓ createCodingApprovalMiddleware > returns skip result when user denies [0.09ms] +✓ createCodingApprovalMiddleware > persists tool when user allows always for project [0.09ms] +[helixent] Could not persist allow for bash: warn: disk full + at persistAllowedTool (/Users/yancy/Desktop/helixent/src/coding/permissions/__tests__/coding-approval-middleware.test.ts:119:21) + at (/Users/yancy/Desktop/helixent/src/coding/permissions/coding-approval-middleware.ts:37:17) + at async (/Users/yancy/Desktop/helixent/src/coding/permissions/__tests__/coding-approval-middleware.test.ts:125:37) + +✓ createCodingApprovalMiddleware > does not throw when persistence fails on allow_always_project [0.19ms] +✓ createCodingApprovalMiddleware > works without approvalPersistence + +src/coding/permissions/__tests__/requires-approval.test.ts: +✓ CODING_TOOLS_REQUIRING_APPROVAL > contains expected tool names +✓ CODING_TOOLS_REQUIRING_APPROVAL > is a non-empty array [0.15ms] +✓ CODING_TOOLS_REQUIRING_APPROVAL > has no duplicate entries + +src/coding/permissions/__tests__/approval-manager.test.ts: +✓ ApprovalManager > askUser queues a request and subscriber receives it [0.15ms] +✓ ApprovalManager > respond resolves the pending request with the decision [0.07ms] +✓ ApprovalManager > respond does nothing when no request is pending [0.02ms] +✓ ApprovalManager > processes queued requests sequentially +✓ ApprovalManager > subscriber receives null when queue empties [0.11ms] +✓ ApprovalManager > subscribe returns unsubscribe function [0.07ms] + +src/community/anthropic/__tests__/utils.test.ts: +✓ extractSystemPrompt > returns undefined when no system messages exist [0.03ms] +✓ extractSystemPrompt > extracts text from a single system message [0.14ms] +✓ extractSystemPrompt > joins multiple system messages with double newline [0.03ms] +✓ extractSystemPrompt > joins multiple text blocks within a system message [0.04ms] +✓ convertToAnthropicMessages > excludes system messages [0.09ms] +✓ convertToAnthropicMessages > converts user message with text [0.03ms] +✓ convertToAnthropicMessages > converts user message with image_url [0.03ms] +✓ convertToAnthropicMessages > converts assistant message with text and tool_use +✓ convertToAnthropicMessages > converts assistant message with thinking content +✓ convertToAnthropicMessages > converts tool messages as user messages with tool_result +✓ parseAssistantMessage (Anthropic) > parses text blocks [0.13ms] +✓ parseAssistantMessage (Anthropic) > parses thinking blocks with signature [0.03ms] +✓ parseAssistantMessage (Anthropic) > parses tool_use blocks [0.02ms] +✓ parseAssistantMessage (Anthropic) > includes usage when provided [0.08ms] +✓ convertToAnthropicTools > converts tools to Anthropic format [0.08ms] +✓ convertToAnthropicTools > returns empty array for no tools [0.01ms] + +src/community/anthropic/__tests__/stream-utils.test.ts: +✓ StreamAccumulator (Anthropic) > accumulates text from content_block_start and delta [0.33ms] +✓ StreamAccumulator (Anthropic) > accumulates thinking content [0.07ms] +✓ StreamAccumulator (Anthropic) > preserves thinking signature [0.02ms] +✓ StreamAccumulator (Anthropic) > accumulates tool_use input JSON progressively [0.11ms] +✓ StreamAccumulator (Anthropic) > returns empty input for incomplete tool_use JSON [0.09ms] +✓ StreamAccumulator (Anthropic) > captures usage from message_start and message_delta [0.06ms] +✓ StreamAccumulator (Anthropic) > handles multiple blocks in order [0.06ms] +✓ StreamAccumulator (Anthropic) > ignores unknown event types [0.02ms] +✓ StreamAccumulator (Anthropic) > returns empty content for text block with no text [0.01ms] + +src/community/openai/__tests__/utils.test.ts: +✓ convertToOpenAIMessages > passes system messages through unchanged [0.10ms] +✓ convertToOpenAIMessages > passes user messages through unchanged [0.02ms] +✓ convertToOpenAIMessages > converts assistant message with text content [0.02ms] +✓ convertToOpenAIMessages > converts assistant message with tool_use content +✓ convertToOpenAIMessages > skips thinking content in assistant messages +✓ convertToOpenAIMessages > converts tool messages into separate tool role messages [0.12ms] +✓ convertToOpenAIMessages > handles mixed assistant content (text + tool_use) +✓ parseAssistantMessage > parses text content [0.07ms] +✓ parseAssistantMessage > parses reasoning_content as thinking [0.02ms] +✓ parseAssistantMessage > parses tool_calls [0.02ms] +✓ parseAssistantMessage > includes usage when provided [0.02ms] +✓ parseAssistantMessage > handles empty content string +✓ convertToOpenAITools > converts tools to OpenAI format [0.07ms] +✓ convertToOpenAITools > returns empty array for no tools [0.01ms] + +src/community/openai/__tests__/stream-utils.test.ts: + +# Unhandled error between tests +------------------------------- +82 | delta: { tool_calls: [{ index: 0, function: { arguments: ':"ls"}' }] }, + ^ +error: Expected "}" but found "]" + at /Users/yancy/Desktop/helixent/src/community/openai/__tests__/stream-utils.test.ts:82:78 + +82 | delta: { tool_calls: [{ index: 0, function: { arguments: ':"ls"}' }] }, + ^ +error: Expected "]" but found "}" + at /Users/yancy/Desktop/helixent/src/community/openai/__tests__/stream-utils.test.ts:82:80 + +85 | ], + ^ +error: Expected identifier but found "]" + at /Users/yancy/Desktop/helixent/src/community/openai/__tests__/stream-utils.test.ts:85:7 + +86 | }); + ^ +error: Expected " =" but found "}" + at /Users/yancy/Desktop/helixent/src/community/openai/__tests__/stream-utils.test.ts:86:5 + +86 | }); + ^ +error: Unexpected ) + at /Users/yancy/Desktop/helixent/src/community/openai/__tests__/stream-utils.test.ts:86:6 +------------------------------- + + +1 tests failed: + + 197 pass + 1 fail + 1 error + 314 expect() calls +Ran 198 tests across 32 files. [162.00ms] + +    ~/Desktop/helixent    test/complete-unit-tests !7 ▓▒░ bun test ░▒▓ 1 ✘  22:21:35  +bun test v1.3.11 (af24e281) + +src/foundation/__tests__/tools.test.ts: +✓ defineTool > creates a tool with the given name, description, and parameters [0.80ms] +✓ defineTool > invoke calls the provided function with input [0.32ms] +✓ defineTool > invoke passes abort signal when provided [2.12ms] +✓ StructuredToolResult types > success result shape is valid +✓ StructuredToolResult types > error result shape is valid [0.18ms] +✓ StructuredToolResult types > error result without optional fields is valid [0.26ms] + +src/agent/__tests__/tool-result-policy.test.ts: +✓ getToolResultPolicy > returns summary-first policy for search and filesystem inspection tools [0.29ms] +✓ getToolResultPolicy > returns data-carrying policy for read_file [0.02ms] +✓ getToolResultPolicy > returns default policy for unknown tools [0.04ms] + +src/agent/__tests__/skills.test.ts: +✓ readSkillFrontMatter > reads frontmatter from a SKILL.md file [8.63ms] +✓ readSkillFrontMatter > throws when file does not exist [0.33ms] +✓ readSkillFrontMatter > handles SKILL.md with no frontmatter [0.52ms] +✓ listSkills > discovers skills from a directory with SKILL.md subfolders [2.12ms] +✓ listSkills > skips directories without SKILL.md [0.55ms] +✓ listSkills > skips non-directory entries [0.75ms] +✓ listSkills > returns empty array for non-existent directory [0.76ms] + +src/agent/__tests__/todos.test.ts: +✓ createTodoSystem > tool invocation > replaces list when merge is false [1.63ms] +✓ createTodoSystem > tool invocation > merges by id when merge is true [0.23ms] +✓ createTodoSystem > tool invocation > appends new items when merging with new ids [0.25ms] +✓ createTodoSystem > tool invocation > handles empty todo list [0.08ms] +✓ createTodoSystem > tool invocation > counts all status types correctly [0.38ms] +✓ createTodoSystem > middleware > beforeModel does nothing when store is empty [0.65ms] + +src/agent/__tests__/tool-result-summary.test.ts: +✓ summarizeToolResultText > error prefix passthrough > returns content as-is when it starts with 'Error:' [0.08ms] +✓ summarizeToolResultText > error prefix passthrough > returns content as-is for 'Error:' with no additional text [0.02ms] +✓ summarizeToolResultText > JSON success path > returns summary when ok is true and summary is a string [0.06ms] +✓ summarizeToolResultText > JSON success path > returns null when ok is true but summary is not a string [0.55ms] +✓ summarizeToolResultText > JSON success path > returns null when ok is true but summary is missing [0.03ms] +✓ summarizeToolResultText > JSON error path > returns formatted error with code when ok is false [0.02ms] +✓ summarizeToolResultText > JSON error path > prefers summary over error for the message when ok is false +✓ summarizeToolResultText > JSON error path > falls back to error string when summary is not a string and ok is false [0.09ms] +✓ summarizeToolResultText > JSON error path > returns formatted error without code when ok is false and code is missing +✓ summarizeToolResultText > JSON error path > returns formatted error using raw content when neither summary nor error is a string [0.02ms] +✓ summarizeToolResultText > non-JSON content > returns null for plain text that is not an error [0.03ms] +✓ summarizeToolResultText > non-JSON content > returns null for empty string [0.05ms] +✓ summarizeToolResultText > non-JSON content > returns null for invalid JSON [0.01ms] +✓ summarizeToolResultText > non-JSON content > returns null for JSON with neither ok true nor ok false [0.02ms] + +src/agent/__tests__/tool-result-runtime.test.ts: +✓ inferToolErrorKind > maps common tool error code families [0.08ms] +✓ normalizeToolResult > preserves structured success results [0.16ms] +✓ normalizeToolResult > preserves structured errors and infers error kind [0.02ms] +✓ normalizeToolResult > normalizes legacy string errors [0.02ms] +✓ normalizeToolResult > normalizes plain success strings [0.03ms] +✓ formatToolResultForMessage > omits data for summary-first tools [0.09ms] +✓ formatToolResultForMessage > preserves data for content-carrying tools [0.10ms] +✓ formatToolResultForMessage > passes through raw read_file text results [0.05ms] +✓ formatToolResultForMessage > passes through read_file text that starts with Error: verbatim +✓ formatToolResultForMessage > formats errors with stable structured shape +✓ formatToolResultForMessage > always returns valid json when payload exceeds limits [0.14ms] + +src/cli/settings/__tests__/settings-loader.test.ts: +✓ SettingsLoader > loadAllowList unions permissions.allow from user, project, and local files [4.01ms] +[helixent] Could not read /var/folders/qy/qgkbp2gd5f9g5398v2qw8zgr0000gn/T/helixent-settings-HiuvhZ/helixent-home/settings.json; skipping settings layer. +✓ SettingsLoader > ignores invalid user layer and still merges project and local [1.77ms] +✓ SettingsLoader > last layer wins for non-allow keys under permissions [1.69ms] +✓ SettingsWriter > appendAllowedTool writes only to project settings.local.json [2.16ms] + +src/cli/settings/__tests__/settings.test.ts: +✓ settingsSchema > accepts valid settings with permissions [0.65ms] +✓ settingsSchema > accepts empty object [0.04ms] +✓ settingsSchema > accepts unknown additional fields (passthrough) +✓ settingsSchema > accepts permissions with additional fields (passthrough) [0.06ms] +✓ appendToolToAllowList > adds tool to empty allow list [0.02ms] +✓ appendToolToAllowList > appends tool to existing allow list [0.03ms] +✓ appendToolToAllowList > does not duplicate tool in allow list +✓ appendToolToAllowList > creates permissions object when it does not exist +✓ appendToolToAllowList > handles non-object permissions gracefully [0.01ms] +✓ appendToolToAllowList > handles array permissions gracefully +✓ appendToolToAllowList > filters non-string entries from existing allow list [0.02ms] +✓ appendToolToAllowList > preserves other fields in the document [0.02ms] + +src/cli/config/__tests__/schema.test.ts: +✓ modelEntrySchema > accepts valid model entry with required fields [0.42ms] +✓ modelEntrySchema > accepts model entry with explicit openai provider [0.05ms] +✓ modelEntrySchema > accepts model entry with anthropic provider [0.02ms] +✓ modelEntrySchema > defaults provider to openai when not specified +✓ modelEntrySchema > rejects empty name [0.25ms] +✓ modelEntrySchema > rejects empty baseURL [0.09ms] +✓ modelEntrySchema > rejects empty APIKey [0.06ms] +✓ modelEntrySchema > rejects invalid provider [0.07ms] +✓ helixentConfigSchema > accepts valid config with models [0.17ms] +✓ helixentConfigSchema > accepts config with defaultModel matching a model name [0.04ms] +✓ helixentConfigSchema > rejects empty models array [0.07ms] +✓ helixentConfigSchema > rejects defaultModel that does not match any model name [0.08ms] +✓ helixentConfigSchema > accepts multiple models [0.07ms] + +src/cli/tui/__tests__/command-registry.test.ts: +✓ resolveBuiltinCommand > resolves a bare builtin [0.31ms] +✓ resolveBuiltinCommand > captures trailing args after a builtin [0.02ms] +✓ resolveBuiltinCommand > treats input with no leading slash the same way +✓ resolveBuiltinCommand > returns null for unknown commands and empty input [0.03ms] +✓ formatHelp > lists builtins and skills when called with no target [0.10ms] +✓ formatHelp > renders details for a single command +✓ formatHelp > tolerates a leading slash and case in target [0.07ms] +✓ formatHelp > returns an error message for unknown targets + +src/coding/tools/__tests__/list-files.test.ts: +✓ listFilesTool > lists directory entries recursively [2.85ms] +✓ listFilesTool > returns structured error for missing directory [0.48ms] + +src/coding/tools/__tests__/ask-user-question-manager.test.ts: +✓ AskUserQuestionManager > resolves requests in FIFO order [0.44ms] +✓ AskUserQuestionManager > notifies subscriber with current request [0.06ms] + +src/coding/tools/__tests__/ask-user-question.test.ts: +✓ askUserQuestionParametersSchema > accepts valid single-select question [0.57ms] +✓ askUserQuestionParametersSchema > accepts valid multi-select question [0.04ms] +✓ askUserQuestionParametersSchema > rejects empty questions array +✓ askUserQuestionParametersSchema > rejects more than 4 questions [0.15ms] +✓ askUserQuestionParametersSchema > rejects question with fewer than 2 options [0.07ms] +✓ askUserQuestionParametersSchema > rejects question with more than 4 options [0.10ms] +✓ askUserQuestionParametersSchema > rejects header longer than 12 characters [0.03ms] +✓ createAskUserQuestionTool > returns result from callback as JSON string [0.32ms] +✓ createAskUserQuestionTool > throws on abort signal before callback [0.09ms] +✓ createAskUserQuestionTool > throws when answer count does not match question count [0.08ms] +✓ createAskUserQuestionTool > throws when single-select question has multiple selections [0.07ms] +✓ createAskUserQuestionTool > throws when answer contains unknown label [0.01ms] +✓ createAskUserQuestionTool > throws when multi-select question has no selections [0.17ms] +✓ createAskUserQuestionTool > throws when answer count does not match question count [0.05ms] + +src/coding/tools/__tests__/str-replace.test.ts: +✓ strReplaceTool > replaces all occurrences when count is omitted [2.89ms] +✓ strReplaceTool > replaces at most count occurrences [1.31ms] +✓ strReplaceTool > returns unchanged when count is zero [0.66ms] +✓ strReplaceTool > returns error when file is missing [0.24ms] +✓ strReplaceTool > returns error when old is empty [0.44ms] +✓ strReplaceTool > returns error when old is not found [0.70ms] +✓ strReplaceTool > returns error for relative path [0.36ms] + +src/coding/tools/__tests__/apply-patch.test.ts: +✓ applyPatchTool > applies a simple patch to an existing file [2.31ms] +✓ applyPatchTool > rejects file deletion patches [0.61ms] +✓ applyPatchTool > fails when hunk counts do not match contents [0.93ms] + +src/coding/tools/__tests__/tool-utils.test.ts: +✓ tool-result helpers > okToolResult returns stable success shape [0.53ms] +✓ tool-result helpers > errorToolResult returns stable error shape [0.05ms] +✓ truncateText > does not truncate short text +✓ truncateText > truncates long text with suffix [0.02ms] + +src/coding/tools/__tests__/write-file.test.ts: +✓ writeFileTool > writes content to an absolute path [0.86ms] +✓ writeFileTool > overwrites an existing file [0.63ms] +✓ writeFileTool > writes into an existing subdirectory [0.63ms] +✓ writeFileTool > creates parent directories when they do not exist [0.90ms] +✓ writeFileTool > returns error for relative path [0.19ms] + +src/coding/tools/__tests__/mkdir.test.ts: +✓ mkdirTool > creates a directory recursively [1.38ms] +✓ mkdirTool > returns structured error for relative path [0.32ms] + +src/coding/tools/__tests__/grep-search.test.ts: +✓ grepSearchTool > returns structured error for invalid directory [1.44ms] +✓ grepSearchTool > finds matches with ripgrep when available [1.58ms] + +src/coding/tools/__tests__/tool-result.test.ts: +✓ okToolResult > returns success result with data +✓ okToolResult > returns success result without data when data is undefined +✓ errorToolResult > returns error result with code and details [0.24ms] +✓ errorToolResult > returns error result without code [0.08ms] +✓ errorToolResult > returns error result with code but no details + +src/coding/tools/__tests__/move-path.test.ts: +✓ movePathTool > moves a file to a new path [1.00ms] +✓ movePathTool > returns structured error for relative source path [0.23ms] + +src/coding/tools/__tests__/glob-search.test.ts: +✓ globSearchTool > finds files matching a glob pattern [2.39ms] +✓ globSearchTool > returns structured error for invalid directory [0.44ms] + +src/coding/tools/__tests__/file-info.test.ts: +✓ fileInfoTool > returns metadata for a file [1.57ms] +✓ fileInfoTool > returns structured error for relative path [0.60ms] + +src/coding/tools/__tests__/bash.test.ts: +✓ bashTool > returns stdout for a successful command [9.17ms] +✓ bashTool > returns an error string when the command fails [6.17ms] + +src/coding/tools/__tests__/read-file.test.ts: +✓ readFileTool > returns raw content for whole-file reads [1.17ms] +✓ readFileTool > returns numbered lines for ranged reads [0.73ms] +✓ readFileTool > returns structured error for invalid range [0.63ms] +✓ readFileTool > returns structured error when file is missing [0.26ms] + +src/coding/permissions/__tests__/coding-approval-middleware.test.ts: +✓ createCodingApprovalMiddleware > allows tools not in the requiresApproval list [0.33ms] +✓ createCodingApprovalMiddleware > asks user for tools in the requiresApproval list [0.09ms] +✓ createCodingApprovalMiddleware > skips approval when tool is in the allow list [0.08ms] +✓ createCodingApprovalMiddleware > returns skip result when user denies [0.07ms] +✓ createCodingApprovalMiddleware > persists tool when user allows always for project [0.06ms] +[helixent] Could not persist allow for bash: warn: disk full + at persistAllowedTool (/Users/yancy/Desktop/helixent/src/coding/permissions/__tests__/coding-approval-middleware.test.ts:119:21) + at (/Users/yancy/Desktop/helixent/src/coding/permissions/coding-approval-middleware.ts:37:17) + at async (/Users/yancy/Desktop/helixent/src/coding/permissions/__tests__/coding-approval-middleware.test.ts:125:37) + +✓ createCodingApprovalMiddleware > does not throw when persistence fails on allow_always_project [0.99ms] +✓ createCodingApprovalMiddleware > works without approvalPersistence [0.06ms] + +src/coding/permissions/__tests__/requires-approval.test.ts: +✓ CODING_TOOLS_REQUIRING_APPROVAL > contains expected tool names [0.05ms] +✓ CODING_TOOLS_REQUIRING_APPROVAL > is a non-empty array [0.27ms] +✓ CODING_TOOLS_REQUIRING_APPROVAL > has no duplicate entries [0.02ms] + +src/coding/permissions/__tests__/approval-manager.test.ts: +✓ ApprovalManager > askUser queues a request and subscriber receives it [0.22ms] +✓ ApprovalManager > respond resolves the pending request with the decision [0.05ms] +✓ ApprovalManager > respond does nothing when no request is pending [0.06ms] +✓ ApprovalManager > processes queued requests sequentially [0.08ms] +✓ ApprovalManager > subscriber receives null when queue empties [0.05ms] +✓ ApprovalManager > subscribe returns unsubscribe function [0.12ms] + +src/community/anthropic/__tests__/utils.test.ts: +✓ extractSystemPrompt > returns undefined when no system messages exist +✓ extractSystemPrompt > extracts text from a single system message [0.10ms] +✓ extractSystemPrompt > joins multiple system messages with double newline +✓ extractSystemPrompt > joins multiple text blocks within a system message +✓ convertToAnthropicMessages > excludes system messages [0.14ms] +✓ convertToAnthropicMessages > converts user message with text +✓ convertToAnthropicMessages > converts user message with image_url +✓ convertToAnthropicMessages > converts assistant message with text and tool_use [0.10ms] +✓ convertToAnthropicMessages > converts assistant message with thinking content [0.02ms] +✓ convertToAnthropicMessages > converts tool messages as user messages with tool_result [0.02ms] +✓ parseAssistantMessage (Anthropic) > parses text blocks [0.05ms] +✓ parseAssistantMessage (Anthropic) > parses thinking blocks with signature [0.02ms] +✓ parseAssistantMessage (Anthropic) > parses tool_use blocks +✓ parseAssistantMessage (Anthropic) > includes usage when provided +✓ convertToAnthropicTools > converts tools to Anthropic format [0.09ms] +✓ convertToAnthropicTools > returns empty array for no tools [0.03ms] + +src/community/anthropic/__tests__/stream-utils.test.ts: +✓ StreamAccumulator (Anthropic) > accumulates text from content_block_start and delta [0.23ms] +✓ StreamAccumulator (Anthropic) > accumulates thinking content [0.04ms] +✓ StreamAccumulator (Anthropic) > preserves thinking signature +✓ StreamAccumulator (Anthropic) > accumulates tool_use input JSON progressively [0.08ms] +✓ StreamAccumulator (Anthropic) > returns empty input for incomplete tool_use JSON [0.06ms] +✓ StreamAccumulator (Anthropic) > captures usage from message_start and message_delta [0.04ms] +✓ StreamAccumulator (Anthropic) > handles multiple blocks in order [0.05ms] +✓ StreamAccumulator (Anthropic) > ignores unknown event types [0.02ms] +✓ StreamAccumulator (Anthropic) > returns empty content for text block with no text + +src/community/openai/__tests__/utils.test.ts: +✓ convertToOpenAIMessages > passes system messages through unchanged +✓ convertToOpenAIMessages > passes user messages through unchanged [0.10ms] +✓ convertToOpenAIMessages > converts assistant message with text content [0.17ms] +✓ convertToOpenAIMessages > converts assistant message with tool_use content [0.10ms] +✓ convertToOpenAIMessages > skips thinking content in assistant messages [0.04ms] +✓ convertToOpenAIMessages > converts tool messages into separate tool role messages [0.03ms] +✓ convertToOpenAIMessages > handles mixed assistant content (text + tool_use) +✓ parseAssistantMessage > parses text content [0.08ms] +✓ parseAssistantMessage > parses reasoning_content as thinking [0.03ms] +✓ parseAssistantMessage > parses tool_calls +✓ parseAssistantMessage > includes usage when provided +✓ parseAssistantMessage > handles empty content string [0.06ms] +✓ convertToOpenAITools > converts tools to OpenAI format [0.06ms] +✓ convertToOpenAITools > returns empty array for no tools [0.04ms] + +src/community/openai/__tests__/stream-utils.test.ts: + +# Unhandled error between tests +------------------------------- +108 | delta: { tool_calls: [{ index: 0, id: "call_1", function: { name: "bash", arguments: '{"command' }] }, + ^ +error: Expected "}" but found "]" + at /Users/yancy/Desktop/helixent/src/community/openai/__tests__/stream-utils.test.ts:108:109 + +108 | delta: { tool_calls: [{ index: 0, id: "call_1", function: { name: "bash", arguments: '{"command' }] }, + ^ +error: Expected "]" but found "}" + at /Users/yancy/Desktop/helixent/src/community/openai/__tests__/stream-utils.test.ts:108:111 + +111 | ], + ^ +error: Expected identifier but found "]" + at /Users/yancy/Desktop/helixent/src/community/openai/__tests__/stream-utils.test.ts:111:7 + +112 | }); + ^ +error: Expected " =" but found "}" + at /Users/yancy/Desktop/helixent/src/community/openai/__tests__/stream-utils.test.ts:112:5 + +112 | }); + ^ +error: Unexpected ) + at /Users/yancy/Desktop/helixent/src/community/openai/__tests__/stream-utils.test.ts:112:6 +------------------------------- + + +1 tests failed: + + 197 pass + 1 fail + 1 error + 314 expect() calls +Ran 198 tests across 32 files. [271.00ms] + +    ~/Desktop/helixent    test/complete-unit-tests !7 ?1 ▓▒░ ░▒▓ 1 ✘  22:23:02  \ No newline at end of file