@vellumai/assistant 0.10.4-dev.202607021737.083736d → 0.10.4-dev.202607021852.8dcc2d9

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 (34) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/acp-session.test.ts +4 -1
  3. package/src/__tests__/guardian-question-mode.test.ts +57 -0
  4. package/src/__tests__/notification-decision-fallback.test.ts +94 -0
  5. package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -0
  6. package/src/__tests__/slack-notification-approval-card.test.ts +89 -2
  7. package/src/acp/__tests__/session-manager-persistence.test.ts +2 -0
  8. package/src/acp/__tests__/session-manager-resume.test.ts +8 -0
  9. package/src/acp/__tests__/session-manager-usage.test.ts +6 -0
  10. package/src/acp/__tests__/session-manager.test.ts +6 -0
  11. package/src/acp/agent-process.test.ts +116 -0
  12. package/src/acp/agent-process.ts +71 -0
  13. package/src/acp/failure-error.test.ts +109 -0
  14. package/src/acp/failure-error.ts +111 -0
  15. package/src/acp/session-manager.test.ts +312 -0
  16. package/src/acp/session-manager.ts +102 -46
  17. package/src/config/feature-flag-registry.json +2 -2
  18. package/src/daemon/handlers/shared.ts +1 -23
  19. package/src/messaging/providers/slack/send.test.ts +79 -0
  20. package/src/messaging/providers/slack/send.ts +48 -12
  21. package/src/notifications/README.md +1 -1
  22. package/src/notifications/adapters/slack.ts +44 -13
  23. package/src/notifications/broadcaster.ts +3 -13
  24. package/src/notifications/decision-engine.ts +46 -5
  25. package/src/notifications/guardian-question-mode.ts +58 -10
  26. package/src/persistence/__tests__/slow-query-log.test.ts +77 -1
  27. package/src/persistence/slow-query-log.ts +94 -1
  28. package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +114 -2
  29. package/src/plugins/defaults/memory/v3/orchestrate.ts +32 -0
  30. package/src/runtime/routes/acp-routes.ts +15 -3
  31. package/src/runtime/slack-reply-session.test.ts +81 -0
  32. package/src/runtime/slack-reply-session.ts +13 -0
  33. package/src/util/__tests__/text-spacing.test.ts +53 -0
  34. package/src/util/text-spacing.ts +53 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.4-dev.202607021737.083736d",
3
+ "version": "0.10.4-dev.202607021852.8dcc2d9",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -185,7 +185,6 @@ describe("VellumAcpClientHandler", () => {
185
185
  expect(sent).toHaveLength(0);
186
186
  expect(handler.pendingRequestIds.size).toBe(0);
187
187
  });
188
-
189
188
  });
190
189
  });
191
190
 
