@vellumai/assistant 0.10.3-dev.202606292229.153e9b2 → 0.10.3-dev.202606292252.adea010

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/openapi.yaml CHANGED
@@ -17599,6 +17599,16 @@ paths:
17599
17599
  - label
17600
17600
  - status
17601
17601
  additionalProperties: false
17602
+ acpNotification:
17603
+ type: object
17604
+ properties:
17605
+ acpSessionId:
17606
+ type: string
17607
+ agent:
17608
+ type: string
17609
+ required:
17610
+ - acpSessionId
17611
+ additionalProperties: false
17602
17612
  slackMessage:
17603
17613
  type: object
17604
17614
  properties:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.3-dev.202606292229.153e9b2",
3
+ "version": "0.10.3-dev.202606292252.adea010",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -3004,4 +3004,47 @@ describe("subagent notification user_message_echo suppression", () => {
3004
3004
  await resolveRun(1);
3005
3005
  await new Promise((r) => setTimeout(r, 10));
3006
3006
  });
3007
+
3008
+ test("drained acp-notification message persists and wakes the agent but emits no user_message_echo", async () => {
3009
+ const conversation = makeConversation();
3010
+ await conversation.loadFromDb();
3011
+
3012
+ const events1: ServerMessage[] = [];
3013
+ const eventsNotif: ServerMessage[] = [];
3014
+
3015
+ // Occupy the conversation so the injected notification queues.
3016
+ const p1 = conversation.processMessage({
3017
+ content: "msg-1",
3018
+ attachments: [],
3019
+ onEvent: (e) => events1.push(e),
3020
+ requestId: "req-1",
3021
+ });
3022
+ await waitForPendingRun(1);
3023
+
3024
+ // A daemon-injected ACP completion notification carries `acpNotification`.
3025
+ conversation.enqueueMessage({
3026
+ content: '[ACP agent "claude" completed]',
3027
+ onEvent: (e) => eventsNotif.push(e),
3028
+ requestId: "req-acp-notif",
3029
+ metadata: {
3030
+ acpNotification: { acpSessionId: "acp-1", agent: "claude" },
3031
+ },
3032
+ });
3033
+
3034
+ await resolveRun(0);
3035
+ await p1;
3036
+ await waitForPendingRun(2);
3037
+
3038
+ // Still persisted (so the orchestrator LLM sees it) and still wakes the
3039
+ // agent...
3040
+ expect(
3041
+ capturedAddMessages.some((m) => m.content.includes("ACP agent")),
3042
+ ).toBe(true);
3043
+ expect(pendingRuns.length).toBe(2);
3044
+ // ...but no user_message_echo, so the client never renders a live bubble.
3045
+ expect(eventsNotif.some((e) => e.type === "user_message_echo")).toBe(false);
3046
+
3047
+ await resolveRun(1);
3048
+ await new Promise((r) => setTimeout(r, 10));
3049
+ });
3007
3050
  });
@@ -89,6 +89,17 @@ describe("redactSensitiveFields", () => {
89
89
  expect(items[1].password).toBe(R);
90
90
  });
91
91
 
92
+ test("recurses into arrays nested inside arrays", () => {
93
+ const input = { calls: [[{ name: "a", token: "plainword" }]] };
94
+ const result = redactSensitiveFields(input);
95
+ const inner = (result.calls as unknown[][])[0][0] as Record<
96
+ string,
97
+ unknown
98
+ >;
99
+ expect(inner.name).toBe("a");
100
+ expect(inner.token).toBe(R);
101
+ });
102
+
92
103
  test("preserves primitive arrays", () => {
93
104
  const input = { tags: ["public", "v1"], token: "tk" };
94
105
  const result = redactSensitiveFields(input);
@@ -408,6 +408,175 @@ describe("VellumAcpClientHandler seq + enriched fields", () => {
408
408
  });
409
409
  });
410
410
 
