@vellumai/assistant 0.8.8-dev.202606070049.ca91213 → 0.8.8-dev.202606071051.c258385

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 (43) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/background-workers-disk-pressure.test.ts +1 -1
  3. package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +1 -1
  4. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +37 -75
  5. package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -1
  6. package/src/__tests__/conversation-crud-inference-profile.test.ts +7 -9
  7. package/src/__tests__/conversation-runtime-assembly.test.ts +239 -37
  8. package/src/__tests__/conversation-workspace-injection.test.ts +1 -1
  9. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -1
  10. package/src/__tests__/inference-profile-reaper.test.ts +62 -0
  11. package/src/__tests__/inference-profile-session-handler.test.ts +86 -0
  12. package/src/__tests__/injector-background-turn.test.ts +12 -22
  13. package/src/__tests__/injector-chain.test.ts +6 -0
  14. package/src/__tests__/injector-disk-pressure.test.ts +6 -0
  15. package/src/__tests__/injector-document-comments.test.ts +96 -113
  16. package/src/__tests__/memory-v2-static-injector.test.ts +1 -2
  17. package/src/agent/loop.ts +15 -0
  18. package/src/daemon/conversation-agent-loop.ts +31 -48
  19. package/src/daemon/conversation-runtime-assembly.ts +66 -102
  20. package/src/daemon/conversation.ts +36 -8
  21. package/src/memory/conversation-crud.ts +27 -19
  22. package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +15 -0
  23. package/src/plugins/defaults/memory-retrieval/injectors.ts +30 -47
  24. package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +5 -2
  25. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +5 -2
  26. package/src/plugins/defaults/memory-v3-shadow/__tests__/router.test.ts +7 -2
  27. package/src/plugins/defaults/memory-v3-shadow/__tests__/selector.test.ts +2 -2
  28. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +5 -6
  29. package/src/plugins/defaults/memory-v3-shadow/__tests__/types.test.ts +2 -2
  30. package/src/plugins/defaults/memory-v3-shadow/assign.ts +5 -1
  31. package/src/plugins/defaults/memory-v3-shadow/capabilities.ts +4 -1
  32. package/src/plugins/defaults/memory-v3-shadow/injector.ts +2 -2
  33. package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +9 -2
  34. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +2 -2
  35. package/src/plugins/defaults/memory-v3-shadow/reconcile.ts +5 -1
  36. package/src/plugins/defaults/memory-v3-shadow/router.ts +2 -2
  37. package/src/plugins/defaults/memory-v3-shadow/selector.ts +3 -3
  38. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +4 -4
  39. package/src/plugins/defaults/memory-v3-shadow/types.ts +1 -1
  40. package/src/plugins/types.ts +39 -56
  41. package/src/runtime/routes/conversation-routes.ts +5 -0
  42. package/src/runtime/routes/inference-profile-session-handler.ts +11 -0
  43. package/src/runtime/routes/inference-profile-session-reaper.ts +6 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.8.8-dev.202606070049.ca91213",