@@ -264,6 +263,8 @@ describe("AcpSessionManager", () => {
264
263
  initialize: mock(() => Promise.resolve()),
265
264
  createSession: mock(() => Promise.resolve("proto-session")),
266
265
  cancel: mock(() => Promise.resolve()),
266
+ markStderr: () => 0,
267
+ stderrSince: () => "",
267
268
  };
268
269
  const fakeHandler = new VellumAcpClientHandler(
269
270
  "test-session",
@@ -324,6 +325,8 @@ describe("AcpSessionManager", () => {
324
325
  const fakeProcess = {
325
326
  prompt: () => promptPromise,
326
327
  kill: mock(() => {}),
328
+ markStderr: () => 0,
329
+ stderrSince: () => "",
327
330
  };
328
331
  const fakeHandler = new VellumAcpClientHandler(
329
332
  "test-session-2",
@@ -9,10 +9,12 @@ import {
9
9
  buildGuardianRequestCodeInstruction,
10
10
  hasGuardianRequestCodeInstruction,
11
11
  parseGuardianQuestionPayload,
12
+ parseInteractiveApprovalPayload,
12
13
  resolveGuardianInstructionModeForRequest,
13
14
  resolveGuardianInstructionModeFromFields,
14
15
  resolveGuardianQuestionInstructionMode,
15
16
  stripConflictingGuardianRequestInstructions,
17
+ stripGuardianRequestCodeInstructions,
16
18
  } from "../notifications/guardian-question-mode.js";
17
19
 
18
20
  describe("guardian-question-mode", () => {
@@ -228,4 +230,59 @@ describe("guardian-question-mode", () => {
228
230
  ),
229
231
  ).toBe("");
230
232
  });
233
+
234
+ test("stripGuardianRequestCodeInstructions removes both-mode directives and bare code mentions", () => {
235
+ const text = [
236
+ "Alice is asking to run ls /tmp.",
237
+ "Approval code: A1B2C3",
238
+ 'Reference code: A1B2C3. Reply "A1B2C3 approve" or "A1B2C3 reject".',
239
+ 'Reply "A1B2C3 <your answer>".',
240
+ ].join("\n");
241
+
242
+ expect(stripGuardianRequestCodeInstructions(text, "A1B2C3")).toBe(
243
+ "Alice is asking to run ls /tmp.",
244
+ );
245
+ });
246
+
247
+ test("stripGuardianRequestCodeInstructions leaves unrelated text and other codes intact", () => {
248
+ const text = 'Approval code: ZZZZZZ. Reply "A1B2C3 approve" if you agree.';
249
+ expect(stripGuardianRequestCodeInstructions(text, "A1B2C3")).toBe(text);
250
+ });
251
+
252
+ test("parseInteractiveApprovalPayload accepts approval-mode payloads with a requestId", () => {
253
+ expect(
254
+ parseInteractiveApprovalPayload({
255
+ requestKind: "tool_grant_request",
256
+ requestId: "req-1",
257
+ requestCode: "A1B2C3",
258
+ questionText: "Allow host bash?",
259
+ toolName: "host_bash",
260
+ }),
261
+ ).not.toBeNull();
262
+ });
263
+
264
+ test("parseInteractiveApprovalPayload rejects answer-mode and unparseable payloads", () => {
265
+ // Answer mode: free-text questions get no Approve/Reject buttons.
266
+ expect(
267
+ parseInteractiveApprovalPayload({
268
+ requestKind: "pending_question",
269
+ requestId: "req-2",
270
+ requestCode: "A1B2C3",
271
+ questionText: "What time works?",
272
+ callSessionId: "call-1",
273
+ activeGuardianRequestCount: 1,
274
+ }),
275
+ ).toBeNull();
276
+
277
+ // Strict-parse failure: missing required pending_question fields.
278
+ expect(
279
+ parseInteractiveApprovalPayload({
280
+ requestKind: "pending_question",
281
+ requestId: "req-3",
282
+ requestCode: "A1B2C3",
283
+ questionText: "Allow send_email?",
284
+ toolName: "send_email",
285
+ }),
286
+ ).toBeNull();
287
+ });
231
288
  });
@@ -285,6 +285,100 @@ describe("notification decision fallback copy", () => {
285
285
  expect(decision.renderedCopy.vellum?.body).toContain('"A1B2C3 reject"');
286
286
  expect(decision.renderedCopy.vellum?.body).not.toContain("<your answer>");
287
287
  });
288
+
289
+ test("slack approval copy is stripped of request-code instructions instead of enforced", async () => {
290
+ const signal = makeSignal({
291
+ contextPayload: {
292
+ requestId: "req-grant-slack-1",
293
+ questionText: "Approve tool: bash — ls /tmp (requested by Alice)",
294
+ requestCode: "A1B2C3",
295
+ requestKind: "tool_grant_request",
296
+ toolName: "bash",
297
+ },
298
+ });
299
+
300
+ const decision = await evaluateSignal(signal, [
301
+ "vellum",
302
+ "slack",
303
+ ] as NotificationChannel[]);
304
+
305
+ expect(decision.fallbackUsed).toBe(true);
306
+ // Vellum keeps the code-reply directive.
307
+ expect(decision.renderedCopy.vellum?.body).toContain('"A1B2C3 approve"');
308
+ // Slack renders Approve/Reject buttons — no code anywhere in its copy.
309
+ expect(decision.renderedCopy.slack?.body).toBe(
310
+ "Approve tool: bash — ls /tmp (requested by Alice)",
311
+ );
312
+ expect(decision.renderedCopy.slack?.body).not.toContain("A1B2C3");
313
+ });
314
+
315
+ test("slack copy strips LLM-authored approval-code phrasing for approval requests", async () => {
316
+ configuredProvider = {
317
+ sendMessage: async () => ({ content: [] }),
318
+ };
319
+ extractedToolUse = {
320
+ name: "record_notification_decision",
321
+ input: {
322
+ shouldNotify: true,
323
+ selectedChannels: ["slack"],
324
+ reasoningSummary: "LLM decision",
325
+ renderedCopy: {
326
+ slack: {
327
+ title: "Tool Grant Request",
328
+ body: "Alice is asking to run ls /tmp.",
329
+ deliveryText:
330
+ 'Alice is asking to run ls /tmp.\nApproval code: A1B2C3\nReference code: A1B2C3. Reply "A1B2C3 approve" or "A1B2C3 reject".',
331
+ },
332
+ },
333
+ dedupeKey: "guardian-question-slack-llm-test",
334
+ confidence: 0.9,
335
+ },
336
+ };
337
+
338
+ const signal = makeSignal({
339
+ contextPayload: {
340
+ requestId: "req-grant-slack-2",
341
+ questionText: "Approve tool: bash — ls /tmp (requested by Alice)",
342
+ requestCode: "A1B2C3",
343
+ requestKind: "tool_grant_request",
344
+ toolName: "bash",
345
+ },
346
+ });
347
+
348
+ const decision = await evaluateSignal(signal, [
349
+ "slack",
350
+ ] as NotificationChannel[]);
351
+
352
+ expect(decision.fallbackUsed).toBe(false);
353
+ expect(decision.renderedCopy.slack?.deliveryText).toBe(
354
+ "Alice is asking to run ls /tmp.",
355
+ );
356
+ expect(decision.renderedCopy.slack?.body).toBe(
357
+ "Alice is asking to run ls /tmp.",
358
+ );
359
+ });
360
+
361
+ test("slack answer-mode questions keep code instructions (no buttons render)", async () => {
362
+ const signal = makeSignal({
363
+ contextPayload: {
364
+ requestId: "req-pending-slack-1",
365
+ questionText: "What is the gate code?",
366
+ requestCode: "A1B2C3",
367
+ requestKind: "pending_question",
368
+ callSessionId: "call-1",
369
+ activeGuardianRequestCount: 1,
370
+ },
371
+ });
372
+
373
+ const decision = await evaluateSignal(signal, [
374
+ "slack",
375
+ ] as NotificationChannel[]);
376
+
377
+ expect(decision.fallbackUsed).toBe(true);
378
+ expect(decision.renderedCopy.slack?.body).toContain(
379
+ '"A1B2C3 <your answer>"',
380
+ );
381
+ });
288
382
  });
289
383
 
290
384
  // ── Access-request instruction enforcement ──────────────────────────────
@@ -144,6 +144,7 @@ const BASELINE: Record<string, readonly string[]> = {
144
144
  "../../../../skills/catalog-cache.js",
145
145
  "../../../../skills/install-meta.js",
146
146
  "../../../../skills/skill-memory.js",
147
+ "../../../../telemetry/watchdog-events-store.js",
147
148
  "../../../../tools/skills/delete-managed.js",
148
149
  "../../../../util/abort-reasons.js",
149
150
  "../../../../util/errors.js",
@@ -10,7 +10,7 @@ import type { ApprovalUIMetadata } from "../runtime/channel-approval-types.js";
10
10
  * quoted-preview body, action callback ids, source/permalink and requester-id
11
11
  * context blocks, the security-warning context block, and guardian
12
12
  * verification note that `buildApprovalNotificationBlocks` emits for an
13
- * access request.
13
+ * access request — plus the tool-approval card body/continuation split.
14
14
  */
15
15
 
16
16
  const APPROVAL: ApprovalUIMetadata = {
@@ -39,7 +39,9 @@ type Block = Record<string, unknown>;
39
39
 
40
40
  function card(blocks: unknown[]): Block {
41
41
  const c = (blocks as Block[]).find((b) => b.type === "card");
42
- if (!c) throw new Error("no card block");
42
+ if (!c) {
43
+ throw new Error("no card block");
44
+ }
43
45
  return c;
44
46
  }
45
47
 
@@ -174,3 +176,88 @@ describe("Slack access-request card blocks", () => {
174
176
  ).toBe(true);
175
177
  });
176
178
  });