411
+ test("tool_call forwards rawInput/rawOutput structurally when present", async () => {
412
+ const { handler, sent } = makeHandler();
413
+
414
+ const rawInput = { command: "ls", args: ["-la"] };
415
+ const rawOutput = { stdout: "file.txt", exitCode: 0 };
416
+
417
+ await handler.sessionUpdate({
418
+ sessionId: ACP_SESSION_ID,
419
+ update: {
420
+ sessionUpdate: "tool_call",
421
+ toolCallId: "tc-raw",
422
+ title: "Run ls",
423
+ kind: "execute",
424
+ status: "completed",
425
+ rawInput,
426
+ rawOutput,
427
+ },
428
+ });
429
+
430
+ expect(sent).toHaveLength(1);
431
+ expect(sent[0]).toMatchObject({
432
+ updateType: "tool_call",
433
+ toolCallId: "tc-raw",
434
+ // Forwarded as-is, NOT stringified (unlike content).
435
+ rawInput: { command: "ls", args: ["-la"] },
436
+ rawOutput: { stdout: "file.txt", exitCode: 0 },
437
+ });
438
+ });
439
+
440
+ test("tool_call leaves rawInput/rawOutput undefined when source omits them", async () => {
441
+ const { handler, sent } = makeHandler();
442
+
443
+ await handler.sessionUpdate({
444
+ sessionId: ACP_SESSION_ID,
445
+ update: {
446
+ sessionUpdate: "tool_call",
447
+ toolCallId: "tc-no-raw",
448
+ title: "Edit main.ts",
449
+ kind: "edit",
450
+ status: "pending",
451
+ },
452
+ });
453
+
454
+ expect(sent).toHaveLength(1);
455
+ const msg = sent[0] as { rawInput?: unknown; rawOutput?: unknown };
456
+ expect(msg.rawInput).toBeUndefined();
457
+ expect(msg.rawOutput).toBeUndefined();
458
+ });
459
+
460
+ test("tool_call_update forwards rawInput/rawOutput structurally when present", async () => {
461
+ const { handler, sent } = makeHandler();
462
+
463
+ const rawInput = { path: "/repo/main.ts", oldText: "a", newText: "b" };
464
+ const rawOutput = { applied: true };
465
+
466
+ await handler.sessionUpdate({
467
+ sessionId: ACP_SESSION_ID,
468
+ update: {
469
+ sessionUpdate: "tool_call_update",
470
+ toolCallId: "tc-raw-update",
471
+ status: "completed",
472
+ rawInput,
473
+ rawOutput,
474
+ },
475
+ });
476
+
477
+ expect(sent).toHaveLength(1);
478
+ expect(sent[0]).toMatchObject({
479
+ updateType: "tool_call_update",
480
+ toolCallId: "tc-raw-update",
481
+ rawInput: { path: "/repo/main.ts", oldText: "a", newText: "b" },
482
+ rawOutput: { applied: true },
483
+ });
484
+ });
485
+
486
+ test("tool_call forwards a small rawOutput unchanged (under the cap)", async () => {
487
+ const { handler, sent } = makeHandler();
488
+
489
+ const rawOutput = { ok: true };
490
+
491
+ await handler.sessionUpdate({
492
+ sessionId: ACP_SESSION_ID,
493
+ update: {
494
+ sessionUpdate: "tool_call",
495
+ toolCallId: "tc-small-raw",
496
+ title: "Run check",
497
+ kind: "execute",
498
+ status: "completed",
499
+ rawOutput,
500
+ },
501
+ });
502
+
503
+ expect(sent).toHaveLength(1);
504
+ const msg = sent[0] as { rawOutput?: unknown };
505
+ // Under the 16 KiB cap — forwarded structurally, unchanged.
506
+ expect(msg.rawOutput).toEqual({ ok: true });
507
+ });
508
+
509
+ test("tool_call caps an oversize rawOutput to a marker string", async () => {
510
+ const { handler, sent } = makeHandler();
511
+
512
+ const huge = "x".repeat(20000);
513
+ const rawOutput = { stdout: huge };
514
+
515
+ await handler.sessionUpdate({
516
+ sessionId: ACP_SESSION_ID,
517
+ update: {
518
+ sessionUpdate: "tool_call",
519
+ toolCallId: "tc-oversize-raw",
520
+ title: "Run noisy command",
521
+ kind: "execute",
522
+ status: "completed",
523
+ rawOutput,
524
+ },
525
+ });
526
+
527
+ expect(sent).toHaveLength(1);
528
+ const msg = sent[0] as { rawOutput?: unknown };
529
+ // Oversize payloads are replaced with a short marker so a single large
530
+ // rawOutput can't evict real transcript events from the session buffer.
531
+ expect(typeof msg.rawOutput).toBe("string");
532
+ expect(msg.rawOutput as string).toStartWith("[raw payload omitted:");
533
+ // The original large value must not survive into the forwarded event.
534
+ expect(JSON.stringify(sent[0])).not.toContain(huge);
535
+ });
536
+
537
+ test("tool_call caps an oversize rawInput to a marker string", async () => {
538
+ const { handler, sent } = makeHandler();
539
+
540
+ const huge = "y".repeat(20000);
541
+ const rawInput = { prompt: huge };
542
+
543
+ await handler.sessionUpdate({
544
+ sessionId: ACP_SESSION_ID,
545
+ update: {
546
+ sessionUpdate: "tool_call",
547
+ toolCallId: "tc-oversize-input",
548
+ title: "Run big prompt",
549
+ kind: "execute",
550
+ status: "pending",
551
+ rawInput,
552
+ },
553
+ });
554
+
555
+ expect(sent).toHaveLength(1);
556
+ const msg = sent[0] as { rawInput?: unknown };
557
+ expect(typeof msg.rawInput).toBe("string");
558
+ expect(msg.rawInput as string).toStartWith("[raw payload omitted:");
559
+ expect(JSON.stringify(sent[0])).not.toContain(huge);
560
+ });
561
+
562
+ test("tool_call_update leaves rawInput/rawOutput undefined when source omits them", async () => {
563
+ const { handler, sent } = makeHandler();
564
+
565
+ await handler.sessionUpdate({
566
+ sessionId: ACP_SESSION_ID,
567
+ update: {
568
+ sessionUpdate: "tool_call_update",
569
+ toolCallId: "tc-no-raw-update",
570
+ status: "in_progress",
571
+ },
572
+ });
573
+
574
+ expect(sent).toHaveLength(1);
575
+ const msg = sent[0] as { rawInput?: unknown; rawOutput?: unknown };
576
+ expect(msg.rawInput).toBeUndefined();
577
+ expect(msg.rawOutput).toBeUndefined();
578
+ });
579
+
411
580
  test("tool_call_update forwards locations: [] when null (explicit clear)", async () => {
412
581
  const { handler, sent } = makeHandler();
413
582
 
@@ -448,4 +617,168 @@ describe("VellumAcpClientHandler seq + enriched fields", () => {
448
617
  expect(msg.locations).toBeUndefined();
449
618
  expect("locations" in msg && msg.locations !== undefined).toBe(false);
450
619
  });
620
+
621
+ test("tool_call redacts secrets in rawInput/rawOutput before forwarding", async () => {
622
+ const { handler, sent } = makeHandler();
623
+
624
+ // AWS access key id — a high-confidence prefix-based secret pattern.
625
+ const secret = "AKIAIOSFODNN7REALKEY";
626
+
627
+ await handler.sessionUpdate({
628
+ sessionId: ACP_SESSION_ID,
629
+ update: {
630
+ sessionUpdate: "tool_call",
631
+ toolCallId: "tc-secret",
632
+ title: "Configure AWS",
633
+ kind: "execute",
634
+ status: "completed",
635
+ rawInput: { env: { AWS_ACCESS_KEY_ID: secret } },
636
+ rawOutput: { echoed: `key is ${secret}` },
637
+ },
638
+ });
639
+
640
+ expect(sent).toHaveLength(1);
641
+ // The raw secret must not survive into the forwarded event, which flows
642
+ // into the session buffer and the persisted event_log_json.
643
+ expect(JSON.stringify(sent[0])).not.toContain(secret);
644
+ const msg = sent[0] as { rawInput?: unknown; rawOutput?: unknown };
645
+ expect(JSON.stringify(msg.rawInput)).toContain("<redacted");
646
+ expect(JSON.stringify(msg.rawOutput)).toContain("<redacted");
647
+ });
648
+
649
+ test("tool_call_update redacts secrets in rawInput/rawOutput before forwarding", async () => {
650
+ const { handler, sent } = makeHandler();
651
+
652
+ const secret = "AKIAIOSFODNN7REALKEY";
653
+
654
+ await handler.sessionUpdate({
655
+ sessionId: ACP_SESSION_ID,
656
+ update: {
657
+ sessionUpdate: "tool_call_update",
658
+ toolCallId: "tc-secret-update",
659
+ status: "completed",
660
+ rawInput: { token: secret },
661
+ rawOutput: { stdout: secret },
662
+ },
663
+ });
664
+
665
+ expect(sent).toHaveLength(1);
666
+ expect(JSON.stringify(sent[0])).not.toContain(secret);
667
+ const msg = sent[0] as { rawInput?: unknown; rawOutput?: unknown };
668
+ expect(JSON.stringify(msg.rawInput)).toContain("<redacted");
669
+ expect(JSON.stringify(msg.rawOutput)).toContain("<redacted");
670
+ });
671
+
672
+ test("tool_call redacts credential-named fields whose values aren't secret-shaped", async () => {
673
+ const { handler, sent } = makeHandler();
674
+
675
+ await handler.sessionUpdate({
676
+ sessionId: ACP_SESSION_ID,
677
+ update: {
678
+ sessionUpdate: "tool_call",
679
+ toolCallId: "tc-cred",
680
+ title: "Configure",
681
+ kind: "execute",
682
+ status: "completed",
683
+ // Plain words — caught by field NAME, not by value shape.
684
+ rawInput: {
685
+ password: "correcthorsebattery",
686
+ nested: { api_key: "plainword" },
687
+ },
688
+ rawOutput: { token: "notasecretbutlong" },
689
+ },
690
+ });
691
+
692
+ expect(sent).toHaveLength(1);
693
+ const serialized = JSON.stringify(sent[0]);
694
+ expect(serialized).not.toContain("correcthorsebattery");
695
+ expect(serialized).not.toContain("plainword");
696
+ expect(serialized).not.toContain("notasecretbutlong");
697
+ expect(serialized).toContain("<redacted");
698
+ });
699
+
700
+ test("tool_call redacts credential-named fields nested inside arrays of arrays", async () => {
701
+ const { handler, sent } = makeHandler();
702
+
703
+ await handler.sessionUpdate({
704
+ sessionId: ACP_SESSION_ID,
705
+ update: {
706
+ sessionUpdate: "tool_call",
707
+ toolCallId: "tc-nested-array",
708
+ title: "Batch",
709
+ kind: "execute",
710
+ status: "completed",
711
+ // Credential-named field buried under array-in-array nesting; the
712
+ // value isn't secret-shaped, so only field-name redaction catches it.
713
+ rawInput: { calls: [[{ token: "plainword" }]] },
714
+ },
715
+ });
716
+
717
+ expect(sent).toHaveLength(1);
718
+ const serialized = JSON.stringify(sent[0]);
719
+ expect(serialized).not.toContain("plainword");
720
+ expect(serialized).toContain("<redacted");
721
+ });
722
+
723
+ test("tool_call_update redacts credential-named fields whose values aren't secret-shaped", async () => {
724
+ const { handler, sent } = makeHandler();
725
+
726
+ await handler.sessionUpdate({
727
+ sessionId: ACP_SESSION_ID,
728
+ update: {
729
+ sessionUpdate: "tool_call_update",
730
+ toolCallId: "tc-cred-update",
731
+ status: "completed",
732
+ rawInput: { authorization: "plainwordvalue" },
733
+ rawOutput: { secret: "justwords" },
734
+ },
735
+ });
736
+
737
+ expect(sent).toHaveLength(1);
738
+ const serialized = JSON.stringify(sent[0]);
739
+ expect(serialized).not.toContain("plainwordvalue");
740
+ expect(serialized).not.toContain("justwords");
741
+ expect(serialized).toContain("<redacted");
742
+ });
743
+
744
+ test("tool_call redacts a shaped secret embedded in the title", async () => {
745
+ const { handler, sent } = makeHandler();
746
+
747
+ await handler.sessionUpdate({
748
+ sessionId: ACP_SESSION_ID,
749
+ update: {
750
+ sessionUpdate: "tool_call",
751
+ toolCallId: "tc-title",
752
+ // Claude's shell tools put the command in the title; a literal
753
+ // credential inline must not reach the wire or persisted event log.
754
+ title: "echo AKIAQ7XK4PNZ3RJD2WTV",
755
+ kind: "execute",
756
+ status: "completed",
757
+ },
758
+ });
759
+
760
+ expect(sent).toHaveLength(1);
761
+ const msg = sent[0] as { toolTitle?: string };
762
+ expect(msg.toolTitle).not.toContain("AKIAQ7XK4PNZ3RJD2WTV");
763
+ expect(msg.toolTitle).toContain("<redacted");
764
+ });
765
+
766
+ test("tool_call_update redacts a shaped secret embedded in the title", async () => {
767
+ const { handler, sent } = makeHandler();
768
+
769
+ await handler.sessionUpdate({
770
+ sessionId: ACP_SESSION_ID,
771
+ update: {
772
+ sessionUpdate: "tool_call_update",
773
+ toolCallId: "tc-title-update",
774
+ title: "echo AKIAFA01L49X7HW9DM2Y",
775
+ status: "completed",
776
+ },
777
+ });
778
+
779
+ expect(sent).toHaveLength(1);
780
+ const msg = sent[0] as { toolTitle?: string };
781
+ expect(msg.toolTitle).not.toContain("AKIAFA01L49X7HW9DM2Y");
782
+ expect(msg.toolTitle).toContain("<redacted");
783
+ });
451
784
  });
