@oh-my-pi/pi-coding-agent 15.13.3 → 16.0.1

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.
Files changed (93) hide show
  1. package/CHANGELOG.md +155 -133
  2. package/dist/cli.js +621 -530
  3. package/dist/types/advisor/__tests__/advisor.test.d.ts +1 -0
  4. package/dist/types/advisor/advise-tool.d.ts +58 -0
  5. package/dist/types/advisor/index.d.ts +3 -0
  6. package/dist/types/advisor/runtime.d.ts +52 -0
  7. package/dist/types/advisor/watchdog.d.ts +5 -0
  8. package/dist/types/config/model-roles.d.ts +1 -1
  9. package/dist/types/config/settings-schema.d.ts +66 -5
  10. package/dist/types/discovery/helpers.d.ts +7 -0
  11. package/dist/types/eval/__tests__/prelude-agent.test.d.ts +1 -0
  12. package/dist/types/extensibility/plugins/runtime-config.d.ts +3 -0
  13. package/dist/types/modes/components/advisor-message.d.ts +9 -0
  14. package/dist/types/modes/components/assistant-message.d.ts +1 -0
  15. package/dist/types/modes/controllers/command-controller.d.ts +3 -1
  16. package/dist/types/modes/interactive-mode.d.ts +3 -1
  17. package/dist/types/modes/types.d.ts +8 -1
  18. package/dist/types/sdk.d.ts +3 -3
  19. package/dist/types/session/agent-session.d.ts +81 -2
  20. package/dist/types/session/session-history-format.d.ts +4 -0
  21. package/dist/types/session/session-manager.d.ts +4 -1
  22. package/dist/types/session/yield-queue.d.ts +2 -0
  23. package/dist/types/task/index.d.ts +21 -0
  24. package/dist/types/tools/github-cache.d.ts +5 -4
  25. package/dist/types/tools/job.d.ts +1 -0
  26. package/dist/types/tools/path-utils.d.ts +1 -0
  27. package/dist/types/tools/report-tool-issue.d.ts +0 -1
  28. package/dist/types/web/search/index.d.ts +2 -2
  29. package/dist/types/web/search/provider.d.ts +2 -0
  30. package/package.json +13 -13
  31. package/src/advisor/__tests__/advisor.test.ts +586 -0
  32. package/src/advisor/advise-tool.ts +87 -0
  33. package/src/advisor/index.ts +3 -0
  34. package/src/advisor/runtime.ts +248 -0
  35. package/src/advisor/watchdog.ts +83 -0
  36. package/src/cli/args.ts +1 -0
  37. package/src/collab/host.ts +1 -1
  38. package/src/config/model-roles.ts +13 -1
  39. package/src/config/settings-schema.ts +65 -6
  40. package/src/discovery/claude-plugins.ts +3 -42
  41. package/src/discovery/github.ts +101 -6
  42. package/src/discovery/helpers.ts +11 -0
  43. package/src/eval/__tests__/prelude-agent.test.ts +73 -0
  44. package/src/eval/js/shared/prelude.txt +12 -3
  45. package/src/eval/py/prelude.py +26 -2
  46. package/src/extensibility/custom-commands/bundled/review/index.ts +289 -80
  47. package/src/extensibility/plugins/loader.ts +3 -2
  48. package/src/extensibility/plugins/manager.ts +4 -3
  49. package/src/extensibility/plugins/marketplace/fetcher.ts +32 -34
  50. package/src/extensibility/plugins/runtime-config.ts +9 -0
  51. package/src/internal-urls/docs-index.generated.ts +10 -9
  52. package/src/internal-urls/issue-pr-protocol.ts +8 -4
  53. package/src/main.ts +9 -1
  54. package/src/modes/acp/acp-agent.ts +3 -3
  55. package/src/modes/components/advisor-message.ts +99 -0
  56. package/src/modes/components/agent-hub.ts +7 -0
  57. package/src/modes/components/assistant-message.ts +86 -0
  58. package/src/modes/components/settings-defs.ts +7 -0
  59. package/src/modes/components/status-line/segments.ts +20 -7
  60. package/src/modes/components/tips.txt +1 -1
  61. package/src/modes/controllers/command-controller.ts +69 -2
  62. package/src/modes/controllers/extension-ui-controller.ts +4 -3
  63. package/src/modes/controllers/input-controller.ts +1 -0
  64. package/src/modes/controllers/selector-controller.ts +7 -0
  65. package/src/modes/interactive-mode.ts +59 -2
  66. package/src/modes/rpc/rpc-mode.ts +3 -3
  67. package/src/modes/runtime-init.ts +2 -1
  68. package/src/modes/types.ts +8 -1
  69. package/src/modes/utils/ui-helpers.ts +9 -0
  70. package/src/prompts/advisor/advise-tool.md +1 -0
  71. package/src/prompts/advisor/system.md +31 -0
  72. package/src/prompts/agents/designer.md +8 -0
  73. package/src/prompts/review-request.md +1 -1
  74. package/src/prompts/system/subagent-system-prompt.md +4 -1
  75. package/src/prompts/tools/eval.md +13 -3
  76. package/src/prompts/tools/irc.md +1 -1
  77. package/src/sdk.ts +61 -14
  78. package/src/session/agent-session.ts +667 -13
  79. package/src/session/session-dump-format.ts +15 -131
  80. package/src/session/session-history-format.ts +30 -11
  81. package/src/session/session-manager.ts +3 -1
  82. package/src/session/yield-queue.ts +5 -1
  83. package/src/slash-commands/builtin-registry.ts +105 -4
  84. package/src/system-prompt.ts +1 -1
  85. package/src/task/executor.ts +5 -4
  86. package/src/task/index.ts +70 -9
  87. package/src/tools/github-cache.ts +32 -7
  88. package/src/tools/job.ts +14 -1
  89. package/src/tools/path-utils.ts +33 -2
  90. package/src/tools/report-tool-issue.ts +2 -7
  91. package/src/web/scrapers/docs-rs.ts +2 -3
  92. package/src/web/search/index.ts +2 -2
  93. package/src/web/search/provider.ts +14 -2
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "15.13.3",
4
+ "version": "16.0.1",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -35,7 +35,7 @@
35
35
  "check": "biome check . && bun run check:types",