179
+
180
+ const TOOL_APPROVAL: ApprovalUIMetadata = {
181
+ requestId: "req-456",
182
+ actions: [
183
+ { id: "approve_once", label: "Approve once" },
184
+ { id: "reject", label: "Reject" },
185
+ ],
186
+ plainTextFallback: 'Reply "ABC123 approve" or "ABC123 reject"',
187
+ permissionDetails: {
188
+ toolName: "bash",
189
+ riskLevel: "medium",
190
+ toolInput: { command: "ls /tmp" },
191
+ requesterIdentifier: "Alice",
192
+ },
193
+ };
194
+
195
+ function buildToolApprovalPayload(): ChannelDeliveryPayload {
196
+ return {
197
+ sourceEventName: "guardian.question",
198
+ copy: { title: "Guardian Question", body: "Approve tool: bash" },
199
+ urgency: "high",
200
+ approvalContext: TOOL_APPROVAL,
201
+ };
202
+ }
203
+
204
+ function sectionTexts(blocks: unknown[]): string[] {
205
+ return (blocks as Block[])
206
+ .filter((b) => b.type === "section")
207
+ .map((b) => text(b.text));
208
+ }
209
+
210
+ describe("Slack tool-approval card blocks", () => {
211
+ test("short message renders entirely in the card body with no companion section", () => {
212
+ const message = "Alice is requesting approval to run: ls /tmp";
213
+ const blocks = buildApprovalNotificationBlocks(
214
+ buildToolApprovalPayload(),
215
+ message,
216
+ );
217
+ expect(text(card(blocks).body)).toBe(message);
218
+ expect(sectionTexts(blocks)).toHaveLength(0);
219
+ });
220
+
221
+ test("card carries tool title and tool/requester subtitle", () => {
222
+ const c = card(
223
+ buildApprovalNotificationBlocks(buildToolApprovalPayload(), "msg"),
224
+ );
225
+ expect(text(c.title)).toBe("Tool Approval");
226
+ expect(text(c.subtitle)).toBe("bash — requested by Alice");
227
+ });
228
+
229
+ test("long message continues in a section without repeating the card body", () => {
230
+ const message = Array.from(
231
+ { length: 40 },
232
+ (_, i) => `word${String(i).padStart(2, "0")}`,
233
+ ).join(" "); // 279 chars of distinct words
234
+ const blocks = buildApprovalNotificationBlocks(
235
+ buildToolApprovalPayload(),
236
+ message,
237
+ );
238
+
239
+ const body = text(card(blocks).body);
240
+ expect(body.length).toBeLessThanOrEqual(200);
241
+ expect(body.endsWith(" ↓")).toBe(true);
242
+
243
+ const sections = sectionTexts(blocks);
244
+ expect(sections).toHaveLength(1);
245
+ const continuation = sections[0];
246
+
247
+ // Body + continuation reassemble the full message with nothing repeated
248
+ // and nothing lost — the guardian reads each word exactly once.
249
+ const bodyWords = body.replace(/ ↓$/, "").split(" ");
250
+ const continuationWords = continuation.replace(/^… /, "").split(" ");
251
+ expect([...bodyWords, ...continuationWords].join(" ")).toBe(message);
252
+ });
253
+
254
+ test("split lands on a word boundary", () => {
255
+ const message = `${"a".repeat(190)} ${"b".repeat(60)}`;
256
+ const blocks = buildApprovalNotificationBlocks(
257
+ buildToolApprovalPayload(),
258
+ message,
259
+ );
260
+ expect(text(card(blocks).body)).toBe(`${"a".repeat(190)} ↓`);
261
+ expect(sectionTexts(blocks)[0]).toBe(`… ${"b".repeat(60)}`);
262
+ });
263
+ });
@@ -75,6 +75,8 @@ function buildSessionWithFakeProcess(opts: {
75
75
  initialize: () => Promise.resolve(),
76
76
  createSession: () => Promise.resolve(opts.protocolSessionId),
77
77
  cancel: () => Promise.resolve(),
78
+ markStderr: () => 0,
79
+ stderrSince: () => "",
78
80
  };
79
81
 
80
82
  // Match spawn()'s wiring: pre-create the buffer and route emitted updates
@@ -129,6 +129,14 @@ class FakeAcpAgentProcess {
129
129
 
130
130
  async cancel(): Promise<void> {}
131
131
 
132
+ markStderr(): number {
133
+ return 0;
134
+ }
135
+
136
+ stderrSince(): string {
137
+ return "";
138
+ }
139
+
132
140
  kill(): void {
133
141
  this.killed = true;
134
142
  }
@@ -35,6 +35,12 @@ mock.module("../agent-process.js", () => ({
35
35
  return new Promise(() => {});
36
36
  }
37
37
  async cancel(): Promise<void> {}
38
+ markStderr(): number {
39
+ return 0;
40
+ }
41
+ stderrSince(): string {
42
+ return "";
43
+ }
38
44
  kill(): void {}
39
45
  },
40
46
  }));
