ashlrcode 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +295 -0
- package/package.json +46 -0
- package/src/__tests__/branded-types.test.ts +47 -0
- package/src/__tests__/context.test.ts +163 -0
- package/src/__tests__/cost-tracker.test.ts +274 -0
- package/src/__tests__/cron.test.ts +197 -0
- package/src/__tests__/dream.test.ts +204 -0
- package/src/__tests__/error-handler.test.ts +192 -0
- package/src/__tests__/features.test.ts +69 -0
- package/src/__tests__/file-history.test.ts +177 -0
- package/src/__tests__/hooks.test.ts +145 -0
- package/src/__tests__/keybindings.test.ts +159 -0
- package/src/__tests__/model-patches.test.ts +82 -0
- package/src/__tests__/permissions-rules.test.ts +121 -0
- package/src/__tests__/permissions.test.ts +108 -0
- package/src/__tests__/project-config.test.ts +63 -0
- package/src/__tests__/retry.test.ts +321 -0
- package/src/__tests__/router.test.ts +158 -0
- package/src/__tests__/session-compact.test.ts +191 -0
- package/src/__tests__/session.test.ts +145 -0
- package/src/__tests__/skill-registry.test.ts +130 -0
- package/src/__tests__/speculation.test.ts +196 -0
- package/src/__tests__/tasks-v2.test.ts +267 -0
- package/src/__tests__/telemetry.test.ts +149 -0
- package/src/__tests__/tool-executor.test.ts +141 -0
- package/src/__tests__/tool-registry.test.ts +166 -0
- package/src/__tests__/undercover.test.ts +93 -0
- package/src/__tests__/workflow.test.ts +195 -0
- package/src/agent/async-context.ts +64 -0
- package/src/agent/context.ts +245 -0
- package/src/agent/cron.ts +189 -0
- package/src/agent/dream.ts +165 -0
- package/src/agent/error-handler.ts +108 -0
- package/src/agent/ipc.ts +256 -0
- package/src/agent/kairos.ts +207 -0
- package/src/agent/loop.ts +314 -0
- package/src/agent/model-patches.ts +68 -0
- package/src/agent/speculation.ts +219 -0
- package/src/agent/sub-agent.ts +125 -0
- package/src/agent/system-prompt.ts +231 -0
- package/src/agent/team.ts +220 -0
- package/src/agent/tool-executor.ts +162 -0
- package/src/agent/workflow.ts +189 -0
- package/src/agent/worktree-manager.ts +86 -0
- package/src/autopilot/queue.ts +186 -0
- package/src/autopilot/scanner.ts +245 -0
- package/src/autopilot/types.ts +58 -0
- package/src/bridge/bridge-client.ts +57 -0
- package/src/bridge/bridge-server.ts +81 -0
- package/src/cli.ts +1120 -0
- package/src/config/features.ts +51 -0
- package/src/config/git.ts +137 -0
- package/src/config/hooks.ts +201 -0
- package/src/config/permissions.ts +251 -0
- package/src/config/project-config.ts +63 -0
- package/src/config/remote-settings.ts +163 -0
- package/src/config/settings-sync.ts +170 -0
- package/src/config/settings.ts +113 -0
- package/src/config/undercover.ts +76 -0
- package/src/config/upgrade-notice.ts +65 -0
- package/src/mcp/client.ts +197 -0
- package/src/mcp/manager.ts +125 -0
- package/src/mcp/oauth.ts +252 -0
- package/src/mcp/types.ts +61 -0
- package/src/persistence/memory.ts +129 -0
- package/src/persistence/session.ts +289 -0
- package/src/planning/plan-mode.ts +128 -0
- package/src/planning/plan-tools.ts +138 -0
- package/src/providers/anthropic.ts +177 -0
- package/src/providers/cost-tracker.ts +184 -0
- package/src/providers/retry.ts +264 -0
- package/src/providers/router.ts +159 -0
- package/src/providers/types.ts +79 -0
- package/src/providers/xai.ts +217 -0
- package/src/repl.tsx +1384 -0
- package/src/setup.ts +119 -0
- package/src/skills/loader.ts +78 -0
- package/src/skills/registry.ts +78 -0
- package/src/skills/types.ts +11 -0
- package/src/state/file-history.ts +264 -0
- package/src/telemetry/event-log.ts +116 -0
- package/src/tools/agent.ts +133 -0
- package/src/tools/ask-user.ts +229 -0
- package/src/tools/bash.ts +146 -0
- package/src/tools/config.ts +147 -0
- package/src/tools/diff.ts +137 -0
- package/src/tools/file-edit.ts +123 -0
- package/src/tools/file-read.ts +82 -0
- package/src/tools/file-write.ts +82 -0
- package/src/tools/glob.ts +76 -0
- package/src/tools/grep.ts +187 -0
- package/src/tools/ls.ts +77 -0
- package/src/tools/lsp.ts +375 -0
- package/src/tools/mcp-resources.ts +83 -0
- package/src/tools/mcp-tool.ts +47 -0
- package/src/tools/memory.ts +148 -0
- package/src/tools/notebook-edit.ts +133 -0
- package/src/tools/peers.ts +113 -0
- package/src/tools/powershell.ts +83 -0
- package/src/tools/registry.ts +114 -0
- package/src/tools/send-message.ts +75 -0
- package/src/tools/sleep.ts +50 -0
- package/src/tools/snip.ts +143 -0
- package/src/tools/tasks.ts +349 -0
- package/src/tools/team.ts +309 -0
- package/src/tools/todo-write.ts +93 -0
- package/src/tools/tool-search.ts +83 -0
- package/src/tools/types.ts +52 -0
- package/src/tools/web-browser.ts +263 -0
- package/src/tools/web-fetch.ts +118 -0
- package/src/tools/web-search.ts +107 -0
- package/src/tools/workflow.ts +188 -0
- package/src/tools/worktree.ts +143 -0
- package/src/types/branded.ts +22 -0
- package/src/ui/App.tsx +184 -0
- package/src/ui/BuddyPanel.tsx +52 -0
- package/src/ui/PermissionPrompt.tsx +29 -0
- package/src/ui/banner.ts +217 -0
- package/src/ui/buddy-ai.ts +108 -0
- package/src/ui/buddy.ts +466 -0
- package/src/ui/context-bar.ts +60 -0
- package/src/ui/effort.ts +65 -0
- package/src/ui/keybindings.ts +143 -0
- package/src/ui/markdown.ts +271 -0
- package/src/ui/message-renderer.ts +73 -0
- package/src/ui/mode.ts +80 -0
- package/src/ui/notifications.ts +57 -0
- package/src/ui/speech-bubble.ts +95 -0
- package/src/ui/spinner.ts +116 -0
- package/src/ui/theme.ts +98 -0
- package/src/version.ts +5 -0
- package/src/voice/voice-mode.ts +169 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { test, expect, describe, beforeEach } from "bun:test";
|
|
2
|
+
import { ToolRegistry } from "../tools/registry.ts";
|
|
3
|
+
import type { Tool, ToolContext } from "../tools/types.ts";
|
|
4
|
+
import type { HooksConfig } from "../config/hooks.ts";
|
|
5
|
+
|
|
6
|
+
/** Create a minimal mock tool for testing. */
|
|
7
|
+
function mockTool(overrides: Partial<Tool> = {}): Tool {
|
|
8
|
+
return {
|
|
9
|
+
name: overrides.name ?? "MockTool",
|
|
10
|
+
prompt: () => "A mock tool",
|
|
11
|
+
inputSchema: () => ({ type: "object", properties: {} }),
|
|
12
|
+
isReadOnly: () => overrides.isReadOnly?.() ?? false,
|
|
13
|
+
isDestructive: () => false,
|
|
14
|
+
isConcurrencySafe: () => true,
|
|
15
|
+
validateInput: overrides.validateInput ?? (() => null),
|
|
16
|
+
call: overrides.call ?? (async () => "mock result"),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function mockContext(overrides: Partial<ToolContext> = {}): ToolContext {
|
|
21
|
+
return {
|
|
22
|
+
cwd: "/tmp",
|
|
23
|
+
requestPermission: overrides.requestPermission ?? (async () => true),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("ToolRegistry", () => {
|
|
28
|
+
let registry: ToolRegistry;
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
registry = new ToolRegistry();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("register and get", () => {
|
|
35
|
+
test("registers and retrieves a tool", () => {
|
|
36
|
+
const tool = mockTool({ name: "Bash" });
|
|
37
|
+
registry.register(tool);
|
|
38
|
+
expect(registry.get("Bash")).toBe(tool);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("returns undefined for unregistered tool", () => {
|
|
42
|
+
expect(registry.get("DoesNotExist")).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("getAll returns all registered tools", () => {
|
|
46
|
+
registry.register(mockTool({ name: "A" }));
|
|
47
|
+
registry.register(mockTool({ name: "B" }));
|
|
48
|
+
expect(registry.getAll()).toHaveLength(2);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("overwriting a tool with the same name replaces it", () => {
|
|
52
|
+
const tool1 = mockTool({ name: "T", call: async () => "v1" });
|
|
53
|
+
const tool2 = mockTool({ name: "T", call: async () => "v2" });
|
|
54
|
+
registry.register(tool1);
|
|
55
|
+
registry.register(tool2);
|
|
56
|
+
expect(registry.get("T")).toBe(tool2);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("getDefinitions", () => {
|
|
61
|
+
test("returns tool definitions for all tools", () => {
|
|
62
|
+
registry.register(mockTool({ name: "Read" }));
|
|
63
|
+
const defs = registry.getDefinitions();
|
|
64
|
+
expect(defs).toHaveLength(1);
|
|
65
|
+
expect(defs[0]!.name).toBe("Read");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("getReadOnlyDefinitions filters to read-only tools", () => {
|
|
69
|
+
registry.register(mockTool({ name: "Read", isReadOnly: () => true }));
|
|
70
|
+
registry.register(mockTool({ name: "Write", isReadOnly: () => false }));
|
|
71
|
+
const readOnly = registry.getReadOnlyDefinitions();
|
|
72
|
+
expect(readOnly).toHaveLength(1);
|
|
73
|
+
expect(readOnly[0]!.name).toBe("Read");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("execute", () => {
|
|
78
|
+
test("returns error for unknown tool", async () => {
|
|
79
|
+
const result = await registry.execute("Nope", {}, mockContext());
|
|
80
|
+
expect(result.isError).toBe(true);
|
|
81
|
+
expect(result.result).toContain("Unknown tool");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("returns validation error when validateInput fails", async () => {
|
|
85
|
+
registry.register(
|
|
86
|
+
mockTool({ name: "Bad", validateInput: () => "missing required field" })
|
|
87
|
+
);
|
|
88
|
+
const result = await registry.execute("Bad", {}, mockContext());
|
|
89
|
+
expect(result.isError).toBe(true);
|
|
90
|
+
expect(result.result).toContain("Validation error");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("executes tool and returns result on success", async () => {
|
|
94
|
+
registry.register(
|
|
95
|
+
mockTool({ name: "Good", call: async () => "hello world" })
|
|
96
|
+
);
|
|
97
|
+
const result = await registry.execute("Good", {}, mockContext());
|
|
98
|
+
expect(result.isError).toBe(false);
|
|
99
|
+
expect(result.result).toBe("hello world");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("catches thrown errors from tool.call", async () => {
|
|
103
|
+
registry.register(
|
|
104
|
+
mockTool({
|
|
105
|
+
name: "Throws",
|
|
106
|
+
call: async () => {
|
|
107
|
+
throw new Error("boom");
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
);
|
|
111
|
+
const result = await registry.execute("Throws", {}, mockContext());
|
|
112
|
+
expect(result.isError).toBe(true);
|
|
113
|
+
expect(result.result).toContain("boom");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("skips permission check for read-only tools", async () => {
|
|
117
|
+
let permissionRequested = false;
|
|
118
|
+
registry.register(
|
|
119
|
+
mockTool({ name: "Viewer", isReadOnly: () => true, call: async () => "data" })
|
|
120
|
+
);
|
|
121
|
+
const ctx = mockContext({
|
|
122
|
+
requestPermission: async () => {
|
|
123
|
+
permissionRequested = true;
|
|
124
|
+
return false; // Would deny if called
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
const result = await registry.execute("Viewer", {}, ctx);
|
|
128
|
+
expect(result.isError).toBe(false);
|
|
129
|
+
expect(permissionRequested).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("denies execution when permission is refused", async () => {
|
|
133
|
+
registry.register(mockTool({ name: "Write" }));
|
|
134
|
+
const ctx = mockContext({ requestPermission: async () => false });
|
|
135
|
+
const result = await registry.execute("Write", {}, ctx);
|
|
136
|
+
expect(result.isError).toBe(true);
|
|
137
|
+
expect(result.result).toContain("Permission denied");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("hook integration", () => {
|
|
142
|
+
test("denies when pre-hook returns deny action", async () => {
|
|
143
|
+
registry.register(mockTool({ name: "Bash" }));
|
|
144
|
+
registry.setHooks({
|
|
145
|
+
preToolUse: [
|
|
146
|
+
{ toolName: "Bash", action: "deny", message: "blocked by policy" },
|
|
147
|
+
],
|
|
148
|
+
});
|
|
149
|
+
const result = await registry.execute("Bash", {}, mockContext());
|
|
150
|
+
expect(result.isError).toBe(true);
|
|
151
|
+
expect(result.result).toContain("blocked by policy");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("allows when pre-hook returns allow action", async () => {
|
|
155
|
+
registry.register(
|
|
156
|
+
mockTool({ name: "Bash", call: async () => "ran ok" })
|
|
157
|
+
);
|
|
158
|
+
registry.setHooks({
|
|
159
|
+
preToolUse: [{ toolName: "Bash", action: "allow" }],
|
|
160
|
+
});
|
|
161
|
+
const result = await registry.execute("Bash", {}, mockContext());
|
|
162
|
+
expect(result.isError).toBe(false);
|
|
163
|
+
expect(result.result).toBe("ran ok");
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { test, expect, describe, beforeEach } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
isUndercoverMode,
|
|
4
|
+
setUndercoverMode,
|
|
5
|
+
maskCodenames,
|
|
6
|
+
sanitizeCommitMessage,
|
|
7
|
+
getUndercoverPrompt,
|
|
8
|
+
} from "../config/undercover.ts";
|
|
9
|
+
|
|
10
|
+
describe("Undercover Mode", () => {
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
// Reset to disabled before each test
|
|
13
|
+
setUndercoverMode(false);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("maskCodenames", () => {
|
|
17
|
+
test("replaces codenames when enabled", () => {
|
|
18
|
+
setUndercoverMode(true);
|
|
19
|
+
expect(maskCodenames("Running claude model")).toContain("cla***");
|
|
20
|
+
expect(maskCodenames("Using opus for analysis")).toContain("opu*");
|
|
21
|
+
expect(maskCodenames("capybara-v8 is fast")).toContain("cap*****");
|
|
22
|
+
expect(maskCodenames("ashlrcode agent")).toContain("ash******");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("is no-op when disabled", () => {
|
|
26
|
+
setUndercoverMode(false);
|
|
27
|
+
const text = "Running claude opus model via ashlrcode";
|
|
28
|
+
expect(maskCodenames(text)).toBe(text);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("is case-insensitive", () => {
|
|
32
|
+
setUndercoverMode(true);
|
|
33
|
+
const result = maskCodenames("CLAUDE and Claude and claude");
|
|
34
|
+
expect(result).not.toContain("CLAUDE");
|
|
35
|
+
expect(result).not.toContain("Claude");
|
|
36
|
+
expect(result).not.toContain("claude");
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("sanitizeCommitMessage", () => {
|
|
41
|
+
test("strips Co-Authored-By when enabled", () => {
|
|
42
|
+
setUndercoverMode(true);
|
|
43
|
+
const msg = `feat: add feature
|
|
44
|
+
|
|
45
|
+
Co-Authored-By: Claude <noreply@anthropic.com>`;
|
|
46
|
+
const result = sanitizeCommitMessage(msg);
|
|
47
|
+
expect(result).not.toContain("Co-Authored-By");
|
|
48
|
+
expect(result).toContain("feat: add feature");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("is no-op when disabled", () => {
|
|
52
|
+
setUndercoverMode(false);
|
|
53
|
+
const msg = `feat: stuff\n\nCo-Authored-By: Claude <noreply@anthropic.com>`;
|
|
54
|
+
expect(sanitizeCommitMessage(msg)).toBe(msg);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("handles multiple Co-Authored-By lines", () => {
|
|
58
|
+
setUndercoverMode(true);
|
|
59
|
+
const msg = `fix: bug
|
|
60
|
+
|
|
61
|
+
Co-Authored-By: Claude <noreply@anthropic.com>
|
|
62
|
+
Co-Authored-By: GPT <noreply@openai.com>`;
|
|
63
|
+
const result = sanitizeCommitMessage(msg);
|
|
64
|
+
expect(result).not.toContain("Co-Authored-By");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("getUndercoverPrompt", () => {
|
|
69
|
+
test("returns empty string when disabled", () => {
|
|
70
|
+
setUndercoverMode(false);
|
|
71
|
+
expect(getUndercoverPrompt()).toBe("");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("returns undercover instructions when enabled", () => {
|
|
75
|
+
setUndercoverMode(true);
|
|
76
|
+
const prompt = getUndercoverPrompt();
|
|
77
|
+
expect(prompt).toContain("UNDERCOVER MODE ACTIVE");
|
|
78
|
+
expect(prompt).toContain("Do NOT reveal");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("toggle", () => {
|
|
83
|
+
test("on/off works correctly", () => {
|
|
84
|
+
expect(isUndercoverMode()).toBe(false);
|
|
85
|
+
|
|
86
|
+
setUndercoverMode(true);
|
|
87
|
+
expect(isUndercoverMode()).toBe(true);
|
|
88
|
+
|
|
89
|
+
setUndercoverMode(false);
|
|
90
|
+
expect(isUndercoverMode()).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { test, expect, describe, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { existsSync, mkdtempSync, rmSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import {
|
|
6
|
+
createWorkflow,
|
|
7
|
+
listWorkflows,
|
|
8
|
+
deleteWorkflow,
|
|
9
|
+
executeWorkflow,
|
|
10
|
+
markWorkflowRun,
|
|
11
|
+
loadWorkflow,
|
|
12
|
+
type WorkflowStep,
|
|
13
|
+
type Workflow,
|
|
14
|
+
} from "../agent/workflow.ts";
|
|
15
|
+
import { setConfigDirForTests } from "../config/settings.ts";
|
|
16
|
+
|
|
17
|
+
let configDir: string;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
configDir = mkdtempSync(join(tmpdir(), "ashlrcode-workflow-test-"));
|
|
21
|
+
setConfigDirForTests(configDir);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
setConfigDirForTests(null);
|
|
26
|
+
if (existsSync(configDir)) rmSync(configDir, { recursive: true, force: true });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/** Helper to create a mock executor */
|
|
30
|
+
function mockExecutor(overrides?: Partial<Parameters<typeof executeWorkflow>[1]>) {
|
|
31
|
+
return {
|
|
32
|
+
runPrompt: async (prompt: string) => `Response to: ${prompt}`,
|
|
33
|
+
runCommand: async (cmd: string) => ({ output: `ran: ${cmd}`, exitCode: 0 }),
|
|
34
|
+
runTool: async (name: string, _input: Record<string, unknown>) => ({
|
|
35
|
+
result: `tool ${name} done`,
|
|
36
|
+
isError: false,
|
|
37
|
+
}),
|
|
38
|
+
onStepStart: () => {},
|
|
39
|
+
onStepEnd: () => {},
|
|
40
|
+
...overrides,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("createWorkflow", () => {
|
|
45
|
+
test("saves workflow to disk", async () => {
|
|
46
|
+
const steps: WorkflowStep[] = [
|
|
47
|
+
{ name: "say hi", type: "prompt", value: "Hello" },
|
|
48
|
+
];
|
|
49
|
+
const wf = await createWorkflow("Test WF", "A test workflow", steps);
|
|
50
|
+
|
|
51
|
+
expect(wf.id).toMatch(/^wf-/);
|
|
52
|
+
expect(wf.name).toBe("Test WF");
|
|
53
|
+
expect(wf.runCount).toBe(0);
|
|
54
|
+
|
|
55
|
+
const path = join(configDir, "workflows", `${wf.id}.json`);
|
|
56
|
+
expect(existsSync(path)).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("listWorkflows", () => {
|
|
61
|
+
test("returns saved workflows sorted by name", async () => {
|
|
62
|
+
await createWorkflow("Zeta", "Last", []);
|
|
63
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
64
|
+
await createWorkflow("Alpha", "First", []);
|
|
65
|
+
|
|
66
|
+
const workflows = await listWorkflows();
|
|
67
|
+
expect(workflows.length).toBe(2);
|
|
68
|
+
expect(workflows[0]!.name).toBe("Alpha");
|
|
69
|
+
expect(workflows[1]!.name).toBe("Zeta");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("returns empty array when no workflows dir exists", async () => {
|
|
73
|
+
const workflows = await listWorkflows();
|
|
74
|
+
expect(workflows).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("deleteWorkflow", () => {
|
|
79
|
+
test("removes workflow file and returns true", async () => {
|
|
80
|
+
const wf = await createWorkflow("ToDelete", "Will be deleted", []);
|
|
81
|
+
const deleted = await deleteWorkflow(wf.id);
|
|
82
|
+
expect(deleted).toBe(true);
|
|
83
|
+
|
|
84
|
+
const path = join(configDir, "workflows", `${wf.id}.json`);
|
|
85
|
+
expect(existsSync(path)).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("returns false for non-existent workflow", async () => {
|
|
89
|
+
const deleted = await deleteWorkflow("wf-nonexistent");
|
|
90
|
+
expect(deleted).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("markWorkflowRun", () => {
|
|
95
|
+
test("increments run counter and sets lastRunAt", async () => {
|
|
96
|
+
const wf = await createWorkflow("Counter", "Tracks runs", []);
|
|
97
|
+
expect(wf.runCount).toBe(0);
|
|
98
|
+
expect(wf.lastRunAt).toBeUndefined();
|
|
99
|
+
|
|
100
|
+
await markWorkflowRun(wf.id);
|
|
101
|
+
const updated = await loadWorkflow(wf.id);
|
|
102
|
+
expect(updated!.runCount).toBe(1);
|
|
103
|
+
expect(updated!.lastRunAt).toBeDefined();
|
|
104
|
+
|
|
105
|
+
await markWorkflowRun(wf.id);
|
|
106
|
+
const updated2 = await loadWorkflow(wf.id);
|
|
107
|
+
expect(updated2!.runCount).toBe(2);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("executeWorkflow", () => {
|
|
112
|
+
test("runs command steps", async () => {
|
|
113
|
+
const wf = await createWorkflow("CmdTest", "Test commands", [
|
|
114
|
+
{ name: "list files", type: "command", value: "ls" },
|
|
115
|
+
{ name: "greet", type: "prompt", value: "Say hello" },
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
const result = await executeWorkflow(wf, mockExecutor());
|
|
119
|
+
expect(result.stepsCompleted).toBe(2);
|
|
120
|
+
expect(result.stepsTotal).toBe(2);
|
|
121
|
+
expect(result.results.length).toBe(2);
|
|
122
|
+
expect(result.results[0]!.success).toBe(true);
|
|
123
|
+
expect(result.results[1]!.success).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("stops on error when continueOnError is not set", async () => {
|
|
127
|
+
const wf = await createWorkflow("FailTest", "Stops on error", [
|
|
128
|
+
{ name: "fail step", type: "command", value: "bad-command" },
|
|
129
|
+
{ name: "never reached", type: "prompt", value: "Should not run" },
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
const executor = mockExecutor({
|
|
133
|
+
runCommand: async () => ({ output: "error", exitCode: 1 }),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const result = await executeWorkflow(wf, executor);
|
|
137
|
+
expect(result.stepsCompleted).toBe(1); // First step runs (but fails)
|
|
138
|
+
expect(result.results[0]!.success).toBe(false);
|
|
139
|
+
// Second step should not have run
|
|
140
|
+
expect(result.results.length).toBe(1);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("continues past error when continueOnError is set", async () => {
|
|
144
|
+
const wf = await createWorkflow("ContinueTest", "Continues on error", [
|
|
145
|
+
{ name: "fail step", type: "command", value: "bad", continueOnError: true },
|
|
146
|
+
{ name: "still runs", type: "prompt", value: "This should run" },
|
|
147
|
+
]);
|
|
148
|
+
|
|
149
|
+
const executor = mockExecutor({
|
|
150
|
+
runCommand: async () => ({ output: "error", exitCode: 1 }),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const result = await executeWorkflow(wf, executor);
|
|
154
|
+
expect(result.stepsCompleted).toBe(2);
|
|
155
|
+
expect(result.results.length).toBe(2);
|
|
156
|
+
expect(result.results[0]!.success).toBe(false);
|
|
157
|
+
expect(result.results[1]!.success).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("handles tool steps", async () => {
|
|
161
|
+
const wf = await createWorkflow("ToolTest", "Test tool steps", [
|
|
162
|
+
{ name: "read file", type: "tool", value: "Read", input: { file_path: "/tmp/test" } },
|
|
163
|
+
]);
|
|
164
|
+
|
|
165
|
+
const result = await executeWorkflow(wf, mockExecutor());
|
|
166
|
+
expect(result.stepsCompleted).toBe(1);
|
|
167
|
+
expect(result.results[0]!.success).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("handles exceptions in steps", async () => {
|
|
171
|
+
const wf = await createWorkflow("ExceptionTest", "Throws", [
|
|
172
|
+
{ name: "explode", type: "command", value: "boom" },
|
|
173
|
+
{ name: "unreached", type: "prompt", value: "nope" },
|
|
174
|
+
]);
|
|
175
|
+
|
|
176
|
+
const executor = mockExecutor({
|
|
177
|
+
runCommand: async () => { throw new Error("kaboom"); },
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const result = await executeWorkflow(wf, executor);
|
|
181
|
+
expect(result.results[0]!.success).toBe(false);
|
|
182
|
+
expect(result.results[0]!.output).toContain("kaboom");
|
|
183
|
+
expect(result.results.length).toBe(1);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("reports timing info", async () => {
|
|
187
|
+
const wf = await createWorkflow("TimingTest", "Timing", [
|
|
188
|
+
{ name: "quick", type: "prompt", value: "fast" },
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
const result = await executeWorkflow(wf, mockExecutor());
|
|
192
|
+
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
|
193
|
+
expect(result.workflow).toBe("TimingTest");
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AsyncLocalStorage context isolation — per-agent namespace safety.
|
|
3
|
+
* Prevents context leakage between concurrent sub-agents sharing a process.
|
|
4
|
+
*
|
|
5
|
+
* Each sub-agent runs inside its own AsyncLocalStorage store so that any
|
|
6
|
+
* code calling getAgentContext() sees the correct agent identity, cwd, and
|
|
7
|
+
* permission flags without prop-drilling through every layer.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
11
|
+
|
|
12
|
+
export interface AgentContext {
|
|
13
|
+
agentId: string;
|
|
14
|
+
agentName: string;
|
|
15
|
+
cwd: string;
|
|
16
|
+
readOnly: boolean;
|
|
17
|
+
parentAgentId?: string;
|
|
18
|
+
/** Nesting depth: 0 = root REPL session */
|
|
19
|
+
depth: number;
|
|
20
|
+
startedAt: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const storage = new AsyncLocalStorage<AgentContext>();
|
|
24
|
+
|
|
25
|
+
/** Run a function with an isolated agent context. */
|
|
26
|
+
export function runWithAgentContext<T>(ctx: AgentContext, fn: () => T): T {
|
|
27
|
+
return storage.run(ctx, fn);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Get the current agent context (null if not inside any agent scope). */
|
|
31
|
+
export function getAgentContext(): AgentContext | null {
|
|
32
|
+
return storage.getStore() ?? null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Create a child context derived from an optional parent. */
|
|
36
|
+
export function createChildContext(
|
|
37
|
+
parentCtx: AgentContext | null,
|
|
38
|
+
name: string,
|
|
39
|
+
cwd: string,
|
|
40
|
+
readOnly: boolean,
|
|
41
|
+
): AgentContext {
|
|
42
|
+
return {
|
|
43
|
+
agentId: `agent-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
44
|
+
agentName: name,
|
|
45
|
+
cwd,
|
|
46
|
+
readOnly,
|
|
47
|
+
parentAgentId: parentCtx?.agentId,
|
|
48
|
+
depth: (parentCtx?.depth ?? -1) + 1,
|
|
49
|
+
startedAt: new Date().toISOString(),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** True when running inside a nested sub-agent (depth > 0). */
|
|
54
|
+
export function isNestedAgent(): boolean {
|
|
55
|
+
const ctx = getAgentContext();
|
|
56
|
+
return ctx !== null && ctx.depth > 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Human-readable label for the current agent scope (for logs/debugging). */
|
|
60
|
+
export function getAgentChain(): string {
|
|
61
|
+
const ctx = getAgentContext();
|
|
62
|
+
if (!ctx) return "(no agent context)";
|
|
63
|
+
return `${ctx.agentName} (depth=${ctx.depth}, id=${ctx.agentId})`;
|
|
64
|
+
}
|