summaryrefslogtreecommitdiffhomepage
path: root/packages/core/tests/agent
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/core/tests/agent
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/core/tests/agent')
-rw-r--r--packages/core/tests/agent/agent.test.ts130
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",