@@ -37,6 +37,12 @@ mock.module("../agent-process.js", () => ({
37
37
  async cancel(sessionId: string): Promise<void> {
38
38
  cancelCalls.push(sessionId);
39
39
  }
40
+ markStderr(): number {
41
+ return 0;
42
+ }
43
+ stderrSince(): string {
44
+ return "";
45
+ }
40
46
  kill(): void {}
41
47
  },
42
48
  }));
@@ -0,0 +1,116 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { AcpAgentProcess } from "./agent-process.js";
4
+ import type { AcpAgentConfig } from "./types.js";
5
+
6
+ const config: AcpAgentConfig = { command: "noop", args: [] };
7
+
8
+ // The client factory is never invoked in these tests (no spawn), so a stub is
9
+ // sufficient.
10
+ const clientFactory = (() => ({})) as never;
11
+
12
+ function newProcess(): AcpAgentProcess {
13
+ return new AcpAgentProcess("test-agent", config, clientFactory);
14
+ }
15
+
16
+ // retainStderr is the private handler path the stderr "data" listener calls
17
+ // after logging; feed it directly to keep the test deterministic (no real
18
+ // child process or stream timing).
19
+ function feed(proc: AcpAgentProcess, line: string): void {
20
+ (proc as unknown as { retainStderr(text: string): void }).retainStderr(line);
21
+ }
22
+
23
+ describe("AcpAgentProcess.stderrSince(0)", () => {
24
+ test("returns empty string before any stderr is captured", () => {
25
+ expect(newProcess().stderrSince(0)).toBe("");
26
+ });
27
+
28
+ test("retains captured lines joined by newlines, in order", () => {
29
+ const proc = newProcess();
30
+ feed(proc, "line 1");
31
+ feed(proc, "line 2");
32
+ feed(proc, "line 3");
33
+
34
+ expect(proc.stderrSince(0)).toBe("line 1\nline 2\nline 3");
35
+ });
36
+
37
+ test("evicts oldest lines once the byte cap is exceeded", () => {
38
+ const proc = newProcess();
39
+ // Each line is ~1 KB; a handful exceeds the ~4 KB cap and forces eviction
40
+ // of the earliest lines while preserving newest-line ordering.
41
+ const kb = "x".repeat(1024);
42
+ for (let i = 0; i < 8; i++) {
43
+ feed(proc, `${i}:${kb}`);
44
+ }
45
+
46
+ const retained = proc.stderrSince(0).split("\n");
47
+ // Oldest ("0:") evicted, newest ("7:") retained, still in insertion order.
48
+ expect(retained.at(0)).not.toContain("0:");
49
+ expect(retained.at(-1)).toContain("7:");
50
+ expect(Buffer.byteLength(proc.stderrSince(0))).toBeLessThanOrEqual(
51
+ 4096 + kb.length,
52
+ );
53
+
54
+ const indices = retained.map((l) => Number(l.split(":")[0]));
55
+ const sorted = [...indices].sort((a, b) => a - b);
56
+ expect(indices).toEqual(sorted);
57
+ });
58
+
59
+ test("truncates an oversized line to the cap, keeping its tail (the diagnostic)", () => {
60
+ const proc = newProcess();
61
+ // The real adapter error sits at the END of the chunk; deriveFailureError
62
+ // reads from the tail, so truncation must drop the head, not the tail.
63
+ const huge = "H".repeat(6000) + "TAIL_DIAGNOSTIC";
64
+ feed(proc, huge);
65
+
66
+ const retained = proc.stderrSince(0);
67
+ expect(retained.length).toBe(4096);
68
+ expect(retained.endsWith("TAIL_DIAGNOSTIC")).toBe(true);
69
+ });
70
+
71
+ test("stderrSince(0) is pure: repeated reads do not clear the buffer", () => {
72
+ const proc = newProcess();
73
+ feed(proc, "only line");
74
+
75
+ expect(proc.stderrSince(0)).toBe("only line");
76
+ expect(proc.stderrSince(0)).toBe("only line");
77
+ });
78
+ });
79
+
80
+ describe("AcpAgentProcess.markStderr / stderrSince", () => {
81
+ test("stderrSince returns only lines produced after the mark", () => {
82
+ const proc = newProcess();
83
+ feed(proc, "before 1");
84
+ feed(proc, "before 2");
85
+
86
+ const mark = proc.markStderr();
87
+ feed(proc, "after 1");
88
+ feed(proc, "after 2");
89
+
90
+ expect(proc.stderrSince(mark)).toBe("after 1\nafter 2");
91
+ });
92
+
93
+ test("a mark taken after all lines excludes everything retained so far", () => {
94
+ const proc = newProcess();
95
+ feed(proc, "line 1");
96
+ feed(proc, "line 2");
97
+
98
+ const mark = proc.markStderr();
99
+ expect(proc.stderrSince(mark)).toBe("");
100
+ });
101
+
102
+ test("mark is monotonic across eviction: seq keeps counting evicted lines", () => {
103
+ const proc = newProcess();
104
+ // Overflow the byte cap so early lines are evicted, then mark and add one
105
+ // fresh line: only the post-mark line comes back, unaffected by eviction.
106
+ const kb = "z".repeat(1024);
107
+ for (let i = 0; i < 8; i++) {
108
+ feed(proc, `${i}:${kb}`);
109
+ }
110
+
111
+ const mark = proc.markStderr();
112
+ feed(proc, "fresh failure");
113
+
114
+ expect(proc.stderrSince(mark)).toBe("fresh failure");
115
+ });
116
+ });
@@ -30,6 +30,13 @@ const log = getLogger("acp");
30
30
  */
