@dungle-scrubs/tallow 0.8.26 → 0.8.27
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/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/interactive-mode-patch.d.ts +1 -0
- package/dist/interactive-mode-patch.d.ts.map +1 -1
- package/dist/interactive-mode-patch.js +40 -1
- package/dist/interactive-mode-patch.js.map +1 -1
- package/dist/pid-manager.d.ts +2 -9
- package/dist/pid-manager.d.ts.map +1 -1
- package/dist/pid-manager.js +1 -58
- package/dist/pid-manager.js.map +1 -1
- package/dist/pid-schema.d.ts +51 -0
- package/dist/pid-schema.d.ts.map +1 -0
- package/dist/pid-schema.js +70 -0
- package/dist/pid-schema.js.map +1 -0
- package/dist/sdk.js +4 -8
- package/dist/sdk.js.map +1 -1
- package/extensions/__integration__/audit-findings.test.ts +309 -0
- package/extensions/__integration__/tasks-runtime.test.ts +63 -12
- package/extensions/_shared/lazy-init.ts +88 -3
- package/extensions/_shared/pid-registry.ts +8 -82
- package/extensions/cheatsheet/__tests__/cheatsheet.test.ts +47 -0
- package/extensions/clear/__tests__/clear.test.ts +38 -0
- package/extensions/git-status/__tests__/git-status.test.ts +32 -0
- package/extensions/mcp-adapter-tool/index.ts +1 -1
- package/extensions/minimal-skill-display/__tests__/minimal-skill-display.test.ts +20 -0
- package/extensions/permissions/__tests__/permissions.test.ts +213 -0
- package/extensions/progress-indicator/__tests__/progress-indicator.test.ts +104 -0
- package/extensions/random-spinner/__tests__/random-spinner.test.ts +35 -0
- package/extensions/show-system-prompt/__tests__/show-system-prompt.test.ts +51 -0
- package/extensions/subagent-tool/__tests__/presentation-rendering.test.ts +5 -4
- package/extensions/subagent-tool/__tests__/process-liveness.test.ts +51 -0
- package/extensions/subagent-tool/__tests__/subprocess-args.test.ts +120 -0
- package/extensions/subagent-tool/formatting.ts +2 -0
- package/extensions/subagent-tool/index.ts +156 -95
- package/extensions/subagent-tool/process.ts +126 -32
- package/extensions/tasks/commands/register-tasks-extension.ts +64 -20
- package/extensions/tasks/extension.json +1 -0
- package/extensions/tasks/index.ts +2 -12
- package/extensions/tasks/state/index.ts +26 -0
- package/extensions/teams-tool/dashboard.ts +13 -1
- package/extensions/teams-tool/tools/register-extension.ts +10 -2
- package/extensions/upstream-check/__tests__/upstream-check.test.ts +49 -0
- package/extensions/wezterm-notify/__tests__/index.test.ts +49 -11
- package/extensions/wezterm-notify/index.ts +5 -3
- package/extensions/write-tool-enhanced/__tests__/write-tool-enhanced.test.ts +296 -0
- package/package.json +3 -2
- package/runtime/pid-schema.ts +13 -0
- package/skills/tallow-expert/SKILL.md +1 -1
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the permissions extension registration and wiring.
|
|
3
|
+
*
|
|
4
|
+
* Uses the ExtensionHarness to avoid mock.module() — which contaminates
|
|
5
|
+
* other test files in the same Bun worker. Tests cover command/handler
|
|
6
|
+
* registration and event handler presence without mocking the _shared modules.
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
10
|
+
import { ExtensionHarness } from "../../../test-utils/extension-harness.js";
|
|
11
|
+
import registerPermissions from "../index.js";
|
|
12
|
+
|
|
13
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
type EventName = "session_start" | "tool_call";
|
|
16
|
+
type EventHandler = (event: unknown, ctx: ExtensionContext) => Promise<unknown>;
|
|
17
|
+
type CommandHandler = (args: string | undefined, ctx: unknown) => Promise<void>;
|
|
18
|
+
|
|
19
|
+
interface CapturedPi {
|
|
20
|
+
handlers: Partial<Record<EventName, EventHandler>>;
|
|
21
|
+
commands: Record<string, { description: string; handler: CommandHandler }>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Run the extension against a spy pi to capture what it registers.
|
|
26
|
+
*
|
|
27
|
+
* @returns Captured event handlers and commands
|
|
28
|
+
*/
|
|
29
|
+
function captureRegistrations(): CapturedPi {
|
|
30
|
+
const handlers: Partial<Record<EventName, EventHandler>> = {};
|
|
31
|
+
const commands: Record<string, { description: string; handler: CommandHandler }> = {};
|
|
32
|
+
const pi = {
|
|
33
|
+
on: (event: string, handler: EventHandler) => {
|
|
34
|
+
handlers[event as EventName] = handler;
|
|
35
|
+
},
|
|
36
|
+
registerCommand: (name: string, opts: { description: string; handler: CommandHandler }) => {
|
|
37
|
+
commands[name] = opts;
|
|
38
|
+
},
|
|
39
|
+
} as unknown as ExtensionAPI;
|
|
40
|
+
|
|
41
|
+
registerPermissions(pi);
|
|
42
|
+
return { handlers, commands };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ════════════════════════════════════════════════════════════════
|
|
46
|
+
// Registration
|
|
47
|
+
// ════════════════════════════════════════════════════════════════
|
|
48
|
+
|
|
49
|
+
describe("permissions extension registration", () => {
|
|
50
|
+
test("registers session_start handler", () => {
|
|
51
|
+
const { handlers } = captureRegistrations();
|
|
52
|
+
expect(handlers.session_start).toBeDefined();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("registers tool_call handler", () => {
|
|
56
|
+
const { handlers } = captureRegistrations();
|
|
57
|
+
expect(handlers.tool_call).toBeDefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("registers /permissions command", () => {
|
|
61
|
+
const { commands } = captureRegistrations();
|
|
62
|
+
expect(commands.permissions).toBeDefined();
|
|
63
|
+
expect(commands.permissions.description).toBeTruthy();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ════════════════════════════════════════════════════════════════
|
|
68
|
+
// tool_call handler wiring
|
|
69
|
+
// ════════════════════════════════════════════════════════════════
|
|
70
|
+
|
|
71
|
+
describe("tool_call handler", () => {
|
|
72
|
+
test("skips bash tool (handled by shell-policy)", async () => {
|
|
73
|
+
const { handlers } = captureRegistrations();
|
|
74
|
+
const result = await handlers.tool_call!(
|
|
75
|
+
{ type: "tool_call", toolName: "bash", toolCallId: "t1", input: { command: "ls" } },
|
|
76
|
+
{ cwd: "/tmp", hasUI: false, ui: {} } as unknown as ExtensionContext
|
|
77
|
+
);
|
|
78
|
+
// No block/error — should be undefined (pass-through)
|
|
79
|
+
expect(result).toBeUndefined();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("skips bg_bash tool (handled by shell-policy)", async () => {
|
|
83
|
+
const { handlers } = captureRegistrations();
|
|
84
|
+
const result = await handlers.tool_call!(
|
|
85
|
+
{ type: "tool_call", toolName: "bg_bash", toolCallId: "t2", input: { command: "ls" } },
|
|
86
|
+
{ cwd: "/tmp", hasUI: false, ui: {} } as unknown as ExtensionContext
|
|
87
|
+
);
|
|
88
|
+
expect(result).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("skips when no rules configured", async () => {
|
|
92
|
+
const { handlers } = captureRegistrations();
|
|
93
|
+
// Session-start hasn't been called yet, so currentCwd is "", and
|
|
94
|
+
// getPermissions("") returns empty rules → skip
|
|
95
|
+
const result = await handlers.tool_call!(
|
|
96
|
+
{
|
|
97
|
+
type: "tool_call",
|
|
98
|
+
toolName: "read",
|
|
99
|
+
toolCallId: "t3",
|
|
100
|
+
input: { path: "/etc/passwd" },
|
|
101
|
+
},
|
|
102
|
+
{ cwd: "/tmp", hasUI: false, ui: {} } as unknown as ExtensionContext
|
|
103
|
+
);
|
|
104
|
+
expect(result).toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ════════════════════════════════════════════════════════════════
|
|
109
|
+
// /permissions command wiring
|
|
110
|
+
// ════════════════════════════════════════════════════════════════
|
|
111
|
+
|
|
112
|
+
describe("/permissions command", () => {
|
|
113
|
+
test("reload subcommand calls reloadPermissions", async () => {
|
|
114
|
+
const { commands } = captureRegistrations();
|
|
115
|
+
const notifications: Array<{ msg: string; type: string }> = [];
|
|
116
|
+
const ctx = {
|
|
117
|
+
cwd: "/tmp",
|
|
118
|
+
ui: {
|
|
119
|
+
notify: (msg: string, type: string) => {
|
|
120
|
+
notifications.push({ msg, type });
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
await commands.permissions.handler("reload", ctx);
|
|
126
|
+
// Should have notified about reload
|
|
127
|
+
expect(notifications.length).toBeGreaterThan(0);
|
|
128
|
+
expect(notifications[0].msg).toContain("Reloaded");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("no args shows rules (or 'no rules' when none configured)", async () => {
|
|
132
|
+
const { commands } = captureRegistrations();
|
|
133
|
+
const notifications: Array<{ msg: string; type: string }> = [];
|
|
134
|
+
const ctx = {
|
|
135
|
+
cwd: "/tmp",
|
|
136
|
+
ui: {
|
|
137
|
+
notify: (msg: string, type: string) => {
|
|
138
|
+
notifications.push({ msg, type });
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
await commands.permissions.handler("", ctx);
|
|
144
|
+
expect(notifications.length).toBeGreaterThan(0);
|
|
145
|
+
// Should show either "No permission rules" or "Active Permission Rules"
|
|
146
|
+
const msg = notifications[0].msg;
|
|
147
|
+
expect(msg.includes("No permission rules") || msg.includes("Permission Rules")).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("test subcommand evaluates Tool(specifier) format", async () => {
|
|
151
|
+
const { commands } = captureRegistrations();
|
|
152
|
+
const notifications: Array<{ msg: string; type: string }> = [];
|
|
153
|
+
const ctx = {
|
|
154
|
+
cwd: "/tmp",
|
|
155
|
+
ui: {
|
|
156
|
+
notify: (msg: string, type: string) => {
|
|
157
|
+
notifications.push({ msg, type });
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
await commands.permissions.handler("test Bash(ls)", ctx);
|
|
163
|
+
expect(notifications.length).toBeGreaterThan(0);
|
|
164
|
+
// Should contain action verdict info
|
|
165
|
+
expect(notifications[0].msg).toContain("Action:");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("test subcommand handles bare tool name", async () => {
|
|
169
|
+
const { commands } = captureRegistrations();
|
|
170
|
+
const notifications: Array<{ msg: string; type: string }> = [];
|
|
171
|
+
const ctx = {
|
|
172
|
+
cwd: "/tmp",
|
|
173
|
+
ui: {
|
|
174
|
+
notify: (msg: string, type: string) => {
|
|
175
|
+
notifications.push({ msg, type });
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
await commands.permissions.handler("test read", ctx);
|
|
181
|
+
expect(notifications.length).toBeGreaterThan(0);
|
|
182
|
+
expect(notifications[0].msg).toContain("Action:");
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ════════════════════════════════════════════════════════════════
|
|
187
|
+
// ExtensionHarness integration
|
|
188
|
+
// ════════════════════════════════════════════════════════════════
|
|
189
|
+
|
|
190
|
+
describe("permissions via ExtensionHarness", () => {
|
|
191
|
+
test("tool_call event handler is registered", async () => {
|
|
192
|
+
const harness = ExtensionHarness.create();
|
|
193
|
+
await harness.loadExtension(registerPermissions);
|
|
194
|
+
// Verify the extension registered a tool_call handler by firing one
|
|
195
|
+
const results = await harness.fireEvent("tool_call", {
|
|
196
|
+
type: "tool_call",
|
|
197
|
+
toolName: "read",
|
|
198
|
+
toolCallId: "h-1",
|
|
199
|
+
input: { path: "foo.txt" },
|
|
200
|
+
});
|
|
201
|
+
// No rules configured → should not block
|
|
202
|
+
const blocked = results.some(
|
|
203
|
+
(r) => r && typeof r === "object" && (r as { block?: boolean }).block
|
|
204
|
+
);
|
|
205
|
+
expect(blocked).toBe(false);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("/permissions command is registered", async () => {
|
|
209
|
+
const harness = ExtensionHarness.create();
|
|
210
|
+
await harness.loadExtension(registerPermissions);
|
|
211
|
+
expect(harness.commands.has("permissions")).toBe(true);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import progressIndicator from "../index.js";
|
|
4
|
+
|
|
5
|
+
describe("progress-indicator extension", () => {
|
|
6
|
+
let originalIsTTY: boolean;
|
|
7
|
+
let written: string[];
|
|
8
|
+
let originalWrite: typeof process.stdout.write;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
originalIsTTY = process.stdout.isTTY;
|
|
12
|
+
originalWrite = process.stdout.write;
|
|
13
|
+
written = [];
|
|
14
|
+
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
|
|
15
|
+
process.stdout.write = ((chunk: string) => {
|
|
16
|
+
written.push(chunk);
|
|
17
|
+
return true;
|
|
18
|
+
}) as typeof process.stdout.write;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
Object.defineProperty(process.stdout, "isTTY", {
|
|
23
|
+
value: originalIsTTY,
|
|
24
|
+
configurable: true,
|
|
25
|
+
});
|
|
26
|
+
process.stdout.write = originalWrite;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("registers turn_start, turn_end, agent_end, session_shutdown handlers", () => {
|
|
30
|
+
const events: string[] = [];
|
|
31
|
+
const pi = {
|
|
32
|
+
on: (event: string) => {
|
|
33
|
+
events.push(event);
|
|
34
|
+
},
|
|
35
|
+
} as unknown as ExtensionAPI;
|
|
36
|
+
|
|
37
|
+
progressIndicator(pi);
|
|
38
|
+
expect(events).toContain("turn_start");
|
|
39
|
+
expect(events).toContain("turn_end");
|
|
40
|
+
expect(events).toContain("agent_end");
|
|
41
|
+
expect(events).toContain("session_shutdown");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("turn_start writes indeterminate OSC sequence", () => {
|
|
45
|
+
const handlers: Record<string, () => void> = {};
|
|
46
|
+
const pi = {
|
|
47
|
+
on: (event: string, handler: () => void) => {
|
|
48
|
+
handlers[event] = handler;
|
|
49
|
+
},
|
|
50
|
+
} as unknown as ExtensionAPI;
|
|
51
|
+
|
|
52
|
+
progressIndicator(pi);
|
|
53
|
+
handlers.turn_start();
|
|
54
|
+
|
|
55
|
+
expect(written).toHaveLength(1);
|
|
56
|
+
expect(written[0]).toContain("9;4;3");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("turn_end writes clear OSC sequence", () => {
|
|
60
|
+
const handlers: Record<string, () => void> = {};
|
|
61
|
+
const pi = {
|
|
62
|
+
on: (event: string, handler: () => void) => {
|
|
63
|
+
handlers[event] = handler;
|
|
64
|
+
},
|
|
65
|
+
} as unknown as ExtensionAPI;
|
|
66
|
+
|
|
67
|
+
progressIndicator(pi);
|
|
68
|
+
handlers.turn_end();
|
|
69
|
+
|
|
70
|
+
expect(written).toHaveLength(1);
|
|
71
|
+
expect(written[0]).toContain("9;4;0");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("agent_end writes clear OSC sequence", () => {
|
|
75
|
+
const handlers: Record<string, () => void> = {};
|
|
76
|
+
const pi = {
|
|
77
|
+
on: (event: string, handler: () => void) => {
|
|
78
|
+
handlers[event] = handler;
|
|
79
|
+
},
|
|
80
|
+
} as unknown as ExtensionAPI;
|
|
81
|
+
|
|
82
|
+
progressIndicator(pi);
|
|
83
|
+
handlers.agent_end();
|
|
84
|
+
|
|
85
|
+
expect(written).toHaveLength(1);
|
|
86
|
+
expect(written[0]).toContain("9;4;0");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("skips write when stdout is not a TTY", () => {
|
|
90
|
+
Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true });
|
|
91
|
+
|
|
92
|
+
const handlers: Record<string, () => void> = {};
|
|
93
|
+
const pi = {
|
|
94
|
+
on: (event: string, handler: () => void) => {
|
|
95
|
+
handlers[event] = handler;
|
|
96
|
+
},
|
|
97
|
+
} as unknown as ExtensionAPI;
|
|
98
|
+
|
|
99
|
+
progressIndicator(pi);
|
|
100
|
+
handlers.turn_start();
|
|
101
|
+
|
|
102
|
+
expect(written).toHaveLength(0);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import randomSpinner from "../index.js";
|
|
4
|
+
|
|
5
|
+
describe("random-spinner extension", () => {
|
|
6
|
+
test("registers session_start handler", () => {
|
|
7
|
+
const events: string[] = [];
|
|
8
|
+
const pi = {
|
|
9
|
+
on: (event: string) => {
|
|
10
|
+
events.push(event);
|
|
11
|
+
},
|
|
12
|
+
} as unknown as ExtensionAPI;
|
|
13
|
+
|
|
14
|
+
randomSpinner(pi);
|
|
15
|
+
expect(events).toContain("session_start");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("does not register any commands or tools", () => {
|
|
19
|
+
const commands: string[] = [];
|
|
20
|
+
const tools: string[] = [];
|
|
21
|
+
const pi = {
|
|
22
|
+
on: () => {},
|
|
23
|
+
registerCommand: (name: string) => {
|
|
24
|
+
commands.push(name);
|
|
25
|
+
},
|
|
26
|
+
registerTool: (opts: { name: string }) => {
|
|
27
|
+
tools.push(opts.name);
|
|
28
|
+
},
|
|
29
|
+
} as unknown as ExtensionAPI;
|
|
30
|
+
|
|
31
|
+
randomSpinner(pi);
|
|
32
|
+
expect(commands).toHaveLength(0);
|
|
33
|
+
expect(tools).toHaveLength(0);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import showPrompt from "../index.js";
|
|
4
|
+
|
|
5
|
+
describe("show-system-prompt extension", () => {
|
|
6
|
+
test("registers show-system-prompt command", () => {
|
|
7
|
+
const commands: string[] = [];
|
|
8
|
+
const pi = {
|
|
9
|
+
registerCommand: (name: string) => {
|
|
10
|
+
commands.push(name);
|
|
11
|
+
},
|
|
12
|
+
} as unknown as ExtensionAPI;
|
|
13
|
+
|
|
14
|
+
showPrompt(pi);
|
|
15
|
+
expect(commands).toContain("show-system-prompt");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("handler logs system prompt and notifies", async () => {
|
|
19
|
+
let handler: ((args: string, ctx: unknown) => Promise<void>) | undefined;
|
|
20
|
+
const pi = {
|
|
21
|
+
registerCommand: (
|
|
22
|
+
_name: string,
|
|
23
|
+
opts: { handler: (args: string, ctx: unknown) => Promise<void> }
|
|
24
|
+
) => {
|
|
25
|
+
handler = opts.handler;
|
|
26
|
+
},
|
|
27
|
+
} as unknown as ExtensionAPI;
|
|
28
|
+
|
|
29
|
+
showPrompt(pi);
|
|
30
|
+
|
|
31
|
+
const notify = mock(() => {});
|
|
32
|
+
const ctx = {
|
|
33
|
+
getSystemPrompt: () => "You are a test prompt",
|
|
34
|
+
ui: { notify },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const origLog = console.log;
|
|
38
|
+
const logged: string[] = [];
|
|
39
|
+
console.log = (...args: unknown[]) => {
|
|
40
|
+
logged.push(args.join(" "));
|
|
41
|
+
};
|
|
42
|
+
try {
|
|
43
|
+
await handler!("", ctx);
|
|
44
|
+
} finally {
|
|
45
|
+
console.log = origLog;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
expect(logged.some((l) => l.includes("You are a test prompt"))).toBe(true);
|
|
49
|
+
expect(notify).toHaveBeenCalledWith("System prompt logged to terminal", "info");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -133,7 +133,7 @@ describe("subagent presentation rendering", () => {
|
|
|
133
133
|
expect(rendered).toContain("<dim>Implement authentication flow with retry handling</dim>");
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
-
it("
|
|
136
|
+
it("renders parallel call header without inlining task text", () => {
|
|
137
137
|
const component = tool.renderCall?.(
|
|
138
138
|
{
|
|
139
139
|
tasks: [
|
|
@@ -152,7 +152,8 @@ describe("subagent presentation rendering", () => {
|
|
|
152
152
|
|
|
153
153
|
const rendered = renderComponent(component);
|
|
154
154
|
expect(rendered).toContain("<accent>parallel (1 tasks)</accent>");
|
|
155
|
-
|
|
155
|
+
// Parallel renderCall shows header + metadata only — no inline task preview
|
|
156
|
+
expect(rendered).not.toContain("KEEP_THIS_SEGMENT_VISIBLE");
|
|
156
157
|
expect(rendered).not.toContain("END_MARKER_SHOULD_TRUNCATE");
|
|
157
158
|
});
|
|
158
159
|
|
|
@@ -260,8 +261,8 @@ describe("subagent presentation rendering", () => {
|
|
|
260
261
|
if (!component) throw new Error("subagent.renderResult returned undefined");
|
|
261
262
|
|
|
262
263
|
const rendered = renderComponent(component);
|
|
263
|
-
|
|
264
|
-
expect(rendered).toContain("<
|
|
264
|
+
// Result no longer repeats the "subagent parallel" header (call header already shows it)
|
|
265
|
+
expect(rendered).not.toContain("<toolTitle>subagent</toolTitle>");
|
|
265
266
|
expect(rendered).toContain("├─");
|
|
266
267
|
expect(rendered).toContain("└─");
|
|
267
268
|
expect(rendered).toContain("<dim>(gpt-5.1)</dim>");
|
|
@@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test";
|
|
|
2
2
|
import type { SingleResult } from "../formatting.js";
|
|
3
3
|
import {
|
|
4
4
|
applyStalledClassification,
|
|
5
|
+
createStalledSubagentErrorMessage,
|
|
5
6
|
createWatchdogHeartbeatState,
|
|
6
7
|
evaluateWatchdogStatus,
|
|
7
8
|
type ForegroundWatchdogThresholds,
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
SUBAGENT_INACTIVITY_TIMEOUT_MS_ENV,
|
|
14
15
|
SUBAGENT_STARTUP_TIMEOUT_MS_ENV,
|
|
15
16
|
SUBAGENT_TOOL_EXECUTION_TIMEOUT_MS_ENV,
|
|
17
|
+
SUBAGENT_WALL_CLOCK_TIMEOUT_MS_ENV,
|
|
16
18
|
terminateProcessWithGrace,
|
|
17
19
|
} from "../process.js";
|
|
18
20
|
|
|
@@ -21,6 +23,7 @@ const TEST_THRESHOLDS: ForegroundWatchdogThresholds = {
|
|
|
21
23
|
killGraceMs: 50,
|
|
22
24
|
startupTimeoutMs: 1_000,
|
|
23
25
|
toolExecutionTimeoutMs: 8_000,
|
|
26
|
+
wallClockTimeoutMs: 20_000,
|
|
24
27
|
};
|
|
25
28
|
|
|
26
29
|
interface ManualTimer {
|
|
@@ -124,6 +127,52 @@ describe("foreground subagent liveness watchdog", () => {
|
|
|
124
127
|
expect(state.activeToolCalls).toBe(0);
|
|
125
128
|
});
|
|
126
129
|
|
|
130
|
+
it("terminates active agents that exceed wall-clock timeout", () => {
|
|
131
|
+
let state = createWatchdogHeartbeatState(0);
|
|
132
|
+
// Agent is actively producing heartbeats — not stalled by liveness checks
|
|
133
|
+
state = recordWatchdogHeartbeat(state, 5_000);
|
|
134
|
+
state = recordWatchdogHeartbeat(state, 10_000);
|
|
135
|
+
state = recordWatchdogHeartbeat(state, 15_000);
|
|
136
|
+
state = recordWatchdogHeartbeat(state, 19_000);
|
|
137
|
+
expect(evaluateWatchdogStatus(state, 19_500, TEST_THRESHOLDS).kind).toBe("healthy");
|
|
138
|
+
|
|
139
|
+
// Wall-clock timeout (20s) fires even though last heartbeat was recent
|
|
140
|
+
const status = evaluateWatchdogStatus(state, 20_100, TEST_THRESHOLDS);
|
|
141
|
+
expect(status.kind).toBe("stalled");
|
|
142
|
+
if (status.kind !== "stalled") return;
|
|
143
|
+
expect(status.phase).toBe("wall_clock");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("wall-clock timeout takes precedence over other stall phases", () => {
|
|
147
|
+
// Agent started and never sent a heartbeat — would normally be startup stall
|
|
148
|
+
const state = createWatchdogHeartbeatState(0);
|
|
149
|
+
// But wall-clock fires first when both thresholds are exceeded
|
|
150
|
+
const status = evaluateWatchdogStatus(state, 25_000, TEST_THRESHOLDS);
|
|
151
|
+
expect(status.kind).toBe("stalled");
|
|
152
|
+
if (status.kind !== "stalled") return;
|
|
153
|
+
expect(status.phase).toBe("wall_clock");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("wall-clock error message differs from liveness stall messages", () => {
|
|
157
|
+
const wallClockMsg = createStalledSubagentErrorMessage({
|
|
158
|
+
elapsedMs: 900_000,
|
|
159
|
+
kind: "stalled",
|
|
160
|
+
phase: "wall_clock",
|
|
161
|
+
timeoutMs: 900_000,
|
|
162
|
+
});
|
|
163
|
+
expect(wallClockMsg).toContain("wall-clock timeout");
|
|
164
|
+
expect(wallClockMsg).toContain("TALLOW_SUBAGENT_WALL_CLOCK_TIMEOUT_MS");
|
|
165
|
+
expect(wallClockMsg).not.toContain("slow provider startup");
|
|
166
|
+
|
|
167
|
+
const startupMsg = createStalledSubagentErrorMessage({
|
|
168
|
+
elapsedMs: 60_000,
|
|
169
|
+
kind: "stalled",
|
|
170
|
+
phase: "startup",
|
|
171
|
+
timeoutMs: 60_000,
|
|
172
|
+
});
|
|
173
|
+
expect(startupMsg).not.toContain("wall-clock");
|
|
174
|
+
});
|
|
175
|
+
|
|
127
176
|
it("treats message updates and tool execution events as heartbeats", () => {
|
|
128
177
|
expect(isWatchdogHeartbeatEventType("message_update")).toBe(true);
|
|
129
178
|
expect(isWatchdogHeartbeatEventType("tool_execution_start")).toBe(true);
|
|
@@ -136,10 +185,12 @@ describe("foreground subagent liveness watchdog", () => {
|
|
|
136
185
|
[SUBAGENT_INACTIVITY_TIMEOUT_MS_ENV]: "7000",
|
|
137
186
|
[SUBAGENT_STARTUP_TIMEOUT_MS_ENV]: "3000",
|
|
138
187
|
[SUBAGENT_TOOL_EXECUTION_TIMEOUT_MS_ENV]: "11000",
|
|
188
|
+
[SUBAGENT_WALL_CLOCK_TIMEOUT_MS_ENV]: "1800000",
|
|
139
189
|
});
|
|
140
190
|
expect(thresholds.inactivityTimeoutMs).toBe(7_000);
|
|
141
191
|
expect(thresholds.startupTimeoutMs).toBe(3_000);
|
|
142
192
|
expect(thresholds.toolExecutionTimeoutMs).toBe(11_000);
|
|
193
|
+
expect(thresholds.wallClockTimeoutMs).toBe(1_800_000);
|
|
143
194
|
});
|
|
144
195
|
|
|
145
196
|
it("stalled termination escalates and resolves without hanging", async () => {
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { buildSubprocessArgs, type SubprocessArgsOptions } from "../process.js";
|
|
3
|
+
|
|
4
|
+
describe("buildSubprocessArgs", () => {
|
|
5
|
+
/** Minimal options — no session, no model, just a task. */
|
|
6
|
+
const minimal: SubprocessArgsOptions = { task: "do something" };
|
|
7
|
+
|
|
8
|
+
it("always places -p as the last flag, right before the task text", () => {
|
|
9
|
+
const args = buildSubprocessArgs(minimal);
|
|
10
|
+
const pIdx = args.lastIndexOf("-p");
|
|
11
|
+
expect(pIdx).toBeGreaterThanOrEqual(0);
|
|
12
|
+
expect(pIdx).toBe(args.length - 2);
|
|
13
|
+
expect(args[pIdx + 1]).toBe("Task: do something");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("uses --no-session when session is omitted", () => {
|
|
17
|
+
const args = buildSubprocessArgs(minimal);
|
|
18
|
+
expect(args).toContain("--no-session");
|
|
19
|
+
expect(args).not.toContain("--session");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("uses --session <id> when session is provided", () => {
|
|
23
|
+
const args = buildSubprocessArgs({ ...minimal, session: "my-session" });
|
|
24
|
+
expect(args).toContain("--session");
|
|
25
|
+
expect(args[args.indexOf("--session") + 1]).toBe("my-session");
|
|
26
|
+
expect(args).not.toContain("--no-session");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("--no-session is never consumed as -p value (regression: 86a8d26e)", () => {
|
|
30
|
+
// The original bug: -p was placed before --no-session, so Commander
|
|
31
|
+
// treated "--no-session" as the prompt text and the real task became
|
|
32
|
+
// a stray positional argument → "too many arguments".
|
|
33
|
+
const args = buildSubprocessArgs(minimal);
|
|
34
|
+
const pIdx = args.indexOf("-p");
|
|
35
|
+
const noSessionIdx = args.indexOf("--no-session");
|
|
36
|
+
// -p must come AFTER --no-session in the array
|
|
37
|
+
expect(pIdx).toBeGreaterThan(noSessionIdx);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("includes --model when modelDisplayName is provided", () => {
|
|
41
|
+
const args = buildSubprocessArgs({
|
|
42
|
+
...minimal,
|
|
43
|
+
modelDisplayName: "anthropic/claude-sonnet-4-6",
|
|
44
|
+
});
|
|
45
|
+
const mIdx = args.indexOf("--model");
|
|
46
|
+
expect(mIdx).toBeGreaterThanOrEqual(0);
|
|
47
|
+
expect(args[mIdx + 1]).toBe("anthropic/claude-sonnet-4-6");
|
|
48
|
+
// Still before -p
|
|
49
|
+
expect(mIdx).toBeLessThan(args.lastIndexOf("-p"));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("includes --tools when tools are provided", () => {
|
|
53
|
+
const args = buildSubprocessArgs({ ...minimal, tools: ["read", "bash", "edit"] });
|
|
54
|
+
const tIdx = args.indexOf("--tools");
|
|
55
|
+
expect(tIdx).toBeGreaterThanOrEqual(0);
|
|
56
|
+
expect(args[tIdx + 1]).toBe("read,bash,edit");
|
|
57
|
+
expect(tIdx).toBeLessThan(args.lastIndexOf("-p"));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("includes --skill for each skill", () => {
|
|
61
|
+
const args = buildSubprocessArgs({ ...minimal, skills: ["tdd", "git"] });
|
|
62
|
+
const firstSkill = args.indexOf("--skill");
|
|
63
|
+
expect(firstSkill).toBeGreaterThanOrEqual(0);
|
|
64
|
+
expect(args[firstSkill + 1]).toBe("tdd");
|
|
65
|
+
const secondSkill = args.indexOf("--skill", firstSkill + 1);
|
|
66
|
+
expect(secondSkill).toBeGreaterThanOrEqual(0);
|
|
67
|
+
expect(args[secondSkill + 1]).toBe("git");
|
|
68
|
+
// Both before -p
|
|
69
|
+
expect(secondSkill).toBeLessThan(args.lastIndexOf("-p"));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("includes --append-system-prompt when path is provided", () => {
|
|
73
|
+
const args = buildSubprocessArgs({
|
|
74
|
+
...minimal,
|
|
75
|
+
systemPromptPath: "/tmp/prompt.md",
|
|
76
|
+
});
|
|
77
|
+
const sIdx = args.indexOf("--append-system-prompt");
|
|
78
|
+
expect(sIdx).toBeGreaterThanOrEqual(0);
|
|
79
|
+
expect(args[sIdx + 1]).toBe("/tmp/prompt.md");
|
|
80
|
+
expect(sIdx).toBeLessThan(args.lastIndexOf("-p"));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("produces correct full arg array with all options", () => {
|
|
84
|
+
const args = buildSubprocessArgs({
|
|
85
|
+
session: "sess-123",
|
|
86
|
+
modelDisplayName: "openai/gpt-5",
|
|
87
|
+
tools: ["read", "write"],
|
|
88
|
+
skills: ["tdd"],
|
|
89
|
+
systemPromptPath: "/tmp/prompt.md",
|
|
90
|
+
task: "fix the tests",
|
|
91
|
+
});
|
|
92
|
+
expect(args).toEqual([
|
|
93
|
+
"--mode",
|
|
94
|
+
"json",
|
|
95
|
+
"--session",
|
|
96
|
+
"sess-123",
|
|
97
|
+
"--model",
|
|
98
|
+
"openai/gpt-5",
|
|
99
|
+
"--tools",
|
|
100
|
+
"read,write",
|
|
101
|
+
"--skill",
|
|
102
|
+
"tdd",
|
|
103
|
+
"--append-system-prompt",
|
|
104
|
+
"/tmp/prompt.md",
|
|
105
|
+
"-p",
|
|
106
|
+
"Task: fix the tests",
|
|
107
|
+
]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("omits optional flags when not provided", () => {
|
|
111
|
+
const args = buildSubprocessArgs({ task: "hello" });
|
|
112
|
+
expect(args).toEqual(["--mode", "json", "--no-session", "-p", "Task: hello"]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("always starts with --mode json", () => {
|
|
116
|
+
const args = buildSubprocessArgs(minimal);
|
|
117
|
+
expect(args[0]).toBe("--mode");
|
|
118
|
+
expect(args[1]).toBe("json");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -37,6 +37,8 @@ export interface SingleResult {
|
|
|
37
37
|
stopReason?: string;
|
|
38
38
|
errorMessage?: string;
|
|
39
39
|
step?: number;
|
|
40
|
+
/** Timestamp (ms) when this subagent started executing. */
|
|
41
|
+
startTime?: number;
|
|
40
42
|
/** Tool names that were denied permission during execution. */
|
|
41
43
|
deniedTools?: string[];
|
|
42
44
|
}
|