36
36
  "check:types": "tsgo -p tsconfig.json --noEmit",
37
37
  "lint": "biome lint .",
38
- "test": "bun test --parallel=2",
38
+ "test": "bun test --parallel",
39
39
  "fix": "biome check --write --unsafe . && bun run format-prompts && bun run generate-docs-index",
40
40
  "fmt": "biome format --write . && bun run format-prompts",
41
41
  "format-prompts": "bun scripts/format-prompts.ts",
@@ -47,17 +47,17 @@
47
47
  "@agentclientprotocol/sdk": "0.25.0",
48
48
  "@babel/parser": "^7.29.7",
49
49
  "@mozilla/readability": "^0.6.0",
50
- "@oh-my-pi/hashline": "15.13.3",
51
- "@oh-my-pi/omp-stats": "15.13.3",
52
- "@oh-my-pi/pi-agent-core": "15.13.3",
53
- "@oh-my-pi/pi-ai": "15.13.3",
54
- "@oh-my-pi/pi-catalog": "15.13.3",
55
- "@oh-my-pi/pi-mnemopi": "15.13.3",
56
- "@oh-my-pi/pi-natives": "15.13.3",
57
- "@oh-my-pi/pi-tui": "15.13.3",
58
- "@oh-my-pi/pi-utils": "15.13.3",
59
- "@oh-my-pi/pi-wire": "15.13.3",
60
- "@oh-my-pi/snapcompact": "15.13.3",
50
+ "@oh-my-pi/hashline": "16.0.1",
51
+ "@oh-my-pi/omp-stats": "16.0.1",
52
+ "@oh-my-pi/pi-agent-core": "16.0.1",
53
+ "@oh-my-pi/pi-ai": "16.0.1",
54
+ "@oh-my-pi/pi-catalog": "16.0.1",
55
+ "@oh-my-pi/pi-mnemopi": "16.0.1",
56
+ "@oh-my-pi/pi-natives": "16.0.1",
57
+ "@oh-my-pi/pi-tui": "16.0.1",
58
+ "@oh-my-pi/pi-utils": "16.0.1",
59
+ "@oh-my-pi/pi-wire": "16.0.1",
60
+ "@oh-my-pi/snapcompact": "16.0.1",
61
61
  "@opentelemetry/api": "^1.9.1",
62
62
  "@opentelemetry/context-async-hooks": "^2.7.1",
63
63
  "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