31
31
  const AUTH_REQUIRED_CODE = -32000;
32
32
 
33
+ /**
34
+ * Rough byte cap for retained stderr. Oldest lines are evicted once the sum of
35
+ * retained line lengths exceeds this, so the stderr ring (read via stderrSince)
36
+ * stays bounded.
37
+ */
38
+ const STDERR_RETENTION_BYTES = 4096;
39
+
33
40
  /**
34
41
  * Detects the ACP auth-required error. Checks the `code` property rather than
35
42
  * `instanceof acp.RequestError` so plain JSON-RPC error objects are also
@@ -69,6 +76,20 @@ export class AcpAgentProcess {
69
76
  */
70
77
  private spawnedEnv: NodeJS.ProcessEnv | null = null;
71
78
 
79
+ /**
80
+ * Ring of the most recent stderr lines, bounded to ~STDERR_RETENTION_BYTES.
81
+ * Read once on the failure path to surface the real adapter error. Each
82
+ * entry carries a monotonic `seq` so a caller can scope reads to lines
83
+ * produced after a checkpoint (see markStderr/stderrSince).
84
+ */
85
+ private stderrRing: { seq: number; text: string }[] = [];
86
+ private stderrRingBytes = 0;
87
+ /**
88
+ * Cumulative count of stderr lines ever retained, including ones later
89
+ * evicted. Assigned as each line's `seq`; markStderr() snapshots it.
90
+ */
91
+ private stderrSeq = 0;
92
+
72
93
  constructor(
73
94
  public readonly agentId: string,
74
95
  private readonly config: AcpAgentConfig,
@@ -108,6 +129,7 @@ export class AcpAgentProcess {
108
129
  const text = chunk.toString().trim();
109
130
  if (text) {
110
131
  log.error({ agentId: this.agentId, stderr: text }, "ACP agent stderr");
132
+ this.retainStderr(text);
111
133
  }
112
134
  });
