summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/tests
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-19 23:20:41 +0900
committerAdam Malczewski <[email protected]>2026-05-19 23:20:41 +0900
commita38d5b1279db6f9de5228c173019fc2ac08daec3 (patch)
tree32c3a535d0b74872ef952b4a44d4d5ba2ec9d638 /packages/frontend/tests
parent0ae805b28b5160b8d9fb43635fa172961f6550cc (diff)
downloaddispatch-a38d5b1279db6f9de5228c173019fc2ac08daec3.tar.gz
dispatch-a38d5b1279db6f9de5228c173019fc2ac08daec3.zip
feat: Phase 2 — shell permissions, tree-sitter analysis, permission UI
Permission engine: - Rule-based engine: wildcard matching, last-match-wins, reject cascade - PermissionService with pending/approved state, PermissionChecker interface - dispatch.yaml config loader with per-permission pattern rules Shell tool: - run_shell tool with child_process spawn, timeout, streaming output - Tree-sitter static analysis (web-tree-sitter + tree-sitter-bash WASM) - BashArity command normalization for 'always allow' patterns - FILE_COMMANDS set: rm, cp, mv, mkdir, ls, find, grep, cat, etc. Agent loop refactored: - Removed maxSteps, manual step loop with tool execution - Permission checks on shell commands (external_directory only) - Permission checks on file tools outside workspace boundary - Symlink bypass fix (realpathSync), .. false positive fix - Shell output streaming via Promise.race + setImmediate polling API layer: - PermissionManager wraps PermissionService, broadcasts via WebSocket - WebSocket handles permission-reply messages from frontend - Config loaded from dispatch.yaml, converted to ruleset Frontend: - Permission prompt modal (native dialog, focus trap, ARIA) - Always-allow confirmation flow with pattern preview - Shell output display (live streaming + final parsed result) - Permission log panel (fixed bottom-right overlay) - Exit code badge (green 0, red non-zero) 134 tests, typecheck clean on all 3 packages
Diffstat (limited to 'packages/frontend/tests')
-rw-r--r--packages/frontend/tests/chat-store.test.ts308
1 files changed, 305 insertions, 3 deletions
diff --git a/packages/frontend/tests/chat-store.test.ts b/packages/frontend/tests/chat-store.test.ts
index 11b7deb..db1132c 100644
--- a/packages/frontend/tests/chat-store.test.ts
+++ b/packages/frontend/tests/chat-store.test.ts
@@ -1,5 +1,5 @@
-import { beforeEach, describe, expect, it } from "vitest";
-import type { AgentEvent, ContentSegment } from "../src/lib/types.js";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { AgentEvent, ContentSegment, LogEntry, PermissionPrompt } from "../src/lib/types.js";
// We test the logic inline since runes require svelte compilation context.
// The chat store logic is tested via a plain reimplementation of the same logic.
@@ -17,10 +17,12 @@ interface ChatMessage {
}
// Plain JS version of the chat store logic (no runes) for unit testing
-function createTestStore() {
+function createTestStore(wsSend?: (data: unknown) => void) {
let messages: ChatMessage[] = [];
let agentStatus: "idle" | "running" | "error" = "idle";
let currentAssistantId: string | null = null;
+ let pendingPermissions: PermissionPrompt[] = [];
+ let permissionLog: LogEntry[] = [];
function getCurrentAssistantMessage(): ChatMessage | null {
if (!currentAssistantId) return null;
@@ -142,6 +144,34 @@ function createTestStore() {
agentStatus = "error";
break;
}
+ case "permission-prompt": {
+ pendingPermissions = event.pending;
+ break;
+ }
+ case "shell-output": {
+ messages = messages.map((m) => {
+ if (m.id === currentAssistantId) {
+ return {
+ ...m,
+ content: m.content.map((seg, i) => {
+ if (seg.type === "tool-call" && i === m.content.length - 1) {
+ const prev = seg.shellOutput ?? { stdout: "", stderr: "" };
+ return {
+ ...seg,
+ shellOutput:
+ event.stream === "stdout"
+ ? { ...prev, stdout: prev.stdout + event.data }
+ : { ...prev, stderr: prev.stderr + event.data },
+ };
+ }
+ return seg;
+ }),
+ };
+ }
+ return m;
+ });
+ break;
+ }
}
}
@@ -155,6 +185,23 @@ function createTestStore() {
currentAssistantId = null;
}
+ function replyPermission(id: string, reply: "once" | "always" | "reject") {
+ const prompt = pendingPermissions.find((p) => p.id === id);
+ if (wsSend) wsSend({ type: "permission-reply", id, reply });
+ pendingPermissions = pendingPermissions.filter((p) => p.id !== id);
+ if (prompt) {
+ const entry: LogEntry = {
+ id: generateId(),
+ permission: prompt.permission,
+ patterns: prompt.patterns,
+ action: reply,
+ timestamp: new Date().toISOString(),
+ description: prompt.description,
+ };
+ permissionLog = [...permissionLog, entry];
+ }
+ }
+
function clear() {
messages = [];
currentAssistantId = null;
@@ -168,8 +215,15 @@ function createTestStore() {
get agentStatus() {
return agentStatus;
},
+ get pendingPermissions() {
+ return pendingPermissions;
+ },
+ get permissionLog() {
+ return permissionLog;
+ },
handleEvent,
sendMessage,
+ replyPermission,
clear,
};
}
@@ -314,3 +368,251 @@ describe("chat store logic", () => {
expect(store.messages[0]?.thinking).toBe("First thought. Second thought.");
});
});
+
+describe("permission-prompt handling", () => {
+ let store: ReturnType<typeof createTestStore>;
+
+ beforeEach(() => {
+ store = createTestStore();
+ });
+
+ it("permission-prompt sets pendingPermissions", () => {
+ const prompt: PermissionPrompt = {
+ id: "p1",
+ permission: "bash",
+ patterns: ["*"],
+ always: ["*"],
+ description: "Run a command",
+ metadata: { command: "ls" },
+ };
+ store.handleEvent({ type: "permission-prompt", pending: [prompt] });
+ expect(store.pendingPermissions).toHaveLength(1);
+ expect(store.pendingPermissions[0]?.id).toBe("p1");
+ });
+
+ it("permission-prompt replaces previous pending permissions", () => {
+ const p1: PermissionPrompt = {
+ id: "p1",
+ permission: "bash",
+ patterns: [],
+ always: [],
+ description: "First",
+ metadata: {},
+ };
+ const p2: PermissionPrompt = {
+ id: "p2",
+ permission: "read",
+ patterns: [],
+ always: [],
+ description: "Second",
+ metadata: {},
+ };
+ store.handleEvent({ type: "permission-prompt", pending: [p1] });
+ store.handleEvent({ type: "permission-prompt", pending: [p2] });
+ expect(store.pendingPermissions).toHaveLength(1);
+ expect(store.pendingPermissions[0]?.id).toBe("p2");
+ });
+
+ it("replyPermission removes the permission from pending and calls wsSend", () => {
+ const mockSend = vi.fn();
+ const storeWithSend = createTestStore(mockSend);
+ const prompt: PermissionPrompt = {
+ id: "p1",
+ permission: "bash",
+ patterns: [],
+ always: [],
+ description: "Run command",
+ metadata: { command: "echo hi" },
+ };
+ storeWithSend.handleEvent({ type: "permission-prompt", pending: [prompt] });
+ storeWithSend.replyPermission("p1", "once");
+ expect(storeWithSend.pendingPermissions).toHaveLength(0);
+ expect(mockSend).toHaveBeenCalledWith({ type: "permission-reply", id: "p1", reply: "once" });
+ });
+
+ it("replyPermission with 'always' sends correct payload", () => {
+ const mockSend = vi.fn();
+ const storeWithSend = createTestStore(mockSend);
+ const prompt: PermissionPrompt = {
+ id: "p2",
+ permission: "read",
+ patterns: ["src/**"],
+ always: ["src/**"],
+ description: "Read a file",
+ metadata: { filepath: "src/foo.ts" },
+ };
+ storeWithSend.handleEvent({ type: "permission-prompt", pending: [prompt] });
+ storeWithSend.replyPermission("p2", "always");
+ expect(mockSend).toHaveBeenCalledWith({ type: "permission-reply", id: "p2", reply: "always" });
+ expect(storeWithSend.pendingPermissions).toHaveLength(0);
+ });
+
+ it("replyPermission with 'reject' removes the permission", () => {
+ const mockSend = vi.fn();
+ const storeWithSend = createTestStore(mockSend);
+ const prompt: PermissionPrompt = {
+ id: "p3",
+ permission: "edit",
+ patterns: [],
+ always: [],
+ description: "Edit a file",
+ metadata: {},
+ };
+ storeWithSend.handleEvent({ type: "permission-prompt", pending: [prompt] });
+ storeWithSend.replyPermission("p3", "reject");
+ expect(storeWithSend.pendingPermissions).toHaveLength(0);
+ expect(mockSend).toHaveBeenCalledWith({ type: "permission-reply", id: "p3", reply: "reject" });
+ });
+});
+
+describe("permission log", () => {
+ let store: ReturnType<typeof createTestStore>;
+
+ beforeEach(() => {
+ store = createTestStore(vi.fn());
+ });
+
+ it("starts with empty permission log", () => {
+ expect(store.permissionLog).toHaveLength(0);
+ });
+
+ it("replyPermission adds an entry to permissionLog", () => {
+ const mockSend = vi.fn();
+ const s = createTestStore(mockSend);
+ const prompt: PermissionPrompt = {
+ id: "p1",
+ permission: "bash",
+ patterns: ["*"],
+ always: ["*"],
+ description: "Run a command",
+ metadata: {},
+ };
+ s.handleEvent({ type: "permission-prompt", pending: [prompt] });
+ s.replyPermission("p1", "once");
+ expect(s.permissionLog).toHaveLength(1);
+ expect(s.permissionLog[0]?.permission).toBe("bash");
+ expect(s.permissionLog[0]?.action).toBe("once");
+ expect(s.permissionLog[0]?.description).toBe("Run a command");
+ });
+
+ it("permissionLog accumulates multiple entries", () => {
+ const mockSend = vi.fn();
+ const s = createTestStore(mockSend);
+ const p1: PermissionPrompt = {
+ id: "p1",
+ permission: "bash",
+ patterns: [],
+ always: [],
+ description: "First",
+ metadata: {},
+ };
+ const p2: PermissionPrompt = {
+ id: "p2",
+ permission: "read",
+ patterns: [],
+ always: [],
+ description: "Second",
+ metadata: {},
+ };
+ s.handleEvent({ type: "permission-prompt", pending: [p1, p2] });
+ s.replyPermission("p1", "always");
+ s.replyPermission("p2", "reject");
+ expect(s.permissionLog).toHaveLength(2);
+ expect(s.permissionLog[0]?.action).toBe("always");
+ expect(s.permissionLog[1]?.action).toBe("reject");
+ });
+
+ it("replyPermission for unknown id does not add to log", () => {
+ const s = createTestStore(vi.fn());
+ s.replyPermission("nonexistent", "once");
+ expect(s.permissionLog).toHaveLength(0);
+ });
+});
+
+// Shell output parsing logic (mirrors ToolCallDisplay logic)
+function parseShellResult(result: string): { stdout: string; stderr: string; exitCode: number } | null {
+ try {
+ const parsed = JSON.parse(result) as unknown;
+ if (
+ parsed !== null &&
+ typeof parsed === "object" &&
+ "stdout" in parsed &&
+ "stderr" in parsed &&
+ "exitCode" in parsed
+ ) {
+ const p = parsed as Record<string, unknown>;
+ return {
+ stdout: String(p.stdout ?? ""),
+ stderr: String(p.stderr ?? ""),
+ exitCode: Number(p.exitCode ?? 0),
+ };
+ }
+ return null;
+ } catch {
+ return null;
+ }
+}
+
+describe("shell output parsing", () => {
+ it("parses a valid shell result JSON", () => {
+ const result = JSON.stringify({ stdout: "hello\n", stderr: "", exitCode: 0 });
+ const parsed = parseShellResult(result);
+ expect(parsed).not.toBeNull();
+ expect(parsed?.stdout).toBe("hello\n");
+ expect(parsed?.stderr).toBe("");
+ expect(parsed?.exitCode).toBe(0);
+ });
+
+ it("parses non-zero exit code and stderr", () => {
+ const result = JSON.stringify({ stdout: "", stderr: "error: not found", exitCode: 1 });
+ const parsed = parseShellResult(result);
+ expect(parsed?.exitCode).toBe(1);
+ expect(parsed?.stderr).toBe("error: not found");
+ });
+
+ it("returns null for invalid JSON", () => {
+ expect(parseShellResult("not json")).toBeNull();
+ });
+
+ it("returns null for JSON that lacks required fields", () => {
+ expect(parseShellResult(JSON.stringify({ stdout: "foo" }))).toBeNull();
+ });
+
+ it("returns null for non-object JSON", () => {
+ expect(parseShellResult(JSON.stringify(42))).toBeNull();
+ });
+});
+
+describe("shell-output event handling", () => {
+ it("shell-output stdout appends to last tool-call shellOutput", () => {
+ const s = createTestStore();
+ s.handleEvent({
+ type: "tool-call",
+ toolCall: { id: "tc1", name: "run_shell", arguments: { command: "ls" } },
+ });
+ s.handleEvent({ type: "shell-output", data: "file1\n", stream: "stdout" });
+ s.handleEvent({ type: "shell-output", data: "file2\n", stream: "stdout" });
+ const seg = s.messages[0]?.content[0];
+ if (seg?.type === "tool-call") {
+ expect(seg.shellOutput?.stdout).toBe("file1\nfile2\n");
+ expect(seg.shellOutput?.stderr).toBe("");
+ } else {
+ expect.fail("Expected tool-call segment");
+ }
+ });
+
+ it("shell-output stderr appends to last tool-call shellOutput stderr", () => {
+ const s = createTestStore();
+ s.handleEvent({
+ type: "tool-call",
+ toolCall: { id: "tc1", name: "run_shell", arguments: { command: "ls" } },
+ });
+ s.handleEvent({ type: "shell-output", data: "err line\n", stream: "stderr" });
+ const seg = s.messages[0]?.content[0];
+ if (seg?.type === "tool-call") {
+ expect(seg.shellOutput?.stderr).toBe("err line\n");
+ } else {
+ expect.fail("Expected tool-call segment");
+ }
+ });
+});