diff options
| author | Adam Malczewski <[email protected]> | 2026-05-19 23:20:41 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-19 23:20:41 +0900 |
| commit | a38d5b1279db6f9de5228c173019fc2ac08daec3 (patch) | |
| tree | 32c3a535d0b74872ef952b4a44d4d5ba2ec9d638 /packages/core/tests/agent | |
| parent | 0ae805b28b5160b8d9fb43635fa172961f6550cc (diff) | |
| download | dispatch-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/core/tests/agent')
| -rw-r--r-- | packages/core/tests/agent/agent.test.ts | 130 |
1 files changed, 62 insertions, 68 deletions
diff --git a/packages/core/tests/agent/agent.test.ts b/packages/core/tests/agent/agent.test.ts index 92df90e..5be210a 100644 --- a/packages/core/tests/agent/agent.test.ts +++ b/packages/core/tests/agent/agent.test.ts @@ -40,15 +40,9 @@ async function* makeFullStream( } } -interface MockStreamOptions { - events: Array<{ type: string; [key: string]: unknown }>; - steps?: Array<{ toolResults: Array<{ toolCallId: string; result: unknown }> }>; -} - -function makeMockStreamResult(opts: MockStreamOptions) { +function makeMockStreamResult(events: Array<{ type: string; [key: string]: unknown }>) { return { - fullStream: makeFullStream(opts.events), - steps: Promise.resolve(opts.steps ?? []), + fullStream: makeFullStream(events), } as ReturnType<typeof import("ai").streamText>; } @@ -66,18 +60,16 @@ describe("Agent", () => { it("yields running then idle status events around a simple message", async () => { const { streamText } = await import("ai"); vi.mocked(streamText).mockReturnValue( - makeMockStreamResult({ - events: [ - { type: "text-delta", textDelta: "Hello!" }, - { - type: "finish", - finishReason: "stop", - usage: {}, - providerMetadata: undefined, - response: {}, - }, - ], - }), + makeMockStreamResult([ + { type: "text-delta", textDelta: "Hello!" }, + { + type: "finish", + finishReason: "stop", + usage: {}, + providerMetadata: undefined, + response: {}, + }, + ]), ); const agent = new Agent(makeConfig()); @@ -97,19 +89,17 @@ describe("Agent", () => { it("yields text-delta events", async () => { const { streamText } = await import("ai"); vi.mocked(streamText).mockReturnValue( - makeMockStreamResult({ - events: [ - { type: "text-delta", textDelta: "Hello" }, - { type: "text-delta", textDelta: " world" }, - { - type: "finish", - finishReason: "stop", - usage: {}, - providerMetadata: undefined, - response: {}, - }, - ], - }), + makeMockStreamResult([ + { type: "text-delta", textDelta: "Hello" }, + { type: "text-delta", textDelta: " world" }, + { + type: "finish", + finishReason: "stop", + usage: {}, + providerMetadata: undefined, + response: {}, + }, + ]), ); const agent = new Agent(makeConfig()); @@ -127,18 +117,16 @@ describe("Agent", () => { it("adds user message and assistant message to history", async () => { const { streamText } = await import("ai"); vi.mocked(streamText).mockReturnValue( - makeMockStreamResult({ - events: [ - { type: "text-delta", textDelta: "Response" }, - { - type: "finish", - finishReason: "stop", - usage: {}, - providerMetadata: undefined, - response: {}, - }, - ], - }), + makeMockStreamResult([ + { type: "text-delta", textDelta: "Response" }, + { + type: "finish", + finishReason: "stop", + usage: {}, + providerMetadata: undefined, + response: {}, + }, + ]), ); const agent = new Agent(makeConfig()); @@ -160,18 +148,16 @@ describe("Agent", () => { it("yields done event with final message", async () => { const { streamText } = await import("ai"); vi.mocked(streamText).mockReturnValue( - makeMockStreamResult({ - events: [ - { type: "text-delta", textDelta: "Done!" }, - { - type: "finish", - finishReason: "stop", - usage: {}, - providerMetadata: undefined, - response: {}, - }, - ], - }), + makeMockStreamResult([ + { type: "text-delta", textDelta: "Done!" }, + { + type: "finish", + finishReason: "stop", + usage: {}, + providerMetadata: undefined, + response: {}, + }, + ]), ); const agent = new Agent(makeConfig()); @@ -190,31 +176,39 @@ describe("Agent", () => { it("yields tool-call and tool-result events", async () => { const { streamText } = await import("ai"); - vi.mocked(streamText).mockReturnValue( - makeMockStreamResult({ - events: [ + + // First call: LLM emits a tool-call + // Second call (after tool execution): LLM emits text response with no tool calls + vi.mocked(streamText) + .mockReturnValueOnce( + makeMockStreamResult([ { type: "tool-call", toolCallId: "tc1", toolName: "read_file", args: { path: "hello.txt" }, }, - { type: "text-delta", textDelta: "Here is the file." }, { type: "finish", - finishReason: "stop", + finishReason: "tool-calls", usage: {}, providerMetadata: undefined, response: {}, }, - ], - steps: [ + ]), + ) + .mockReturnValueOnce( + makeMockStreamResult([ + { type: "text-delta", textDelta: "Here is the file." }, { - toolResults: [{ toolCallId: "tc1", result: "file contents" }], + type: "finish", + finishReason: "stop", + usage: {}, + providerMetadata: undefined, + response: {}, }, - ], - }), - ); + ]), + ); const toolDef = { name: "read_file", |