113
135
 
@@ -124,6 +146,55 @@ export class AcpAgentProcess {
124
146
  });
125
147
  }
126
148
 
149
+ /**
150
+ * Appends a stderr line to the ring, evicting oldest lines once the retained
151
+ * total exceeds STDERR_RETENTION_BYTES. Always keeps at least the newest line
152
+ * so a single oversized line is not fully dropped.
153
+ */
154
+ private retainStderr(text: string): void {
155
+ // Cap a single oversized line: the "keep newest" rule below never evicts it,
156
+ // so an untruncated multi-MB chunk would blow the ring budget and make the
157
+ // failure-path stderr scan (deriveFailureError) super-linear on huge input.
158
+ // Keep the TAIL: the adapter's error JSON / last line sits at the end, and
159
+ // deriveFailureError reads from the end, so dropping the head is lossless.
160
+ const line =
161
+ text.length > STDERR_RETENTION_BYTES
162
+ ? text.slice(-STDERR_RETENTION_BYTES)
163
+ : text;
164
+ this.stderrSeq += 1;
165
+ this.stderrRing.push({ seq: this.stderrSeq, text: line });
166
+ this.stderrRingBytes += Buffer.byteLength(line);
167
+ while (
168
+ this.stderrRingBytes > STDERR_RETENTION_BYTES &&
169
+ this.stderrRing.length > 1
170
+ ) {
171
+ const evicted = this.stderrRing.shift()!;
172
+ this.stderrRingBytes -= Buffer.byteLength(evicted.text);
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Snapshots the current cumulative stderr line count. Pair with
178
+ * stderrSince() to read only stderr produced after this checkpoint, so a
179
+ * prompt's failure derives from its own stderr rather than lines retained
180
+ * from startup, resume, or an earlier (possibly cancelled) prompt.
181
+ */
182
+ markStderr(): number {
183
+ return this.stderrSeq;
184
+ }
185
+
186
+ /**
187
+ * Returns retained stderr lines produced after `mark` (a markStderr()
188
+ * checkpoint), joined by newlines. Best-effort: lines pushed after the mark
189
+ * but since evicted are simply absent; returns "" when nothing newer remains.
190
+ */
191
+ stderrSince(mark: number): string {
192
+ return this.stderrRing
193
+ .filter((e) => e.seq > mark)
194
+ .map((e) => e.text)
195
+ .join("\n");
196
+ }
197
+
127
198
  /**
128
199
  * Initializes the ACP connection by negotiating protocol version and capabilities.
129
200
  */