diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..00b6e26
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,31 @@
+name: Test
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Install dependencies
+ run: bun install
+
+ - name: Run linter
+ run: bunx oxlint
+
+ - name: Run type check
+ run: bun run type-check
+
+ - name: Run tests
+ run: bun test
diff --git a/bun.lock b/bun.lock
index 21f5fd6..0ab5bf2 100644
--- a/bun.lock
+++ b/bun.lock
@@ -15,7 +15,7 @@
"ink-spinner": "^5.0.0",
"ink-text-input": "^6.0.0",
"marked": "^16.3.0",
- "react": "^19.1.1",
+ "react": "^19.2.0",
},
"devDependencies": {
"@types/bun": "latest",
@@ -23,6 +23,7 @@
"@types/express": "^5.0.3",
"@types/react": "^19.1.16",
"husky": "^9.1.7",
+ "ink-testing-library": "^4.0.0",
"lint-staged": "^16.2.3",
"prettier": "^3.6.2",
},
@@ -246,6 +247,8 @@
"ink-spinner": ["ink-spinner@5.0.0", "", { "dependencies": { "cli-spinners": "^2.7.0" }, "peerDependencies": { "ink": ">=4.0.0", "react": ">=18.0.0" } }, "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA=="],
+ "ink-testing-library": ["ink-testing-library@4.0.0", "", { "peerDependencies": { "@types/react": ">=18.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q=="],
+
"ink-text-input": ["ink-text-input@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "type-fest": "^4.18.2" }, "peerDependencies": { "ink": ">=5", "react": ">=18" } }, "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
@@ -346,7 +349,7 @@
"raw-body": ["raw-body@3.0.1", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.7.0", "unpipe": "1.0.0" } }, "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA=="],
- "react": ["react@19.1.1", "", {}, "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ=="],
+ "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="],
"react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="],
diff --git a/package.json b/package.json
index 908a3c0..1f4cc43 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
"@types/express": "^5.0.3",
"@types/react": "^19.1.16",
"husky": "^9.1.7",
+ "ink-testing-library": "^4.0.0",
"lint-staged": "^16.2.3",
"prettier": "^3.6.2"
},
@@ -35,7 +36,7 @@
"ink-spinner": "^5.0.0",
"ink-text-input": "^6.0.0",
"marked": "^16.3.0",
- "react": "^19.1.1"
+ "react": "^19.2.0"
},
"lint-staged": {
"*.{ts,tsx,js,jsx}": [
diff --git a/src/__tests__/store.test.tsx b/src/__tests__/store.test.tsx
new file mode 100644
index 0000000..68c4cce
--- /dev/null
+++ b/src/__tests__/store.test.tsx
@@ -0,0 +1,417 @@
+import React from "react"
+import { render } from "ink-testing-library"
+import { test, expect, describe } from "bun:test"
+import { AgentStore } from "../store"
+import type { Message, McpServerStatus, ToolUse } from "../store"
+import { MessageQueue } from "../utils/MessageQueue"
+
+describe("Store", () => {
+ const setup = (initialState = {}) => {
+ let state: any
+ let actions: any
+
+ const TestComponent = () => {
+ state = AgentStore.useStoreState((state) => state)
+ actions = AgentStore.useStoreActions((actions) => actions)
+ return null
+ }
+
+ const { rerender } = render(
+
+
+
+ )
+
+ return {
+ getState: () => {
+ rerender(
+
+
+
+ )
+ return state
+ },
+ actions,
+ }
+ }
+
+ describe("initialization", () => {
+ test("should initialize with default state", () => {
+ const { getState } = setup()
+
+ expect(getState().chatHistory).toEqual([])
+ expect(getState().currentAssistantMessage).toBe("")
+ expect(getState().currentToolUses).toEqual([])
+ expect(getState().input).toBe("")
+ expect(getState().isProcessing).toBe(false)
+ expect(getState().mcpServers).toEqual([])
+ expect(getState().sessionId).toBeUndefined()
+ expect(getState().stats).toBeUndefined()
+ expect(getState().pendingToolPermission).toBeUndefined()
+ expect(getState().abortController).toBeUndefined()
+ })
+
+ test("should have MessageQueue instance", () => {
+ const { getState } = setup()
+
+ expect(getState().messageQueue).toBeInstanceOf(MessageQueue)
+ })
+
+ test("should expose all expected actions", () => {
+ const { actions } = setup()
+
+ expect(Object.keys(actions)).toEqual(
+ expect.arrayContaining([
+ "abortRequest",
+ "addChatHistoryEntry",
+ "addToolUse",
+ "appendCurrentAssistantMessage",
+ "clearCurrentAssistantMessage",
+ "clearToolUses",
+ "reset",
+ "sendMessage",
+ "setPendingToolPermission",
+ "setAbortController",
+ "setConfig",
+ "setcurrentAssistantMessage",
+ "setCurrentToolUses",
+ "setInput",
+ "setIsProcessing",
+ "setMcpServers",
+ "setSessionId",
+ "setStats",
+ ])
+ )
+ })
+ })
+
+ describe("computed properties", () => {
+ test("isBooted should be false when config is null", () => {
+ const { getState } = setup()
+
+ expect(getState().isBooted).toBe(false)
+ })
+
+ test("isBooted should be true when config is set", () => {
+ const { getState, actions } = setup()
+
+ actions.setConfig({
+ mcpServers: {},
+ })
+
+ expect(getState().isBooted).toBe(true)
+ })
+ })
+
+ describe("chat history actions", () => {
+ test("addChatHistoryEntry should add message to chat history", () => {
+ const { getState, actions } = setup()
+
+ const message: Message = {
+ type: "message",
+ role: "user",
+ content: "Hello",
+ }
+
+ actions.addChatHistoryEntry(message)
+
+ expect(getState().chatHistory).toHaveLength(1)
+ expect(getState().chatHistory[0]).toEqual(message)
+ })
+
+ test("addChatHistoryEntry should append to existing history", () => {
+ const { getState, actions } = setup()
+
+ const message1: Message = {
+ type: "message",
+ role: "user",
+ content: "Hello",
+ }
+
+ const message2: Message = {
+ type: "message",
+ role: "assistant",
+ content: "Hi there",
+ }
+
+ actions.addChatHistoryEntry(message1)
+ actions.addChatHistoryEntry(message2)
+
+ expect(getState().chatHistory).toHaveLength(2)
+ expect(getState().chatHistory).toEqual([message1, message2])
+ })
+ })
+
+ describe("assistant message actions", () => {
+ test("setcurrentAssistantMessage should set message", () => {
+ const { getState, actions } = setup()
+
+ actions.setcurrentAssistantMessage("Hello")
+
+ expect(getState().currentAssistantMessage).toBe("Hello")
+ })
+
+ test("appendCurrentAssistantMessage should append to current message", () => {
+ const { getState, actions } = setup()
+
+ actions.setcurrentAssistantMessage("Hello")
+ actions.appendCurrentAssistantMessage(" world")
+
+ expect(getState().currentAssistantMessage).toBe("Hello world")
+ })
+
+ test("clearCurrentAssistantMessage should clear message", () => {
+ const { getState, actions } = setup()
+
+ actions.setcurrentAssistantMessage("Hello")
+ actions.clearCurrentAssistantMessage()
+
+ expect(getState().currentAssistantMessage).toBe("")
+ })
+ })
+
+ describe("tool use actions", () => {
+ test("addToolUse should add tool to current tool uses", () => {
+ const { getState, actions } = setup()
+
+ const toolUse: ToolUse = {
+ type: "tool_use",
+ name: "test_tool",
+ input: { param: "value" },
+ }
+
+ actions.addToolUse(toolUse)
+
+ expect(getState().currentToolUses).toHaveLength(1)
+ expect(getState().currentToolUses[0]).toEqual(toolUse)
+ })
+
+ test("setCurrentToolUses should replace all tool uses", () => {
+ const { getState, actions } = setup()
+
+ const toolUse1: ToolUse = {
+ type: "tool_use",
+ name: "tool1",
+ input: {},
+ }
+
+ const toolUse2: ToolUse = {
+ type: "tool_use",
+ name: "tool2",
+ input: {},
+ }
+
+ actions.addToolUse(toolUse1)
+ actions.setCurrentToolUses([toolUse2])
+
+ expect(getState().currentToolUses).toHaveLength(1)
+ expect(getState().currentToolUses[0]).toEqual(toolUse2)
+ })
+
+ test("clearToolUses should clear all tool uses", () => {
+ const { getState, actions } = setup()
+
+ const toolUse: ToolUse = {
+ type: "tool_use",
+ name: "test_tool",
+ input: {},
+ }
+
+ actions.addToolUse(toolUse)
+ actions.clearToolUses()
+
+ expect(getState().currentToolUses).toEqual([])
+ })
+ })
+
+ describe("message sending actions", () => {
+ test("sendMessage should set processing state and clear input", () => {
+ const { getState, actions } = setup()
+
+ actions.setInput("Hello")
+ actions.sendMessage("Hello")
+
+ expect(getState().isProcessing).toBe(true)
+ expect(getState().input).toBe("")
+ expect(getState().stats).toBeNull()
+ })
+ })
+
+ describe("MCP server actions", () => {
+ test("setMcpServers should update server status", () => {
+ const { getState, actions } = setup()
+
+ const servers: McpServerStatus[] = [
+ { name: "server1", status: "connected" },
+ { name: "server2", status: "error" },
+ ]
+
+ actions.setMcpServers(servers)
+
+ expect(getState().mcpServers).toEqual(servers)
+ })
+ })
+
+ describe("session actions", () => {
+ test("setSessionId should update session ID", () => {
+ const { getState, actions } = setup()
+
+ actions.setSessionId("session-123")
+
+ expect(getState().sessionId).toBe("session-123")
+ })
+ })
+
+ describe("processing state actions", () => {
+ test("setIsProcessing should update processing state", () => {
+ const { getState, actions } = setup()
+
+ actions.setIsProcessing(true)
+
+ expect(getState().isProcessing).toBe(true)
+
+ actions.setIsProcessing(false)
+
+ expect(getState().isProcessing).toBe(false)
+ })
+ })
+
+ describe("input actions", () => {
+ test("setInput should update input value", () => {
+ const { getState, actions } = setup()
+
+ actions.setInput("test input")
+
+ expect(getState().input).toBe("test input")
+ })
+ })
+
+ describe("stats actions", () => {
+ test("setStats should update stats", () => {
+ const { getState, actions } = setup()
+
+ const statsString = "Cost: $0.01 | Duration: 2.5s"
+
+ actions.setStats(statsString)
+
+ expect(getState().stats).toBe(statsString)
+ })
+
+ test("setStats should accept null", () => {
+ const { getState, actions } = setup()
+
+ actions.setStats("some stats")
+ actions.setStats(null)
+
+ expect(getState().stats).toBeNull()
+ })
+ })
+
+ describe("config actions", () => {
+ test("setConfig should update configuration", () => {
+ const { getState, actions } = setup()
+
+ const config = {
+ mcpServers: {
+ server1: {
+ command: "node",
+ args: ["server.js"],
+ },
+ },
+ stream: true,
+ }
+
+ actions.setConfig(config)
+
+ expect(getState().config).toEqual(config)
+ })
+ })
+
+ describe("tool permission actions", () => {
+ test("setPendingToolPermission should set pending permission", () => {
+ const { getState, actions } = setup()
+
+ const permission = {
+ toolName: "test_tool",
+ input: { param: "value" },
+ }
+
+ actions.setPendingToolPermission(permission)
+
+ expect(getState().pendingToolPermission).toEqual(permission)
+ })
+
+ test("setPendingToolPermission should accept undefined", () => {
+ const { getState, actions } = setup()
+
+ actions.setPendingToolPermission({
+ toolName: "test",
+ input: {},
+ })
+ actions.setPendingToolPermission(undefined)
+
+ expect(getState().pendingToolPermission).toBeUndefined()
+ })
+ })
+
+ describe("abort controller actions", () => {
+ test("setAbortController should set abort controller", () => {
+ const { getState, actions } = setup()
+
+ const controller = new AbortController()
+
+ actions.setAbortController(controller)
+
+ expect(getState().abortController).toBe(controller)
+ })
+
+ test("abortRequest should call abort and set processing to false", () => {
+ const { getState, actions } = setup()
+
+ const controller = new AbortController()
+
+ actions.setAbortController(controller)
+ actions.setIsProcessing(true)
+ actions.abortRequest()
+
+ expect(controller.signal.aborted).toBe(true)
+ expect(getState().isProcessing).toBe(false)
+ })
+
+ test("abortRequest should handle undefined controller", () => {
+ const { getState, actions } = setup()
+
+ actions.setIsProcessing(true)
+ actions.abortRequest()
+
+ expect(getState().isProcessing).toBe(false)
+ })
+ })
+
+ describe("reset action", () => {
+ test("reset should clear all state except config and session", () => {
+ const { getState, actions } = setup()
+
+ actions.addChatHistoryEntry({
+ type: "message",
+ role: "user",
+ content: "Hello",
+ })
+ actions.setcurrentAssistantMessage("Assistant message")
+ actions.addToolUse({
+ type: "tool_use",
+ name: "tool",
+ input: {},
+ })
+ actions.setInput("input text")
+ actions.setStats("stats")
+ actions.reset()
+
+ expect(getState().chatHistory).toEqual([])
+ expect(getState().currentAssistantMessage).toBe("")
+ expect(getState().currentToolUses).toEqual([])
+ expect(getState().input).toBe("")
+ expect(getState().stats).toBeNull()
+ })
+ })
+})
diff --git a/src/components/__tests__/BlinkCaret.test.tsx b/src/components/__tests__/BlinkCaret.test.tsx
new file mode 100644
index 0000000..6c1a9c5
--- /dev/null
+++ b/src/components/__tests__/BlinkCaret.test.tsx
@@ -0,0 +1,30 @@
+import React from "react"
+import { render } from "ink-testing-library"
+import { test, expect, describe } from "bun:test"
+import { BlinkCaret } from "../BlinkCaret"
+
+describe("BlinkCaret", () => {
+ test("should render caret symbol", () => {
+ const { lastFrame } = render()
+
+ expect(lastFrame()).toContain("▷")
+ })
+
+ test("should not blink when disabled", () => {
+ const { lastFrame } = render()
+
+ expect(lastFrame()).toContain("▷")
+ })
+
+ test("should render when enabled", () => {
+ const { lastFrame } = render()
+
+ expect(lastFrame()).toContain("▷")
+ })
+
+ test("should cleanup on unmount", () => {
+ const { unmount } = render()
+
+ unmount()
+ })
+})
diff --git a/src/components/__tests__/ChatHeader.test.tsx b/src/components/__tests__/ChatHeader.test.tsx
new file mode 100644
index 0000000..489b187
--- /dev/null
+++ b/src/components/__tests__/ChatHeader.test.tsx
@@ -0,0 +1,103 @@
+import React from "react"
+import { render } from "ink-testing-library"
+import { test, expect, describe } from "bun:test"
+import { ChatHeader } from "../ChatHeader"
+import { AgentStore } from "../../store"
+
+describe("ChatHeader", () => {
+ test("should render title", () => {
+ const { lastFrame } = render(
+
+
+
+ )
+
+ expect(lastFrame()).toContain("@ Agent CLI")
+ })
+
+ test("should render instructions", () => {
+ const { lastFrame } = render(
+
+
+
+ )
+
+ expect(lastFrame()).toContain("Type your message and press Enter")
+ expect(lastFrame()).toContain("Type 'exit' to quit")
+ })
+
+ test("should show connecting message when no servers", () => {
+ const { lastFrame } = render(
+
+
+
+ )
+
+ expect(lastFrame()).toContain("Connecting to MCP servers...")
+ })
+
+ test("should display connected servers", () => {
+ const { lastFrame, rerender } = render(
+
+
+
+ )
+
+ rerender(
+
+
+
+ )
+
+ expect(lastFrame()).toContain("MCP Servers:")
+ expect(lastFrame()).toContain("github")
+ expect(lastFrame()).toContain("gitlab")
+ })
+
+ test("should show server status with colors", () => {
+ const { lastFrame, rerender } = render(
+
+
+
+ )
+
+ rerender(
+
+
+
+ )
+
+ expect(lastFrame()).toContain("github")
+ expect(lastFrame()).toContain("gitlab")
+ })
+})
+
+const TestWrapper = ({
+ servers,
+}: {
+ servers: Array<{ name: string; status: string }>
+}) => {
+ const actions = AgentStore.useStoreActions((actions) => actions)
+ actions.setMcpServers(servers)
+ return
+}
diff --git a/src/components/__tests__/Markdown.test.tsx b/src/components/__tests__/Markdown.test.tsx
new file mode 100644
index 0000000..191d4e3
--- /dev/null
+++ b/src/components/__tests__/Markdown.test.tsx
@@ -0,0 +1,93 @@
+import React from "react"
+import { render } from "ink-testing-library"
+import { test, expect, describe } from "bun:test"
+import { Markdown } from "../Markdown"
+
+describe("Markdown", () => {
+ test("should render plain text", () => {
+ const { lastFrame } = render(Hello world)
+
+ expect(lastFrame()).toContain("Hello world")
+ })
+
+ test("should render bold text", () => {
+ const { lastFrame } = render(**bold text**)
+
+ expect(lastFrame()).toContain("bold text")
+ })
+
+ test("should render italic text", () => {
+ const { lastFrame } = render(*italic text*)
+
+ expect(lastFrame()).toContain("italic text")
+ })
+
+ test("should render inline code", () => {
+ const { lastFrame } = render(`code`)
+
+ expect(lastFrame()).toContain("code")
+ })
+
+ test("should render code blocks", () => {
+ const { lastFrame } = render(
+ {`\`\`\`\nconst x = 1;\n\`\`\``}
+ )
+
+ expect(lastFrame()).toContain("const x = 1;")
+ })
+
+ test("should render headers", () => {
+ const { lastFrame } = render(# Heading)
+
+ expect(lastFrame()).toContain("Heading")
+ })
+
+ test("should render lists", () => {
+ const { lastFrame } = render(
+ {`- Item 1\n- Item 2\n- Item 3`}
+ )
+
+ expect(lastFrame()).toContain("Item 1")
+ expect(lastFrame()).toContain("Item 2")
+ expect(lastFrame()).toContain("Item 3")
+ expect(lastFrame()).toContain("•")
+ })
+
+ test("should render links", () => {
+ const { lastFrame } = render(
+ [link text](https://example.com)
+ )
+
+ expect(lastFrame()).toContain("link text")
+ })
+
+ test("should render strikethrough", () => {
+ const { lastFrame } = render(~~strikethrough~~)
+
+ expect(lastFrame()).toContain("strikethrough")
+ })
+
+ test("should render blockquotes", () => {
+ const content = "> Quote text"
+ const { lastFrame } = render({content})
+
+ expect(lastFrame()).toContain("Quote text")
+ })
+
+ test("should render horizontal rules", () => {
+ const { lastFrame } = render(---)
+
+ expect(lastFrame()).toContain("─")
+ })
+
+ test("should render mixed content", () => {
+ const { lastFrame } = render(
+ {`# Title\n\nSome **bold** and *italic* text with \`code\`.`}
+ )
+
+ expect(lastFrame()).toContain("Title")
+ expect(lastFrame()).toContain("bold")
+ expect(lastFrame()).toContain("italic")
+ expect(lastFrame()).toContain("code")
+ })
+})
diff --git a/src/components/__tests__/Stats.test.tsx b/src/components/__tests__/Stats.test.tsx
new file mode 100644
index 0000000..ac86960
--- /dev/null
+++ b/src/components/__tests__/Stats.test.tsx
@@ -0,0 +1,51 @@
+import React from "react"
+import { render } from "ink-testing-library"
+import { test, expect, describe } from "bun:test"
+import { Stats } from "../Stats"
+import { AgentStore } from "../../store"
+
+describe("Stats", () => {
+ test("should display stats string", () => {
+ const { lastFrame, rerender } = render(
+
+
+
+ )
+
+ rerender(
+
+
+
+ )
+
+ expect(lastFrame()).toContain("Cost: $0.01")
+ expect(lastFrame()).toContain("Duration: 2.5s")
+ expect(lastFrame()).toContain("Turns: 3")
+ })
+
+ test("should not render when stats are null", () => {
+ const { lastFrame } = render(
+
+
+
+ )
+
+ expect(lastFrame()).toBe("")
+ })
+
+ test("should not render when stats are undefined", () => {
+ const { lastFrame } = render(
+
+
+
+ )
+
+ expect(lastFrame()).toBe("")
+ })
+})
+
+const TestWrapper = ({ statsValue }: { statsValue: string }) => {
+ const actions = AgentStore.useStoreActions((actions) => actions)
+ actions.setStats(statsValue)
+ return
+}
diff --git a/src/components/__tests__/ToolPermissionPrompt.test.tsx b/src/components/__tests__/ToolPermissionPrompt.test.tsx
new file mode 100644
index 0000000..5a6f2c6
--- /dev/null
+++ b/src/components/__tests__/ToolPermissionPrompt.test.tsx
@@ -0,0 +1,137 @@
+import React from "react"
+import { render } from "ink-testing-library"
+import { test, expect, describe } from "bun:test"
+import { ToolPermissionPrompt } from "../ToolPermissionPrompt"
+import { AgentStore } from "../../store"
+
+describe("ToolPermissionPrompt", () => {
+ test("should not render when no pending permission", () => {
+ const { lastFrame } = render(
+
+
+
+ )
+
+ expect(lastFrame()).toBe("")
+ })
+
+ test("should render permission request with MCP tool", () => {
+ const { lastFrame, rerender } = render(
+
+
+
+ )
+
+ rerender(
+
+
+
+ )
+
+ expect(lastFrame()).toContain("[Tool Permission Request]")
+ expect(lastFrame()).toContain("Tool:")
+ expect(lastFrame()).toContain("[github]")
+ expect(lastFrame()).toContain("search_repositories")
+ })
+
+ test("should render permission request with regular tool", () => {
+ const { lastFrame, rerender } = render(
+
+
+
+ )
+
+ rerender(
+
+
+
+ )
+
+ expect(lastFrame()).toContain("[Tool Permission Request]")
+ expect(lastFrame()).toContain("regular_tool")
+ })
+
+ test("should show prompt text", () => {
+ const { lastFrame, rerender } = render(
+
+
+
+ )
+
+ rerender(
+
+
+
+ )
+
+ expect(lastFrame()).toContain("Allow?")
+ expect(lastFrame()).toContain("Enter=yes")
+ expect(lastFrame()).toContain("ESC=no")
+ })
+
+ test("should show blink caret", () => {
+ const { lastFrame, rerender } = render(
+
+
+
+ )
+
+ rerender(
+
+
+
+ )
+
+ expect(lastFrame()).toContain("▷")
+ })
+})
+
+const TestWrapper = ({
+ permission,
+}: {
+ permission: { toolName: string; input: any }
+}) => {
+ const actions = AgentStore.useStoreActions((actions) => actions)
+ actions.setPendingToolPermission(permission)
+ return
+}
diff --git a/src/components/__tests__/ToolUses.test.tsx b/src/components/__tests__/ToolUses.test.tsx
new file mode 100644
index 0000000..728edc6
--- /dev/null
+++ b/src/components/__tests__/ToolUses.test.tsx
@@ -0,0 +1,224 @@
+import React from "react"
+import { render } from "ink-testing-library"
+import { test, expect, describe } from "bun:test"
+import { ToolUses } from "../ToolUses"
+import { AgentStore } from "../../store"
+import type { ToolUse } from "../../store"
+
+describe("ToolUses", () => {
+ test("should display MCP tool with server name", () => {
+ const toolUse: ToolUse = {
+ type: "tool_use",
+ name: "mcp__github__search_repositories",
+ input: { query: "test" },
+ }
+
+ const { lastFrame, rerender } = render(
+
+
+
+ )
+
+ rerender(
+
+
+
+ )
+
+ expect(lastFrame()).toContain("[github]")
+ expect(lastFrame()).toContain("search_repositories")
+ })
+
+ test("should display regular tool without server name", () => {
+ const toolUse: ToolUse = {
+ type: "tool_use",
+ name: "regular_tool",
+ input: { param: "value" },
+ }
+
+ const { lastFrame, rerender } = render(
+
+
+
+ )
+
+ rerender(
+
+
+
+ )
+
+ expect(lastFrame()).toContain("[tool] regular_tool")
+ })
+
+ test("should display formatted tool input", () => {
+ const toolUse: ToolUse = {
+ type: "tool_use",
+ name: "test_tool",
+ input: { param1: "value1", param2: "value2" },
+ }
+
+ const { lastFrame, rerender } = render(
+
+
+
+ )
+
+ rerender(
+
+
+
+ )
+
+ expect(lastFrame()).toContain("param1")
+ expect(lastFrame()).toContain("value1")
+ expect(lastFrame()).toContain("param2")
+ expect(lastFrame()).toContain("value2")
+ })
+
+ test("should show denied tool indicator", () => {
+ const toolUse: ToolUse = {
+ type: "tool_use",
+ name: "mcp__github__search",
+ input: {},
+ }
+
+ const { lastFrame, rerender } = render(
+
+
+
+ )
+
+ rerender(
+
+
+
+ )
+
+ expect(lastFrame()).toContain("✖ Tool denied by configuration")
+ })
+
+ test("should not show denied indicator for allowed tools", () => {
+ const toolUse: ToolUse = {
+ type: "tool_use",
+ name: "mcp__github__create_issue",
+ input: {},
+ }
+
+ const { lastFrame, rerender } = render(
+
+
+
+ )
+
+ rerender(
+
+
+
+ )
+
+ expect(lastFrame()).not.toContain("✖ Tool denied by configuration")
+ })
+
+ test("should handle empty input", () => {
+ const toolUse: ToolUse = {
+ type: "tool_use",
+ name: "test_tool",
+ input: {},
+ }
+
+ const { lastFrame, rerender } = render(
+
+
+
+ )
+
+ rerender(
+
+
+
+ )
+
+ expect(lastFrame()).toContain("test_tool")
+ })
+
+ test("should handle multiline input", () => {
+ const toolUse: ToolUse = {
+ type: "tool_use",
+ name: "test_tool",
+ input: { query: "line1\nline2\nline3" },
+ }
+
+ const { lastFrame, rerender } = render(
+
+
+
+ )
+
+ rerender(
+
+
+
+ )
+
+ expect(lastFrame()).toContain("line1")
+ expect(lastFrame()).toContain("line2")
+ expect(lastFrame()).toContain("line3")
+ })
+})
+
+const TestWrapper = ({
+ toolUse,
+ config,
+}: {
+ toolUse: ToolUse
+ config: any
+}) => {
+ const actions = AgentStore.useStoreActions((actions) => actions)
+ actions.setConfig(config)
+ return
+}
diff --git a/src/components/__tests__/UserInput.test.tsx b/src/components/__tests__/UserInput.test.tsx
new file mode 100644
index 0000000..f1f3776
--- /dev/null
+++ b/src/components/__tests__/UserInput.test.tsx
@@ -0,0 +1,53 @@
+import React from "react"
+import { render } from "ink-testing-library"
+import { test, expect, describe } from "bun:test"
+import { UserInput } from "../UserInput"
+import { AgentStore } from "../../store"
+
+describe("UserInput", () => {
+ test("should render input field", () => {
+ const { lastFrame } = render(
+
+
+
+ )
+
+ expect(lastFrame()).toContain("▷")
+ })
+
+ test("should show blink caret when servers are connected", () => {
+ const { lastFrame, rerender } = render(
+
+
+
+ )
+
+ rerender(
+
+
+
+ )
+
+ expect(lastFrame()).toContain("▷")
+ })
+
+ test("should not show blink caret when no servers", () => {
+ const { lastFrame } = render(
+
+
+
+ )
+
+ expect(lastFrame()).toContain("▷")
+ })
+})
+
+const TestWrapper = ({
+ servers,
+}: {
+ servers: Array<{ name: string; status: string }>
+}) => {
+ const actions = AgentStore.useStoreActions((actions) => actions)
+ actions.setMcpServers(servers)
+ return
+}
diff --git a/src/hooks/__tests__/useCycleMessages.test.tsx b/src/hooks/__tests__/useCycleMessages.test.tsx
new file mode 100644
index 0000000..15c178a
--- /dev/null
+++ b/src/hooks/__tests__/useCycleMessages.test.tsx
@@ -0,0 +1,230 @@
+import React from "react"
+import { render } from "ink-testing-library"
+import { test, expect, describe } from "bun:test"
+import { useCycleMessages } from "../useCycleMessages"
+import { AgentStore } from "../../store"
+
+describe("useCycleMessages", () => {
+ test("should initialize with no messages", () => {
+ const { rerender } = render(
+
+
+
+ )
+
+ rerender(
+
+
+
+ )
+
+ expect(true).toBe(true)
+ })
+
+ test("should filter user messages from chat history", () => {
+ const { rerender } = render(
+
+
+
+ )
+
+ rerender(
+
+
+
+ )
+
+ expect(true).toBe(true)
+ })
+
+ test("should expose reset function", () => {
+ let resetFn: (() => void) | undefined
+
+ const TestComponentWithReset = () => {
+ const { reset } = useCycleMessages()
+ resetFn = reset
+ return null
+ }
+
+ render(
+
+
+
+ )
+
+ expect(typeof resetFn).toBe("function")
+ })
+
+ test("should handle empty chat history", () => {
+ const { rerender } = render(
+
+
+
+ )
+
+ rerender(
+
+
+
+ )
+
+ expect(true).toBe(true)
+ })
+
+ test("should handle chat history with only assistant messages", () => {
+ const { rerender } = render(
+
+
+
+ )
+
+ rerender(
+
+
+
+ )
+
+ expect(true).toBe(true)
+ })
+
+ test("should handle multiple user messages", () => {
+ const { rerender } = render(
+
+
+
+ )
+
+ rerender(
+
+
+
+ )
+
+ expect(true).toBe(true)
+ })
+})
+
+const TestComponent = () => {
+ useCycleMessages()
+ return null
+}
+
+const TestComponentWithMessages = ({
+ messages,
+}: {
+ messages: Array<{
+ type: "message"
+ role: "user" | "assistant"
+ content: string
+ }>
+}) => {
+ const actions = AgentStore.useStoreActions((actions) => actions)
+ const store = AgentStore.useStoreState((state) => state)
+
+ if (store.chatHistory.length === 0 && messages.length > 0) {
+ messages.forEach((message) => {
+ actions.addChatHistoryEntry(message)
+ })
+ }
+
+ useCycleMessages()
+ return null
+}
diff --git a/src/utils/__tests__/MessageQueue.test.ts b/src/utils/__tests__/MessageQueue.test.ts
new file mode 100644
index 0000000..f5281bb
--- /dev/null
+++ b/src/utils/__tests__/MessageQueue.test.ts
@@ -0,0 +1,116 @@
+import { test, expect, describe } from "bun:test"
+import { MessageQueue } from "../MessageQueue"
+
+describe("MessageQueue", () => {
+ test("should create an empty queue", () => {
+ const queue = new MessageQueue()
+
+ expect(queue.hasPendingRequests()).toBe(false)
+ })
+
+ test("should send and receive messages", () => {
+ const queue = new MessageQueue()
+
+ queue.sendMessage("hello")
+ const promise = queue.waitForMessage()
+
+ expect(promise).toBeInstanceOf(Promise)
+ })
+
+ test("should dequeue messages in FIFO order", async () => {
+ const queue = new MessageQueue()
+
+ queue.sendMessage("first")
+ queue.sendMessage("second")
+ queue.sendMessage("third")
+
+ const first = await queue.waitForMessage()
+ const second = await queue.waitForMessage()
+ const third = await queue.waitForMessage()
+
+ expect(first).toBe("first")
+ expect(second).toBe("second")
+ expect(third).toBe("third")
+ })
+
+ test("should wait for messages when queue is empty", async () => {
+ const queue = new MessageQueue()
+
+ const promise = queue.waitForMessage()
+ queue.sendMessage("delayed")
+
+ const message = await promise
+
+ expect(message).toBe("delayed")
+ })
+
+ test("should handle hasPendingRequests correctly", async () => {
+ const queue = new MessageQueue()
+
+ expect(queue.hasPendingRequests()).toBe(false)
+
+ const promise = queue.waitForMessage()
+ expect(queue.hasPendingRequests()).toBe(true)
+
+ queue.sendMessage("message")
+ await promise
+
+ expect(queue.hasPendingRequests()).toBe(false)
+ })
+
+ test("should clear queue and listeners", async () => {
+ const queue = new MessageQueue()
+
+ queue.sendMessage("first")
+ queue.sendMessage("second")
+ queue.waitForMessage()
+
+ queue.clear()
+
+ expect(queue.hasPendingRequests()).toBe(false)
+
+ queue.sendMessage("after-clear")
+ const message = await queue.waitForMessage()
+ expect(message).toBe("after-clear")
+ })
+
+ test("should handle multiple waiting consumers", async () => {
+ const queue = new MessageQueue()
+
+ const promise1 = queue.waitForMessage()
+
+ queue.sendMessage("first")
+ const msg1 = await promise1
+
+ const promise2 = queue.waitForMessage()
+ queue.sendMessage("second")
+ const msg2 = await promise2
+
+ expect(msg1).toBe("first")
+ expect(msg2).toBe("second")
+ })
+
+ test("should buffer messages when no listeners", async () => {
+ const queue = new MessageQueue()
+
+ queue.sendMessage("buffered1")
+ queue.sendMessage("buffered2")
+
+ const msg1 = await queue.waitForMessage()
+ const msg2 = await queue.waitForMessage()
+
+ expect(msg1).toBe("buffered1")
+ expect(msg2).toBe("buffered2")
+ })
+
+ test("should emit directly when listeners exist", async () => {
+ const queue = new MessageQueue()
+
+ const promise = queue.waitForMessage()
+ queue.sendMessage("direct")
+
+ const message = await promise
+
+ expect(message).toBe("direct")
+ })
+})
diff --git a/src/utils/__tests__/canUseTool.test.ts b/src/utils/__tests__/canUseTool.test.ts
new file mode 100644
index 0000000..9f5f4b7
--- /dev/null
+++ b/src/utils/__tests__/canUseTool.test.ts
@@ -0,0 +1,284 @@
+import { test, expect, describe, mock } from "bun:test"
+import { createCanUseTool } from "../canUseTool"
+import { MessageQueue } from "../MessageQueue"
+import type { PermissionUpdate } from "@anthropic-ai/claude-agent-sdk"
+
+describe("createCanUseTool", () => {
+ test("should allow tool when user responds with 'yes'", async () => {
+ const queue = new MessageQueue()
+ const canUseTool = createCanUseTool({ messageQueue: queue })
+
+ const promise = canUseTool(
+ "test_tool",
+ { param: "value" },
+ {
+ signal: new AbortController().signal,
+ }
+ )
+
+ queue.sendMessage("yes")
+ const result = await promise
+
+ expect(result.behavior).toBe("allow")
+ if (result.behavior === "allow") {
+ expect(result.updatedInput).toEqual({ param: "value" })
+ }
+ })
+
+ test("should allow tool when user responds with 'y'", async () => {
+ const queue = new MessageQueue()
+ const canUseTool = createCanUseTool({ messageQueue: queue })
+
+ const promise = canUseTool(
+ "test_tool",
+ {},
+ {
+ signal: new AbortController().signal,
+ }
+ )
+
+ queue.sendMessage("y")
+ const result = await promise
+
+ expect(result.behavior).toBe("allow")
+ })
+
+ test("should allow tool when user responds with 'allow'", async () => {
+ const queue = new MessageQueue()
+ const canUseTool = createCanUseTool({ messageQueue: queue })
+
+ const promise = canUseTool(
+ "test_tool",
+ {},
+ {
+ signal: new AbortController().signal,
+ }
+ )
+
+ queue.sendMessage("allow")
+ const result = await promise
+
+ expect(result.behavior).toBe("allow")
+ })
+
+ test("should deny tool when user responds with 'no'", async () => {
+ const queue = new MessageQueue()
+ const canUseTool = createCanUseTool({ messageQueue: queue })
+
+ const promise = canUseTool(
+ "test_tool",
+ {},
+ {
+ signal: new AbortController().signal,
+ }
+ )
+
+ queue.sendMessage("no")
+ const result = await promise
+
+ expect(result.behavior).toBe("deny")
+ if (result.behavior === "deny") {
+ expect(result.message).toBe("User denied permission")
+ expect(result.interrupt).toBe(true)
+ }
+ })
+
+ test("should deny tool when user responds with 'n'", async () => {
+ const queue = new MessageQueue()
+ const canUseTool = createCanUseTool({ messageQueue: queue })
+
+ const promise = canUseTool(
+ "test_tool",
+ {},
+ {
+ signal: new AbortController().signal,
+ }
+ )
+
+ queue.sendMessage("n")
+ const result = await promise
+
+ expect(result.behavior).toBe("deny")
+ })
+
+ test("should deny tool when user responds with 'deny'", async () => {
+ const queue = new MessageQueue()
+ const canUseTool = createCanUseTool({ messageQueue: queue })
+
+ const promise = canUseTool(
+ "test_tool",
+ {},
+ {
+ signal: new AbortController().signal,
+ }
+ )
+
+ queue.sendMessage("deny")
+ const result = await promise
+
+ expect(result.behavior).toBe("deny")
+ })
+
+ test("should handle case-insensitive responses", async () => {
+ const queue = new MessageQueue()
+ const canUseTool = createCanUseTool({ messageQueue: queue })
+
+ const promise = canUseTool(
+ "test_tool",
+ {},
+ {
+ signal: new AbortController().signal,
+ }
+ )
+
+ queue.sendMessage("YES")
+ const result = await promise
+
+ expect(result.behavior).toBe("allow")
+ })
+
+ test("should handle whitespace in responses", async () => {
+ const queue = new MessageQueue()
+ const canUseTool = createCanUseTool({ messageQueue: queue })
+
+ const promise = canUseTool(
+ "test_tool",
+ {},
+ {
+ signal: new AbortController().signal,
+ }
+ )
+
+ queue.sendMessage(" yes ")
+ const result = await promise
+
+ expect(result.behavior).toBe("allow")
+ })
+
+ test("should treat other input as new message and deny", async () => {
+ const queue = new MessageQueue()
+ const canUseTool = createCanUseTool({ messageQueue: queue })
+
+ const promise = canUseTool(
+ "test_tool",
+ {},
+ {
+ signal: new AbortController().signal,
+ }
+ )
+
+ queue.sendMessage("do something else")
+ const result = await promise
+
+ expect(result.behavior).toBe("deny")
+ if (result.behavior === "deny") {
+ expect(result.message).toBe("do something else")
+ expect(result.interrupt).toBe(true)
+ }
+ })
+
+ test("should call onToolPermissionRequest callback", async () => {
+ const queue = new MessageQueue()
+ const callback = mock(() => {})
+ const canUseTool = createCanUseTool({
+ messageQueue: queue,
+ onToolPermissionRequest: callback,
+ })
+
+ const promise = canUseTool(
+ "test_tool",
+ { param: "value" },
+ {
+ signal: new AbortController().signal,
+ }
+ )
+
+ queue.sendMessage("yes")
+ await promise
+
+ expect(callback).toHaveBeenCalledTimes(1)
+ expect(callback).toHaveBeenCalledWith("test_tool", { param: "value" })
+ })
+
+ test("should add MCP tool rules when allowing MCP tools", async () => {
+ const queue = new MessageQueue()
+ const canUseTool = createCanUseTool({ messageQueue: queue })
+
+ const promise = canUseTool(
+ "mcp__github__search",
+ {},
+ {
+ signal: new AbortController().signal,
+ }
+ )
+
+ queue.sendMessage("yes")
+ const result = await promise
+
+ expect(result.behavior).toBe("allow")
+ if (result.behavior === "allow") {
+ expect(result.updatedPermissions).toEqual([
+ {
+ type: "addRules",
+ rules: [{ toolName: "mcp__github__search" }],
+ behavior: "allow",
+ destination: "session",
+ },
+ ])
+ }
+ })
+
+ test("should use suggestions for non-MCP tools", async () => {
+ const queue = new MessageQueue()
+ const canUseTool = createCanUseTool({ messageQueue: queue })
+
+ const suggestions: PermissionUpdate[] = [
+ {
+ type: "addRules",
+ rules: [{ toolName: "regular_tool" }],
+ behavior: "allow",
+ destination: "session",
+ },
+ ]
+
+ const promise = canUseTool(
+ "regular_tool",
+ {},
+ {
+ signal: new AbortController().signal,
+ suggestions,
+ }
+ )
+
+ queue.sendMessage("yes")
+ const result = await promise
+
+ expect(result.behavior).toBe("allow")
+ if (result.behavior === "allow") {
+ expect(result.updatedPermissions).toEqual(suggestions)
+ }
+ })
+
+ test("should call setIsProcessing on deny", async () => {
+ const queue = new MessageQueue()
+ const setIsProcessing = mock(() => {})
+ const canUseTool = createCanUseTool({
+ messageQueue: queue,
+ setIsProcessing,
+ })
+
+ const promise = canUseTool(
+ "test_tool",
+ {},
+ {
+ signal: new AbortController().signal,
+ }
+ )
+
+ queue.sendMessage("no")
+ await promise
+ await new Promise((resolve) => setTimeout(resolve, 100))
+
+ expect(setIsProcessing).toHaveBeenCalledWith(true)
+ })
+})
diff --git a/src/utils/__tests__/formatToolInput.test.ts b/src/utils/__tests__/formatToolInput.test.ts
new file mode 100644
index 0000000..d1ec7e5
--- /dev/null
+++ b/src/utils/__tests__/formatToolInput.test.ts
@@ -0,0 +1,89 @@
+import { test, expect, describe } from "bun:test"
+import { formatToolInput } from "../formatToolInput"
+
+describe("formatToolInput", () => {
+ test("should format simple object input", () => {
+ const input = { param: "value" }
+ const result = formatToolInput(input)
+
+ expect(result).toBe('{\n "param": "value"\n}')
+ })
+
+ test("should format nested object input", () => {
+ const input = {
+ outer: {
+ inner: "value",
+ },
+ }
+ const result = formatToolInput(input)
+
+ expect(result).toContain('"outer"')
+ expect(result).toContain('"inner"')
+ expect(result).toContain('"value"')
+ })
+
+ test("should format array input", () => {
+ const input = { items: ["one", "two", "three"] }
+ const result = formatToolInput(input)
+
+ expect(result).toContain('"items"')
+ expect(result).toContain('"one"')
+ expect(result).toContain('"two"')
+ expect(result).toContain('"three"')
+ })
+
+ test("should handle empty input", () => {
+ const input = {}
+ const result = formatToolInput(input)
+
+ expect(result).toBe("{}")
+ })
+
+ test("should format GraphQL query input", () => {
+ const input = {
+ query: "query { user { name } }",
+ }
+ const result = formatToolInput(input)
+
+ expect(result).toBe("query { user { name } }")
+ })
+
+ test("should replace escaped newlines with actual newlines", () => {
+ const jsonString = JSON.stringify({ text: "line1\nline2" })
+ const input = JSON.parse(jsonString)
+ const result = formatToolInput(input)
+
+ expect(result).toContain("line1\nline2")
+ })
+
+ test("should replace escaped tabs with spaces", () => {
+ const jsonString = JSON.stringify({ text: "tab\there" })
+ const input = JSON.parse(jsonString)
+ const result = formatToolInput(input)
+
+ expect(result).toContain("tab here")
+ })
+
+ test("should handle null values", () => {
+ const input = { value: null }
+ const result = formatToolInput(input)
+
+ expect(result).toContain('"value": null')
+ })
+
+ test("should handle number values", () => {
+ const input = { count: 42, price: 19.99 }
+ const result = formatToolInput(input)
+
+ expect(result).toContain('"count": 42')
+ expect(result).toContain('"price": 19.99')
+ })
+
+ test("should handle boolean values", () => {
+ const input = { enabled: true, disabled: false }
+ const result = formatToolInput(input)
+
+ expect(result).toContain('"enabled": true')
+ expect(result).toContain('"disabled": false')
+ })
+})
diff --git a/src/utils/__tests__/getPrompt.test.ts b/src/utils/__tests__/getPrompt.test.ts
new file mode 100644
index 0000000..ab2f8e1
--- /dev/null
+++ b/src/utils/__tests__/getPrompt.test.ts
@@ -0,0 +1,134 @@
+import { test, expect, describe } from "bun:test"
+import { getPrompt, buildSystemPrompt } from "../getPrompt"
+import type { AgentChatConfig } from "../../store"
+
+describe("getPrompt", () => {
+ test("should load system prompt from file", () => {
+ const prompt = getPrompt("system.md")
+
+ expect(typeof prompt).toBe("string")
+ expect(prompt.length).toBeGreaterThan(0)
+ })
+
+ test("should trim whitespace from prompt", () => {
+ const prompt = getPrompt("system.md")
+
+ expect(prompt).toBe(prompt.trim())
+ })
+})
+
+describe("buildSystemPrompt", () => {
+ test("should include current date", () => {
+ const config: AgentChatConfig = {
+ mcpServers: {},
+ }
+
+ const prompt = buildSystemPrompt(config)
+
+ expect(prompt).toContain("Current date:")
+ })
+
+ test("should use default prompt when systemPrompt is not provided", () => {
+ const config: AgentChatConfig = {
+ mcpServers: {},
+ }
+
+ const prompt = buildSystemPrompt(config)
+
+ expect(prompt).toContain("You are a helpful agent.")
+ })
+
+ test("should use custom systemPrompt when provided", () => {
+ const config: AgentChatConfig = {
+ mcpServers: {},
+ systemPrompt: "You are a custom agent.",
+ }
+
+ const prompt = buildSystemPrompt(config)
+
+ expect(prompt).toContain("You are a custom agent.")
+ expect(prompt).not.toContain("You are a helpful agent.")
+ })
+
+ test("should include MCP server prompts", () => {
+ const config: AgentChatConfig = {
+ mcpServers: {
+ github: {
+ command: "node",
+ args: [],
+ prompt: "GitHub server instructions",
+ },
+ },
+ }
+
+ const prompt = buildSystemPrompt(config)
+
+ expect(prompt).toContain("# github MCP Server")
+ expect(prompt).toContain("GitHub server instructions")
+ })
+
+ test("should combine multiple MCP server prompts", () => {
+ const config: AgentChatConfig = {
+ mcpServers: {
+ github: {
+ command: "node",
+ args: [],
+ prompt: "GitHub instructions",
+ },
+ gitlab: {
+ command: "node",
+ args: [],
+ prompt: "GitLab instructions",
+ },
+ },
+ }
+
+ const prompt = buildSystemPrompt(config)
+
+ expect(prompt).toContain("# github MCP Server")
+ expect(prompt).toContain("GitHub instructions")
+ expect(prompt).toContain("# gitlab MCP Server")
+ expect(prompt).toContain("GitLab instructions")
+ })
+
+ test("should skip MCP servers without prompts", () => {
+ const config: AgentChatConfig = {
+ mcpServers: {
+ github: {
+ command: "node",
+ args: [],
+ prompt: "GitHub instructions",
+ },
+ gitlab: {
+ command: "node",
+ args: [],
+ },
+ },
+ }
+
+ const prompt = buildSystemPrompt(config)
+
+ expect(prompt).toContain("# github MCP Server")
+ expect(prompt).not.toContain("# gitlab MCP Server")
+ })
+
+ test("should build prompt with custom system prompt and MCP prompts", () => {
+ const config: AgentChatConfig = {
+ mcpServers: {
+ github: {
+ command: "node",
+ args: [],
+ prompt: "GitHub instructions",
+ },
+ },
+ systemPrompt: "Custom base prompt",
+ }
+
+ const prompt = buildSystemPrompt(config)
+
+ expect(prompt).toContain("Current date:")
+ expect(prompt).toContain("Custom base prompt")
+ expect(prompt).toContain("# github MCP Server")
+ expect(prompt).toContain("GitHub instructions")
+ })
+})
diff --git a/src/utils/__tests__/getToolInfo.test.ts b/src/utils/__tests__/getToolInfo.test.ts
new file mode 100644
index 0000000..46c3629
--- /dev/null
+++ b/src/utils/__tests__/getToolInfo.test.ts
@@ -0,0 +1,198 @@
+import { test, expect, describe } from "bun:test"
+import {
+ getToolInfo,
+ getDisallowedTools,
+ isToolDisallowed,
+} from "../getToolInfo"
+import type { AgentChatConfig } from "../../store"
+
+describe("getToolInfo", () => {
+ test("should extract server name and tool name from MCP format", () => {
+ const result = getToolInfo("mcp__github__search_repositories")
+
+ expect(result.serverName).toBe("github")
+ expect(result.toolName).toBe("search_repositories")
+ })
+
+ test("should handle tool name without MCP prefix", () => {
+ const result = getToolInfo("regular_tool")
+
+ expect(result.serverName).toBeNull()
+ expect(result.toolName).toBe("regular_tool")
+ })
+
+ test("should handle tool names with multiple underscores", () => {
+ const result = getToolInfo("mcp__server__tool__with__underscores")
+
+ expect(result.serverName).toBe("server")
+ expect(result.toolName).toBe("tool__with__underscores")
+ })
+
+ test("should handle malformed tool names", () => {
+ const result = getToolInfo("mcp__only_one_part")
+
+ expect(result.serverName).toBeNull()
+ expect(result.toolName).toBe("mcp__only_one_part")
+ })
+
+ test("should handle empty string", () => {
+ const result = getToolInfo("")
+
+ expect(result.serverName).toBeNull()
+ expect(result.toolName).toBe("")
+ })
+})
+
+describe("getDisallowedTools", () => {
+ test("should convert short names to full MCP format", () => {
+ const config: AgentChatConfig = {
+ mcpServers: {
+ github: {
+ command: "node",
+ args: [],
+ denyTools: ["search_repositories", "create_issue"],
+ },
+ },
+ }
+
+ const result = getDisallowedTools(config)
+
+ expect(result).toEqual([
+ "mcp__github__search_repositories",
+ "mcp__github__create_issue",
+ ])
+ })
+
+ test("should handle empty deny list", () => {
+ const config: AgentChatConfig = {
+ mcpServers: {
+ github: {
+ command: "node",
+ args: [],
+ },
+ },
+ }
+
+ const result = getDisallowedTools(config)
+
+ expect(result).toEqual([])
+ })
+
+ test("should handle multiple servers", () => {
+ const config: AgentChatConfig = {
+ mcpServers: {
+ github: {
+ command: "node",
+ args: [],
+ denyTools: ["search"],
+ },
+ gitlab: {
+ command: "node",
+ args: [],
+ denyTools: ["merge"],
+ },
+ },
+ }
+
+ const result = getDisallowedTools(config)
+
+ expect(result).toEqual(["mcp__github__search", "mcp__gitlab__merge"])
+ })
+
+ test("should handle servers with no denyTools", () => {
+ const config: AgentChatConfig = {
+ mcpServers: {
+ github: {
+ command: "node",
+ args: [],
+ denyTools: ["search"],
+ },
+ gitlab: {
+ command: "node",
+ args: [],
+ },
+ },
+ }
+
+ const result = getDisallowedTools(config)
+
+ expect(result).toEqual(["mcp__github__search"])
+ })
+})
+
+describe("isToolDisallowed", () => {
+ test("should return true for disallowed tools", () => {
+ const config: AgentChatConfig = {
+ mcpServers: {
+ github: {
+ command: "node",
+ args: [],
+ denyTools: ["search_repositories"],
+ },
+ },
+ }
+
+ const result = isToolDisallowed({
+ toolName: "mcp__github__search_repositories",
+ config,
+ })
+
+ expect(result).toBe(true)
+ })
+
+ test("should return false for allowed tools", () => {
+ const config: AgentChatConfig = {
+ mcpServers: {
+ github: {
+ command: "node",
+ args: [],
+ denyTools: ["search_repositories"],
+ },
+ },
+ }
+
+ const result = isToolDisallowed({
+ toolName: "mcp__github__create_issue",
+ config,
+ })
+
+ expect(result).toBe(false)
+ })
+
+ test("should handle empty disallowed list", () => {
+ const config: AgentChatConfig = {
+ mcpServers: {
+ github: {
+ command: "node",
+ args: [],
+ },
+ },
+ }
+
+ const result = isToolDisallowed({
+ toolName: "mcp__github__any_tool",
+ config,
+ })
+
+ expect(result).toBe(false)
+ })
+
+ test("should handle non-MCP tool names", () => {
+ const config: AgentChatConfig = {
+ mcpServers: {
+ github: {
+ command: "node",
+ args: [],
+ denyTools: ["search"],
+ },
+ },
+ }
+
+ const result = isToolDisallowed({
+ toolName: "regular_tool",
+ config,
+ })
+
+ expect(result).toBe(false)
+ })
+})
diff --git a/src/utils/__tests__/validateEnv.test.ts b/src/utils/__tests__/validateEnv.test.ts
new file mode 100644
index 0000000..0ee60a5
--- /dev/null
+++ b/src/utils/__tests__/validateEnv.test.ts
@@ -0,0 +1,106 @@
+import { test, expect, describe, beforeEach, afterEach, spyOn } from "bun:test"
+import { validateEnv } from "../validateEnv"
+
+describe("validateEnv", () => {
+ const originalEnv = { ...process.env }
+ let consoleErrorSpy: any
+ let processExitSpy: any
+
+ beforeEach(() => {
+ consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {})
+ processExitSpy = spyOn(process, "exit").mockImplementation(
+ (() => {}) as never
+ )
+ })
+
+ afterEach(() => {
+ process.env = { ...originalEnv }
+ consoleErrorSpy.mockRestore()
+ processExitSpy.mockRestore()
+ })
+
+ test("should pass when all required environment variables are present", () => {
+ process.env.ANTHROPIC_API_KEY = "test-key"
+ process.env.ARTSY_MCP_X_ACCESS_TOKEN = "test-token"
+ process.env.ARTSY_MCP_X_USER_ID = "test-user"
+ process.env.GITHUB_ACCESS_TOKEN = "test-github"
+
+ validateEnv()
+
+ expect(consoleErrorSpy).not.toHaveBeenCalled()
+ expect(processExitSpy).not.toHaveBeenCalled()
+ })
+
+ test("should exit when ANTHROPIC_API_KEY is missing", () => {
+ delete process.env.ANTHROPIC_API_KEY
+ process.env.ARTSY_MCP_X_ACCESS_TOKEN = "test-token"
+ process.env.ARTSY_MCP_X_USER_ID = "test-user"
+ process.env.GITHUB_ACCESS_TOKEN = "test-github"
+
+ validateEnv()
+
+ expect(consoleErrorSpy).toHaveBeenCalled()
+ expect(processExitSpy).toHaveBeenCalledWith(1)
+ })
+
+ test("should exit when ARTSY_MCP_X_ACCESS_TOKEN is missing", () => {
+ process.env.ANTHROPIC_API_KEY = "test-key"
+ delete process.env.ARTSY_MCP_X_ACCESS_TOKEN
+ process.env.ARTSY_MCP_X_USER_ID = "test-user"
+ process.env.GITHUB_ACCESS_TOKEN = "test-github"
+
+ validateEnv()
+
+ expect(consoleErrorSpy).toHaveBeenCalled()
+ expect(processExitSpy).toHaveBeenCalledWith(1)
+ })
+
+ test("should exit when ARTSY_MCP_X_USER_ID is missing", () => {
+ process.env.ANTHROPIC_API_KEY = "test-key"
+ process.env.ARTSY_MCP_X_ACCESS_TOKEN = "test-token"
+ delete process.env.ARTSY_MCP_X_USER_ID
+ process.env.GITHUB_ACCESS_TOKEN = "test-github"
+
+ validateEnv()
+
+ expect(consoleErrorSpy).toHaveBeenCalled()
+ expect(processExitSpy).toHaveBeenCalledWith(1)
+ })
+
+ test("should exit when GITHUB_ACCESS_TOKEN is missing", () => {
+ process.env.ANTHROPIC_API_KEY = "test-key"
+ process.env.ARTSY_MCP_X_ACCESS_TOKEN = "test-token"
+ process.env.ARTSY_MCP_X_USER_ID = "test-user"
+ delete process.env.GITHUB_ACCESS_TOKEN
+
+ validateEnv()
+
+ expect(consoleErrorSpy).toHaveBeenCalled()
+ expect(processExitSpy).toHaveBeenCalledWith(1)
+ })
+
+ test("should exit when all environment variables are missing", () => {
+ delete process.env.ANTHROPIC_API_KEY
+ delete process.env.ARTSY_MCP_X_ACCESS_TOKEN
+ delete process.env.ARTSY_MCP_X_USER_ID
+ delete process.env.GITHUB_ACCESS_TOKEN
+
+ validateEnv()
+
+ expect(consoleErrorSpy).toHaveBeenCalled()
+ expect(processExitSpy).toHaveBeenCalledWith(1)
+ })
+
+ test("should display helpful error message with missing variables", () => {
+ delete process.env.ANTHROPIC_API_KEY
+ delete process.env.GITHUB_ACCESS_TOKEN
+ process.env.ARTSY_MCP_X_ACCESS_TOKEN = "test-token"
+ process.env.ARTSY_MCP_X_USER_ID = "test-user"
+
+ validateEnv()
+
+ const errorMessage = consoleErrorSpy.mock.calls[0][0]
+ expect(errorMessage).toContain("ANTHROPIC_API_KEY")
+ expect(errorMessage).toContain("GITHUB_ACCESS_TOKEN")
+ })
+})