@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 +10 -0
- package/package.json +1 -1
- package/src/__tests__/conversation-queue.test.ts +43 -0
- package/src/__tests__/tool-executor-redaction.test.ts +11 -0
- package/src/acp/__tests__/client-handler.test.ts +333 -0
- package/src/acp/__tests__/session-manager-persistence.test.ts +43 -0
- package/src/acp/client-handler.ts +65 -2
- package/src/acp/session-manager.ts +12 -1
- package/src/api/events/acp-session-update.test.ts +52 -0
- package/src/api/events/acp-session-update.ts +3 -0
- package/src/api/responses/conversation-message.ts +14 -0
- package/src/daemon/conversation-process.ts +13 -11
- package/src/daemon/message-types/acp.ts +4 -0
- package/src/persistence/conversation-crud.ts +6 -0
- package/src/persistence/migrations/309-drop-redundant-indexes.ts +60 -0
- package/src/persistence/schema/conversations.ts +0 -1
- package/src/persistence/schema/infrastructure.ts +0 -1
- package/src/persistence/steps.ts +2 -0
- package/src/runtime/routes/acp-routes-event-log.test.ts +51 -0
- package/src/runtime/routes/conversation-routes.ts +12 -0
- package/src/security/redaction.ts +18 -14
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
|
@@ -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
|
|
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({
|
|
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
|
|
65
|
-
*
|
|
66
|
-
* wakes and reads the
|
|
67
|
-
* user sees
|
|
68
|
-
* Skip the `user_message_echo` broadcast for these so they never render as a
|
|
69
|
-
* user bubble; the persisted row is filtered from the rendered transcript on
|
|
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
|
|
72
|
+
function isHiddenRunNotificationMessage(
|
|
73
73
|
metadata: Record<string, unknown> | undefined,
|
|
74
74
|
): boolean {
|
|
75
|
-
return
|
|
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 (!
|
|
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 (!
|
|
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,
|
package/src/persistence/steps.ts
CHANGED
|
@@ -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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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;
|