3
+ "version": "0.8.8-dev.202606071051.c258385",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -117,7 +117,7 @@ mock.module("../memory/conversation-crud.js", () => ({
117
117
  findMostRecentRetrospectiveFor: mock(() => null),
118
118
  forkConversation: mock(() => ({ id: "conv-fork" })),
119
119
  getConversationOverrideProfile: () => undefined,
120
- getConversationOverrideProfileFromRow: () => undefined,
120
+ resolveOverrideProfile: () => undefined,
121
121
  getConversationMemoryScopeId: () => "default",
122
122
  getConversationOriginChannel: () => null,
123
123
  getConversationOriginInterface: () => null,
@@ -96,7 +96,7 @@ mock.module("../memory/conversation-crud.js", () => ({
96
96
  getMessageById: () => null,
97
97
  getConversationOriginChannel: () => null,
98
98
  getConversationOriginInterface: () => null,
99
- getConversationOverrideProfileFromRow: () => null,
99
+ resolveOverrideProfile: () => null,
100
100
  provenanceFromTrustContext: () => ({}),
101
101
  setConversationHistoryStrippedAt: () => {},
102
102
  updateConversationContextWindow: () => {},
@@ -150,12 +150,26 @@ mock.module("../memory/conversation-crud.js", () => ({
150
150
  updateMessageMetadata: () => {},
151
151
  getMessages: () => [],
152
152
  getConversation: () => mockConversationRow,
153
- getConversationOverrideProfileFromRow: (
154
- row: { conversationType?: string; inferenceProfile?: string | null } | null,
153
+ resolveOverrideProfile: (
154
+ fields: {
155
+ conversationType?: string | null;
156
+ inferenceProfile?: string | null;
157
+ inferenceProfileExpiresAt?: number | null;
158
+ } | null,
155
159
  ) => {
156
- if (row?.conversationType === "background") return undefined;
157
- const profile = row?.inferenceProfile;
158
- return typeof profile === "string" ? profile : undefined;
160
+ if (
161
+ fields?.conversationType === "background" ||
162
+ fields?.conversationType === "scheduled"
163
+ ) {
164
+ return undefined;
165
+ }
166
+ if (
167
+ fields?.inferenceProfileExpiresAt != null &&
168
+ fields.inferenceProfileExpiresAt <= Date.now()
169
+ ) {
170
+ return undefined;
171
+ }
172
+ return fields?.inferenceProfile ?? undefined;
159
173
  },
160
174
  provenanceFromTrustContext: () => ({
161
175
  source: "user",
@@ -536,20 +550,11 @@ afterAll(() => {
536
550
 
537
551
  describe("runAgentLoopImpl — per-conversation inferenceProfile", () => {
538
552
  test("non-background conversation with inferenceProfile threads it through as overrideProfile", async () => {
539
- mockConversationRow = {
540
- id: "conv-1",
553
+ const captured: CapturedAgentLoopRun[] = [];
554
+ const ctx = makeCtx(captured, {
541
555
  conversationType: "standard",
542
556
  inferenceProfile: "quality-optimized",
543
- contextSummary: null,
544
- contextCompactedMessageCount: 0,
545
- totalInputTokens: 0,
546
- totalOutputTokens: 0,
547
- totalEstimatedCost: 0,
548
- title: null,
549
- };
550
-
551
- const captured: CapturedAgentLoopRun[] = [];
552
- const ctx = makeCtx(captured);
557
+ });
553
558
 
554
559
  await runAgentLoopImpl(ctx, "hello", "msg-1", () => {});
555
560
 
@@ -559,21 +564,12 @@ describe("runAgentLoopImpl — per-conversation inferenceProfile", () => {
559
564
  }
560
565
  });
561
566
 
562
- test("background conversation ignores inferenceProfile column", async () => {
563
- mockConversationRow = {
564
- id: "conv-1",
567
+ test("background conversation ignores inferenceProfile", async () => {
568
+ const captured: CapturedAgentLoopRun[] = [];
569
+ const ctx = makeCtx(captured, {
565
570
  conversationType: "background",
566
571
  inferenceProfile: "quality-optimized",
567
- contextSummary: null,
568
- contextCompactedMessageCount: 0,
569
- totalInputTokens: 0,
570
- totalOutputTokens: 0,
571
- totalEstimatedCost: 0,
572
- title: null,
573
- };
574
-
575
- const captured: CapturedAgentLoopRun[] = [];
576
- const ctx = makeCtx(captured);
572
+ });
577
573
 
578
574
  await runAgentLoopImpl(ctx, "hello", "msg-1", () => {});
579
575
 
@@ -583,22 +579,9 @@ describe("runAgentLoopImpl — per-conversation inferenceProfile", () => {
583
579
  }
584
580
  });
585
581
 
586
- test("absence of inferenceProfile column behaves identically to today (no override)", async () => {
587
- // `mockConversationRow` already defaults to inferenceProfile: null.
588
- // Also explicitly cover the case where the column is missing entirely.
589
- mockConversationRow = {
590
- id: "conv-1",
591
- conversationType: "standard",
592
- contextSummary: null,
593
- contextCompactedMessageCount: 0,
594
- totalInputTokens: 0,
595
- totalOutputTokens: 0,
596
- totalEstimatedCost: 0,
597
- title: null,
598
- };
599
-
582
+ test("absence of inferenceProfile behaves identically to today (no override)", async () => {
600
583
  const captured: CapturedAgentLoopRun[] = [];
601
- const ctx = makeCtx(captured);
584
+ const ctx = makeCtx(captured, { conversationType: "standard" });
602
585
 
603
586
  await runAgentLoopImpl(ctx, "hello", "msg-1", () => {});
604
587
 
@@ -608,26 +591,17 @@ describe("runAgentLoopImpl — per-conversation inferenceProfile", () => {
608
591
  }
609
592
  });
610
593
 
611
- test("explicit options.overrideProfile takes precedence over the column read", async () => {
594
+ test("explicit options.overrideProfile takes precedence over the conversation override", async () => {
612
595
  // Subagent path: SubagentManager forwards the parent's pinned profile
613
596
  // into the spawned (background) conversation's runAgentLoop call via
614
597
  // `options.overrideProfile`. The agent loop must respect that even
615
- // though the subagent's own conversation row is `background` (which
598
+ // though the subagent's own conversation is `background` (which
616
599
  // would otherwise zero out the override per the rule above).
617
- mockConversationRow = {
618
- id: "conv-1",
600
+ const captured: CapturedAgentLoopRun[] = [];
601
+ const ctx = makeCtx(captured, {
619
602
  conversationType: "background",
620
603
  inferenceProfile: null,
621
- contextSummary: null,
622
- contextCompactedMessageCount: 0,
623
- totalInputTokens: 0,
624
- totalOutputTokens: 0,
625
- totalEstimatedCost: 0,
626
- title: null,
627
- };
628
-
629
- const captured: CapturedAgentLoopRun[] = [];
630
- const ctx = makeCtx(captured);
604
+ });
631
605
 
632
606
  await runAgentLoopImpl(ctx, "hello", "msg-1", () => {}, {
633
607
  overrideProfile: "fast",
@@ -640,27 +614,15 @@ describe("runAgentLoopImpl — per-conversation inferenceProfile", () => {
640
614
  });
641
615
 
642
616
  test("re-resolves inferenceProfile when a tool changes it mid-turn", async () => {
643
- mockConversationRow = {
644
- id: "conv-1",
617
+ const captured: CapturedAgentLoopRun[] = [];
618
+ const ctx = makeCtx(captured, {
645
619
  conversationType: "standard",
646
620
  inferenceProfile: null,
647
- contextSummary: null,
648
- contextCompactedMessageCount: 0,
649
- totalInputTokens: 0,
650
- totalOutputTokens: 0,
651
- totalEstimatedCost: 0,
652
- title: null,
653
- };
621
+ });
654
622
  mutateBeforeResolveOverrideProfile = () => {
655
- mockConversationRow = {
656
- ...mockConversationRow!,
657
- inferenceProfile: "quality-optimized",
658
- };
623
+ ctx.inferenceProfile = "quality-optimized";
659
624
  };
660
625
 
661
- const captured: CapturedAgentLoopRun[] = [];
662
- const ctx = makeCtx(captured);
663
-
664
626
  await runAgentLoopImpl(ctx, "hello", "msg-1", () => {});
665
627
 
666
628
  expect(captured.length).toBeGreaterThan(0);
@@ -202,7 +202,7 @@ mock.module("../memory/conversation-crud.js", () => ({
202
202
  updateMessageMetadata: () => {},
203
203
  setLastNotifiedInferenceProfile: () => {},
204
204
  getLastUserTimestampBefore: () => 0,
205
- getConversationOverrideProfileFromRow: () => undefined,
205
+ resolveOverrideProfile: () => undefined,
206
206
  reserveMessage: mock(async () => ({ id: "msg-reserve" })),
207
207
  }));
208
208
 
@@ -9,7 +9,7 @@ mock.module("../util/logger.js", () => ({
9
9
  import {
10
10
  createConversation,
11
11
  getConversation,
12
- getConversationOverrideProfileFromRow,
12
+ resolveOverrideProfile,
13
13
  setConversationInferenceProfile,
14
14
  setConversationInferenceProfileSession,
15
15
  } from "../memory/conversation-crud.js";
@@ -55,7 +55,7 @@ describe("setConversationInferenceProfile", () => {
55
55
  });
56
56
  });
57
57
 
58
- describe("getConversationOverrideProfileFromRow — lazy expiry check", () => {
58
+ describe("resolveOverrideProfile — lazy expiry check", () => {
59
59
  beforeEach(() => {
60
60
  const db = getDb();
61
61
  db.run(`DELETE FROM messages`);
@@ -73,7 +73,7 @@ describe("getConversationOverrideProfileFromRow — lazy expiry check", () => {
73
73
  );
74
74
  const row = getConversation(conv.id);
75
75
  expect(row).not.toBeNull();
76
- expect(getConversationOverrideProfileFromRow(row)).toBeUndefined();
76
+ expect(resolveOverrideProfile(row)).toBeUndefined();
77
77
  });
78
78
 
79
79
  test("returns the profile when inferenceProfileExpiresAt is in the future", () => {
@@ -86,7 +86,7 @@ describe("getConversationOverrideProfileFromRow — lazy expiry check", () => {
86
86
  );
87
87
  const row = getConversation(conv.id);
88
88
  expect(row).not.toBeNull();
89
- expect(getConversationOverrideProfileFromRow(row)).toBe("balanced");
89
+ expect(resolveOverrideProfile(row)).toBe("balanced");
90
90
  });
91
91
 
92
92
  test("returns undefined at the exact-expiry boundary (expiresAt === now)", () => {
@@ -110,7 +110,7 @@ describe("getConversationOverrideProfileFromRow — lazy expiry check", () => {
110
110
  const realNow = Date.now;
111
111
  Date.now = () => now;
112
112
  try {
113
- expect(getConversationOverrideProfileFromRow(row)).toBeUndefined();
113
+ expect(resolveOverrideProfile(row)).toBeUndefined();
114
114
  } finally {
115
115
  Date.now = realNow;
116
116
  }
@@ -126,9 +126,7 @@ describe("getConversationOverrideProfileFromRow — lazy expiry check", () => {
126
126
  );
127
127
  const row = getConversation(conv.id);
128
128
  expect(row).not.toBeNull();
129
- expect(getConversationOverrideProfileFromRow(row)).toBe(
130
- "quality-optimized",
131
- );
129
+ expect(resolveOverrideProfile(row)).toBe("quality-optimized");
132
130
  });
133
131
 
134
132
  test.each<"background" | "scheduled">(["background", "scheduled"])(
@@ -148,7 +146,7 @@ describe("getConversationOverrideProfileFromRow — lazy expiry check", () => {
148
146
  expect(row).not.toBeNull();
149
147
  expect(row?.conversationType).toBe(conversationType);
150
148
  expect(row?.inferenceProfile).toBe("quality-optimized");
151
- expect(getConversationOverrideProfileFromRow(row)).toBeUndefined();
149
+ expect(resolveOverrideProfile(row)).toBeUndefined();
152
150
  },
153
151
  );
154
152
  });
@@ -2,6 +2,8 @@ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
4
4
 
5
+ import { eq } from "drizzle-orm";
6
+
5
7
  // This test exercises v1 PKB injection. `config.memory.v2.enabled` (default
6
8
  // `true`) makes the PKB injector go silent — force it off here so the v1
7
9
  // injection chain assertions stay meaningful.
@@ -75,8 +77,11 @@ import {
75
77
  import type { SurfaceData, SurfaceType } from "../daemon/message-protocol.js";
76
78
  import { buildPkbReminder } from "../daemon/pkb-reminder-builder.js";
77
79
  import type { MessageRow } from "../memory/conversation-crud.js";
80
+ import { getDb } from "../memory/db-connection.js";
81
+ import { initializeDb } from "../memory/db-init.js";
78
82
  import { ConversationGraphMemory } from "../memory/graph/conversation-graph-memory.js";
79
83
  import { getPkbRoot } from "../memory/pkb/types.js";
84
+ import { conversations, messages } from "../memory/schema.js";
80
85
  import {
81
86
  type SlackMessageMetadata,
82
87
  writeSlackMetadata,
@@ -92,6 +97,11 @@ import { getSubagentManager } from "../subagent/index.js";
92
97
  import type { SubagentState } from "../subagent/types.js";
93
98
  import { getWorkspacePromptPath } from "../util/platform.js";
94
99
 
100
+ // `applyRuntimeInjections` self-resolves the Slack active-thread focus block by
101
+ // reading the live conversation's persisted message rows, so the schema must
102
+ // exist before any Slack-channel assembly test runs.
103
+ initializeDb();
104
+
95
105
  // The pkb-reminder injector derives PKB-active state from the workspace itself
96
106
  // — `readPkbContext()` returning content behind the personal-memory trust gate
97
107
  // — rather than from a threaded flag. Tests that need the reminder to fire seed
@@ -2732,7 +2742,18 @@ describe("applyRuntimeInjections — PKB relevance hints", () => {
2732
2742
  // ---------------------------------------------------------------------------
2733
2743
 
2734
2744
  describe("Slack channel chronological rendering — multi-thread", () => {
2735
- afterEach(clearConversations);
2745
+ afterEach(() => {
2746
+ clearConversations();
2747
+ // The focus-block tests persist rows under the runtime-assembly fallback
2748
+ // conversation; clear them so later tests resolve against an empty DB.
2749
+ const db = getDb();
2750
+ db.delete(messages)
2751
+ .where(eq(messages.conversationId, "runtime-assembly-fallback"))
2752
+ .run();
2753
+ db.delete(conversations)
2754
+ .where(eq(conversations.id, "runtime-assembly-fallback"))
2755
+ .run();
2756
+ });
2736
2757
 
2737
2758
  // Slack ts values are seconds-since-epoch with microsecond precision.
2738
2759
  // Pick a few stable anchors so thread aliases (sha-derived) stay
@@ -3718,10 +3739,72 @@ describe("Slack channel chronological rendering — multi-thread", () => {
3718
3739
  // interleaved. The block is non-persisted: replays / re-injections strip
3719
3740
  // any prior `<active_thread>` blocks via `RUNTIME_INJECTION_PREFIXES`.
3720
3741
 
3721
- // Re-run a Slack-channel turn through the public assembly path with the
3722
- // active-thread focus block plumbed in (mirrors production wiring in
3723
- // conversation-agent-loop.ts).
3724
- async function runSlackChannelAssemblyWithFocus(rows: MessageRow[]): Promise<{
3742
+ interface SeededCompactionState {
3743
+ slackContextCompactionWatermarkTs?: string | null;
3744
+ contextCompactedMessageCount?: number;
3745
+ }
3746
+
3747
+ // Persist the Slack rows under the runtime-assembly fallback conversation and
3748
+ // mirror the channel capabilities + trust class + compaction state onto its
3749
+ // live registry entry, so `applyRuntimeInjections` self-resolves the
3750
+ // `<active_thread>` focus block from the conversation's stored rows — the same
3751
+ // way it does in production, where the injector reads the live conversation
3752
+ // rather than receiving a pre-built block.
3753
+ function seedSlackChannelConversationWithRows(
3754
+ caps: ChannelCapabilities,
3755
+ rows: MessageRow[],
3756
+ compaction: SeededCompactionState = {},
3757
+ ): void {
3758
+ const db = getDb();
3759
+ const now = Date.now();
3760
+ db.delete(messages)
3761
+ .where(eq(messages.conversationId, "runtime-assembly-fallback"))
3762
+ .run();
3763
+ db.insert(conversations)
3764
+ .values({
3765
+ id: "runtime-assembly-fallback",
3766
+ createdAt: now,
3767
+ updatedAt: now,
3768
+ })
3769
+ .run();
3770
+ if (rows.length > 0) {
3771
+ db.insert(messages)
3772
+ .values(
3773
+ rows.map((r) => ({
3774
+ id: r.id,
3775
+ conversationId: "runtime-assembly-fallback",
3776
+ role: r.role,
3777
+ content: r.content,
3778
+ createdAt: r.createdAt,
3779
+ metadata: r.metadata,
3780
+ })),
3781
+ )
3782
+ .run();
3783
+ }
3784
+ setConversation("runtime-assembly-fallback", {
3785
+ conversationId: "runtime-assembly-fallback",
3786
+ workingDir: "/sandbox",
3787
+ workspaceTopLevelContext: "",
3788
+ workspaceTopLevelDirty: false,
3789
+ surfaceState: new Map(),
3790
+ channelCapabilities: caps,
3791
+ trustContext: { trustClass: "guardian" },
3792
+ slackContextCompactionWatermarkTs:
3793
+ compaction.slackContextCompactionWatermarkTs ?? null,
3794
+ contextCompactedMessageCount:
3795
+ compaction.contextCompactedMessageCount ?? 0,
3796
+ } as never);
3797
+ }
3798
+
3799
+ // Re-run a Slack-channel turn through the public assembly path. The
3800
+ // active-thread focus block self-resolves inside `applyRuntimeInjections`
3801
+ // from the seeded live conversation; `focusBlock` resolves it the same way
3802
+ // (default DB loader, guardian trust + the seeded compaction state) for the
3803
+ // expected-content assertions.
3804
+ async function runSlackChannelAssemblyWithFocus(
3805
+ rows: MessageRow[],
3806
+ compaction: SeededCompactionState = {},
3807
+ ): Promise<{
3725
3808
  messages: Message[];
3726
3809
  focusBlock: string | null;
3727
3810
  }> {
@@ -3733,23 +3816,27 @@ describe("Slack channel chronological rendering — multi-thread", () => {
3733
3816
  chatType: "channel",
3734
3817
  };
3735
3818
  const slackChronologicalMessages = loadSlackChronologicalMessages(
3736
- "conv-1",
3819
+ "runtime-assembly-fallback",
3737
3820
  slackChannelCaps,
3738
3821
  { loader: () => rows, trustClass: "guardian" },
3739
3822
  );
3823
+ seedSlackChannelConversationWithRows(slackChannelCaps, rows, compaction);
3740
3824
  const focusBlock = loadSlackActiveThreadFocusBlock(
3741
- "conv-1",
3825
+ "runtime-assembly-fallback",
3742
3826
  slackChannelCaps,
3743
- { loader: () => rows, trustClass: "guardian" },
3827
+ {
3828
+ trustClass: "guardian",
3829
+ contextCompactedMessageCount: compaction.contextCompactedMessageCount,
3830
+ slackContextCompactionWatermarkTs:
3831
+ compaction.slackContextCompactionWatermarkTs,
3832
+ },
3744
3833
  );
3745
3834
  const lastUserMessage: Message = {
3746
3835
  role: "user",
3747
3836
  content: [{ type: "text", text: "current turn" }],
3748
3837
  };
3749
- seedChannelCapabilitiesConversation(slackChannelCaps);
3750
3838
  const { messages } = await applyRuntimeInjections([lastUserMessage], {
3751
3839
  slackChronologicalMessages,
3752
- slackActiveThreadFocusBlock: focusBlock,
3753
3840
  });
3754
3841
  return { messages, focusBlock };
3755
3842
  }
@@ -3966,14 +4053,143 @@ describe("Slack channel chronological rendering — multi-thread", () => {
3966
4053
  }
3967
4054
  });
3968
4055
 
4056
+ test("focus block self-resolves the live conversation's compaction watermark", async () => {
4057
+ /**
4058
+ * The watermark + compacted count live on the live `Conversation`, not in
4059
+ * the injection options, so `applyRuntimeInjections` must read them off the
4060
+ * live conversation to bound the self-resolved `<active_thread>` block.
4061
+ */
4062
+ // GIVEN a thread whose earliest rows were folded into a prior compaction
4063
+ const rows: MessageRow[] = [
4064
+ userRow({
4065
+ id: "thread-root",
4066
+ createdAt: 1700000000_000,
4067
+ text: "compacted root",
4068
+ slackMeta: buildSlackMeta({
4069
+ channelTs: T0,
4070
+ threadTs: T0,
4071
+ displayName: "alice",
4072
+ }),
4073
+ }),
4074
+ userRow({
4075
+ id: "reply-before",
4076
+ createdAt: 1700000005_000,
4077
+ text: "compacted reply",
4078
+ slackMeta: buildSlackMeta({
4079
+ channelTs: T0_REPLY1,
4080
+ threadTs: T0,
4081
+ displayName: "bob",
4082
+ }),
4083
+ }),
4084
+ userRow({
4085
+ id: "reply-after",
4086
+ createdAt: 1700000020_000,
4087
+ text: "live reply",
4088
+ slackMeta: buildSlackMeta({
4089
+ channelTs: T0_REPLY2,
4090
+ threadTs: T0,
4091
+ displayName: "carol",
4092
+ }),
4093
+ }),
4094
+ ];
4095
+
4096
+ // WHEN the live conversation carries a Slack compaction watermark at T1
4097
+ const { messages, focusBlock } = await runSlackChannelAssemblyWithFocus(
4098
+ rows,
4099
+ {
4100
+ slackContextCompactionWatermarkTs: T1,
4101
+ contextCompactedMessageCount: 2,
4102
+ },
4103
+ );
4104
+
4105
+ // THEN the self-resolved focus block keeps only the post-watermark reply
4106
+ expect(focusBlock).not.toBeNull();
4107
+ expect(focusBlock!).toContain("live reply");
4108
+ expect(focusBlock!).not.toContain("compacted root");
4109
+ expect(focusBlock!).not.toContain("compacted reply");
4110
+ // AND `applyRuntimeInjections` injected exactly that self-resolved block,
4111
+ // proving it read the watermark + count off the live conversation.
4112
+ const allText = messages
4113
+ .flatMap((m) => m.content)
4114
+ .filter((b): b is { type: "text"; text: string } => b.type === "text")
4115
+ .map((b) => b.text)
4116
+ .join("\n");
4117
+ expect(allText).toContain("<active_thread>");
4118
+ expect(allText).toContain(focusBlock!);
4119
+ });
4120
+
4121
+ test("focus block bounds by the compacted count when there is no watermark", async () => {
4122
+ /**
4123
+ * Conversations compacted before the Slack watermark migration have no
4124
+ * watermark, so the raw `contextCompactedMessageCount` is the sole boundary
4125
+ * the self-resolved focus block uses to drop already-compacted rows.
4126
+ */
4127
+ // GIVEN a thread whose first two rows were folded into a prior compaction
4128
+ const rows: MessageRow[] = [
4129
+ userRow({
4130
+ id: "thread-root",
4131
+ createdAt: 1700000000_000,
4132
+ text: "compacted root",
4133
+ slackMeta: buildSlackMeta({
4134
+ channelTs: T0,
4135
+ threadTs: T0,
4136
+ displayName: "alice",
4137
+ }),
4138
+ }),
4139
+ userRow({
4140
+ id: "reply-before",
4141
+ createdAt: 1700000005_000,
4142
+ text: "compacted reply",
4143
+ slackMeta: buildSlackMeta({
4144
+ channelTs: T0_REPLY1,
4145
+ threadTs: T0,
4146
+ displayName: "bob",
4147
+ }),
4148
+ }),
4149
+ userRow({
4150
+ id: "reply-after",
4151
+ createdAt: 1700000020_000,
4152
+ text: "live reply",
4153
+ slackMeta: buildSlackMeta({
4154
+ channelTs: T0_REPLY2,
4155
+ threadTs: T0,
4156
+ displayName: "carol",
4157
+ }),
4158
+ }),
4159
+ ];
4160
+
4161
+ // WHEN the live conversation has a compacted count of 2 and no watermark
4162
+ const { messages, focusBlock } = await runSlackChannelAssemblyWithFocus(
4163
+ rows,
4164
+ {
4165
+ slackContextCompactionWatermarkTs: null,
4166
+ contextCompactedMessageCount: 2,
4167
+ },
4168
+ );
4169
+
4170
+ // THEN the self-resolved focus block drops the two compacted rows and keeps
4171
+ // only the live reply
4172
+ expect(focusBlock).not.toBeNull();
4173
+ expect(focusBlock!).toContain("live reply");
4174
+ expect(focusBlock!).not.toContain("compacted root");
4175
+ expect(focusBlock!).not.toContain("compacted reply");
4176
+ const allText = messages
4177
+ .flatMap((m) => m.content)
4178
+ .filter((b): b is { type: "text"; text: string } => b.type === "text")
4179
+ .map((b) => b.text)
4180
+ .join("\n");
4181
+ expect(allText).toContain("<active_thread>");
4182
+ expect(allText).toContain(focusBlock!);
4183
+ });
4184
+
3969
4185
  test("focus block is dropped when injection is replayed (rebuilds re-derive it)", async () => {
3970
4186
  // Defensive: the `<active_thread>` block is a per-turn injection. When
3971
4187
  // overflow recovery / compaction re-runs `applyRuntimeInjections` on
3972
4188
  // already-injected messages, prior `<active_thread>` blocks must be
3973
4189
  // stripped so the rebuild's freshly-derived block is the only one
3974
4190
  // present. We simulate by building a Slack channel turn, then
3975
- // running the strip pipeline + applying injections again with a
3976
- // different focus block to confirm no duplication occurs.
4191
+ // running the strip pipeline + applying injections again to confirm the
4192
+ // rebuild re-derives the block and no duplication occurs.
3977
4193
  const rows: MessageRow[] = [
3978
4194
  userRow({
3979
4195
  id: "m1",
@@ -4006,20 +4222,10 @@ describe("Slack channel chronological rendering — multi-thread", () => {
4006
4222
  .join("\n");
4007
4223
  expect(strippedTexts).not.toContain("<active_thread>");
4008
4224
 
4009
- // Re-run injection with a fresh focus block — only ONE
4225
+ // Re-run injection on the stripped messages — the conversation is still
4226
+ // seeded, so the rebuild re-derives the focus block; only ONE
4010
4227
  // `<active_thread>` block must end up in the result.
4011
- const slackChannelCaps: ChannelCapabilities = {
4012
- channel: "slack",
4013
- dashboardCapable: false,
4014
- supportsDynamicUi: false,
4015
- supportsVoiceInput: false,
4016
- chatType: "channel",
4017
- };
4018
- const newFocus = "<active_thread>\nnewly built\n</active_thread>";
4019
- seedChannelCapabilitiesConversation(slackChannelCaps);
4020
- const { messages: reInjected } = await applyRuntimeInjections(stripped, {
4021
- slackActiveThreadFocusBlock: newFocus,
4022
- });
4228
+ const { messages: reInjected } = await applyRuntimeInjections(stripped, {});
4023
4229
  const reInjectedTexts = reInjected
4024
4230
  .flatMap((m) => m.content)
4025
4231
  .filter((b): b is { type: "text"; text: string } => b.type === "text")
@@ -4030,13 +4236,13 @@ describe("Slack channel chronological rendering — multi-thread", () => {
4030
4236
  expect(blockCount).toBe(1);
4031
4237
  expect(
4032
4238
  reInjectedTexts.find((t) => t.startsWith("<active_thread>")),
4033
- ).toContain("newly built");
4239
+ ).toContain("Reply in thread A");
4034
4240
  });
4035
4241
 
4036
- test("non-slack conversations ignore slackActiveThreadFocusBlock", async () => {
4037
- // Defensive: the focus injection is gated on `slackChannel` (i.e.
4038
- // `isSlackChannelConversation`). Even if a caller mistakenly forwards
4039
- // a focus block on a non-Slack channel, it must NOT be appended.
4242
+ test("non-slack conversations never get an active-thread focus block", async () => {
4243
+ // The focus block is gated on a Slack *channel* conversation
4244
+ // (`isSlackChannelConversation`). A non-Slack conversation must never
4245
+ // self-resolve an `<active_thread>` block.
4040
4246
  seedChannelCapabilitiesConversation({
4041
4247
  channel: "vellum",
4042
4248
  dashboardCapable: true,
@@ -4045,9 +4251,7 @@ describe("Slack channel chronological rendering — multi-thread", () => {
4045
4251
  });
4046
4252
  const { messages: result } = await applyRuntimeInjections(
4047
4253
  [{ role: "user", content: [{ type: "text", text: "vellum question" }] }],
4048
- {
4049
- slackActiveThreadFocusBlock: "<active_thread>\nbogus\n</active_thread>",
4050
- },
4254
+ {},
4051
4255
  );
4052
4256
  const allText = result
4053
4257
  .flatMap((m) => m.content)
@@ -4058,7 +4262,7 @@ describe("Slack channel chronological rendering — multi-thread", () => {
4058
4262
  expect(allText).toContain("vellum question");
4059
4263
  });
4060
4264
 
4061
- test("slack DMs ignore slackActiveThreadFocusBlock", async () => {
4265
+ test("slack DMs never get an active-thread focus block", async () => {
4062
4266
  // Same as above but for Slack DMs (chatType === "im"). The focus
4063
4267
  // injection is keyed on `isSlackChannelConversation` which excludes
4064
4268
  // DMs, so the block must not appear.
@@ -4071,9 +4275,7 @@ describe("Slack channel chronological rendering — multi-thread", () => {
4071
4275
  });
4072
4276
  const { messages: result } = await applyRuntimeInjections(
4073
4277
  [{ role: "user", content: [{ type: "text", text: "DM question" }] }],
4074
- {
4075
- slackActiveThreadFocusBlock: "<active_thread>\nbogus\n</active_thread>",
4076
- },
4278
+ {},
4077
4279
  );
4078
4280
  const allText = result
4079
4281
  .flatMap((m) => m.content)
@@ -144,7 +144,7 @@ mock.module("../memory/conversation-crud.js", () => ({
144
144
  getMessageById: () => null,
145
145
  getLastUserTimestampBefore: () => 0,
146
146
  setLastNotifiedInferenceProfile: () => {},
147
- getConversationOverrideProfileFromRow: () => undefined,
147
+ resolveOverrideProfile: () => undefined,
148
148
  updateMessageMetadata: () => {},
149
149
  reserveMessage: mock(async () => ({ id: "msg-reserve" })),
150
150
  updateMessageContent: mock(() => {}),
@@ -141,7 +141,7 @@ mock.module("../memory/conversation-crud.js", () => ({
141
141
  getMessageById: () => null,
142
142
  getLastUserTimestampBefore: () => 0,
143
143
  setLastNotifiedInferenceProfile: () => {},
144
- getConversationOverrideProfileFromRow: () => undefined,
144
+ resolveOverrideProfile: () => undefined,
145
145
  updateMessageMetadata: () => {},
146
146
  reserveMessage: mock(async () => ({ id: "msg-reserve" })),
147
147
  updateMessageContent: mock(() => {}),