@fiale-plus/pi-rogue-bundle 0.1.10 → 0.1.12

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.
@@ -6,6 +6,7 @@ import {
6
6
  completeWithModelFallback,
7
7
  contentText,
8
8
  normalizeAdvisorConfig,
9
+ sanitizeAdvisorText,
9
10
  shouldRunCheckin,
10
11
  type AdvisorConfig,
11
12
  } from "./extension.js";
@@ -86,6 +87,17 @@ describe("advisor message extraction", () => {
86
87
  expect(contentText([{ type: "toolResult", content: [{ type: "text", text: "ok" }] }])).toBe("ok");
87
88
  expect(contentText({ arbitrary: "shape" })).toBe("");
88
89
  });
90
+
91
+ it("redacts transient clipboard image paths from advisor-facing text", () => {
92
+ const text = "see /var/folders/fm/rwczdnws5j58x7kbyn3vcx_h0000gn/T/clipboard-2026-06-04-012248-DEE3A154.png please";
93
+ expect(sanitizeAdvisorText(text)).toBe("see [clipboard image] please");
94
+ expect(contentText({ content: [{ type: "text", text }] })).toBe("see [clipboard image] please");
95
+ });
96
+
97
+ it("does not redact ordinary repo or temp file paths", () => {
98
+ const text = "inspect /Users/pavel/repos/fiale-plus/pi-rogue/packages/advisor/src/extension.ts and /tmp/benchmark-results.json";
99
+ expect(sanitizeAdvisorText(text)).toBe(text);
100
+ });
89
101
  });
90
102
 