@@ -0,0 +1,586 @@
1
+ import { describe, expect, it, vi } from "bun:test";
2
+ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
3
+ import { createAdvisorMessageCard } from "../../modes/components/advisor-message";
4
+ import { getThemeByName } from "../../modes/theme/theme";
5
+ import { formatSessionHistoryMarkdown } from "../../session/session-history-format";
6
+ import { YieldQueue } from "../../session/yield-queue";
7
+ import {
8
+ ADVISOR_READONLY_TOOL_NAMES,
9
+ AdviseTool,
10
+ type AdvisorAgent,
11
+ AdvisorRuntime,
12
+ type AdvisorRuntimeHost,
13
+ formatAdvisorBatchContent,
14
+ isInterruptingSeverity,
15
+ } from "..";
16
+
17
+ describe("advisor", () => {
18
+ describe("formatSessionHistoryMarkdown includeThinking", () => {
19
+ it("includes thinking text when includeThinking is true", () => {
20
+ const thinking = "I should check the edge case first.";
21
+ const assistantMsg = {
22
+ role: "assistant",
23
+ content: [{ type: "thinking", thinking }],
24
+ timestamp: Date.now(),
25
+ } as AgentMessage;
26
+ const md = formatSessionHistoryMarkdown([assistantMsg], { includeThinking: true });
27
+ expect(md).toContain(thinking);
28
+ expect(md).toContain("_thinking:_");
29
+ });
30
+
31
+ it("elides thinking text by default", () => {
32
+ const thinking = "I should check the edge case first.";
33
+ const assistantMsg = {
34
+ role: "assistant",
35
+ content: [{ type: "thinking", thinking }],
36
+ timestamp: Date.now(),
37
+ } as AgentMessage;
38
+ const md = formatSessionHistoryMarkdown([assistantMsg]);
39
+ expect(md).not.toContain(thinking);
40
+ expect(md).not.toContain("_thinking:_");
41
+ });
42
+ });
43
+
44
+ describe("advisor yield-queue dispatcher", () => {
45
+ it("batches advice notes into one custom message", async () => {
46
+ const injected: AgentMessage[] = [];
47
+ const yq = new YieldQueue({
48
+ isStreaming: () => false,
49
+ injectIdle: async messages => {
50
+ injected.push(...messages);
51
+ },
52
+ scheduleIdleFlush: () => {},
53
+ });
54
+ yq.register<{ note: string; severity?: "nit" | "concern" | "blocker" }>("advisor", {
55
+ build: entries =>
56
+ entries.length === 0
57
+ ? null
58
+ : ({
59
+ role: "custom",
60
+ customType: "advisor",
61
+ display: true,
62
+ attribution: "agent",
63
+ timestamp: Date.now(),
64
+ content:
65
+ "Advisor (a senior reviewer watching your work — weigh it, don't blindly obey):\n" +
66
+ entries.map(e => `- ${e.severity ? `[${e.severity}] ` : ""}${e.note}`).join("\n"),
67
+ } as AgentMessage),
68
+ });
69
+
70
+ yq.enqueue("advisor", { note: "first note" });
71
+ yq.enqueue("advisor", { note: "second note", severity: "blocker" });
72
+ await yq.flush("idle");
73
+
74
+ expect(injected).toHaveLength(1);
75
+ const msg = injected[0] as { role: string; customType?: string; display?: boolean; content: string };
76
+ expect(msg.role).toBe("custom");
77
+ expect(msg.customType).toBe("advisor");
78
+ expect(msg.display).toBe(true);
79
+ expect(msg.content).toContain("[blocker] second note");
80
+ expect(msg.content).toContain("- first note");
81
+ });
82
+
83
+ it("skipIdleFlush prevents idle scheduling", () => {
84
+ let scheduled = 0;
85
+ const yq = new YieldQueue({
86
+ isStreaming: () => false,
87
+ injectIdle: async () => {},
88
+ scheduleIdleFlush: () => {
89
+ scheduled++;
90
+ },
91
+ });
92
+ yq.register<{ note: string }>("advisor", {
93
+ build: entries => (entries.length === 0 ? null : ({ role: "custom", content: "x" } as AgentMessage)),
94
+ skipIdleFlush: true,
95
+ });
96
+ yq.register<{ note: string }>("normal", {
97
+ build: entries => (entries.length === 0 ? null : ({ role: "custom", content: "y" } as AgentMessage)),
98
+ });
99
+
100
+ yq.enqueue("advisor", { note: "a" });
101
+ expect(scheduled).toBe(0);
102
+ yq.enqueue("normal", { note: "b" });
103
+ expect(scheduled).toBe(1);
104
+ });
105
+ });
106
+
107
+ describe("AdviseTool", () => {
108
+ it("forwards advice to the callback and returns details", async () => {
109
+ const onAdvice = vi.fn();
110
+ const tool = new AdviseTool(onAdvice);
111
+ const result = await tool.execute("tc-1", { note: "x", severity: "concern" });
112
+ expect(onAdvice).toHaveBeenCalledWith("x", "concern");
113
+ expect(result.details).toEqual({ note: "x", severity: "concern" });
114
+ expect(result.useless).toBe(true);
115
+ });
116
+ });
117
+
118
+ describe("advice delivery policy", () => {
119
+ it("interrupts on concern and blocker, queues a plain nit", () => {
120
+ expect(isInterruptingSeverity("blocker")).toBe(true);
121
+ expect(isInterruptingSeverity("concern")).toBe(true);
122
+ expect(isInterruptingSeverity("nit")).toBe(false);
123
+ expect(isInterruptingSeverity(undefined)).toBe(false);
124
+ });
125
+
126
+ it("formats a batch with the advisor prefix and severity-tagged bullets", () => {
127
+ const content = formatAdvisorBatchContent([
128
+ { note: "first note" },
129
+ { note: "second note", severity: "blocker" },
130
+ ]);
131
+ const lines = content.split("\n");
132
+ expect(lines[0]).toContain("senior reviewer");
133
+ expect(lines[1]).toBe("- first note");
134
+ expect(lines[2]).toBe("- [blocker] second note");
135
+ });
136
+ });
137
+
138
+ describe("AdvisorRuntime", () => {
139
+ function makeAgent(promptInputs: string[]): AdvisorAgent {
140
+ return {
141
+ prompt: async input => {
142
+ promptInputs.push(input);
143
+ },
144
+ abort: () => {},
145
+ reset: () => {},
146
+ state: { messages: [] },
147
+ };
148
+ }
149
+
150
+ it("coalesces multiple onTurnEnd calls while a prompt is in-flight", async () => {
151
+ const promptInputs: string[] = [];
152
+ const { promise: firstPromptPromise, resolve: finishFirstPrompt } = Promise.withResolvers<void>();
153
+ const agent: AdvisorAgent = {
154
+ prompt: async input => {
155
+ promptInputs.push(input);
156
+ await firstPromptPromise;
157
+ },
158
+ abort: () => {},
159
+ reset: () => {},
160
+ state: { messages: [] },
161
+ };
162
+ const messages: AgentMessage[] = [{ role: "user", content: "first", timestamp: 1 } as AgentMessage];
163
+ const host: AdvisorRuntimeHost = {
164
+ snapshotMessages: () => messages,
165
+ enqueueAdvice: () => {},
166
+ };
167
+ const runtime = new AdvisorRuntime(agent, host);
168
+
169
+ runtime.onTurnEnd();
170
+ await Promise.resolve();
171
+ expect(promptInputs).toHaveLength(1);
172
+ expect(promptInputs[0]).toContain("first");
173
+
174
+ messages.push({ role: "user", content: "second", timestamp: 2 } as AgentMessage);
175
+ runtime.onTurnEnd();
176
+ await Promise.resolve();
177
+ expect(promptInputs).toHaveLength(1);
178
+
179
+ finishFirstPrompt();
180
+ await Promise.resolve();
181
+ await Promise.resolve();
182
+ expect(promptInputs).toHaveLength(2);
183
+ expect(promptInputs[1]).toContain("second");
184
+ });
185
+
186
+ it("budgets only the batch sent after async context maintenance", async () => {
187
+ const promptInputs: string[] = [];
188
+ const { promise: firstMaintainStarted, resolve: startFirstMaintain } = Promise.withResolvers<void>();
189
+ const { promise: finishFirstMaintain, resolve: releaseFirstMaintain } = Promise.withResolvers<boolean>();
190
+ const { promise: firstPromptStarted, resolve: startFirstPrompt } = Promise.withResolvers<void>();
191
+ const { promise: secondPromptStarted, resolve: startSecondPrompt } = Promise.withResolvers<void>();
192
+ const { promise: finishFirstPrompt, resolve: releaseFirstPrompt } = Promise.withResolvers<void>();
193
+ let maintainCalls = 0;
194
+ let promptCalls = 0;
195
+ const agent: AdvisorAgent = {
196
+ prompt: async input => {
197
+ promptInputs.push(input);
198
+ promptCalls++;
199
+ if (promptCalls === 1) {
200
+ startFirstPrompt();
201
+ await finishFirstPrompt;
202
+ } else if (promptCalls === 2) {
203
+ startSecondPrompt();
204
+ }
205
+ },
206
+ abort: () => {},
207
+ reset: () => {},
208
+ state: { messages: [] },
209
+ };
210
+ const messages: AgentMessage[] = [{ role: "user", content: "first", timestamp: 1 } as AgentMessage];
211
+ const host: AdvisorRuntimeHost = {
212
+ snapshotMessages: () => messages,
213
+ enqueueAdvice: () => {},
214
+ maintainContext: async () => {
215
+ maintainCalls++;
216
+ if (maintainCalls === 1) {
217
+ startFirstMaintain();
218
+ return await finishFirstMaintain;
219
+ }
220
+ return false;
221
+ },
222
+ };
223
+ const runtime = new AdvisorRuntime(agent, host);
224
+
225
+ runtime.onTurnEnd();
226
+ await firstMaintainStarted;
227
+ messages.push({ role: "user", content: "second", timestamp: 2 } as AgentMessage);
228
+ runtime.onTurnEnd();
229
+
230
+ releaseFirstMaintain(false);
231
+ await firstPromptStarted;
232
+ expect(promptInputs).toHaveLength(1);
233
+ expect(promptInputs[0]).toContain("first");
234
+ expect(promptInputs[0]).not.toContain("second");
235
+
236
+ releaseFirstPrompt();
237
+ await secondPromptStarted;
238
+ expect(promptInputs).toHaveLength(2);
239
+ expect(promptInputs[1]).toContain("second");
240
+ });
241
+
242
+ it("sends the batch when context maintenance fails", async () => {
243
+ const promptInputs: string[] = [];
244
+ const agent = makeAgent(promptInputs);
245
+ const messages: AgentMessage[] = [{ role: "user", content: "first", timestamp: 1 } as AgentMessage];
246
+ const host: AdvisorRuntimeHost = {
247
+ snapshotMessages: () => messages,
248
+ enqueueAdvice: () => {},
249
+ maintainContext: async () => {
250
+ throw new Error("maintenance failed");
251
+ },
252
+ };
253
+ const runtime = new AdvisorRuntime(agent, host);
254
+
255
+ runtime.onTurnEnd();
256
+ await Promise.resolve();
257
+ await Promise.resolve();
258
+
259
+ expect(promptInputs).toHaveLength(1);
260
+ expect(promptInputs[0]).toContain("first");
261
+ });
262
+
263
+ it("excludes advisor custom messages from the rendered delta", () => {
264
+ const promptInputs: string[] = [];
265
+ const agent = makeAgent(promptInputs);
266
+ const messages: AgentMessage[] = [
267
+ { role: "user", content: "hello", timestamp: 1 } as AgentMessage,
268
+ { role: "custom", customType: "advisor", content: "note", display: true, timestamp: 2 } as AgentMessage,
269
+ ];
270
+ const host: AdvisorRuntimeHost = {
271
+ snapshotMessages: () => messages,
272
+ enqueueAdvice: () => {},
273
+ };
274
+ const runtime = new AdvisorRuntime(agent, host);
275
+ runtime.onTurnEnd();
276
+ expect(promptInputs).toHaveLength(1);
277
+ expect(promptInputs[0]).toContain("hello");
278
+ expect(promptInputs[0]).not.toContain("note");
279
+ });
280
+
281
+ it("handles compaction shrink without prompting", () => {
282
+ const promptInputs: string[] = [];
283
+ const agent = makeAgent(promptInputs);
284
+ let messages: AgentMessage[] = [
285
+ { role: "user", content: "a", timestamp: 1 } as AgentMessage,
286
+ { role: "user", content: "b", timestamp: 2 } as AgentMessage,
287
+ ];
288
+ const host: AdvisorRuntimeHost = {
289
+ snapshotMessages: () => messages,
290
+ enqueueAdvice: () => {},
291
+ };
292
+ const runtime = new AdvisorRuntime(agent, host);
293
+ runtime.onTurnEnd();
294
+ expect(promptInputs).toHaveLength(1);
295
+
296
+ messages = [{ role: "user", content: "a", timestamp: 1 } as AgentMessage];
297
+ expect(() => runtime.onTurnEnd()).not.toThrow();
298
+ expect(promptInputs).toHaveLength(1);
299
+ });
300
+
301
+ it("reset re-primes the advisor with the full current transcript", async () => {
302
+ const promptInputs: string[] = [];
303
+ const agent = makeAgent(promptInputs);
304
+ const messages: AgentMessage[] = [{ role: "user", content: "aaa", timestamp: 1 } as AgentMessage];
305
+ const host: AdvisorRuntimeHost = {
306
+ snapshotMessages: () => messages,
307
+ enqueueAdvice: () => {},
308
+ };
309
+ const runtime = new AdvisorRuntime(agent, host);
310
+ runtime.onTurnEnd();
311
+ await Promise.resolve();
312
+ expect(promptInputs).toHaveLength(1);
313
+ expect(promptInputs[0]).toContain("aaa");
314
+
315
+ // Simulate a compaction: transcript replaced, then reset.
316
+ messages.length = 0;
317
+ messages.push({ role: "user", content: "summary-bbb", timestamp: 2 } as AgentMessage);
318
+ runtime.reset();
319
+
320
+ runtime.onTurnEnd();
321
+ await Promise.resolve();
322
+ // The next turn replays the full post-compaction transcript, not just new tail.
323
+ expect(promptInputs).toHaveLength(2);
324
+ expect(promptInputs[1]).toContain("summary-bbb");
325
+ });
326
+
327
+ it("triggers a re-prime and full replay when maintainContext returns true", async () => {
328
+ const promptInputs: string[] = [];
329
+ let resetCount = 0;
330
+ const agent: AdvisorAgent = {
331
+ prompt: async input => {
332
+ promptInputs.push(input);
333
+ },
334
+ abort: () => {},
335
+ reset: () => {
336
+ resetCount++;
337
+ },
338
+ state: { messages: [] },
339
+ };
340
+ const messages: AgentMessage[] = [{ role: "user", content: "aaa", timestamp: 1 } as AgentMessage];
341
+ let shouldRePrime = false;
342
+ const host: AdvisorRuntimeHost = {
343
+ snapshotMessages: () => messages,
344
+ enqueueAdvice: () => {},
345
+ maintainContext: async tokens => {
346
+ expect(tokens).toBeGreaterThan(0);
347
+ return shouldRePrime;
348
+ },
349
+ };
350
+ const runtime = new AdvisorRuntime(agent, host);
351
+
352
+ // First turn: normal incremental prompt
353
+ runtime.onTurnEnd(messages);
354
+ await Promise.resolve();
355
+ expect(promptInputs).toHaveLength(1);
356
+ expect(promptInputs[0]).toContain("aaa");
357
+ expect(resetCount).toBe(0);
358
+
359
+ // Second turn: maintainContext resolves true, triggering a re-prime
360
+ shouldRePrime = true;
361
+ messages.push({ role: "user", content: "bbb", timestamp: 2 } as AgentMessage);
362
+ runtime.onTurnEnd(messages);
363
+ await Promise.resolve();
364
+ await Promise.resolve();
365
+
366
+ // The reset cleared history and prompted a full replay (so the batch contains both aaa and bbb)
367
+ expect(promptInputs).toHaveLength(2);
368
+ expect(promptInputs[1]).toContain("aaa");
369
+ expect(promptInputs[1]).toContain("bbb");
370
+ expect(resetCount).toBe(1);
371
+ });
372
+ it("tracks backlog and blocks until caught up", async () => {
373
+ const promptInputs: string[] = [];
374
+ const { promise: promptStarted, resolve: startPrompt } = Promise.withResolvers<void>();
375
+ const { promise: promptFinish, resolve: finishPrompt } = Promise.withResolvers<void>();
376
+ const agent: AdvisorAgent = {
377
+ prompt: async input => {
378
+ promptInputs.push(input);
379
+ startPrompt();
380
+ await promptFinish;
381
+ },
382
+ abort: () => {},
383
+ reset: () => {},
384
+ state: { messages: [] },
385
+ };
386
+ const messages: AgentMessage[] = [{ role: "user", content: "aaa", timestamp: 1 } as AgentMessage];
387
+ const host: AdvisorRuntimeHost = {
388
+ snapshotMessages: () => messages,
389
+ enqueueAdvice: () => {},
390
+ };
391
+ const runtime = new AdvisorRuntime(agent, host);
392
+
393
+ // First turn starts advisor drain (which is now busy).
394
+ runtime.onTurnEnd(messages);
395
+ await promptStarted;
396
+
397
+ // Second turn completes. Backlog is now 2 (1 in-flight, 1 pending).
398
+ messages.push({ role: "user", content: "bbb", timestamp: 2 } as AgentMessage);
399
+ runtime.onTurnEnd(messages);
400
+
401
+ // waitForCatchup with threshold=2 should resolve immediately (backlog 2 is < threshold 2? No, backlog 2 is not < 2, so it waits. Wait, threshold=3 should resolve immediately since backlog 2 < 3).
402
+ // Let's verify: backlog=2.
403
+ // threshold=3 -> backlog < 3 is true -> resolves immediately.
404
+ let threshold3Resolved = false;
405
+ void runtime.waitForCatchup(100, 3).then(() => {
406
+ threshold3Resolved = true;
407
+ });
408
+ await Promise.resolve();
409
+ expect(threshold3Resolved).toBe(true);
410
+
411
+ // threshold=2 -> backlog < 2 is false -> should wait.
412
+ let threshold2Resolved = false;
413
+ const catchupPromise = runtime.waitForCatchup(1000, 2).then(() => {
414
+ threshold2Resolved = true;
415
+ });
416
+
417
+ await Promise.resolve();
418
+ expect(threshold2Resolved).toBe(false);
419
+
420
+ // Complete the first prompt. Backlog should drop to 1 (prompt finishes, decrements by 1).
421
+ // Wait, the popped entries had turns = 1. So backlog drops to 1.
422
+ // Since 1 < 2, the threshold=2 waiter should resolve.
423
+ finishPrompt();
424
+ await catchupPromise;
425
+ expect(threshold2Resolved).toBe(true);
426
+ });
427
+
428
+ it("cancels catch-up waits when the run aborts", async () => {
429
+ const { promise: promptStarted, resolve: startPrompt } = Promise.withResolvers<void>();
430
+ const { promise: promptFinish, resolve: finishPrompt } = Promise.withResolvers<void>();
431
+ const agent: AdvisorAgent = {
432
+ prompt: async () => {
433
+ startPrompt();
434
+ await promptFinish;
435
+ },
436
+ abort: () => {},
437
+ reset: () => {},
438
+ state: { messages: [] },
439
+ };
440
+ const messages: AgentMessage[] = [{ role: "user", content: "aaa", timestamp: 1 } as AgentMessage];
441
+ const host: AdvisorRuntimeHost = {
442
+ snapshotMessages: () => messages,
443
+ enqueueAdvice: () => {},
444
+ };
445
+ const runtime = new AdvisorRuntime(agent, host);
446
+ const controller = new AbortController();
447
+
448
+ runtime.onTurnEnd(messages);
449
+ await promptStarted;
450
+
451
+ let resolved = false;
452
+ const wait = runtime.waitForCatchup(30000, 1, controller.signal).then(() => {
453
+ resolved = true;
454
+ });
455
+
456
+ await Promise.resolve();
457
+ expect(resolved).toBe(false);
458
+
459
+ controller.abort();
460
+ await wait;
461
+ expect(resolved).toBe(true);
462
+
463
+ finishPrompt();
464
+ await Promise.resolve();
465
+ });
466
+
467
+ it("retries failed prompts and only decrements backlog on success", async () => {
468
+ const promptInputs: string[] = [];
469
+ let fail = true;
470
+ const agent: AdvisorAgent = {
471
+ prompt: async input => {
472
+ promptInputs.push(input);
473
+ if (fail) {
474
+ fail = false;
475
+ throw new Error("fail");
476
+ }
477
+ },
478
+ abort: () => {},
479
+ reset: () => {},
480
+ state: { messages: [] },
481
+ };
482
+ const messages: AgentMessage[] = [{ role: "user", content: "aaa", timestamp: 1 } as AgentMessage];
483
+ const host: AdvisorRuntimeHost = {
484
+ snapshotMessages: () => messages,
485
+ enqueueAdvice: () => {},
486
+ };
487
+ const runtime = new AdvisorRuntime(agent, host, 0);
488
+
489
+ runtime.onTurnEnd(messages);
490
+ await Bun.sleep(0);
491
+ await Bun.sleep(0);
492
+
493
+ expect(promptInputs).toHaveLength(2);
494
+ expect(runtime.backlog).toBe(0);
495
+ });
496
+
497
+ it("drops backlog after 3 consecutive failures to prevent permanent stall", async () => {
498
+ const promptInputs: string[] = [];
499
+ const agent: AdvisorAgent = {
500
+ prompt: async input => {
501
+ promptInputs.push(input);
502
+ throw new Error("fail");
503
+ },
504
+ abort: () => {},
505
+ reset: () => {},
506
+ state: { messages: [] },
507
+ };
508
+ const messages: AgentMessage[] = [{ role: "user", content: "aaa", timestamp: 1 } as AgentMessage];
509
+ const host: AdvisorRuntimeHost = {
510
+ snapshotMessages: () => messages,
511
+ enqueueAdvice: () => {},
512
+ };
513
+ const runtime = new AdvisorRuntime(agent, host, 0);
514
+
515
+ runtime.onTurnEnd(messages);
516
+ await Bun.sleep(0);
517
+ await Bun.sleep(0);
518
+ await Bun.sleep(0);
519
+
520
+ expect(promptInputs).toHaveLength(3);
521
+ expect(runtime.backlog).toBe(0);
522
+ });
523
+ });
524
+
525
+ describe("read-only tool allowlist", () => {
526
+ it("selects only the investigation tools from a mixed toolset", () => {
527
+ const toolset = ["read", "edit", "search", "bash", "find", "write", "advise"];
528
+ const selected = toolset.filter(name => ADVISOR_READONLY_TOOL_NAMES.has(name));
529
+ expect(selected).toEqual(["read", "search", "find"]);
530
+ expect(ADVISOR_READONLY_TOOL_NAMES.has("edit")).toBe(false);
531
+ expect(ADVISOR_READONLY_TOOL_NAMES.has("bash")).toBe(false);
532
+ expect(ADVISOR_READONLY_TOOL_NAMES.has("write")).toBe(false);
533
+ });
534
+ });
535
+
536
+ describe("createAdvisorMessageCard", () => {
537
+ const strip = (lines: readonly string[]): string => lines.join("\n").replace(/\x1b\[[0-9;]*m/g, "");
538
+
539
+ it("renders the advisor header, severity badge, and note text", async () => {
540
+ const uiTheme = await getThemeByName("dark");
541
+ if (!uiTheme) throw new Error("theme unavailable");
542
+ const card = createAdvisorMessageCard(
543
+ { notes: [{ note: "deleting the wrong file", severity: "blocker" }, { note: "watch the empty case" }] },
544
+ () => true,
545
+ uiTheme,
546
+ );
547
+ const text = strip(card.render(80));
548
+ expect(text).toContain("Advisor");
549
+ expect(text).toContain("2 notes");
550
+ expect(text).toContain("blocker");
551
+ expect(text).toContain("deleting the wrong file");
552
+ expect(text).toContain("watch the empty case");
553
+ });
554
+
555
+ it("collapses to the first notes with an overflow hint", async () => {
556
+ const uiTheme = await getThemeByName("dark");
557
+ if (!uiTheme) throw new Error("theme unavailable");
558
+ const notes = Array.from({ length: 5 }, (_, i) => ({ note: `note ${i}` }));
559
+ const card = createAdvisorMessageCard({ notes }, () => false, uiTheme);
560
+ const text = strip(card.render(80));
561
+ expect(text).toContain("note 0");
562
+ expect(text).toContain("+2 more");
563
+ expect(text).not.toContain("note 4");
564
+ });
565
+
566
+ it("wraps long notes across multiple lines based on render width instead of truncating them", async () => {
567
+ const uiTheme = await getThemeByName("dark");
568
+ if (!uiTheme) throw new Error("theme unavailable");
569
+ const note =
570
+ "This is a very long advisor note that will definitely exceed the restricted width constraint of thirty characters and should therefore wrap across multiple lines rather than getting truncated.";
571
+ const card = createAdvisorMessageCard({ notes: [{ note, severity: "concern" }] }, () => true, uiTheme);
572
+ const text = strip(card.render(30));
573
+ expect(text).toContain("truncated.");
574
+ });
575
+
576
+ it("wraps long notes even when the message card is collapsed", async () => {
577
+ const uiTheme = await getThemeByName("dark");
578
+ if (!uiTheme) throw new Error("theme unavailable");
579
+ const note =
580
+ "This is a very long advisor note that will definitely exceed the restricted width constraint of thirty characters and should therefore wrap across multiple lines rather than getting truncated.";
581
+ const card = createAdvisorMessageCard({ notes: [{ note, severity: "concern" }] }, () => false, uiTheme);
582
+ const text = strip(card.render(30));
583
+ expect(text).toContain("truncated.");
584
+ });
585
+ });
586
+ });