@@ -240,6 +240,49 @@ describe("AcpSessionManager — terminal persistence", () => {
240
240
  expect(eventBuffers.has(id)).toBe(false);
241
241
  });
242
242
 
243
+ test("persists tool_call rawInput/rawOutput verbatim through the event log", async () => {
244
+ const id = "session-raw-io-1";
245
+ const handles = buildSessionWithFakeProcess({
246
+ id,
247
+ agentId: "agent-raw",
248
+ protocolSessionId: "proto-raw",
249
+ parentConversationId: "conv-raw",
250
+ });
251
+
252
+ const rawInput = { command: "ls -la", args: ["-la"] };
253
+ const rawOutput = "total 0\ndrwxr-xr-x 2 user staff 64 .";
254
+
255
+ handles.emitUpdate({
256
+ type: "acp_session_update",
257
+ acpSessionId: id,
258
+ updateType: "tool_call",
259
+ toolCallId: "tc-raw",
260
+ toolTitle: "Run command",
261
+ toolKind: "execute",
262
+ toolStatus: "running",
263
+ rawInput,
264
+ });
265
+ handles.emitUpdate({
266
+ type: "acp_session_update",
267
+ acpSessionId: id,
268
+ updateType: "tool_call_update",
269
+ toolCallId: "tc-raw",
270
+ toolStatus: "completed",
271
+ rawOutput,
272
+ });
273
+
274
+ handles.resolvePrompt({ stopReason: "end_turn" });
275
+ await Promise.resolve();
276
+ await Promise.resolve();
277
+
278
+ const row = readHistoryRow(id);
279
+ expect(row).not.toBeNull();
280
+ const log = JSON.parse(row!.event_log_json) as AcpSessionUpdate[];
281
+ expect(log).toHaveLength(2);
282
+ expect(log[0]!.rawInput).toEqual(rawInput);
283
+ expect(log[1]!.rawOutput).toBe(rawOutput);
284
+ });
285
+
243
286
  test("persists status='failed' with the error message on prompt rejection", async () => {
244
287
  const id = "session-failed-1";
245
288
  const handles = buildSessionWithFakeProcess({
@@ -33,10 +33,30 @@ import type {
33
33
 
34
34
  import type { ServerMessage } from "../daemon/message-protocol.js";
35
35
  import type { AcpSessionUpdate } from "../daemon/message-types/acp.js";
36
+ import { redactJsonStringLeaves } from "../security/redact-json.js";
37
+ import { redactSensitiveFields } from "../security/redaction.js";
38
+ import { redactSecrets } from "../security/secret-scanner.js";
36
39
  import { getLogger } from "../util/logger.js";
37
40
 
38
41
  const log = getLogger("acp:client-handler");
39
42
 
43
+ // Field-name redaction across object/array shapes (covers top-level arrays;
44
+ // redactSensitiveFields handles the nested recursion within objects).
45
+ function redactSensitivePayload(value: unknown): unknown {
46
+ if (Array.isArray(value)) return value.map(redactSensitivePayload);
47
+ if (value !== null && typeof value === "object") {
48
+ return redactSensitiveFields(value as Record<string, unknown>);
49
+ }
50
+ return value;
51
+ }
52
+
53
+ // The execute kind's title is the command line, which can carry a literal
54
+ // credential — scrub shaped secrets before forwarding/persisting it.
55
+ function redactTitle(title: string | null | undefined): string | undefined {
56
+ if (title == null) return undefined;
57
+ return redactSecrets(title);
58
+ }
59
+
40
60
  interface TerminalState {
41
61
  proc: ChildProcess;
42
62
  output: string;
@@ -93,6 +113,41 @@ export class VellumAcpClientHandler implements Client {
93
113
  });
94
114
  }
95
115
 
116
+ /**
117
+ * Cap a raw tool payload so a single large one can't evict real transcript
118
+ * events from the bounded session buffer (and the persisted event log). Small
119
+ * payloads pass through unchanged; oversize ones become a short marker string.
120
+ */
121
+ private capRawPayload(value: unknown): unknown {
122
+ const CAP_BYTES = 16 * 1024;
123
+ if (value === undefined) return undefined;
124
+ let serialized: string;
125
+ try {
126
+ serialized = JSON.stringify(value) ?? "";
127
+ } catch {
128
+ return "[raw payload omitted: not serializable]";
129
+ }
130
+ if (serialized.length <= CAP_BYTES) return value;
131
+ return `[raw payload omitted: ${serialized.length} bytes exceeds ${CAP_BYTES}-byte cap]`;
132
+ }
133
+
134
+ /**
135
+ * Redact secrets, then cap size, before forwarding — so a leaked credential
136
+ * never reaches the SSE stream, the session buffer, or persisted
137
+ * `event_log_json`. Two passes: `redactSensitivePayload` blanks values under
138
+ * credential-named keys (value-only scanning misses these once JSON-leaf
139
+ * isolation drops the key↔value context), then `redactJsonStringLeaves`
140
+ * catches shape-based secrets anywhere in the payload. `undefined` passes
141
+ * straight through.
142
+ */
143
+ private prepareRawPayload(value: unknown): unknown {
144
+ if (value === undefined) return undefined;
145
+ const redacted = redactJsonStringLeaves(
146
+ redactSensitivePayload(value),
147
+ ).value;
148
+ return this.capRawPayload(redacted);
149
+ }
150
+
96
151
  /**
97
152
  * Begins suppressing session updates from being forwarded to Vellum.
98
153
  *
@@ -166,13 +221,19 @@ export class VellumAcpClientHandler implements Client {
166
221
  this.forwardUpdate({
167
222
  updateType: "tool_call",
168
223
  toolCallId: update.toolCallId,
169
- toolTitle: update.title,
224
+ toolTitle: redactTitle(update.title),
170
225
  toolKind: update.kind,
171
226
  toolStatus: update.status,
172
227
  // An agent may put output/diff on the initial tool_call and never
173
228
  // follow up with an update; forward it like the update branch so the
174
229
  // chat/file-diff UI has content to render.
175
230
  content: update.content ? JSON.stringify(update.content) : undefined,
231
+ // rawInput/rawOutput are unknown-shaped; forward them structurally
232
+ // (the SSE layer serializes the message) after redacting secrets and
233
+ // capping size — so leaked credentials never persist and a single
234
+ // large payload can't evict real transcript events from the buffer.
235
+ rawInput: this.prepareRawPayload(update.rawInput),
236
+ rawOutput: this.prepareRawPayload(update.rawOutput),
176
237
  locations: mapLocations(update.locations),
177
238
  });
178
239
  break;
@@ -182,10 +243,12 @@ export class VellumAcpClientHandler implements Client {
182
243
  this.forwardUpdate({
183
244
  updateType: "tool_call_update",
184
245
  toolCallId: update.toolCallId,
185
- toolTitle: update.title ?? undefined,
246
+ toolTitle: redactTitle(update.title),
186
247
  toolKind: update.kind ?? undefined,
187
248
  toolStatus: update.status ?? undefined,
188
249
  content: update.content ? JSON.stringify(update.content) : undefined,
250
+ rawInput: this.prepareRawPayload(update.rawInput),
251
+ rawOutput: this.prepareRawPayload(update.rawOutput),
189
252
  locations: mapLocations(update.locations),
190
253
  });
191
254
  break;
@@ -1034,16 +1034,27 @@ export class AcpSessionManager {
1034
1034
  );
1035
1035
  const resumeHint = hint ? `\n\n${hint}` : "";
1036
1036
  const notifyMessage = `[ACP agent "${agentLabel}" completed]\n\n${responseText}${resumeHint}`;
1037
+ // Tag the injected message so the daemon skips its user_message_echo
1038
+ // and the client filters it from the rendered transcript — the user
1039
+ // sees the run through its inline card, not a chat turn.
1040
+ const acpNotification = {
1041
+ acpSessionId: sessionId,
1042
+ agent: agentLabel,
1043
+ };
1037
1044
  const parentConversation = findConversation(
1038
1045
  current.parentConversationId,
1039
1046
  );
1040
1047
  if (parentConversation) {
1041
1048
  const enqueueResult = parentConversation.enqueueMessage({
1042
1049
  content: notifyMessage,
1050
+ metadata: { acpNotification },
1043
1051
  });
1044
1052
  if (!enqueueResult.queued && !enqueueResult.rejected) {
1045
1053
  parentConversation
1046
- .persistUserMessage({ content: notifyMessage })
1054
+ .persistUserMessage({
1055
+ content: notifyMessage,
1056
+ metadata: { acpNotification },
1057
+ })
1047
1058
  .then(({ id: messageId }) =>
1048
1059
  parentConversation.runAgentLoop(notifyMessage, messageId),
1049
1060
  )
@@ -42,6 +42,58 @@ describe("AcpSessionUpdateEventSchema", () => {
42
42
  ).toEqual([{ path: "src/baz.ts", line: 7 }]);
43
43
  });
44
44
 
45
+ test("accepts a tool_call event carrying object rawInput/rawOutput", () => {
46
+ const event = {
47
+ type: "acp_session_update" as const,
48
+ acpSessionId: "acp-1",
49
+ updateType: "tool_call" as const,
50
+ toolCallId: "tool-1",
51
+ rawInput: { command: "ls -la", cwd: "/tmp" },
52
+ rawOutput: { exitCode: 0, stdout: "file.txt" },
53
+ };
54
+
55
+ const result = AcpSessionUpdateEventSchema.safeParse(event);
56
+ expect(result.success).toBe(true);
57
+ expect(result.success && result.data.rawInput).toEqual({
58
+ command: "ls -la",
59
+ cwd: "/tmp",
60
+ });
61
+ expect(result.success && result.data.rawOutput).toEqual({
62
+ exitCode: 0,
63
+ stdout: "file.txt",
64
+ });
65
+ });
66
+
67
+ test("accepts a tool_call_update event carrying string rawInput/rawOutput", () => {
68
+ const event = {
69
+ type: "acp_session_update" as const,
70
+ acpSessionId: "acp-1",
71
+ updateType: "tool_call_update" as const,
72
+ toolCallId: "tool-1",
73
+ rawInput: "ls -la",
74
+ rawOutput: "file.txt",
75
+ };
76
+
77
+ const result = AcpSessionUpdateEventSchema.safeParse(event);
78
+ expect(result.success).toBe(true);
79
+ expect(result.success && result.data.rawInput).toBe("ls -la");
80
+ expect(result.success && result.data.rawOutput).toBe("file.txt");
81
+ });
82
+
83
+ test("still accepts a tool_call event omitting rawInput/rawOutput (optional)", () => {
84
+ const event = {
85
+ type: "acp_session_update" as const,
86
+ acpSessionId: "acp-1",
87
+ updateType: "tool_call" as const,
88
+ toolCallId: "tool-1",
89
+ };
90
+
91
+ const result = AcpSessionUpdateEventSchema.safeParse(event);
92
+ expect(result.success).toBe(true);
93
+ expect(result.success && result.data.rawInput).toBeUndefined();
94
+ expect(result.success && result.data.rawOutput).toBeUndefined();
95
+ });
96
+
45
97
  test("still accepts a normal event without locations", () => {
46
98
  const event = {
47
99
  type: "acp_session_update" as const,
@@ -34,6 +34,9 @@ export const AcpSessionUpdateEventSchema = z
34
34
  toolTitle: z.string().optional(),
35
35
  toolKind: z.string().optional(),
36
36
  toolStatus: z.string().optional(),
37
+ /** Optional raw tool input/output (ACP rawInput/rawOutput); apply to tool_call/tool_call_update. */
38
+ rawInput: z.unknown().optional(),
39
+ rawOutput: z.unknown().optional(),
37
40
  /** Files touched by this tool call (for the file-diff affordance). */
38
41
  locations: z
39
42
  .array(z.object({ path: z.string(), line: z.number().optional() }))
@@ -283,6 +283,19 @@ export type ConversationSubagentNotification = z.infer<
283
283
  typeof ConversationSubagentNotificationSchema
284
284
  >;
285
285
 
286
+ // ---------------------------------------------------------------------------
287
+ // ACP run notification
288
+ // ---------------------------------------------------------------------------
289
+
290
+ /** Daemon-injected ACP-run lifecycle notification attached to a history row. */
291
+ export const ConversationAcpNotificationSchema = z.object({
292
+ acpSessionId: z.string(),
293
+ agent: z.string().optional(),
294
+ });
295
+ export type ConversationAcpNotification = z.infer<
296
+ typeof ConversationAcpNotificationSchema
297
+ >;
298
+
286
299
  // ---------------------------------------------------------------------------
287
300
  // Slack message envelope
288
301
  // ---------------------------------------------------------------------------
@@ -530,6 +543,7 @@ export const ConversationMessageSchema = z.object({
530
543
  */
531
544
  contentBlocks: z.array(ConversationContentBlockSchema).optional(),
532
545
  subagentNotification: ConversationSubagentNotificationSchema.optional(),
546
+ acpNotification: ConversationAcpNotificationSchema.optional(),
533
547
  slackMessage: ConversationSlackMessageSchema.optional(),
534
548
  /**
535
549
  * Queue state for a user message that is still waiting in the daemon's
@@ -61,18 +61,20 @@ import { resolveVerificationSessionIntent } from "./verification-session-intent.
61
61
  const log = getLogger("conversation-process");
62
62
 
63
63
  /**
64
- * Daemon-injected subagent lifecycle notifications carry `subagentNotification`
65
- * metadata. They are persisted into the parent conversation so the orchestrator
66
- * wakes and reads the subagent's result, but they are internal scaffolding — the
67
- * user sees subagent activity through the inline progress card, not a chat turn.
68
- * Skip the `user_message_echo` broadcast for these so they never render as a live
69
- * user bubble; the persisted row is filtered from the rendered transcript on the
70
- * client.
64
+ * Daemon-injected run lifecycle notifications subagent (`subagentNotification`)
65
+ * and ACP run (`acpNotification`) — are persisted into the parent conversation so
66
+ * the orchestrator wakes and reads the run's result, but they are internal
67
+ * scaffolding: the user sees the run through its inline progress card, not a chat
68
+ * turn. Skip the `user_message_echo` broadcast for these so they never render as a
69
+ * live user bubble; the persisted row is filtered from the rendered transcript on
70
+ * the client.
71
71
  */
72
- function isSubagentNotificationMessage(
72
+ function isHiddenRunNotificationMessage(
73
73
  metadata: Record<string, unknown> | undefined,
74
74
  ): boolean {
75
- return metadata?.subagentNotification != null;
75
+ return (
76
+ metadata?.subagentNotification != null || metadata?.acpNotification != null
77
+ );
76
78
  }
77
79
 
78
80
  /** Format the result of a forced compaction into a user-facing message. */
@@ -868,7 +870,7 @@ async function drainSingleMessage(
868
870
 
869
871
  // Broadcast the user message to all hub subscribers so passive devices
870
872
  // see the user turn before the assistant reply starts streaming.
871
- if (!isSubagentNotificationMessage(next.metadata)) {
873
+ if (!isHiddenRunNotificationMessage(next.metadata)) {
872
874
  next.onEvent({
873
875
  type: "user_message_echo",
874
876
  text: resolvedContent,
@@ -1221,7 +1223,7 @@ async function drainBatch(
1221
1223
 
1222
1224
  // Broadcast the user message to all hub subscribers so passive devices
1223
1225
  // see each batched user turn before the assistant reply starts streaming.
1224
- if (!isSubagentNotificationMessage(qm.metadata)) {
1226
+ if (!isHiddenRunNotificationMessage(qm.metadata)) {
1225
1227
  qm.onEvent({
1226
1228
  type: "user_message_echo",
1227
1229
  text: qmContent,
@@ -28,6 +28,10 @@ export interface AcpSessionUpdate {
28
28
  toolTitle?: string;
29
29
  toolKind?: string;
30
30
  toolStatus?: string;
31
+ /** Optional raw input parameters sent to the tool (ACP `rawInput`); shape is tool-specific. */
32
+ rawInput?: unknown;
33
+ /** Optional raw output returned by the tool (ACP `rawOutput`); shape is tool-specific. */
34
+ rawOutput?: unknown;
31
35
  /** Files touched by this tool call (for the file-diff affordance). */
32
36
  locations?: { path: string; line?: number }[];
33
37
  /** Stable id for the message this chunk belongs to. */
@@ -143,6 +143,11 @@ const subagentNotificationSchema = z.object({
143
143
  objective: z.string().optional(),
144
144
  });
145
145
 
146
+ const acpNotificationSchema = z.object({
147
+ acpSessionId: z.string(),
148
+ agent: z.string().optional(),
149
+ });
150
+
146
151
  export const messageMetadataSchema = z
147
152
  .object({
148
153
  userMessageChannel: channelIdSchema.optional(),
@@ -160,6 +165,7 @@ export const messageMetadataSchema = z
160
165
  */
161
166
  client: z.record(z.string(), z.unknown()).optional(),
162
167
  subagentNotification: subagentNotificationSchema.optional(),
168
+ acpNotification: acpNotificationSchema.optional(),
163
169
  /**
164
170
  * Trust class of the actor at the time this message was persisted.
165
171
  * This is a durable snapshot -- it does NOT change if the actor's
@@ -0,0 +1,60 @@
1
+ import type { DrizzleDb } from "../db-connection.js";
2
+
3
+ /**
4
+ * Drop nine redundant single-/narrow-column indexes whose columns are a
5
+ * leading prefix of a wider, non-partial index already present on the same
6
+ * table. SQLite uses the wider index to serve the narrower lookup (a prefix of
7
+ * its key), so the narrow index adds nothing on reads while still costing a
8
+ * B-tree write on every insert/update to the table. Dropping them trims
9
+ * write-amplification on hot tables (`messages`, `trace_events`,
10
+ * `notification_deliveries`) and frees space on the rest.
11
+ *
12
+ * Each drop and the index that subsumes it (verified non-partial, so it covers
13
+ * all rows the narrow index did):
14
+ *
15
+ * messages
16
+ * idx_messages_conversation_id [conversation_id]
17
+ * ⊂ idx_messages_conversation_created_at [conversation_id, created_at]
18
+ * trace_events
19
+ * idx_trace_events_conversation_id [conversation_id]
20
+ * ⊂ idx_trace_events_conversation_timestamp [conversation_id, timestamp_ms]
21
+ * notification_deliveries
22
+ * idx_notification_deliveries_decision_id [notification_decision_id]
23
+ * ⊂ idx_notification_deliveries_unique
24
+ * [notification_decision_id, channel, destination, attempt]
25
+ * external_conversation_bindings
26
+ * idx_ext_conv_bindings_channel [source_channel]
27
+ * idx_ext_conv_bindings_channel_chat [source_channel, external_chat_id]
28
+ * both ⊂ idx_ext_conv_bindings_channel_chat_thread
29
+ * [source_channel, external_chat_id, external_thread_id]
30
+ * followups
31
+ * idx_followups_channel [channel] ⊂ idx_followups_channel_thread
32
+ * [channel, conversation_id]
33
+ * idx_followups_status [status] ⊂ idx_followups_status_expected
34
+ * [status, expected_response_by]
35
+ * guardian_action_requests
36
+ * idx_guardian_action_requests_call_session [call_session_id]
37
+ * ⊂ idx_guardian_action_requests_session_status_created
38
+ * [call_session_id, status, created_at]
39
+ * media_keyframes
40
+ * idx_media_keyframes_asset_id [asset_id]
41
+ * ⊂ idx_media_keyframes_asset_timestamp [asset_id, timestamp]
42
+ *
43
+ * `IF EXISTS` keeps this idempotent and tolerant of databases where an index
44
+ * was never created.
45
+ */
46
+ export function migrateDropRedundantIndexes(database: DrizzleDb): void {
47
+ for (const name of [
48
+ "idx_messages_conversation_id",
49
+ "idx_trace_events_conversation_id",
50
+ "idx_notification_deliveries_decision_id",
51
+ "idx_ext_conv_bindings_channel",
52
+ "idx_ext_conv_bindings_channel_chat",
53
+ "idx_followups_channel",
54
+ "idx_followups_status",
55
+ "idx_guardian_action_requests_call_session",
56
+ "idx_media_keyframes_asset_id",
57
+ ]) {
58
+ database.run(/*sql*/ `DROP INDEX IF EXISTS ${name}`);
59
+ }
60
+ }
@@ -99,7 +99,6 @@ export const messages = sqliteTable(
99
99
  clientMessageId: text("client_message_id"),
100
100
  },
101
101
  (table) => [
102
- index("idx_messages_conversation_id").on(table.conversationId),
103
102
  uniqueIndex("idx_messages_conv_client_msg_id")
104
103
  .on(table.conversationId, table.clientMessageId)
105
104
  .where(sql`client_message_id IS NOT NULL`),
@@ -373,7 +373,6 @@ export const traceEvents = sqliteTable(
373
373
  createdAt: integer("created_at").notNull(),
374
374
  },
375
375
  (table) => [
376
- index("idx_trace_events_conversation_id").on(table.conversationId),
377
376
  index("idx_trace_events_conversation_timestamp").on(
378
377
  table.conversationId,
379
378
  table.timestampMs,
@@ -416,6 +416,7 @@ import { migrateDropContactAclColumns } from "./migrations/305-drop-contact-acl-
416
416
  import { migrateRewriteFrontierProfilePins } from "./migrations/306-rewrite-frontier-profile-pins.js";
417
417
  import { migrateAcpSessionHistoryUsageColumns } from "./migrations/307-acp-session-history-usage-columns.js";
418
418
  import { migrateAcpSessionHistoryTokenColumns } from "./migrations/308-acp-session-history-token-columns.js";
419
+ import { migrateDropRedundantIndexes } from "./migrations/309-drop-redundant-indexes.js";
419
420
  import type { MigrationStep } from "./migrations/run-migrations.js";
420
421
 
421
422
  export const migrationSteps: MigrationStep[] = [
@@ -1303,4 +1304,5 @@ export const migrationSteps: MigrationStep[] = [
1303
1304
  migrateRewriteFrontierProfilePins,
1304
1305
  migrateAcpSessionHistoryUsageColumns,
1305
1306
  migrateAcpSessionHistoryTokenColumns,
1307
+ migrateDropRedundantIndexes,
1306
1308
  ];
@@ -155,6 +155,57 @@ describe("GET /v1/acp/sessions — eventLog", () => {
155
155
  expect(eventLog[0]?.content).toBe("persisted");
156
156
  });
157
157
 
158
+ test("active and terminal sessions both surface tool_call rawInput/rawOutput in eventLog", async () => {
159
+ const rawInput = { command: "grep -rn foo", pattern: "foo" };
160
+ const rawOutput = "src/a.ts:1: foo\nsrc/b.ts:9: foo";
161
+
162
+ const activeToolCall: AcpSessionUpdate = {
163
+ type: "acp_session_update",
164
+ acpSessionId: "active",
165
+ updateType: "tool_call",
166
+ toolCallId: "tc-active",
167
+ toolKind: "search",
168
+ toolStatus: "completed",
169
+ rawInput,
170
+ rawOutput,
171
+ seq: 1,
172
+ };
173
+ inMemoryStates.set("active", makeInMemoryState("active", "conv-1"));
174
+ bufferedUpdates.set("active", [activeToolCall]);
175
+
176
+ insertHistoryRow({
177
+ id: "terminal",
178
+ parentConversationId: "conv-1",
179
+ eventLogJson: JSON.stringify([
180
+ {
181
+ type: "acp_session_update",
182
+ acpSessionId: "terminal",
183
+ updateType: "tool_call",
184
+ toolCallId: "tc-terminal",
185
+ toolKind: "search",
186
+ toolStatus: "completed",
187
+ rawInput,
188
+ rawOutput,
189
+ seq: 1,
190
+ } satisfies AcpSessionUpdate,
191
+ ]),
192
+ });
193
+
194
+ const handler = getListHandler();
195
+ const result = (await handler({
196
+ queryParams: { conversationId: "conv-1" },
197
+ })) as { sessions: Array<Record<string, unknown>> };
198
+
199
+ for (const id of ["active", "terminal"]) {
200
+ const session = result.sessions.find((s) => s.id === id);
201
+ expect(session).toBeDefined();
202
+ const eventLog = session?.eventLog as AcpSessionUpdate[];
203
+ expect(eventLog).toHaveLength(1);
204
+ expect(eventLog[0]?.rawInput).toEqual(rawInput);
205
+ expect(eventLog[0]?.rawOutput).toBe(rawOutput);
206
+ }
207
+ });
208
+
158
209
  test("active in-memory session surfaces latestUsage as the usage fields", async () => {
159
210
  inMemoryStates.set(
160
211
  "active",
@@ -837,6 +837,7 @@ export function handleListMessages({
837
837
  conversationId?: string;
838
838
  }
839
839
  | undefined;
840
+ let acpNotification: { acpSessionId: string; agent?: string } | undefined;
840
841
  if (msg.metadata) {
841
842
  try {
842
843
  const meta = JSON.parse(msg.metadata);
@@ -858,6 +859,15 @@ export function handleListMessages({
858
859
  };
859
860
  }
860
861
  }
862
+ if (meta.acpNotification) {
863
+ const n = meta.acpNotification;
864
+ if (typeof n.acpSessionId === "string") {
865
+ acpNotification = {
866
+ acpSessionId: n.acpSessionId,
867
+ ...(typeof n.agent === "string" ? { agent: n.agent } : {}),
868
+ };
869
+ }
870
+ }
861
871
  } catch {
862
872
  // Ignore malformed metadata
863
873
  }
@@ -883,6 +893,7 @@ export function handleListMessages({
883
893
  createdAt: msg.createdAt,
884
894
  sentAt,
885
895
  subagentNotification,
896
+ acpNotification,
886
897
  slackMessage,
887
898
  clientMessageId: msg.clientMessageId ?? undefined,
888
899
  };
@@ -1066,6 +1077,7 @@ export function handleListMessages({
1066
1077
  ...(m.subagentNotification
1067
1078
  ? { subagentNotification: m.subagentNotification }
1068
1079
  : {}),
1080
+ ...(m.acpNotification ? { acpNotification: m.acpNotification } : {}),
1069
1081
  ...(m.slackMessage ? { slackMessage: m.slackMessage } : {}),
1070
1082
  };
1071
1083
  });
@@ -57,11 +57,24 @@ function isSensitiveKey(key: string): boolean {
57
57
  return SENSITIVE_STEMS.has(normalizeKey(key));
58
58
  }
59
59
 
60
+ /**
61
+ * Recurse a value of any shape, redacting sensitive fields within. Walks
62
+ * arrays at every depth (including arrays nested in arrays) so a sensitive
63
+ * key buried under array nesting can't bypass field-name redaction.
64
+ */
65
+ function redactValue(val: unknown): unknown {
66
+ if (Array.isArray(val)) return val.map(redactValue);
67
+ if (val != null && typeof val === "object") {
68
+ return redactSensitiveFields(val as Record<string, unknown>);
69
+ }
70
+ return val;
71
+ }
72
+
60
73
  /**
61
74
  * Recursively redact sensitive fields from an object.
62
75
  *
63
76
  * - Replaces values of sensitive keys with `<redacted />` regardless of type
64
- * - Recurses into nested objects and arrays
77
+ * - Recurses into nested objects and arrays at any depth
65
78
  * - Returns a shallow copy — never mutates the original
66
79
  */
67
80
  export function redactSensitiveFields(
@@ -70,19 +83,10 @@ export function redactSensitiveFields(
70
83
  const result: Record<string, unknown> = {};
71
84
 
72
85
  for (const [key, val] of Object.entries(obj)) {
73
- if (isSensitiveKey(key) && val != null) {
74
- result[key] = REDACTION_PLACEHOLDER;
75
- } else if (Array.isArray(val)) {
76
- result[key] = val.map((item) =>
77
- item != null && typeof item === "object" && !Array.isArray(item)
78
- ? redactSensitiveFields(item as Record<string, unknown>)
79
- : item,
80
- );
81
- } else if (val != null && typeof val === "object") {
82
- result[key] = redactSensitiveFields(val as Record<string, unknown>);
83
- } else {
84
- result[key] = val;
85
- }
86
+ result[key] =
87
+ isSensitiveKey(key) && val != null
88
+ ? REDACTION_PLACEHOLDER
89
+ : redactValue(val);
86
90
  }
87
91
 
88
92
  return result;