@oh-my-pi/pi-coding-agent 15.13.3 → 16.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/cli.js +506 -443
  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 +44 -5
  10. package/dist/types/modes/components/advisor-message.d.ts +9 -0
  11. package/dist/types/modes/components/assistant-message.d.ts +1 -0
  12. package/dist/types/modes/controllers/command-controller.d.ts +3 -1
  13. package/dist/types/modes/interactive-mode.d.ts +3 -1
  14. package/dist/types/modes/types.d.ts +3 -1
  15. package/dist/types/sdk.d.ts +3 -3
  16. package/dist/types/session/agent-session.d.ts +71 -2
  17. package/dist/types/session/session-history-format.d.ts +4 -0
  18. package/dist/types/session/yield-queue.d.ts +2 -0
  19. package/dist/types/tools/path-utils.d.ts +1 -0
  20. package/dist/types/tools/report-tool-issue.d.ts +0 -1
  21. package/package.json +13 -13
  22. package/src/advisor/__tests__/advisor.test.ts +586 -0
  23. package/src/advisor/advise-tool.ts +87 -0
  24. package/src/advisor/index.ts +3 -0
  25. package/src/advisor/runtime.ts +248 -0
  26. package/src/advisor/watchdog.ts +83 -0
  27. package/src/config/model-roles.ts +13 -1
  28. package/src/config/settings-schema.ts +42 -5
  29. package/src/internal-urls/docs-index.generated.ts +6 -5
  30. package/src/main.ts +4 -0
  31. package/src/modes/components/advisor-message.ts +99 -0
  32. package/src/modes/components/agent-hub.ts +7 -0
  33. package/src/modes/components/assistant-message.ts +86 -0
  34. package/src/modes/components/status-line/segments.ts +20 -7
  35. package/src/modes/controllers/command-controller.ts +69 -2
  36. package/src/modes/interactive-mode.ts +12 -2
  37. package/src/modes/types.ts +3 -1
  38. package/src/modes/utils/ui-helpers.ts +9 -0
  39. package/src/prompts/advisor/advise-tool.md +1 -0
  40. package/src/prompts/advisor/system.md +31 -0
  41. package/src/sdk.ts +52 -13
  42. package/src/session/agent-session.ts +560 -13
  43. package/src/session/session-dump-format.ts +15 -131
  44. package/src/session/session-history-format.ts +30 -11
  45. package/src/session/yield-queue.ts +5 -1
  46. package/src/slash-commands/builtin-registry.ts +102 -4
  47. package/src/system-prompt.ts +1 -1
  48. package/src/tools/path-utils.ts +33 -2
  49. package/src/tools/report-tool-issue.ts +2 -7
  50. package/src/web/scrapers/docs-rs.ts +2 -3
@@ -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
+ });
@@ -0,0 +1,87 @@
1
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
2
+ import { z } from "zod/v4";
3
+ import adviseDescription from "../prompts/advisor/advise-tool.md" with { type: "text" };
4
+
5
+ const adviseSchema = z.object({
6
+ note: z
7
+ .string()
8
+ .describe("One concrete piece of advice for the agent you are watching. Terse, specific, actionable."),
9
+ severity: z
10
+ .enum(["nit", "concern", "blocker"])
11
+ .optional()
12
+ .describe("How strongly to weigh this. Omit for a plain nit."),
13
+ });
14
+
15
+ export type AdviseParams = z.infer<typeof adviseSchema>;
16
+
17
+ export type AdvisorSeverity = "nit" | "concern" | "blocker";
18
+
19
+ export interface AdviseDetails {
20
+ note: string;
21
+ severity?: AdvisorSeverity;
22
+ }
23
+
24
+ /** One queued advice note. */
25
+ export interface AdvisorNote {
26
+ note: string;
27
+ severity?: AdvisorSeverity;
28
+ }
29
+
30
+ /** Details payload on the batched `advisor` custom message rendered in the transcript. */
31
+ export interface AdvisorMessageDetails {
32
+ notes: AdvisorNote[];
33
+ }
34
+
35
+ /**
36
+ * Prose framing prepended to every batched advisor message. Kept here so the
37
+ * non-interrupting YieldQueue dispatcher and the interrupting steer path build
38
+ * byte-identical content.
39
+ */
40
+ const ADVISOR_BATCH_PREFIX = "Advisor (a senior reviewer watching your work — weigh it, don't blindly obey):";
41
+
42
+ /** Render one advisor card body from a batch of notes (prefix + one bullet per note). */
43
+ export function formatAdvisorBatchContent(notes: readonly AdvisorNote[]): string {
44
+ return `${ADVISOR_BATCH_PREFIX}\n${notes.map(n => `- ${n.severity ? `[${n.severity}] ` : ""}${n.note}`).join("\n")}`;
45
+ }
46
+
47
+ /**
48
+ * Whether advice at this severity should interrupt the running agent (delivered
49
+ * via the steering channel, aborting in-flight tools) rather than ride the
50
+ * non-interrupting aside queue that lands at the next step boundary. `concern`
51
+ * and `blocker` interrupt; a plain `nit` queues.
52
+ */
53
+ export function isInterruptingSeverity(severity: AdvisorSeverity | undefined): boolean {
54
+ return severity === "concern" || severity === "blocker";
55
+ }
56
+
57
+ /**
58
+ * Side-effect-free investigation tools handed to the advisor agent so it can
59
+ * inspect the workspace before weighing in. Names match the primary session's
60
+ * tool instances, which the advisor reuses.
61
+ */
62
+ export const ADVISOR_READONLY_TOOL_NAMES: ReadonlySet<string> = new Set(["read", "search", "find"]);
63
+
64
+ export class AdviseTool implements AgentTool<typeof adviseSchema, AdviseDetails> {
65
+ readonly name = "advise";
66
+ readonly label = "Advise";
67
+ readonly description = adviseDescription;
68
+ readonly parameters = adviseSchema;
69
+ readonly intent = "omit" as const;
70
+
71
+ constructor(private readonly onAdvice: (note: string, severity?: AdviseDetails["severity"]) => void) {}
72
+
73
+ async execute(
74
+ _toolCallId: string,
75
+ args: AdviseParams,
76
+ _signal?: AbortSignal,
77
+ _onUpdate?: AgentToolUpdateCallback<AdviseDetails>,
78
+ _context?: AgentToolContext,
79
+ ): Promise<AgentToolResult<AdviseDetails>> {
80
+ this.onAdvice(args.note, args.severity);
81
+ return {
82
+ content: [{ type: "text", text: "Recorded." }],
83
+ details: { note: args.note, severity: args.severity },
84
+ useless: true,
85
+ };
86
+ }
87
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./advise-tool";
2
+ export * from "./runtime";
3
+ export * from "./watchdog";