91
103
  describe("mid-hour check-ins", () => {
@@ -234,16 +234,22 @@ function hash(...parts: string[]): string {
234
234
 
235
235
  function brief(s: SessionState): string {
236
236
  const lines: string[] = [];
237
- if (s.lastTask) lines.push(`Task: ${truncate(s.lastTask, 200)}`);
237
+ if (s.lastTask) lines.push(`Task: ${truncate(sanitizeAdvisorText(s.lastTask), 200)}`);
238
238
  if (s.turns) lines.push(`Turns: ${s.turns}`);
239
239
  if (s.notes.length) { lines.push("Notes:"); s.notes.slice(-4).forEach(n => lines.push(`- ${truncate(n, 200)}`)); }
240
- if (s.files.length) lines.push(`Files: ${s.files.slice(-4).join(", ")}`);
241
- if (s.errors.length) lines.push(`Errors: ${s.errors.slice(-2).join(" | ")}`);
240
+ if (s.files.length) lines.push(`Files: ${sanitizeAdvisorText(s.files.slice(-4).join(", "))}`);
241
+ if (s.errors.length) lines.push(`Errors: ${sanitizeAdvisorText(s.errors.slice(-2).join(" | "))}`);
242
242
  return lines.join("\n").slice(0, 1200);
243
243
  }
244
244
 
245
+ const CLIPBOARD_IMAGE_PATH_RE = /(?:\/(?:private\/)?var\/folders\/[^\s"'`<>]+\/T|\/(?:tmp|var\/tmp))\/clipboard-\d{4}-\d{2}-\d{2}-[A-Za-z0-9-]+\.(?:png|jpe?g|gif|webp)\b/g;
246
+
247
+ export function sanitizeAdvisorText(text: unknown): string {
248
+ return String(text ?? "").replace(CLIPBOARD_IMAGE_PATH_RE, "[clipboard image]");
249
+ }
250
+
245
251
  function squish(t: unknown, max = 200): string {
246
- const s = String(t ?? "").replace(/\s+/g, " ").trim();
252
+ const s = sanitizeAdvisorText(t).replace(/\s+/g, " ").trim();
247
253
  return s.length <= max ? s : s.slice(0, max - 1).trimEnd() + "…";
248
254
  }
249
255
 
@@ -355,10 +361,11 @@ function recoverReviewControl(state: SessionState): void {
355
361
  }
356
362
 
357
363
  type AdvisorHintDetails = {
364
+ kind?: "handoff" | "answer";
358
365
  decision?: "continue" | "review" | "defer";
359
366
  reason?: string;
360
367
  summary?: string;
361
- actions?: string[];
368
+ actions?: unknown;
362
369
  };
363
370
 
364
371
  type ReviewControlState = {
@@ -379,37 +386,117 @@ type ReviewMaterialMeta = {
379
386
  isAgentEnd: boolean;
380
387
  materialSignals?: string[];
381
388
  };
382
- function sendAdvisorHint(pi: ExtensionAPI, decision: "continue" | "review" | "defer", reason: string, summary: string, actions: string[] = []) {
389
+
390
+ function normalizeAdvisorActions(actions: unknown): string[] {
391
+ const raw = Array.isArray(actions) ? actions : typeof actions === "string" ? [actions] : [];
392
+ return raw.map((action) => squish(action, 200)).filter(Boolean).slice(0, 2);
393
+ }
394
+
395
+ function comparableAdvisorText(text: string): string {
396
+ return sanitizeAdvisorText(text).toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
397
+ }
398
+
399
+ function isRedundantAdvisorSummary(reason: string, summary: string): boolean {
400
+ const r = comparableAdvisorText(reason);
401
+ const s = comparableAdvisorText(summary);
402
+ if (!s) return true;
403
+ if (!r) return false;
404
+ if (r === s) return true;
405
+ if (Math.min(r.length, s.length) >= 60 && (r.includes(s) || s.includes(r))) return true;
406
+
407
+ const rTokens = new Set(r.split(" ").filter((token) => token.length > 2));
408
+ const sTokens = new Set(s.split(" ").filter((token) => token.length > 2));
409
+ if (rTokens.size < 8 || sTokens.size < 8) return false;
410
+ const overlap = [...sTokens].filter((token) => rTokens.has(token)).length;
411
+ return overlap / Math.max(rTokens.size, sTokens.size) >= 0.86;
412
+ }
413
+
414
+ function distinctAdvisorSummary(reason: string, summary: string): string {
415
+ const cleanSummary = sanitizeAdvisorText(summary).trim();
416
+ return isRedundantAdvisorSummary(reason, cleanSummary) ? "" : cleanSummary;
417
+ }
418
+
419
+ function advisorHandoffText(decision: "continue" | "review" | "defer", reason: string, summary: string, actions: unknown = []): string {
420
+ const limitedActions = normalizeAdvisorActions(actions);
421
+ const cleanReason = sanitizeAdvisorText(reason);
422
+ const cleanSummary = distinctAdvisorSummary(cleanReason, summary);
423
+ return [
424
+ `Advisor verdict: ${decision}.`,
425
+ cleanReason ? `Reason: ${cleanReason}` : "",
426
+ cleanSummary ? `Summary: ${cleanSummary}` : "",
427
+ limitedActions.length ? `Actions: ${limitedActions.join("; ")}` : "",
428
+ ].filter(Boolean).join("\n");
429
+ }
430
+
431
+ function sendAdvisorHint(pi: ExtensionAPI, decision: "continue" | "review" | "defer", reason: string, summary: string, actions: unknown = []) {
432
+ const cleanReason = sanitizeAdvisorText(reason);
433
+ const cleanSummary = distinctAdvisorSummary(cleanReason, summary);
434
+ const limitedActions = normalizeAdvisorActions(actions);
383
435
  pi.sendMessage(
384
436
  {
385
437
  customType: "advisor:llm",
386
- content: reason,
438
+ content: advisorHandoffText(decision, cleanReason, cleanSummary, limitedActions),
387
439
  display: true,
388
- details: { decision, reason, summary, actions: actions.slice(0, 2) },
440
+ details: { kind: "handoff", decision, reason: cleanReason, summary: cleanSummary, actions: limitedActions },
389
441
  },
390
442
  { deliverAs: "followUp" },
391
443
  );
392
444
  }
393
445
 
446
+ function sendAdvisorAnswer(pi: ExtensionAPI, text: string) {
447
+ const cleanText = sanitizeAdvisorText(text);
448
+ pi.sendMessage({
449
+ customType: "advisor:llm",
450
+ content: cleanText,
451
+ display: true,
452
+ details: { kind: "answer", summary: cleanText },
453
+ });
454
+ }
455
+
394
456
  function renderAdvisorHint(message: any, options: { expanded?: boolean }, theme: any) {
395
457
  const details = (message?.details ?? {}) as AdvisorHintDetails;
396
458
  const customType = String(message?.customType ?? "advisor:rules");
397
- const decision = details.decision ?? "defer";
398
459
  const sourceColor = customType === "advisor:llm" ? "success" : customType === "advisor:model" ? "accent" : "muted";
399
- const decisionColor = decision === "review" ? "accent" : decision === "continue" ? "muted" : "dim";
400
460
  const source = theme.bold(theme.fg(sourceColor, `[${customType}]`));
461
+
462
+ if (details.kind === "answer") {
463
+ const body = sanitizeAdvisorText(contentText(message?.content) || details.summary || "No advisor response.");
464
+ const box = new Box(1, 1, (s: string) => theme.bg("customMessageBg", s));
465
+ box.addChild(new Text(`${theme.bold(theme.fg("success", "↗"))} ${source} ${theme.bold(theme.fg("success", "answer"))}`, 0, 0));
466
+ box.addChild(new Text(theme.fg("dim", body), 0, 0));
467
+ return box;
468
+ }
469
+
470
+ const decision = details.decision ?? "defer";
471
+ const decisionColor = decision === "review" ? "accent" : decision === "continue" ? "muted" : "dim";
401
472
  const verdict = theme.bold(theme.fg(decisionColor, decision));
402
473
  const glyph = decision === "review" ? "↗" : decision === "defer" ? "…" : "·";
403
474
  const reason = squish(details.reason || contentText(message?.content) || "no extra detail", 180);
475
+ const actions = normalizeAdvisorActions(details.actions);
476
+ const fullHandoff = sanitizeAdvisorText(
477
+ (details.reason || details.summary || actions.length)
478
+ ? advisorHandoffText(decision, details.reason || "", details.summary || "", actions)
479
+ : contentText(message?.content),
480
+ );
404
481
 
405
482
  const box = new Box(1, 1, (s: string) => theme.bg("customMessageBg", s));
406
- box.addChild(new Text(`${theme.bold(theme.fg(decisionColor, glyph))} ${source} ${verdict} · ${theme.fg("dim", "reason: ")}${reason}`, 0, 0));
407
-
408
- if (options.expanded && details.summary) {
409
- box.addChild(new Text(theme.fg("dim", `summary: ${squish(details.summary, 220)}`), 0, 0));
410
- }
411
- if (options.expanded && details.actions?.length) {
412
- box.addChild(new Text(theme.fg("dim", `actions: ${details.actions.map((a) => squish(a, 80)).join(" • ")}`), 0, 0));
483
+ box.addChild(new Text(`${theme.bold(theme.fg(decisionColor, glyph))} ${source} ${verdict}`, 0, 0));
484
+ box.addChild(new Text(theme.fg("dim", `reason: ${reason}`), 0, 0));
485
+
486
+ if (options.expanded) {
487
+ box.addChild(new Text(theme.fg("dim", "full handoff:"), 0, 0));
488
+ box.addChild(new Text(theme.fg("dim", fullHandoff), 0, 0));
489
+ } else {
490
+ const summary = distinctAdvisorSummary(details.reason || "", details.summary || "");
491
+ if (summary) {
492
+ box.addChild(new Text(theme.fg("dim", `summary: ${squish(summary, 220)}`), 0, 0));
493
+ }
494
+ if (actions.length) {
495
+ box.addChild(new Text(theme.fg("dim", `actions: ${actions.map((a) => squish(a, 80)).join(" • ")}`), 0, 0));
496
+ }
497
+ if (fullHandoff.split("\n").length > 3) {
498
+ box.addChild(new Text(theme.fg("dim", "Ctrl+O full advisor handoff"), 0, 0));
499
+ }
413
500
  }
414
501
 
415
502
  return box;
@@ -417,15 +504,15 @@ function renderAdvisorHint(message: any, options: { expanded?: boolean }, theme:
417
504
 
418
505
  /** Extract readable text from message content (handles strings, blocks, and nested message payloads). */
419
506
  export function contentText(content: unknown): string {
420
- if (typeof content === "string") return content.trim();
507
+ if (typeof content === "string") return sanitizeAdvisorText(content).trim();
421
508
  if (content && typeof content === "object" && !Array.isArray(content)) {
422
509
  const obj = content as Record<string, unknown>;
423
- if (typeof obj.text === "string") return obj.text.trim();
510
+ if (typeof obj.text === "string") return sanitizeAdvisorText(obj.text).trim();
424
511
  if (obj.content !== undefined) return contentText(obj.content);
425
512
  if (obj.message !== undefined) return contentText(obj.message);
426
513
  return "";
427
514
  }
428
- if (!Array.isArray(content)) return String(content ?? "").trim();
515
+ if (!Array.isArray(content)) return sanitizeAdvisorText(content).trim();
429
516
  const parts: string[] = [];
430
517
  for (const item of content) {
431
518
  if (!item) continue;
@@ -442,7 +529,7 @@ export function contentText(content: unknown): string {
442
529
  if (nested) parts.push(nested);
443
530
  }
444
531
  }
445
- return parts.join("\n").replace(/\s+/g, " ").trim();
532
+ return sanitizeAdvisorText(parts.join("\n")).replace(/\s+/g, " ").trim();
446
533
  }
447
534
 
448
535
  /** Check if a tool result or message indicates an actual execution failure */
@@ -933,14 +1020,15 @@ async function doReview(pi: ExtensionAPI, ctx: any, trigger: string, delta: stri
933
1020
  : json.verdict === "not_done" ? "review"
934
1021
  : "defer";
935
1022
  finalDecision = decision;
936
- finalReason = (json.reason || json.summary || "review result").slice(0, 120);
1023
+ const rawReason = sanitizeAdvisorText(json.reason || json.summary || "review result");
1024
+ finalReason = rawReason.slice(0, 120);
937
1025
 
938
1026
  const display = formatAdvisorDisplay("advisor:llm", decision, finalReason);
939
1027
  writeText(CURRENT_PATH, `${display}\n`);
940
- sendAdvisorHint(pi, decision, finalReason, json.summary || "", json.actions || []);
1028
+ sendAdvisorHint(pi, decision, rawReason, json.summary || "", json.actions || []);
941
1029
 
942
1030
  if (json.verdict !== "on_track") {
943
- state.followUp = [json.summary, ...(json.actions?.slice(0, 2) || [])].filter(Boolean).join(" — ");
1031
+ state.followUp = [sanitizeAdvisorText(json.summary), ...normalizeAdvisorActions(json.actions)].filter(Boolean).join(" — ");
944
1032
  }
945
1033
 
946
1034
  markReviewApplied(state, signature, trigger, finalDecision, finalReason, false);
@@ -1326,7 +1414,11 @@ export function registerAdvisor(pi: ExtensionAPI): void {
1326
1414
 
1327
1415
  // Anything else: treat as a question to the advisor
1328
1416
  const r = await askAdvisor(pi, ctx, a, "slash", true);
1329
- ctx.ui.notify(r.text, r.error ? "warning" : "info");
1417
+ if (r.error) {
1418
+ ctx.ui.notify(r.text, "warning");
1419
+ return;
1420
+ }
1421
+ sendAdvisorAnswer(pi, r.text);
1330
1422
  },
1331
1423
  });
1332
1424
  }
@@ -17,9 +17,13 @@ vi.mock("@earendil-works/pi-ai", async () => {
17
17
  type Handler = (event: any, ctx: any) => any;
18
18
 
19
19
  type HandlerMap = Record<string, Handler[]>;
20
+ type CommandMap = Record<string, { handler: (args: string, ctx: any) => any }>;
21
+ type MessageRendererMap = Record<string, (message: any, options: { expanded?: boolean }, theme: any) => any>;
20
22
 
21
23
  function makeHandlers() {
22
24
  const handlers: HandlerMap = {};
25
+ const commands: CommandMap = {};
26
+ const messageRenderers: MessageRendererMap = {};
23
27
  const sendMessage = vi.fn();
24
28
 
25
29
  const pi = {
@@ -27,8 +31,12 @@ function makeHandlers() {
27
31
  handlers[event] ??= [];
28
32
  handlers[event].push(handler);
29
33
  },
30
- registerMessageRenderer: () => undefined,
31
- registerCommand: () => undefined,
34
+ registerMessageRenderer: (customType: string, renderer: MessageRendererMap[string]) => {
35
+ messageRenderers[customType] = renderer;
36
+ },
37
+ registerCommand: (name: string, command: { handler: (args: string, ctx: any) => any }) => {
38
+ commands[name] = command;
39
+ },
32
40
  registerTool: vi.fn(),
33
41
  sendMessage,
34
42
  sendUserMessage: () => undefined,
@@ -38,12 +46,13 @@ function makeHandlers() {
38
46
  },
39
47
  };
40
48
 
41
- return { handlers, pi: pi as any, sendMessage };
49
+ return { handlers, commands, messageRenderers, pi: pi as any, sendMessage };
42
50
  }
43
51
 
44
52
  const ADVISOR_STATE_DIR = join(homedir(), ".pi", "agent", "pi-rogue", "advisor");
45
53
  const ADVISOR_STATE_PATH = join(ADVISOR_STATE_DIR, "state.json");
46
54
  const ADVISOR_CONFIG_PATH = join(ADVISOR_STATE_DIR, "config.json");
55
+ const ADVISOR_CACHE_PATH = join(ADVISOR_STATE_DIR, "cache.json");
47
56
 
48
57
  function readAdvisorState(): any {
49
58
  return JSON.parse(readFileSync(ADVISOR_STATE_PATH, "utf8"));
@@ -73,21 +82,28 @@ function mkCtx() {
73
82
  describe("advisor two-agent convergence", () => {
74
83
  let ctx: any;
75
84
  let handlers: HandlerMap;
85
+ let commands: CommandMap;
86
+ let messageRenderers: MessageRendererMap;
76
87
  let sendMessageMock: ReturnType<typeof vi.fn>;
77
88
  let completeSimpleMock: ReturnType<typeof vi.fn>;
78
89
  let priorState: string | null = null;
79
90
  let priorConfig: string | null = null;
91
+ let priorCache: string | null = null;
80
92
 
81
93
  beforeEach(() => {
82
94
  priorState = existsSync(ADVISOR_STATE_PATH) ? readFileSync(ADVISOR_STATE_PATH, "utf8") : null;
83
95
  priorConfig = existsSync(ADVISOR_CONFIG_PATH) ? readFileSync(ADVISOR_CONFIG_PATH, "utf8") : null;
96
+ priorCache = existsSync(ADVISOR_CACHE_PATH) ? readFileSync(ADVISOR_CACHE_PATH, "utf8") : null;
84
97
 
85
98
  const setup = makeHandlers();
86
99
  handlers = setup.handlers;
100
+ commands = setup.commands;
101
+ messageRenderers = setup.messageRenderers;
87
102
  sendMessageMock = setup.sendMessage;
88
103
 
89
104
  mkdirSync(dirname(ADVISOR_STATE_PATH), { recursive: true });
90
105
  writeFileSync(ADVISOR_CONFIG_PATH, JSON.stringify({ mode: "auto", review: "light", checkins: "off", checkinIntervalMinutes: 30 }, null, 2), "utf8");
106
+ writeFileSync(ADVISOR_CACHE_PATH, "{}", "utf8");
91
107
  writeFileSync(ADVISOR_STATE_PATH, JSON.stringify({
92
108
  turns: 0,
93
109
  lastTask: "",
@@ -136,6 +152,12 @@ describe("advisor two-agent convergence", () => {
136
152
  } else {
137
153
  writeFileSync(ADVISOR_CONFIG_PATH, priorConfig, "utf8");
138
154
  }
155
+
156
+ if (priorCache === null) {
157
+ writeFileSync(ADVISOR_CACHE_PATH, "{}", "utf8");
158
+ } else {
159
+ writeFileSync(ADVISOR_CACHE_PATH, priorCache, "utf8");
160
+ }
139
161
  });
140
162
 
141
163
  it("does not re-run advisory review on repeated material snapshots", async () => {
@@ -160,6 +182,17 @@ describe("advisor two-agent convergence", () => {
160
182
  const firstState = readAdvisorState();
161
183
  expect(firstState.reviewControl.lastDecision).toBe("review");
162
184
  expect(firstState.followUp).toContain("Closeout is incomplete");
185
+ expect(sendMessageMock).toHaveBeenCalledWith(
186
+ expect.objectContaining({
187
+ customType: "advisor:llm",
188
+ content: expect.stringContaining("Summary: Closeout is incomplete"),
189
+ }),
190
+ expect.anything(),
191
+ );
192
+ expect(sendMessageMock).toHaveBeenCalledWith(
193
+ expect.objectContaining({ content: expect.stringContaining("Actions: run focused check") }),
194
+ expect.anything(),
195
+ );
163
196
  expect(completeSimpleMock).toHaveBeenCalledTimes(1);
164
197
 
165
198
  const consumedPrompt = await preflight![0]({ systemPrompt: "SYS", prompt: basePrompt }, ctx);
@@ -183,6 +216,157 @@ describe("advisor two-agent convergence", () => {
183
216
  expect(String(withoutFollowUp?.systemPrompt)).not.toContain("Advisor follow-up");
184
217
  });
185
218
 
219
+ it("normalizes string actions in advisor handoffs", async () => {
220
+ const preflight = handlers.before_agent_start;
221
+ const turnEnd = handlers.turn_end;
222
+ expect(preflight?.length).toBe(1);
223
+ expect(turnEnd?.length).toBe(1);
224
+
225
+ completeSimpleMock.mockResolvedValue({
226
+ content: [{
227
+ type: "text",
228
+ text: JSON.stringify({
229
+ verdict: "not_done",
230
+ summary: "Closeout is incomplete",
231
+ reason: "Verification is missing",
232
+ actions: "run focused check",
233
+ checklist: [],
234
+ notify: true,
235
+ }),
236
+ }],
237
+ });
238
+
239
+ await handlers.session_start?.[0]?.({}, ctx);
240
+ await preflight![0]({ systemPrompt: "SYS", prompt: "Continue the current goal" }, ctx);
241
+ await turnEnd![0]({
242
+ toolResults: [{ toolName: "edit" }],
243
+ message: { role: "assistant", content: "Repo-side autoresearch is verified closed. Only optional external rollout/CI smoke remains." },
244
+ }, ctx);
245
+
246
+ const state = readAdvisorState();
247
+ expect(completeSimpleMock).toHaveBeenCalledTimes(1);
248
+ expect(state.followUp).toBe("Closeout is incomplete — run focused check");
249
+ expect(sendMessageMock).toHaveBeenCalledWith(
250
+ expect.objectContaining({
251
+ customType: "advisor:llm",
252
+ content: expect.stringContaining("Actions: run focused check"),
253
+ details: expect.objectContaining({ actions: ["run focused check"] }),
254
+ }),
255
+ expect.anything(),
256
+ );
257
+ });
258
+
259
+ it("redacts transient clipboard image paths from emitted advisor handoffs", async () => {
260
+ const preflight = handlers.before_agent_start;
261
+ const turnEnd = handlers.turn_end;
262
+ const clipboardPath = "/var/folders/fm/rwczdnws5j58x7kbyn3vcx_h0000gn/T/clipboard-2026-06-04-012248-DEE3A154.png";
263
+
264
+ completeSimpleMock.mockResolvedValue({
265
+ content: [{
266
+ type: "text",
267
+ text: JSON.stringify({
268
+ verdict: "not_done",
269
+ summary: `The visible handoff should not include ${clipboardPath}`,
270
+ reason: `Expanded Ctrl+O output leaks ${clipboardPath}`,
271
+ actions: [`redact ${clipboardPath}`],
272
+ checklist: [],
273
+ notify: true,
274
+ }),
275
+ }],
276
+ });
277
+
278
+ await handlers.session_start?.[0]?.({}, ctx);
279
+ await preflight![0]({ systemPrompt: "SYS", prompt: `Continue the current goal ${clipboardPath}` }, ctx);
280
+ await turnEnd![0]({
281
+ toolResults: [{ toolName: "edit" }],
282
+ message: { role: "assistant", content: "Repo-side autoresearch is verified closed. Only optional external rollout/CI smoke remains." },
283
+ }, ctx);
284
+
285
+ expect(sendMessageMock).toHaveBeenCalled();
286
+ const sent = sendMessageMock.mock.calls[0]?.[0];
287
+ expect(JSON.stringify(sent)).not.toContain(clipboardPath);
288
+ expect(sent.content).toContain("[clipboard image]");
289
+ expect(readAdvisorState().followUp).toContain("[clipboard image]");
290
+
291
+ const theme = {
292
+ fg: (_name: string, text: string) => text,
293
+ bg: (_name: string, text: string) => text,
294
+ bold: (text: string) => text,
295
+ };
296
+ const expanded = messageRenderers["advisor:llm"](sent, { expanded: true }, theme).render(120).join("\n");
297
+ expect(expanded).toContain("full handoff:");
298
+ expect(expanded).toContain("Advisor verdict: review.");
299
+ expect(expanded).toContain("[clipboard image]");
300
+ expect(expanded).not.toContain(clipboardPath);
301
+ });
302
+
303
+ it("suppresses duplicate reason and summary in advisor handoffs", async () => {
304
+ const preflight = handlers.before_agent_start;
305
+ const turnEnd = handlers.turn_end;
306
+ const duplicate = "The agent made a safe attempt, but it did not demonstrate that the advisor post-turn review was induced.";
307
+
308
+ completeSimpleMock.mockResolvedValue({
309
+ content: [{
310
+ type: "text",
311
+ text: JSON.stringify({
312
+ verdict: "not_done",
313
+ reason: duplicate,
314
+ summary: duplicate,
315
+ actions: ["Invoke the real review hook if available."],
316
+ checklist: [],
317
+ notify: true,
318
+ }),
319
+ }],
320
+ });
321
+
322
+ await handlers.session_start?.[0]?.({}, ctx);
323
+ await preflight![0]({ systemPrompt: "SYS", prompt: "Continue the current goal" }, ctx);
324
+ await turnEnd![0]({
325
+ toolResults: [{ toolName: "edit" }],
326
+ message: { role: "assistant", content: "Repo-side autoresearch is verified closed. Only optional external rollout/CI smoke remains." },
327
+ }, ctx);
328
+
329
+ const sent = sendMessageMock.mock.calls[0]?.[0];
330
+ expect(sent.content).toContain(`Reason: ${duplicate}`);
331
+ expect(sent.content).not.toContain("Summary:");
332
+ expect(sent.details.summary).toBe("");
333
+
334
+ const theme = {
335
+ fg: (_name: string, text: string) => text,
336
+ bg: (_name: string, text: string) => text,
337
+ bold: (text: string) => text,
338
+ };
339
+ const collapsed = messageRenderers["advisor:llm"](sent, { expanded: false }, theme).render(120).join("\n");
340
+ const expanded = messageRenderers["advisor:llm"](sent, { expanded: true }, theme).render(120).join("\n");
341
+ expect(collapsed).not.toContain("summary:");
342
+ expect(expanded).not.toContain("Summary:");
343
+ });
344
+
345
+ it("renders manual advisor answers as advisor custom messages", async () => {
346
+ expect(commands.advisor).toBeTruthy();
347
+
348
+ completeSimpleMock.mockResolvedValue({
349
+ content: [{
350
+ type: "text",
351
+ text: "Post-turn review: no merge blockers identified from the session brief.",
352
+ }],
353
+ });
354
+
355
+ await commands.advisor.handler("should we merge this pr?", ctx);
356
+
357
+ expect(sendMessageMock).toHaveBeenCalledWith(
358
+ expect.objectContaining({
359
+ customType: "advisor:llm",
360
+ content: "Post-turn review: no merge blockers identified from the session brief.",
361
+ display: true,
362
+ details: expect.objectContaining({
363
+ kind: "answer",
364
+ summary: "Post-turn review: no merge blockers identified from the session brief.",
365
+ }),
366
+ }),
367
+ );
368
+ });
369
+
186
370
  it("does not re-run advisory review on repeated agent-end material snapshots", async () => {
187
371
  const preflight = handlers.before_agent_start;
188
372
  const agentEnd = handlers.agent_end;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fiale-plus/pi-rogue-bundle",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Public Pi-Rogue bundle for advisor and orchestration. Single consolidated artefact (advisor and orchestration releases paused; their packages are private and bundled here).",
5
5
  "type": "module",
6
6
  "license": "MIT",