From c4dc2c59e01d9fbfe3b906bcae466e2c3a538e1f Mon Sep 17 00:00:00 2001 From: knqiufan Date: Sat, 18 Jul 2026 03:07:53 +0800 Subject: [PATCH 1/3] test(core): cover issue 91 runtime modules --- .../src/bridge/coding-bridge.test.ts | 186 +++++++++++++ packages/core/src/agent/agent-team.test.ts | 244 ++++++++++++++++++ .../src/agent/conversation-memory.test.ts | 125 ++++++++- packages/core/src/agent/harness.test.ts | 196 ++++++++++++++ .../src/tools/native-impls/file-tools.test.ts | 135 ++++++++++ .../tools/native-impls/shell-tools.test.ts | 128 +++++++++ .../src/tools/native-impls/shell-tools.ts | 8 +- packages/core/src/tools/runtime.test.ts | 178 +++++++++++++ vitest.config.ts | 7 + 9 files changed, 1204 insertions(+), 3 deletions(-) create mode 100644 extensions/realtime-voice/src/bridge/coding-bridge.test.ts create mode 100644 packages/core/src/agent/agent-team.test.ts create mode 100644 packages/core/src/agent/harness.test.ts create mode 100644 packages/core/src/tools/native-impls/file-tools.test.ts create mode 100644 packages/core/src/tools/native-impls/shell-tools.test.ts create mode 100644 packages/core/src/tools/runtime.test.ts diff --git a/extensions/realtime-voice/src/bridge/coding-bridge.test.ts b/extensions/realtime-voice/src/bridge/coding-bridge.test.ts new file mode 100644 index 0000000..eb701cc --- /dev/null +++ b/extensions/realtime-voice/src/bridge/coding-bridge.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it, vi } from "vitest"; + +const sdk = vi.hoisted(() => { + class SdkSessionError extends Error { + code: string; + constructor(code: string, message = code) { + super(message); + this.code = code; + } + } + return { query: vi.fn(), SdkSessionError }; +}); + +vi.mock("@step-cli/agent-sdk", () => ({ + ERR_SESSION_CORRUPT: "SESSION_CORRUPT", + ERR_SESSION_NOT_FOUND: "SESSION_NOT_FOUND", + query: sdk.query, + SdkSessionError: sdk.SdkSessionError, +})); + +import { CodingBridge } from "./coding-bridge.js"; + +async function* messages(values: unknown[]) { + for (const value of values) yield value as never; +} + +function setup() { + let registration: any; + const session = { + registerTask: vi.fn((value) => { + registration = value; + }), + }; + const bridge = new CodingBridge( + session as never, + { + cwd: "/workspace", + model: "test-model", + permissionMode: "acceptEdits", + maxTurns: 4, + budgetUsd: 1, + }, + {} as never, + ); + return { bridge, session, registration: () => registration }; +} + +describe("CodingBridge", () => { + it("returns immediately, registers a background task, and translates SDK progress", async () => { + sdk.query.mockReturnValue( + messages([ + { + type: "assistant", + message: { + content: [ + { type: "text", text: "working" }, + { + type: "tool_use", + id: "u1", + name: "Read", + input: { file: "a" }, + }, + { type: "image" }, + ], + }, + }, + { + type: "user", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "u1", + content: "ok", + is_error: false, + }, + { type: "other" }, + ], + }, + }, + { + type: "stream_event", + event: { + type: "content_block_delta", + delta: { type: "text_delta", text: "partial" }, + }, + }, + { + type: "stream_event", + event: { + type: "content_block_delta", + delta: { type: "input_json_delta", partial_json: "{" }, + }, + }, + { type: "stream_event", event: { type: "other" } }, + { type: "system", subtype: "status", status: "requesting" }, + { type: "system", subtype: "status", status: null }, + { type: "system", subtype: "compact_boundary" }, + { + type: "system", + subtype: "permission_denied", + tool_name: "Write", + tool_use_id: "u2", + decision_reason: "no", + }, + { type: "unknown" }, + { + type: "result", + result: "completed", + session_id: "sdk-session", + total_cost_usd: 0.25, + }, + ]), + ); + const { bridge, session, registration } = setup(); + const started = JSON.parse( + await bridge.onToolCall({ task: "fix it", session: "new" }), + ); + expect(started.status).toBe("started"); + expect(session.registerTask).toHaveBeenCalledTimes(1); + const task = registration(); + const emitted: any[] = []; + const summary = await task.run({}, (entry: any) => emitted.push(entry)); + expect(summary).toMatchObject({ status: "done", summary: "completed" }); + expect(emitted.map((entry) => entry.kind)).toEqual([ + "message", + "tool_use", + "tool_result", + "message", + "tool_use_delta", + "status", + "status", + "message", + "tool_denied", + ]); + expect(task.completionAnnouncement(summary)).toContain("coding_agent done"); + expect( + task.statusInstruction({ taskId: started.taskId, progress: {} }, 61), + ).toContain("1"); + }); + + it("resumes an existing coding session and retries once with a fresh session on a resume error", async () => { + const { bridge, registration } = setup(); + sdk.query.mockReturnValueOnce( + messages([{ type: "result", result: "first", session_id: "saved" }]), + ); + await bridge.onToolCall({ task: "first", session: "new" }); + await registration().run({}, vi.fn()); + + sdk.query + .mockImplementationOnce(() => { + throw new sdk.SdkSessionError("SESSION_NOT_FOUND"); + }) + .mockReturnValueOnce( + messages([{ type: "result", result: "fresh", session_id: "new-id" }]), + ); + await bridge.onToolCall({ task: "second", session: "continue" }); + const summary = await registration().run({}, vi.fn()); + expect(summary).toMatchObject({ status: "done", summary: "fresh" }); + expect(sdk.query.mock.calls.at(-2)?.[0].options.resume).toBe("saved"); + expect(sdk.query.mock.calls.at(-1)?.[0].options.resume).toBeUndefined(); + }); + + it("reports regular failures and resolves cancellation without waiting for the SDK stream", async () => { + const { bridge, registration } = setup(); + sdk.query.mockImplementation(() => { + throw new Error("network down"); + }); + await bridge.onToolCall({ task: "fail", session: "new" }); + await expect(registration().run({}, vi.fn())).resolves.toMatchObject({ + status: "failed", + summary: "Error: network down", + }); + + let neverResolve!: () => void; + sdk.query.mockReturnValue( + messages([{ type: "assistant", message: { content: [] } }]), + ); + await bridge.onToolCall({ task: "cancel", session: "new" }); + const task = registration(); + const pending = task.run({}, vi.fn()); + task.abortController.abort(); + await expect(pending).resolves.toMatchObject({ status: "interrupted" }); + neverResolve?.(); + }); +}); diff --git a/packages/core/src/agent/agent-team.test.ts b/packages/core/src/agent/agent-team.test.ts new file mode 100644 index 0000000..6432b51 --- /dev/null +++ b/packages/core/src/agent/agent-team.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, it, vi } from "vitest"; +import { createMutableRef } from "@step-cli/utils/mutable-ref.js"; + +const { compileTeammateHarness } = vi.hoisted(() => ({ + compileTeammateHarness: vi.fn(), +})); +vi.mock("./scaffolding.js", () => ({ compileTeammateHarness })); + +import { AgentTeam } from "./agent-team.js"; + +function makeHarness(name = "worker") { + const state = { + identity: { + executionProfile: { + workspaceMode: "shared", + memoryMode: "persistent", + priority: "background", + }, + }, + allowedTools: ["Read"], + memory: { + messages: [], + summary: "", + summarizedUntil: 0, + decisionChain: [], + compactedUserMessages: [], + compactedToolMessages: 0, + }, + toolRuntime: { approvedFingerprints: [] }, + }; + return { + getContext: vi.fn(() => ({ + id: `teammate:${name}`, + name, + executionProfile: state.identity.executionProfile, + })), + getMemory: vi.fn(() => ({ exportState: () => state.memory })), + exportState: vi.fn(() => state), + finalize: vi.fn(), + run: vi.fn().mockResolvedValue({ + output: "finished", + steps: 1, + toolCalls: 0, + actions: [], + stateTimeline: [], + run: { sessionId: "s", goalId: "g", attemptId: "a" }, + }), + }; +} + +function setup() { + const messages: any[] = []; + const inboxStore = { + append: vi.fn(async (message) => { + messages.push(message); + }), + read: vi.fn(async (inbox: string, sessionId?: string) => + messages.filter( + (message) => + message.to === inbox && + (!sessionId || message.sessionId === sessionId), + ), + ), + }; + const ref = createMutableRef("factory"); + ref.set({}); + const team = new AgentTeam({ inboxStore, harnessFactoryRef: ref }); + return { team, messages, inboxStore }; +} + +describe("AgentTeam", () => { + it("spawns a normalized persistent teammate, sends its initial message, and reuses it", async () => { + const harness = makeHarness("dev"); + compileTeammateHarness.mockReturnValue({ + harness, + systemPrompt: "sys", + warnings: ["warning"], + }); + const { team, messages } = setup(); + const created = await team.spawnTeammate({ + name: " Dev ", + role: "developer", + requester: " Lead ", + parentDepth: 0, + parentId: "main", + workspaceRoot: "/workspace", + prompt: "implement", + }); + expect(created.teammate).toMatchObject({ + name: "Dev", + lead: "Lead", + status: "working", + }); + expect(created.warnings).toEqual(["warning"]); + expect(messages[0]).toMatchObject({ + from: "Lead", + to: "Dev", + type: "message", + content: "implement", + }); + const again = await team.spawnTeammate({ + name: "Dev", + role: "other", + requester: "lead", + parentDepth: 0, + parentId: "main", + workspaceRoot: "/workspace", + prompt: "next", + }); + expect(again.warnings).toContainEqual( + expect.stringContaining("already exists with role"), + ); + expect(compileTeammateHarness).toHaveBeenCalledTimes(1); + await team.close({ abortRunning: true }); + }); + + it("tracks inbox cursors per reader/session and respects markRead and limits", async () => { + const { team } = setup(); + await team.sendMessage({ + from: "lead", + to: "dev", + content: "one", + sessionId: "s1", + }); + await team.sendMessage({ + from: "lead", + to: "dev", + content: "two", + sessionId: "s1", + }); + await team.sendMessage({ + from: "lead", + to: "dev", + content: "other", + sessionId: "s2", + }); + const first = await team.readInbox({ + inboxName: "dev", + reader: "reader", + sessionId: "s1", + limit: 1, + }); + expect(first).toMatchObject({ remaining: 1, total: 2 }); + const unread = await team.readInbox({ + inboxName: "dev", + reader: "reader", + sessionId: "s1", + markRead: false, + }); + expect(unread.messages).toHaveLength(1); + const other = await team.readInbox({ + inboxName: "dev", + reader: "reader", + sessionId: "s2", + }); + expect(other.messages.map((message) => message.content)).toEqual(["other"]); + }); + + it("handles shutdown and plan approval protocols with ownership checks", async () => { + const harness = makeHarness("dev"); + compileTeammateHarness.mockReturnValue({ + harness, + systemPrompt: "sys", + warnings: [], + }); + const { team } = setup(); + await team.spawnTeammate({ + name: "dev", + role: "developer", + requester: "lead", + parentDepth: 0, + parentId: "main", + workspaceRoot: "/workspace", + prompt: "work", + }); + const shutdown = await team.requestShutdown({ + from: "lead", + to: "dev", + requestId: "stop", + sessionId: "s", + }); + await expect( + team.respondShutdown({ + from: "other", + requestId: shutdown.request.requestId, + approve: true, + }), + ).rejects.toThrow("assigned"); + const answered = await team.respondShutdown({ + from: "dev", + requestId: "stop", + approve: true, + }); + expect(answered.request.status).toBe("approved"); + const plan = await team.requestPlanApproval({ + from: "dev", + to: "lead", + content: "plan", + requestId: "plan-1", + }); + const approved = await team.respondPlanApproval({ + from: "lead", + requestId: plan.request.requestId, + approve: false, + }); + expect(approved.request).toMatchObject({ + status: "rejected", + response: "Plan rejected.", + }); + await team.close({ abortRunning: true }); + }); + + it("exports and reloads durable team state and rejects invalid state", async () => { + const harness = makeHarness("dev"); + compileTeammateHarness.mockReturnValue({ + harness, + systemPrompt: "sys", + warnings: [], + }); + const { team } = setup(); + await team.spawnTeammate({ + name: "dev", + role: "developer", + requester: "lead", + parentDepth: 0, + parentId: "main", + workspaceRoot: "/workspace", + prompt: "work", + sessionId: "s", + }); + const state = team.exportState(); + expect(state.version).toBe(4); + const restored = setup().team; + restored.loadState(state); + expect(restored.getTeammate("dev")).toMatchObject({ + name: "dev", + sessionId: "s", + }); + restored.loadState({ version: 99, teammates: [] }); + expect(restored.listTeammates()).toEqual([]); + await team.close({ abortRunning: true }); + await restored.close({ abortRunning: true }); + }); +}); diff --git a/packages/core/src/agent/conversation-memory.test.ts b/packages/core/src/agent/conversation-memory.test.ts index 757070e..86f0492 100644 --- a/packages/core/src/agent/conversation-memory.test.ts +++ b/packages/core/src/agent/conversation-memory.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import type { SystemMessage } from "@step-cli/protocol"; import { ConversationMemory, @@ -198,4 +198,127 @@ describe("ConversationMemory", () => { expect(exported.checkpoint!.objective).toHaveLength(1); }); }); + + describe("context assembly and recovery", () => { + it("repairs unmatched tool calls before exporting context", () => { + const memory = new ConversationMemory(makeConfig()); + memory.addAssistant("", [ + { + id: "call-1", + type: "function", + function: { name: "Read", arguments: "{}" }, + }, + ]); + expect(memory.repairIncompleteToolCalls()).toBe(1); + expect(memory.exportMessages()).toMatchObject([ + { role: "assistant" }, + { role: "tool", tool_call_id: "call-1" }, + ]); + }); + + it("builds an assembly, compacts oversized tool output, and exposes usage", () => { + const memory = new ConversationMemory( + makeConfig({ + maxContextTokens: 300, + reserveOutputTokens: 50, + minRecentMessages: 1, + microCompactKeepRecentToolMessages: 0, + microCompactToolContentChars: 20, + }), + ); + memory.addUser("task"); + memory.addAssistant("", [ + { + id: "c1", + type: "function", + function: { name: "Bash", arguments: "{}" }, + }, + { + id: "c2", + type: "function", + function: { name: "Bash", arguments: "{}" }, + }, + ]); + memory.addTool("c1", "Bash", "x".repeat(500)); + memory.addTool("c2", "Bash", "y".repeat(500)); + const assembled = memory.buildContextWithAssembly("system"); + expect(assembled.messages[0]).toMatchObject({ role: "system" }); + expect(memory.getLastContextAssembly()).toEqual(assembled.assembly); + expect(memory.getLastContextUsage().selectedMessages).toBeGreaterThan(0); + expect(memory.getStats().compactedToolMessages).toBeGreaterThan(0); + }); + + it("reports repeated context-rot issues and saves a fresh-attempt checkpoint", async () => { + const transcriptStore = { + save: vi + .fn() + .mockResolvedValue({ transcriptPath: "/tmp/transcript.jsonl" }), + }; + const progressStore = { + save: vi.fn().mockResolvedValue("/tmp/progress.md"), + }; + const memory = new ConversationMemory(makeConfig(), { + sessionId: "session", + transcriptStore: transcriptStore as never, + progressStore, + }); + for (let index = 0; index < 3; index += 1) { + memory.addTool( + `call-${index}`, + "Bash", + JSON.stringify({ + ok: false, + summary: "same failure", + error: { code: "EFAIL", message: "same failure" }, + }), + ); + } + const report = memory.getContextRotReport(); + expect(report.shouldRestart).toBe(true); + const checkpoint = await memory.prepareFreshAttempt({ + workspaceRoot: "/workspace", + reason: "repeated failure", + repeatedIssue: report.repeatedIssue, + }); + expect(checkpoint.progressPath).toBe("/tmp/progress.md"); + expect(checkpoint.summary).toContain("Progress file"); + expect(memory.exportState().messages).toEqual([]); + }); + + it("degrades gracefully when stores fail and smart compaction is within budget or aborted", async () => { + const memory = new ConversationMemory(makeConfig(), { + transcriptStore: { + save: vi.fn().mockRejectedValue(new Error("disk")), + } as never, + progressStore: { save: vi.fn().mockRejectedValue(new Error("disk")) }, + }); + memory.addUser("small"); + const skipped = await memory.smartCompactIfNeeded({ + systemPrompt: "system", + model: "model", + workspaceRoot: "/workspace", + client: { countPromptTokens: vi.fn().mockResolvedValue(1) } as never, + }); + expect(skipped).toMatchObject({ + compacted: false, + reason: "within_budget", + }); + const checkpoint = await memory.prepareFreshAttempt({ + workspaceRoot: "/workspace", + reason: "disk failure", + }); + expect(checkpoint.progressPath).toBeUndefined(); + const ac = new AbortController(); + ac.abort(); + await expect( + memory.smartCompactIfNeeded({ + systemPrompt: "system", + model: "model", + workspaceRoot: "/workspace", + signal: ac.signal, + client: { countPromptTokens: vi.fn() } as never, + }), + ).rejects.toThrow(); + }); + }); }); diff --git a/packages/core/src/agent/harness.test.ts b/packages/core/src/agent/harness.test.ts new file mode 100644 index 0000000..1b02e66 --- /dev/null +++ b/packages/core/src/agent/harness.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it, vi } from "vitest"; +import { + AgentHarness, + AgentHarnessFactory, + filterToolSpecsForOperatingMode, +} from "./harness.js"; +import { + ConversationMemory, + type MemoryConfig, +} from "./conversation-memory.js"; +import type { ToolSpec } from "@step-cli/protocol"; + +const memoryConfig: MemoryConfig = { + maxContextTokens: 8_000, + reserveOutputTokens: 1_000, + minRecentMessages: 2, + compressionTriggerRatio: 0.8, + compressionTargetRatio: 0.6, + maxSummaryChars: 500, + compactedUserMessageTokenBudget: 100, + maxCompactedUserMessages: 2, + compactedUserMessageMaxChars: 100, + maxDecisionEntries: 5, + decisionEntryMaxChars: 100, + microCompactKeepRecentToolMessages: 2, + microCompactToolContentChars: 100, +}; + +function identity() { + return { + id: "h-1", + kind: "main" as const, + name: "main", + depth: 0, + workspaceRoot: "/workspace", + sessionId: "session", + goalId: "goal", + executionProfile: { + workspaceMode: "shared" as const, + memoryMode: "session" as const, + priority: "interactive" as const, + }, + lifecycleState: "inactive" as const, + attemptCount: 0, + }; +} + +function makeHarness(overrides: Record = {}) { + const memory = new ConversationMemory(memoryConfig); + const tools = { + setSignal: vi.fn(), + exportState: vi.fn(() => ({ approvedFingerprints: [] })), + }; + const result = { + output: "done", + steps: 1, + toolCalls: 0, + actions: [], + stateTimeline: [], + run: { + harnessId: "h-1", + harnessType: "main", + harnessName: "main", + sessionId: "session", + goalId: "goal", + }, + }; + const agent = { + setSignal: vi.fn(), + run: vi.fn().mockResolvedValue(result), + dispatchExternalAction: vi.fn(() => ({ kind: "fresh_attempt_restart" })), + }; + const harness = new AgentHarness({ + context: identity(), + memory, + tools: tools as never, + agent: agent as never, + allowedTools: ["Read"], + ...overrides, + }); + return { harness, memory, tools, agent, result }; +} + +function tool( + name: string, + risk: "read" | "write" | "execute" = "read", +): ToolSpec { + return { + definition: { + type: "function", + function: { name, description: name, parameters: {} }, + }, + security: { risk, defaultMode: "allow" }, + parseArgs: () => ({}), + execute: async () => ({ ok: true, summary: name }), + } as ToolSpec; +} + +describe("AgentHarness", () => { + it("runs with an attempt context, resets signals, and exports isolated state", async () => { + const { harness, agent, tools, memory } = makeHarness(); + memory.addUser("hello"); + const result = await harness.run("hello"); + expect(result.output).toBe("done"); + expect(agent.run).toHaveBeenCalledWith("hello"); + expect(agent.setSignal).toHaveBeenLastCalledWith(undefined); + expect(tools.setSignal).toHaveBeenLastCalledWith(undefined); + const exported = harness.exportState(); + exported.allowedTools.push("Write"); + expect(harness.exportState().allowedTools).toEqual(["Read"]); + expect(harness.getContext().attemptCount).toBe(1); + }); + + it("rejects re-entry and finalizes only an inactive harness", async () => { + const { harness, agent } = makeHarness(); + agent.run.mockImplementation(async () => { + await expect(harness.run("again")).rejects.toThrow("already running"); + return { + output: "done", + steps: 0, + toolCalls: 0, + actions: [], + stateTimeline: [], + run: {}, + }; + }); + await harness.run("first"); + harness.finalize(); + harness.finalize(); + await expect(harness.run("after")).rejects.toThrow( + "already been finalized", + ); + }); + + it("filters operating-mode tool surfaces", () => { + const specs = [ + tool("Read"), + tool("Write", "write"), + tool("Bash", "execute"), + ]; + expect(filterToolSpecsForOperatingMode(specs, "normal").specs).toHaveLength( + 3, + ); + const plan = filterToolSpecsForOperatingMode(specs, "plan"); + expect(plan.specs.map((entry) => entry.definition.function.name)).toEqual([ + "Read", + ]); + expect(plan.hidden).toEqual(["Bash", "Write"]); + }); + + it("builds a harness, applies tool filtering, and reports plugin registration errors", () => { + const factory = new AgentHarnessFactory({ + model: "model", + client: {} as never, + defaultSystemPrompt: "system", + memoryConfig, + runConfig: { maxSteps: 2 } as never, + commandTimeoutMs: 1_000, + commandOutputLimit: 1_000, + interactionProfile: { kind: "cli" } as never, + plugins: [ + { + source: "builtin", + plugin: { + id: "good", + description: "good", + register: () => [tool("Read"), tool("Write", "write")], + }, + }, + { + source: "builtin", + plugin: { + id: "bad", + description: "bad", + register: () => { + throw new Error("broken"); + }, + }, + }, + ], + }); + const created = factory.createHarness({ + id: "id", + kind: "subagent", + name: "sub", + depth: 1, + workspaceRoot: "/workspace", + allowedTools: ["Read"], + }); + expect(created.harness.getTools().listToolNames()).toEqual(["Read"]); + expect(created.warnings).toContainEqual(expect.stringContaining("broken")); + expect(factory.getDefaultExecutionProfile("subagent").priority).toBe( + "delegated", + ); + }); +}); diff --git a/packages/core/src/tools/native-impls/file-tools.test.ts b/packages/core/src/tools/native-impls/file-tools.test.ts new file mode 100644 index 0000000..379d7bd --- /dev/null +++ b/packages/core/src/tools/native-impls/file-tools.test.ts @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { buildEditTool, buildReadTool, buildWriteTool } from "./file-tools.js"; + +let workspaceRoot: string; + +beforeEach(async () => { + workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "step-core-file-")); +}); + +afterEach(async () => { + await fs.rm(workspaceRoot, { recursive: true, force: true }); +}); + +const context = () => ({ + workspaceRoot, + commandTimeoutMs: 1_000, + commandOutputLimit: 10_000, +}); + +describe("core file tools", () => { + it("exposes typed definitions and rejects malformed arguments", async () => { + const read = buildReadTool(); + expect(read.definition.function.name).toBe("Read"); + expect(() => read.parseArgs("{}")).toThrow( + 'Read: required string field "file_path" is missing', + ); + expect(() => buildWriteTool().parseArgs('{"file_path":"x"}')).toThrow( + 'Write: required string field "content" is missing', + ); + }); + + it("writes nested files and reads a one-based line slice", async () => { + const write = buildWriteTool(); + const read = buildReadTool(); + const writeResult = await write.execute( + write.parseArgs( + '{"file_path":"nested/a.txt","content":"one\\ntwo\\nthree"}', + ), + context(), + undefined as never, + ); + expect(writeResult).toMatchObject({ ok: true }); + expect( + await fs.readFile(path.join(workspaceRoot, "nested/a.txt"), "utf8"), + ).toBe("one\ntwo\nthree"); + + const result = await read.execute( + read.parseArgs('{"file_path":"nested/a.txt","offset":2,"limit":1}'), + context(), + undefined as never, + ); + expect(result).toEqual({ ok: true, summary: "2\ttwo" }); + }); + + it("reports read failures and directory targets", async () => { + const read = buildReadTool(); + const missing = await read.execute( + read.parseArgs('{"file_path":"missing.txt"}'), + context(), + undefined as never, + ); + expect(missing).toMatchObject({ ok: false, error: { code: "ENOENT" } }); + + await fs.mkdir(path.join(workspaceRoot, "dir")); + const directory = await read.execute( + read.parseArgs('{"file_path":"dir"}'), + context(), + undefined as never, + ); + expect(directory).toMatchObject({ + ok: false, + error: { code: "IS_DIRECTORY" }, + }); + }); + + it("edits a unique occurrence and handles no-match, ambiguity, and replace-all", async () => { + const edit = buildEditTool(); + await fs.writeFile( + path.join(workspaceRoot, "edit.txt"), + "a\nb\na\n", + "utf8", + ); + + const ambiguous = await edit.execute( + edit.parseArgs( + '{"file_path":"edit.txt","old_string":"a","new_string":"z"}', + ), + context(), + undefined as never, + ); + expect(ambiguous).toMatchObject({ + ok: false, + error: { code: "AMBIGUOUS_MATCH" }, + }); + + const all = await edit.execute( + edit.parseArgs( + '{"file_path":"edit.txt","old_string":"a","new_string":"z","replace_all":true}', + ), + context(), + undefined as never, + ); + expect(all).toMatchObject({ ok: true }); + expect( + await fs.readFile(path.join(workspaceRoot, "edit.txt"), "utf8"), + ).toBe("z\nb\nz\n"); + + const noMatch = await edit.execute( + edit.parseArgs( + '{"file_path":"edit.txt","old_string":"missing","new_string":"x"}', + ), + context(), + undefined as never, + ); + expect(noMatch).toMatchObject({ ok: false, error: { code: "NO_MATCH" } }); + }); + + it.skipIf(process.platform === "win32")( + "rejects Windows drive paths on POSIX hosts", + async () => { + const result = await buildReadTool().execute( + buildReadTool().parseArgs('{"file_path":"C:\\\\temp\\\\file.txt"}'), + context(), + undefined as never, + ); + expect(result).toMatchObject({ + ok: false, + error: { code: "INVALID_PATH" }, + }); + }, + ); +}); diff --git a/packages/core/src/tools/native-impls/shell-tools.test.ts b/packages/core/src/tools/native-impls/shell-tools.test.ts new file mode 100644 index 0000000..3880378 --- /dev/null +++ b/packages/core/src/tools/native-impls/shell-tools.test.ts @@ -0,0 +1,128 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { buildBashTool, buildGlobTool, buildGrepTool } from "./shell-tools.js"; + +let workspaceRoot: string; + +beforeEach(async () => { + workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "step-core-shell-")); +}); + +afterEach(async () => { + await fs.rm(workspaceRoot, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +const context = () => ({ + workspaceRoot, + commandTimeoutMs: 1_000, + commandOutputLimit: 10_000, +}); + +describe("core shell tools", () => { + it("parses tool arguments and executes a successful Bash command", async () => { + const bash = buildBashTool(); + expect(bash.definition.function.name).toBe("Bash"); + expect(() => bash.parseArgs("{}")).toThrow(); + const result = await bash.execute( + bash.parseArgs( + process.platform === "win32" + ? '{"command":"echo hello"}' + : '{"command":"printf hello"}', + ), + context(), + undefined as never, + ); + expect(result).toEqual({ ok: true, summary: "hello" }); + }); + + it("maps Bash timeout, interruption, and non-zero exits to tool errors", async () => { + const bash = buildBashTool(); + const timeout = await bash.execute( + bash.parseArgs( + process.platform === "win32" + ? '{"command":"ping -n 3 127.0.0.1","timeout":1}' + : '{"command":"sleep 1","timeout":1}', + ), + context(), + undefined as never, + ); + expect(timeout).toMatchObject({ ok: false, error: { code: "TIMEOUT" } }); + + const ac = new AbortController(); + ac.abort(); + const interrupted = await bash.execute( + bash.parseArgs( + process.platform === "win32" + ? '{"command":"echo x"}' + : '{"command":"echo x"}', + ), + { ...context(), signal: ac.signal }, + undefined as never, + ); + expect(interrupted).toMatchObject({ + ok: false, + error: { code: "INTERRUPTED" }, + }); + + const failed = await bash.execute( + bash.parseArgs( + process.platform === "win32" + ? '{"command":"cmd /c exit 7"}' + : '{"command":"sh -c \'exit 7\'"}', + ), + context(), + undefined as never, + ); + expect(failed).toMatchObject({ + ok: false, + error: { code: "NONZERO_EXIT" }, + }); + }); + + it("globs files recursively while skipping generated directories", async () => { + await fs.mkdir(path.join(workspaceRoot, "src"), { recursive: true }); + await fs.mkdir(path.join(workspaceRoot, "node_modules", "pkg"), { + recursive: true, + }); + await fs.writeFile(path.join(workspaceRoot, "src", "a.ts"), "x"); + await fs.writeFile( + path.join(workspaceRoot, "node_modules", "pkg", "skip.ts"), + "x", + ); + + const glob = buildGlobTool(); + const result = await glob.execute( + glob.parseArgs('{"pattern":"**/*.ts"}'), + context(), + undefined as never, + ); + expect(result).toMatchObject({ ok: true }); + expect(result.summary).toContain(path.join(workspaceRoot, "src", "a.ts")); + expect(result.summary).not.toContain("skip.ts"); + }); + + it("greps matching files and returns no-match output", async () => { + await fs.mkdir(path.join(workspaceRoot, "src"), { recursive: true }); + await fs.writeFile( + path.join(workspaceRoot, "src", "a.ts"), + "const needle = 1;\n", + ); + const grep = buildGrepTool(); + const found = await grep.execute( + grep.parseArgs('{"pattern":"needle","include":"*.ts"}'), + context(), + undefined as never, + ); + expect(found).toMatchObject({ ok: true }); + expect(found.summary).toContain("needle"); + const missing = await grep.execute( + grep.parseArgs('{"pattern":"absent"}'), + context(), + undefined as never, + ); + expect(missing).toEqual({ ok: true, summary: "" }); + }); +}); diff --git a/packages/core/src/tools/native-impls/shell-tools.ts b/packages/core/src/tools/native-impls/shell-tools.ts index 534bea8..53335ca 100644 --- a/packages/core/src/tools/native-impls/shell-tools.ts +++ b/packages/core/src/tools/native-impls/shell-tools.ts @@ -212,7 +212,7 @@ async function globExecute( const regex = globToRegex(args.pattern); const matches: string[] = []; await walkDir(base, async (filePath) => { - const rel = path.relative(ctx.workspaceRoot, filePath); + const rel = toGlobPath(path.relative(ctx.workspaceRoot, filePath)); if (regex.test(rel)) matches.push(filePath); }); matches.sort(); @@ -349,7 +349,7 @@ async function jsGrep( const out: string[] = []; await walkDir(base, async (file) => { if (includeRegex) { - const rel = path.relative(base, file); + const rel = toGlobPath(path.relative(base, file)); if (!includeRegex.test(rel)) return; } let stat: import("node:fs").Stats; @@ -401,3 +401,7 @@ function resolveWorkspacePath( ? candidate : path.resolve(workspaceRoot, candidate); } + +function toGlobPath(filePath: string): string { + return filePath.replaceAll("\\", "/"); +} diff --git a/packages/core/src/tools/runtime.test.ts b/packages/core/src/tools/runtime.test.ts new file mode 100644 index 0000000..1fae87e --- /dev/null +++ b/packages/core/src/tools/runtime.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it, vi } from "vitest"; +import { + ToolRuntime, + isWorkspacePathEscapeError, + toolResultFromExecutionError, +} from "./runtime.js"; +import type { ToolSpec } from "@step-cli/protocol"; + +function spec( + name: string, + options: { + risk?: "meta" | "read" | "write" | "execute"; + parse?: (raw: string) => unknown; + execute?: ToolSpec["execute"]; + supportsParallel?: boolean; + } = {}, +): ToolSpec { + return { + definition: { + type: "function", + function: { + name, + description: `${name} description`, + parameters: { + type: "object", + properties: { value: { type: "string" } }, + }, + }, + }, + security: { risk: options.risk ?? "read", defaultMode: "allow" }, + parseArgs: options.parse ?? ((raw) => JSON.parse(raw)), + execute: + options.execute ?? vi.fn(async () => ({ ok: true, summary: name })), + supportsParallel: options.supportsParallel, + } as ToolSpec; +} + +const context = { + workspaceRoot: "/workspace", + commandTimeoutMs: 1_000, + commandOutputLimit: 1_000, +}; + +describe("ToolRuntime", () => { + it("returns defensive catalog data, searches tools, and rejects duplicates", () => { + const read = spec("Read"); + const runtime = new ToolRuntime([read], context); + expect(runtime.listToolNames()).toEqual(["Read"]); + expect(runtime.searchTools("read")[0]?.tool.name).toBe("Read"); + const definitions = runtime.getDefinitions(); + definitions[0]!.function.name = "changed"; + expect(runtime.getDefinitions()[0]!.function.name).toBe("Read"); + expect(() => new ToolRuntime([read, read], context)).toThrow( + "Duplicate tool definition", + ); + }); + + it("normalizes unknown tools, invalid arguments, thrown errors, and path escapes", async () => { + const bad = spec("Bad", { + parse: () => { + throw new Error("bad args"); + }, + }); + const throwing = spec("Throw", { + execute: async () => { + throw new Error("boom"); + }, + }); + const runtime = new ToolRuntime([bad, throwing], context); + await expect(runtime.executeTool("unknown", "{}")).resolves.toMatchObject({ + ok: false, + error: { code: "UNKNOWN_TOOL" }, + }); + await expect(runtime.executeTool("Bad", "{}")).resolves.toMatchObject({ + error: { code: "INVALID_ARGUMENTS" }, + }); + await expect(runtime.executeTool("Throw", "{}")).resolves.toMatchObject({ + error: { code: "TOOL_EXECUTION_FAILED" }, + }); + const error = new Error("Path escapes workspace root: /etc/passwd"); + expect(isWorkspacePathEscapeError(error)).toBe(true); + expect(toolResultFromExecutionError("Read", error)).toMatchObject({ + error: { code: "PATH_ESCAPES_WORKSPACE_ROOT" }, + }); + }); + + it("enforces deny and confirmation decisions and caches allow-always", async () => { + const execute = vi.fn(async () => ({ ok: true, summary: "ran" })); + const policy = { + evaluate: vi.fn(() => ({ + mode: "confirm", + reason: "review", + risk: "write", + })), + }; + const approval = vi.fn().mockResolvedValue("allow-always"); + const runtime = new ToolRuntime( + [spec("Write", { risk: "write", execute })], + context, + { + permissionPolicy: policy as never, + approvalHandler: approval, + }, + ); + await expect( + runtime.executeTool("Write", '{"value":"x"}'), + ).resolves.toMatchObject({ ok: true }); + await runtime.executeTool("Write", '{"value":"x"}'); + expect(approval).toHaveBeenCalledTimes(1); + expect(execute).toHaveBeenCalledTimes(2); + expect(runtime.exportState().approvedFingerprints).toHaveLength(1); + + const denied = new ToolRuntime([spec("No")], context, { + permissionPolicy: { + evaluate: () => ({ mode: "deny", reason: "no", risk: "read" }), + } as never, + }); + await expect(denied.executeTool("No", "{}")).resolves.toMatchObject({ + error: { code: "PERMISSION_DENIED" }, + }); + }); + + it("runs nested hooks, converts workspace escapes, and restores approvals", async () => { + const before = vi.fn(); + const after = vi.fn(); + const runtime = new ToolRuntime([spec("Read")], context, { + beforeNestedToolExecution: before, + afterNestedToolExecution: after, + }); + await runtime.executeNestedTool("Read", "{}"); + expect(before).toHaveBeenCalledWith( + expect.objectContaining({ toolName: "Read" }), + ); + expect(after).toHaveBeenCalledWith( + expect.objectContaining({ + toolName: "Read", + result: { ok: true, summary: "Read" }, + }), + ); + runtime.loadState({ approvedFingerprints: ["one", "two"] }); + expect(runtime.exportState().approvedFingerprints).toEqual(["one", "two"]); + runtime.loadState({ approvedFingerprints: "bad" }); + expect(runtime.exportState().approvedFingerprints).toEqual(["one", "two"]); + }); + + it("serializes writers while allowing read-capable tools to run", async () => { + const order: string[] = []; + let releaseFirst!: () => void; + const first = new Promise((resolve) => { + releaseFirst = resolve; + }); + const write = spec("Write", { + risk: "write", + execute: async () => { + order.push("write-start"); + await first; + order.push("write-end"); + return { ok: true, summary: "write" }; + }, + }); + const read = spec("Read", { + supportsParallel: true, + execute: async () => { + order.push("read"); + return { ok: true, summary: "read" }; + }, + }); + const runtime = new ToolRuntime([write, read], context); + const writing = runtime.executeTool("Write", "{}"); + await vi.waitFor(() => expect(order).toEqual(["write-start"])); + const reading = runtime.executeTool("Read", "{}"); + await Promise.resolve(); + expect(order).toEqual(["write-start"]); + releaseFirst(); + await Promise.all([writing, reading]); + expect(order).toEqual(["write-start", "write-end", "read"]); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 2ea1608..c85add8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -59,7 +59,13 @@ export default defineConfig({ "packages/core/src/agent/conversation-memory-checkpoint.ts", "packages/core/src/agent/delegation-view.ts", "packages/core/src/agent/harness-context.ts", + "packages/core/src/agent/agent-team.ts", + "packages/core/src/agent/harness.ts", + "packages/core/src/agent/conversation-memory.ts", "packages/core/src/agent/state-machine.ts", + "packages/core/src/tools/runtime.ts", + "packages/core/src/tools/native-impls/shell-tools.ts", + "packages/core/src/tools/native-impls/file-tools.ts", "packages/core/src/max-steps.ts", "packages/agent-sdk/src/**/*.ts", "packages/realtime/src/capability/schema.ts", @@ -68,6 +74,7 @@ export default defineConfig({ "extensions/llm/src/**/*.ts", "extensions/mcp/src/manager.ts", "extensions/mcp/src/tool-plugin.ts", + "extensions/realtime-voice/src/bridge/coding-bridge.ts", "skills/builtin/src/apply-patch.ts", "skills/builtin/src/command-output.ts", "skills/builtin/src/tool-inspection.ts", From 4a9db3ac9a58dbdca66f26ee4f22317de58c4a07 Mon Sep 17 00:00:00 2001 From: knqiufan Date: Sat, 18 Jul 2026 03:20:48 +0800 Subject: [PATCH 2/3] fix(core): align grep fallback include matching --- .../tools/native-impls/shell-tools.test.ts | 25 ++++++++++++++----- .../src/tools/native-impls/shell-tools.ts | 6 ++++- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/packages/core/src/tools/native-impls/shell-tools.test.ts b/packages/core/src/tools/native-impls/shell-tools.test.ts index 3880378..fe5c6e7 100644 --- a/packages/core/src/tools/native-impls/shell-tools.test.ts +++ b/packages/core/src/tools/native-impls/shell-tools.test.ts @@ -111,13 +111,26 @@ describe("core shell tools", () => { "const needle = 1;\n", ); const grep = buildGrepTool(); - const found = await grep.execute( - grep.parseArgs('{"pattern":"needle","include":"*.ts"}'), - context(), - undefined as never, + const pathKeys = Object.keys(process.env).filter( + (key) => key.toUpperCase() === "PATH", + ); + const originalPaths = new Map( + pathKeys.map((key) => [key, process.env[key]]), ); - expect(found).toMatchObject({ ok: true }); - expect(found.summary).toContain("needle"); + for (const key of pathKeys) delete process.env[key]; + process.env.PATH = ""; + try { + const found = await grep.execute( + grep.parseArgs('{"pattern":"needle","include":"*.ts"}'), + context(), + undefined as never, + ); + expect(found).toMatchObject({ ok: true }); + expect(found.summary).toContain("needle"); + } finally { + delete process.env.PATH; + for (const [key, value] of originalPaths) process.env[key] = value; + } const missing = await grep.execute( grep.parseArgs('{"pattern":"absent"}'), context(), diff --git a/packages/core/src/tools/native-impls/shell-tools.ts b/packages/core/src/tools/native-impls/shell-tools.ts index 53335ca..140b4d3 100644 --- a/packages/core/src/tools/native-impls/shell-tools.ts +++ b/packages/core/src/tools/native-impls/shell-tools.ts @@ -350,7 +350,11 @@ async function jsGrep( await walkDir(base, async (file) => { if (includeRegex) { const rel = toGlobPath(path.relative(base, file)); - if (!includeRegex.test(rel)) return; + // ripgrep treats an include without a path separator as a filename + // pattern at every directory depth. Match that behavior in the JS + // fallback so environments without `rg` produce the same result. + const fileName = path.basename(file); + if (!includeRegex.test(rel) && !includeRegex.test(fileName)) return; } let stat: import("node:fs").Stats; try { From a89ec992209737d37503b5ab895b27c935a8b553 Mon Sep 17 00:00:00 2001 From: knqiufan Date: Sat, 18 Jul 2026 03:26:28 +0800 Subject: [PATCH 3/3] test(core): stabilize grep fallback coverage --- .../core/src/tools/native-impls/shell-tools.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/core/src/tools/native-impls/shell-tools.test.ts b/packages/core/src/tools/native-impls/shell-tools.test.ts index fe5c6e7..1a0ed4b 100644 --- a/packages/core/src/tools/native-impls/shell-tools.test.ts +++ b/packages/core/src/tools/native-impls/shell-tools.test.ts @@ -127,15 +127,15 @@ describe("core shell tools", () => { ); expect(found).toMatchObject({ ok: true }); expect(found.summary).toContain("needle"); + const missing = await grep.execute( + grep.parseArgs('{"pattern":"absent"}'), + context(), + undefined as never, + ); + expect(missing).toEqual({ ok: true, summary: "(no matches)" }); } finally { delete process.env.PATH; for (const [key, value] of originalPaths) process.env[key] = value; } - const missing = await grep.execute( - grep.parseArgs('{"pattern":"absent"}'), - context(), - undefined as never, - ); - expect(missing).toEqual({ ok: true, summary: "" }); }); });