@vellumai/assistant 0.8.9 → 0.8.10-dev.202606092334.09948c8

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 (161) hide show
  1. package/AGENTS.md +1 -1
  2. package/examples/plugins/echo/README.md +3 -3
  3. package/node_modules/@vellumai/skill-host-contracts/src/tool-types.ts +2 -0
  4. package/openapi.yaml +38 -12
  5. package/package.json +1 -1
  6. package/scripts/sync-llm-catalog.ts +2 -0
  7. package/src/__tests__/agent-loop-exit-reason.test.ts +21 -15
  8. package/src/__tests__/approval-cascade.test.ts +1 -0
  9. package/src/__tests__/build-persisted-content.test.ts +75 -1
  10. package/src/__tests__/compaction-events.test.ts +1 -0
  11. package/src/__tests__/config-managed-gemini-defaults.test.ts +2 -46
  12. package/src/__tests__/context-overflow-reducer.test.ts +264 -124
  13. package/src/__tests__/context-window-manager-overflow-rung.test.ts +351 -0
  14. package/src/__tests__/conversation-abort-tool-results.test.ts +1 -0
  15. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +11 -0
  16. package/src/__tests__/conversation-agent-loop-overflow.test.ts +90 -49
  17. package/src/__tests__/conversation-agent-loop.test.ts +113 -64
  18. package/src/__tests__/conversation-confirmation-signals.test.ts +1 -0
  19. package/src/__tests__/conversation-fork-crud.test.ts +110 -0
  20. package/src/__tests__/conversation-process-callsite.test.ts +1 -0
  21. package/src/__tests__/conversation-provider-retry-repair.test.ts +48 -48
  22. package/src/__tests__/conversation-queue.test.ts +1 -0
  23. package/src/__tests__/conversation-runtime-assembly.test.ts +119 -8
  24. package/src/__tests__/conversation-slash-queue.test.ts +1 -0
  25. package/src/__tests__/conversation-slash-unknown.test.ts +1 -0
  26. package/src/__tests__/conversation-speed-override.test.ts +1 -0
  27. package/src/__tests__/conversation-surfaces-app-control.test.ts +4 -1
  28. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +91 -142
  29. package/src/__tests__/conversation-tool-setup-skill-attribution.test.ts +116 -0
  30. package/src/__tests__/conversation-tool-setup-test-helpers.ts +98 -0
  31. package/src/__tests__/conversation-workspace-cache-state.test.ts +1 -0
  32. package/src/__tests__/conversation-workspace-injection.test.ts +1 -0
  33. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -0
  34. package/src/__tests__/corrected-target.test.ts +93 -0
  35. package/src/__tests__/credential-execution-feature-gates.test.ts +3 -5
  36. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  37. package/src/__tests__/embedding-managed-proxy-selection.test.ts +6 -40
  38. package/src/__tests__/external-plugin-loader.test.ts +0 -1
  39. package/src/__tests__/guardian-routing-invariants.test.ts +91 -0
  40. package/src/__tests__/history-repair-hook.test.ts +2 -0
  41. package/src/__tests__/host-app-control-proxy.test.ts +45 -0
  42. package/src/__tests__/llm-catalog-parity.test.ts +4 -0
  43. package/src/__tests__/memory-retrieval-hook.test.ts +29 -15
  44. package/src/__tests__/notification-decision-strategy.test.ts +3 -3
  45. package/src/__tests__/persistence-secret-redaction.test.ts +78 -0
  46. package/src/__tests__/plugin-bootstrap.test.ts +82 -73
  47. package/src/__tests__/plugin-tool-contribution.test.ts +7 -4
  48. package/src/__tests__/plugin-types.test.ts +0 -8
  49. package/src/__tests__/provider-catalog-visibility.test.ts +1 -9
  50. package/src/__tests__/retry-thinking-adaptive-only.test.ts +245 -0
  51. package/src/__tests__/server-history-render.test.ts +39 -0
  52. package/src/__tests__/skills.test.ts +123 -0
  53. package/src/__tests__/title-generate-hook.test.ts +2 -0
  54. package/src/__tests__/tool-audit-listener.test.ts +51 -0
  55. package/src/__tests__/tool-executor-lifecycle-events.test.ts +32 -0
  56. package/src/__tests__/tool-start-timestamp.test.ts +218 -0
  57. package/src/agent/loop.ts +26 -39
  58. package/src/api/events/tool-result.ts +9 -0
  59. package/src/api/events/tool-use-start.ts +7 -0
  60. package/src/api/responses/conversation-message.ts +91 -14
  61. package/src/approvals/guardian-request-resolvers.ts +26 -0
  62. package/src/cli/commands/channel-verification-sessions.ts +6 -6
  63. package/src/cli/commands/inference-providers.ts +0 -8
  64. package/src/cli/commands/plugins.ts +2 -2
  65. package/src/cli/lib/__tests__/install-from-github.test.ts +174 -18
  66. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +1 -1
  67. package/src/cli/lib/__tests__/plugin-details.test.ts +1 -1
  68. package/src/cli/lib/__tests__/plugin-marketplace.test.ts +1 -1
  69. package/src/cli/lib/__tests__/search-plugins.test.ts +11 -11
  70. package/src/cli/lib/install-from-github.ts +130 -19
  71. package/src/cli/lib/plugin-catalog-cache.ts +1 -1
  72. package/src/cli/lib/plugin-details.ts +4 -4
  73. package/src/cli/lib/plugin-marketplace.ts +2 -2
  74. package/src/cli/lib/search-plugins.ts +6 -6
  75. package/src/config/bundled-skills/skill-management/SKILL.md +89 -1
  76. package/src/config/env-registry.ts +12 -0
  77. package/src/config/feature-flag-registry.json +0 -80
  78. package/src/config/loader.ts +5 -22
  79. package/src/config/seed-inference-profiles.ts +1 -1
  80. package/src/config/skills.ts +101 -47
  81. package/src/context/strip-injections.ts +7 -4
  82. package/src/daemon/conversation-agent-loop-handlers.ts +102 -6
  83. package/src/daemon/conversation-agent-loop.ts +126 -270
  84. package/src/daemon/conversation-runtime-assembly.ts +44 -8
  85. package/src/daemon/conversation-tool-setup.ts +9 -1
  86. package/src/daemon/conversation.ts +13 -0
  87. package/src/daemon/external-plugins-bootstrap.ts +33 -87
  88. package/src/daemon/handlers/config-channels.ts +11 -2
  89. package/src/daemon/handlers/shared.ts +9 -1
  90. package/src/daemon/handlers/skills.ts +6 -2
  91. package/src/daemon/host-app-control-proxy.ts +72 -57
  92. package/src/daemon/message-protocol.ts +0 -7
  93. package/src/daemon/message-types/skills.ts +4 -0
  94. package/src/events/tool-audit-listener.ts +7 -1
  95. package/src/memory/__tests__/tool-invocation-test-helpers.ts +69 -0
  96. package/src/memory/conversation-crud.ts +44 -1
  97. package/src/memory/db-init.ts +4 -0
  98. package/src/memory/embedding-backend.ts +5 -22
  99. package/src/memory/memory-retrospective-startup-cleanup.ts +1 -1
  100. package/src/memory/migrations/275-tool-invocations-add-skill-id.test.ts +81 -0
  101. package/src/memory/migrations/275-tool-invocations-add-skill-id.ts +20 -0
  102. package/src/memory/migrations/276-tool-invocations-created-at-id-index.test.ts +68 -0
  103. package/src/memory/migrations/276-tool-invocations-created-at-id-index.ts +20 -0
  104. package/src/memory/migrations/index.ts +2 -0
  105. package/src/memory/schema/conversations.ts +3 -0
  106. package/src/memory/tool-execution-events-store.test.ts +162 -0
  107. package/src/memory/tool-execution-events-store.ts +73 -0
  108. package/src/memory/tool-usage-store.ts +4 -3
  109. package/src/memory/v2/__tests__/injected-block-slugs.test.ts +51 -0
  110. package/src/memory/v2/activation-store.ts +50 -1
  111. package/src/memory/v2/injected-block-slugs.ts +30 -0
  112. package/src/notifications/__tests__/copy-composer.test.ts +244 -0
  113. package/src/notifications/access-request-copy.ts +298 -0
  114. package/src/notifications/adapters/slack.ts +3 -3
  115. package/src/notifications/adapters/telegram.ts +2 -1
  116. package/src/notifications/copy-composer.ts +49 -267
  117. package/src/notifications/decision-engine.ts +16 -35
  118. package/src/notifications/home-feed-side-effect.ts +1 -6
  119. package/src/platform/client.ts +1 -3
  120. package/src/platform/feature-gate.ts +3 -12
  121. package/src/plugin-api/constants.ts +2 -0
  122. package/src/plugin-api/index.ts +37 -6
  123. package/src/plugin-api/types.ts +75 -0
  124. package/src/plugins/defaults/compaction/compact.ts +39 -1
  125. package/src/plugins/defaults/compaction/context-overflow-reducer.ts +240 -32
  126. package/src/plugins/defaults/compaction/corrected-target.ts +53 -0
  127. package/src/plugins/defaults/compaction/window-manager.ts +195 -1
  128. package/src/plugins/defaults/index.ts +24 -0
  129. package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +76 -112
  130. package/src/plugins/defaults/memory-retrieval/hooks/{user-prompt-submit-temp.ts → user-prompt-submit.ts} +36 -67
  131. package/src/plugins/defaults/memory-retrieval/package.json +14 -0
  132. package/src/plugins/external-api.ts +1 -1
  133. package/src/plugins/types.ts +5 -51
  134. package/src/providers/inference/__tests__/base-url-route-validation.test.ts +1 -2
  135. package/src/providers/model-catalog.ts +57 -1
  136. package/src/providers/registry.ts +0 -8
  137. package/src/providers/retry.ts +35 -7
  138. package/src/runtime/routes/__tests__/acp-routes.test.ts +151 -0
  139. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +0 -62
  140. package/src/runtime/routes/__tests__/plugins-routes.test.ts +4 -4
  141. package/src/runtime/routes/acp-routes.test.ts +106 -0
  142. package/src/runtime/routes/acp-routes.ts +248 -2
  143. package/src/runtime/routes/browser-tabs-routes.ts +1 -1
  144. package/src/runtime/routes/channel-verification-routes.ts +14 -5
  145. package/src/runtime/routes/chatgpt-subscription-auth-routes.ts +0 -14
  146. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +33 -34
  147. package/src/runtime/routes/inference-provider-connection-routes.ts +0 -45
  148. package/src/runtime/routes/plugins-routes.ts +3 -3
  149. package/src/runtime/verification-outbound-actions.ts +147 -2
  150. package/src/runtime/verification-templates.ts +29 -3
  151. package/src/telemetry/types.ts +25 -1
  152. package/src/telemetry/usage-telemetry-reporter.test.ts +141 -7
  153. package/src/telemetry/usage-telemetry-reporter.ts +63 -7
  154. package/src/tools/executor.ts +4 -1
  155. package/src/tools/skills/load.ts +11 -6
  156. package/src/tools/terminal/safe-env.ts +1 -0
  157. package/src/tools/types.ts +10 -0
  158. package/src/__tests__/plugin-skill-contribution.test.ts +0 -355
  159. package/src/daemon/message-types/browser.ts +0 -10
  160. package/src/notifications/__tests__/emit-signal-home-feed.test.ts +0 -187
  161. package/src/plugins/plugin-skill-contributions.ts +0 -292
@@ -114,39 +114,70 @@ mock.module("../context/token-estimator.js", () => ({
114
114
  let mockReducerStepFn:
115
115
  | ((msgs: Message[], cfg: unknown, state: unknown) => unknown)
116
116
  | null = null;
117
+ const makeInitialReducerState = () => ({
118
+ appliedTiers: [] as string[],
119
+ injectionMode: "full" as const,
120
+ exhausted: false,
121
+ });
122
+ const runMockReducer = async (
123
+ msgs: Message[],
124
+ cfg: unknown,
125
+ state: unknown,
126
+ ) => {
127
+ if (mockReducerStepFn) return mockReducerStepFn(msgs, cfg, state);
128
+ return {
129
+ messages: msgs,
130
+ tier: "forced_compaction",
131
+ state: {
132
+ appliedTiers: [
133
+ "forced_compaction",
134
+ "tool_result_truncation",
135
+ "media_stubbing",
136
+ "injection_downgrade",
137
+ ],
138
+ injectionMode: "full",
139
+ exhausted: true,
140
+ },
141
+ estimatedTokens: 1000,
142
+ };
143
+ };
117
144
  mock.module(
118
145
  "../plugins/defaults/compaction/context-overflow-reducer.js",
119
146
  () => ({
120
- createInitialReducerState: () => ({
121
- appliedTiers: [],
122
- injectionMode: "full" as const,
123
- exhausted: false,
124
- }),
125
- reduceContextOverflow: async (
126
- msgs: Message[],
127
- cfg: unknown,
128
- state: unknown,
129
- ) => {
130
- if (mockReducerStepFn) return mockReducerStepFn(msgs, cfg, state);
131
- return {
132
- messages: msgs,
133
- tier: "forced_compaction",
134
- state: {
135
- appliedTiers: [
136
- "forced_compaction",
137
- "tool_result_truncation",
138
- "media_stubbing",
139
- "injection_downgrade",
140
- ],
141
- injectionMode: "full",
142
- exhausted: true,
143
- },
144
- estimatedTokens: 1000,
145
- };
146
- },
147
+ createInitialReducerState: makeInitialReducerState,
148
+ reduceContextOverflow: runMockReducer,
147
149
  }),
148
150
  );
149
151
 
152
+ // Stand-in for `ContextWindowManager`'s turn-scoped overflow ladder. Threads
153
+ // reducer state across a turn's rungs and delegates each rung to the mocked
154
+ // reducer, mirroring `reduceOverflowOneRung` / `resetOverflowRecovery` so the
155
+ // convergence driver exercises the same per-rung escalation the real manager
156
+ // drives.
157
+ function makeOverflowLadderStub(): {
158
+ resetOverflowRecovery: () => void;
159
+ reduceOverflowOneRung: (
160
+ msgs: Message[],
161
+ opts: unknown,
162
+ signal?: AbortSignal,
163
+ ) => Promise<unknown>;
164
+ } {
165
+ let state: unknown;
166
+ return {
167
+ resetOverflowRecovery: () => {
168
+ state = undefined;
169
+ },
170
+ reduceOverflowOneRung: async (msgs: Message[], opts: unknown) => {
171
+ if (!state) state = makeInitialReducerState();
172
+ const step = (await runMockReducer(msgs, opts, state)) as {
173
+ state: unknown;
174
+ };
175
+ state = step.state;
176
+ return step;
177
+ },
178
+ };
179
+ }
180
+
150
181
  // Policy: default to fail_gracefully
151
182
  let mockOverflowAction: string = "fail_gracefully";
152
183
  mock.module("../daemon/context-overflow-policy.js", () => ({
@@ -716,6 +747,16 @@ function makeCtx(
716
747
 
717
748
  ...ctxOverrides,
718
749
  } as unknown as Conversation;
750
+ // The convergence driver resolves the turn-scoped overflow ladder off the
751
+ // manager; give every fake manager the ladder methods unless a test supplied
752
+ // its own.
753
+ const manager = ctx.contextWindowManager as unknown as Record<
754
+ string,
755
+ unknown
756
+ >;
757
+ if (typeof manager.reduceOverflowOneRung !== "function") {
758
+ Object.assign(manager, makeOverflowLadderStub());
759
+ }
719
760
  fakeContextWindowManagers.set(conversationId, ctx.contextWindowManager);
720
761
  return ctx;
721
762
  }
@@ -1710,27 +1751,31 @@ describe("session-agent-loop", () => {
1710
1751
  test("non-interactive auto-compress continues without approval prompt", async () => {
1711
1752
  const events: ServerMessage[] = [];
1712
1753
 
1713
- // Reducer exhausts all tiers
1714
- mockReducerStepFn = (msgs: Message[]) => ({
1715
- messages: msgs,
1716
- tier: "injection_downgrade",
1717
- state: {
1718
- appliedTiers: [
1719
- "forced_compaction",
1720
- "tool_result_truncation",
1721
- "media_stubbing",
1722
- "injection_downgrade",
1723
- ],
1724
- injectionMode: "minimal",
1725
- exhausted: true,
1726
- },
1727
- estimatedTokens: 120000,
1728
- });
1754
+ // The ladder applies a middle tier first, then escalates to its terminal
1755
+ // auto-compress-latest-turn rung (permitted by the overflow policy) and
1756
+ // reports exhaustion — mirroring the reducer owning every rung.
1757
+ let reducerCalls = 0;
1758
+ mockReducerStepFn = (msgs: Message[]) => {
1759
+ reducerCalls++;
1760
+ const terminal = reducerCalls >= 2;
1761
+ return {
1762
+ messages: msgs,
1763
+ tier: terminal ? "auto_compress_latest_turn" : "forced_compaction",
1764
+ state: {
1765
+ appliedTiers: terminal
1766
+ ? ["forced_compaction", "auto_compress_latest_turn"]
1767
+ : ["forced_compaction"],
1768
+ injectionMode: "full",
1769
+ exhausted: terminal,
1770
+ },
1771
+ estimatedTokens: 120000,
1772
+ };
1773
+ };
1729
1774
 
1730
1775
  mockOverflowAction = "auto_compress_latest_turn";
1731
1776
 
1732
1777
  // The provider rejects the first two calls with context-too-large errors,
1733
- // then succeeds after the emergency auto-compress runs.
1778
+ // then succeeds after the terminal auto-compress rung runs.
1734
1779
  const ctx = makeCtx({
1735
1780
  providerResponses: [
1736
1781
  new Error("context_length_exceeded"),
@@ -1785,24 +1830,26 @@ describe("session-agent-loop", () => {
1785
1830
  // attribute the silent stall.
1786
1831
  const events: ServerMessage[] = [];
1787
1832
 
1788
- // Reducer exhausts all 4 tiers on first call so the convergence
1789
- // loop runs exactly one iteration before falling through to
1790
- // the auto_compress_latest_turn branch.
1791
- mockReducerStepFn = (msgs: Message[]) => ({
1792
- messages: msgs,
1793
- tier: "injection_downgrade",
1794
- state: {
1795
- appliedTiers: [
1796
- "forced_compaction",
1797
- "tool_result_truncation",
1798
- "media_stubbing",
1799
- "injection_downgrade",
1800
- ],
1801
- injectionMode: "minimal",
1802
- exhausted: true,
1803
- },
1804
- estimatedTokens: 120_000,
1805
- });
1833
+ // The ladder applies a middle tier, then runs its terminal
1834
+ // auto-compress-latest-turn rung and reports exhaustion. Its rerun is the
1835
+ // one that still yields at the mid-loop budget checkpoint.
1836
+ let reducerCalls = 0;
1837
+ mockReducerStepFn = (msgs: Message[]) => {
1838
+ reducerCalls++;
1839
+ const terminal = reducerCalls >= 2;
1840
+ return {
1841
+ messages: msgs,
1842
+ tier: terminal ? "auto_compress_latest_turn" : "forced_compaction",
1843
+ state: {
1844
+ appliedTiers: terminal
1845
+ ? ["forced_compaction", "auto_compress_latest_turn"]
1846
+ : ["forced_compaction"],
1847
+ injectionMode: "full",
1848
+ exhausted: terminal,
1849
+ },
1850
+ estimatedTokens: 120_000,
1851
+ };
1852
+ };
1806
1853
 
1807
1854
  mockOverflowAction = "auto_compress_latest_turn";
1808
1855
 
@@ -1934,8 +1981,10 @@ describe("session-agent-loop", () => {
1934
1981
 
1935
1982
  await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg));
1936
1983
 
1937
- // maxAttempts is 3 — reducer should be called at most 3 times
1938
- expect(reducerCalls).toBeLessThanOrEqual(3);
1984
+ // A misbehaving reducer that never reports exhaustion is bounded by the
1985
+ // driver's safety backstop: at most the middle-tier budget plus the
1986
+ // emergency and terminal rungs (maxAttempts + 2 = 5).
1987
+ expect(reducerCalls).toBeLessThanOrEqual(5);
1939
1988
  });
1940
1989
  });
1941
1990
 
@@ -174,6 +174,7 @@ mock.module("../plugins/defaults/compaction/window-manager.js", () => ({
174
174
  async maybeCompact() {
175
175
  return { compacted: false };
176
176
  }
177
+ resetOverflowRecovery() {}
177
178
  },
178
179
  createContextSummaryMessage: () => ({
179
180
  role: "user",
@@ -922,6 +922,116 @@ describe("forkConversation", () => {
922
922
  expect(loadGraphMemoryState(fork.id)).toBeNull();
923
923
  });
924
924
 
925
+ test("truncated fork seeds everInjected from inherited memory attachments", async () => {
926
+ const source = createConversation("Truncated seed thread");
927
+ await addMessage(source.id, "user", "first turn", {
928
+ metadata: {
929
+ memoryInjectedBlock:
930
+ "# memory/concepts/topics/page-a.md\nSummary A\n\n# memory/concepts/topics/page-b.md\nSummary B",
931
+ },
932
+ skipIndexing: true,
933
+ });
934
+ await addMessage(source.id, "assistant", "first reply", {
935
+ skipIndexing: true,
936
+ });
937
+ const boundaryMessage = await addMessage(source.id, "user", "second turn", {
938
+ metadata: {
939
+ memoryInjectedBlock: "# memory/concepts/topics/page-c.md\nSummary C",
940
+ },
941
+ skipIndexing: true,
942
+ });
943
+ await addMessage(source.id, "assistant", "second reply", {
944
+ skipIndexing: true,
945
+ });
946
+ // Past the fork boundary — its attachment must NOT be claimed.
947
+ await addMessage(source.id, "user", "third turn", {
948
+ metadata: {
949
+ memoryInjectedBlock: "# memory/concepts/topics/page-d.md\nSummary D",
950
+ },
951
+ skipIndexing: true,
952
+ });
953
+
954
+ const db = getDb();
955
+ db.insert(activationState)
956
+ .values({
957
+ conversationId: source.id,
958
+ messageId: "parent-msg",
959
+ stateJson: JSON.stringify({ "topics/page-d": 0.9 }),
960
+ everInjectedJson: JSON.stringify([
961
+ { slug: "topics/page-a", turn: 1 },
962
+ { slug: "topics/page-b", turn: 1 },
963
+ { slug: "topics/page-c", turn: 2 },
964
+ { slug: "topics/page-d", turn: 3 },
965
+ ]),
966
+ currentTurn: 3,
967
+ updatedAt: 1_700_000_000_000,
968
+ })
969
+ .run();
970
+
971
+ const fork = forkConversation({
972
+ conversationId: source.id,
973
+ throughMessageId: boundaryMessage.id,
974
+ });
975
+
976
+ const childState = await hydrateActivationState(db, fork.id);
977
+ expect(childState).not.toBeNull();
978
+ // Exactly the slugs whose attachments live in the copied history —
979
+ // page-d (injected past the boundary) stays re-injectable.
980
+ expect(childState?.everInjected.map((e) => e.slug).sort()).toEqual([
981
+ "topics/page-a",
982
+ "topics/page-b",
983
+ "topics/page-c",
984
+ ]);
985
+ expect(childState?.everInjected.every((e) => e.turn === 0)).toBe(true);
986
+ // Activation scores and the turn counter start fresh, matching the
987
+ // graph tracker (also not copied for truncated forks).
988
+ expect(childState?.state).toEqual({});
989
+ expect(childState?.currentTurn).toBe(0);
990
+ });
991
+
992
+ test("truncated fork ignores attachments behind an inherited compaction boundary", async () => {
993
+ const source = createConversation("Compacted truncated thread");
994
+ await addMessage(source.id, "user", "compacted turn", {
995
+ metadata: {
996
+ memoryInjectedBlock:
997
+ "# memory/concepts/topics/page-compacted.md\nOld summary",
998
+ },
999
+ skipIndexing: true,
1000
+ });
1001
+ await addMessage(source.id, "assistant", "compacted reply", {
1002
+ skipIndexing: true,
1003
+ });
1004
+ const boundaryMessage = await addMessage(source.id, "user", "live turn", {
1005
+ metadata: {
1006
+ memoryInjectedBlock: "# memory/concepts/topics/page-live.md\nSummary",
1007
+ },
1008
+ skipIndexing: true,
1009
+ });
1010
+ await addMessage(source.id, "assistant", "live reply", {
1011
+ skipIndexing: true,
1012
+ });
1013
+ await addMessage(source.id, "user", "past boundary", {
1014
+ skipIndexing: true,
1015
+ });
1016
+ // First two messages sit behind the compaction boundary: their
1017
+ // attachments are not rendered, so the fork must not claim them.
1018
+ getDb()
1019
+ .update(conversations)
1020
+ .set({ contextCompactedMessageCount: 2 })
1021
+ .where(eq(conversations.id, source.id))
1022
+ .run();
1023
+
1024
+ const fork = forkConversation({
1025
+ conversationId: source.id,
1026
+ throughMessageId: boundaryMessage.id,
1027
+ });
1028
+
1029
+ const childState = await hydrateActivationState(getDb(), fork.id);
1030
+ expect(childState?.everInjected.map((e) => e.slug)).toEqual([
1031
+ "topics/page-live",
1032
+ ]);
1033
+ });
1034
+
925
1035
  test("defaults conversationType to standard and inherits the parent's group", async () => {
926
1036
  const source = createConversation("Default inheritance thread");
927
1037
  await addMessage(source.id, "user", "first message", {
@@ -259,6 +259,7 @@ mock.module("../plugins/defaults/compaction/window-manager.js", () => ({
259
259
  async maybeCompact() {
260
260
  return { compacted: false };
261
261
  }
262
+ resetOverflowRecovery() {}
262
263
  },
263
264
  createContextSummaryMessage: () => ({
264
265
  role: "user",
@@ -96,10 +96,9 @@ mock.module("../context/token-estimator.js", () => ({
96
96
  estimateToolsTokens: () => 0,
97
97
  }));
98
98
 
99
- // Overflow recovery module mocks — the convergence loop delegates to these
100
- // but these tests exercise the Conversation-level flow, not the reducer internals.
101
- // The reducer mock delegates to the compactFn to simulate a forced compaction
102
- // tier, matching the real reducer's behavior for Tier 1.
99
+ // The wrapper's convergence loop seeds its gate-loop reducer state from this
100
+ // factory; the per-rung reduction itself runs through the manager's
101
+ // `reduceOverflowOneRung` (see the ContextWindowManager mock below).
103
102
  mock.module(
104
103
  "../plugins/defaults/compaction/context-overflow-reducer.js",
105
104
  () => ({
@@ -108,49 +107,6 @@ mock.module(
108
107
  injectionMode: "full" as const,
109
108
  exhausted: false,
110
109
  }),
111
- reduceContextOverflow: async (
112
- msgs: Message[],
113
- _cfg: unknown,
114
- _state: unknown,
115
- compactFn?: (
116
- m: Message[],
117
- s: AbortSignal | undefined,
118
- o: Record<string, unknown>,
119
- ) => Promise<{
120
- compacted: boolean;
121
- messages: Message[];
122
- compactedPersistedMessages?: number;
123
- summaryText?: string;
124
- [k: string]: unknown;
125
- }>,
126
- signal?: AbortSignal,
127
- ) => {
128
- let resultMessages = msgs;
129
- let compactionResult;
130
- if (compactFn) {
131
- const cr = await compactFn(msgs, signal, { force: true });
132
- if (cr.compacted) {
133
- resultMessages = cr.messages;
134
- compactionResult = cr;
135
- }
136
- }
137
- return {
138
- messages: resultMessages,
139
- tier: "forced_compaction",
140
- state: {
141
- appliedTiers: [
142
- "forced_compaction",
143
- "tool_result_truncation",
144
- "media_stubbing",
145
- "injection_downgrade",
146
- ],
147
- injectionMode: "full",
148
- exhausted: true,
149
- },
150
- estimatedTokens: 1000,
151
- compactionResult,
152
- };
153
- },
154
110
  }),
155
111
  );
156
112
 
@@ -223,7 +179,10 @@ let forceCompactionEnabled = false;
223
179
 
224
180
  mock.module("../plugins/defaults/compaction/window-manager.js", () => ({
225
181
  ContextWindowManager: class {
226
- constructor() {}
182
+ conversationId: string | undefined;
183
+ constructor(options?: { conversationId?: string }) {
184
+ this.conversationId = options?.conversationId;
185
+ }
227
186
  updateConfig() {}
228
187
  shouldCompact() {
229
188
  return { needed: false, estimatedTokens: 0 };
@@ -253,6 +212,47 @@ mock.module("../plugins/defaults/compaction/window-manager.js", () => ({
253
212
  }
254
213
  return { compacted: false };
255
214
  }
215
+ resetOverflowRecovery() {}
216
+ // Mirrors the real reducer's Tier 1: run forced compaction through
217
+ // defaultCompact (which resolves this same manager from the store) and
218
+ // report the ladder exhausted in one rung.
219
+ async reduceOverflowOneRung(
220
+ msgs: Message[],
221
+ _options: unknown,
222
+ signal?: AbortSignal,
223
+ ) {
224
+ const { defaultCompact } =
225
+ await import("../plugins/defaults/compaction/compact.js");
226
+ const cr = await defaultCompact({
227
+ conversationId: this.conversationId!,
228
+ messages: msgs,
229
+ signal,
230
+ force: true,
231
+ minKeepRecentUserTurns: 0,
232
+ });
233
+ let resultMessages = msgs;
234
+ let compactionResult;
235
+ if (cr.compacted) {
236
+ resultMessages = cr.messages;
237
+ compactionResult = cr;
238
+ }
239
+ return {
240
+ messages: resultMessages,
241
+ tier: "forced_compaction",
242
+ state: {
243
+ appliedTiers: [
244
+ "forced_compaction",
245
+ "tool_result_truncation",
246
+ "media_stubbing",
247
+ "injection_downgrade",
248
+ ],
249
+ injectionMode: "full",
250
+ exhausted: true,
251
+ },
252
+ estimatedTokens: 1000,
253
+ compactionResult,
254
+ };
255
+ }
256
256
  },
257
257
  createContextSummaryMessage: () => ({
258
258
  role: "user",
@@ -272,6 +272,7 @@ mock.module("../plugins/defaults/compaction/window-manager.js", () => ({
272
272
  async maybeCompact() {
273
273
  return { compacted: false };
274
274
  }
275
+ resetOverflowRecovery() {}
275
276
  },
276
277
  createContextSummaryMessage: () => ({
277
278
  role: "user",
@@ -2,6 +2,7 @@ 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 type { PostCompactContext } from "@vellumai/plugin-api";
5
6
  import { eq } from "drizzle-orm";
6
7
 
7
8
  // This test exercises v1 PKB injection. `config.memory.v2.enabled` (default
@@ -64,6 +65,8 @@ mock.module("../daemon/date-context.js", () => ({
64
65
  formatTurnTimestamp: () => FIXED_TURN_TIMESTAMP,
65
66
  }));
66
67
 
68
+ import { resolveCallSiteConfig } from "../config/llm-resolver.js";
69
+ import { LLMSchema } from "../config/schemas/llm.js";
67
70
  import {
68
71
  clearConversations,
69
72
  setConversation,
@@ -87,6 +90,7 @@ import {
87
90
  loadSlackChronologicalMessages,
88
91
  resolveChannelCapabilities,
89
92
  resolveTurnInboundActorContext,
93
+ resolveTurnModelProfileLabel,
90
94
  stripChannelCapabilityContext,
91
95
  stripInjectionsForCompaction,
92
96
  stripNowScratchpad,
@@ -1025,18 +1029,18 @@ describe("applyRuntimeInjections — injection mode", () => {
1025
1029
  // GIVEN the seeded live conversation `injection-mode-conv` whose
1026
1030
  // conversation-scoped blocks (`<active_workspace>`, `<channel_capabilities>`)
1027
1031
  // only resolve when `applyRuntimeInjections` finds it by conversation id
1028
- // AND the agent loop hands the post-compaction hook the turn-identity fields
1029
- // flat (`requestId`, `conversationId`, `trust`)
1030
- const { messages: result } = await postCompact({
1032
+ // AND the agent loop hands the post-compaction hook the irreducible
1033
+ // turn-identity fields flat (`requestId`, `conversationId`), with trust and
1034
+ // actor identity self-resolved by the hook from the live conversation
1035
+ const postCompactCtx: PostCompactContext = {
1031
1036
  history: baseMessages,
1032
1037
  requestId: "reinject-req",
1033
1038
  conversationId: "injection-mode-conv",
1034
- trust: { sourceChannel: "vellum", trustClass: "guardian" },
1035
1039
  isNonInteractive: false,
1036
- mode: "full",
1037
- modelProfile: null,
1038
- actorContext: null,
1039
- });
1040
+ modelProfileKey: null,
1041
+ };
1042
+ await postCompact(postCompactCtx);
1043
+ const result = postCompactCtx.history;
1040
1044
  const allText = result[0].content
1041
1045
  .filter((b): b is { type: "text"; text: string } => b.type === "text")
1042
1046
  .map((b) => b.text)
@@ -1509,6 +1513,113 @@ describe("resolveTurnInboundActorContext", () => {
1509
1513
  });
1510
1514
  });
1511
1515
 
1516
+ // ---------------------------------------------------------------------------
1517
+ // resolveTurnModelProfileLabel
1518
+ // ---------------------------------------------------------------------------
1519
+
1520
+ describe("resolveTurnModelProfileLabel", () => {
1521
+ /**
1522
+ * A null key means the active profile is unchanged since the last notified
1523
+ * one, so there is no `model_profile` line to render this turn.
1524
+ */
1525
+ test("returns null when the profile key is null", () => {
1526
+ // GIVEN a turn whose profile is unchanged since the last notification
1527
+ const llm = LLMSchema.parse({
1528
+ default: { provider: "anthropic", model: "claude-sonnet-4-7" },
1529
+ });
1530
+
1531
+ // WHEN the label is resolved for a null key
1532
+ const label = resolveTurnModelProfileLabel(null, "mainAgent", llm);
1533
+
1534
+ // THEN there is no profile line to inject
1535
+ expect(label).toBeNull();
1536
+ });
1537
+
1538
+ /**
1539
+ * A known profile renders its configured display label paired with the model
1540
+ * the call site resolves to.
1541
+ */
1542
+ test("renders the profile's label with the resolved model", () => {
1543
+ // GIVEN a workspace with a labelled "fast" profile
1544
+ const llm = LLMSchema.parse({
1545
+ default: { provider: "anthropic", model: "claude-sonnet-4-7" },
1546
+ profiles: {
1547
+ fast: { label: "Fast", provider: "anthropic", model: "claude-haiku-4" },
1548
+ },
1549
+ });
1550
+
1551
+ // WHEN the label is resolved for that profile at the main-agent call site
1552
+ const label = resolveTurnModelProfileLabel("fast", "mainAgent", llm);
1553
+
1554
+ // THEN the rendered line pairs the display label with the resolved model
1555
+ expect(label).toBe("Fast (claude-haiku-4)");
1556
+ });
1557
+
1558
+ /**
1559
+ * A profile with no configured label falls back to its raw key so the model
1560
+ * still gets a stable identifier.
1561
+ */
1562
+ test("falls back to the raw key when the profile has no label", () => {
1563
+ // GIVEN a profile that sets a model but no display label
1564
+ const llm = LLMSchema.parse({
1565
+ default: { provider: "anthropic", model: "claude-sonnet-4-7" },
1566
+ profiles: {
1567
+ deep: { provider: "anthropic", model: "claude-opus-4" },
1568
+ },
1569
+ });
1570
+
1571
+ // WHEN the label is resolved for that profile
1572
+ const label = resolveTurnModelProfileLabel("deep", "mainAgent", llm);
1573
+
1574
+ // THEN the raw key stands in for the missing label
1575
+ expect(label).toBe("deep (claude-opus-4)");
1576
+ });
1577
+
1578
+ /**
1579
+ * A key naming a `mix` profile must announce the arm the turn's provider
1580
+ * calls actually run on. The conversation id is threaded as the selection
1581
+ * seed so the announced model is a deterministic function of the
1582
+ * conversation, matching the seeded expansion the provider layer performs.
1583
+ */
1584
+ test("announces the seeded mix arm so it matches the turn's model", () => {
1585
+ // GIVEN a mix profile routing between two models
1586
+ const llm = LLMSchema.parse({
1587
+ default: { provider: "anthropic", model: "claude-sonnet-4-7" },
1588
+ profiles: {
1589
+ a: { label: "Arm A", provider: "anthropic", model: "model-a" },
1590
+ b: { label: "Arm B", provider: "anthropic", model: "model-b" },
1591
+ ab: {
1592
+ label: "AB",
1593
+ mix: [
1594
+ { profile: "a", weight: 50 },
1595
+ { profile: "b", weight: 50 },
1596
+ ],
1597
+ },
1598
+ },
1599
+ });
1600
+
1601
+ // AND the model the provider calls resolve to for this conversation's seed
1602
+ const seededModel = resolveCallSiteConfig("mainAgent", llm, {
1603
+ overrideProfile: "ab",
1604
+ selectionSeed: "conv-seed-1",
1605
+ }).model;
1606
+
1607
+ // WHEN the label is resolved with the same conversation seed
1608
+ const label = resolveTurnModelProfileLabel(
1609
+ "ab",
1610
+ "mainAgent",
1611
+ llm,
1612
+ "conv-seed-1",
1613
+ );
1614
+
1615
+ // THEN the announced model is the seeded arm (deterministic, not re-rolled)
1616
+ expect(label).toBe(`AB (${seededModel})`);
1617
+ expect(
1618
+ resolveTurnModelProfileLabel("ab", "mainAgent", llm, "conv-seed-1"),
1619
+ ).toBe(label);
1620
+ });
1621
+ });
1622
+
1512
1623
  // ---------------------------------------------------------------------------
1513
1624
  // buildUnifiedTurnContextBlock
1514
1625
  // ---------------------------------------------------------------------------
@@ -163,6 +163,7 @@ mock.module("../plugins/defaults/compaction/window-manager.js", () => ({
163
163
  async maybeCompact() {
164
164
  return { compacted: false };
165
165
  }
166
+ resetOverflowRecovery() {}
166
167
  },
167
168
  createContextSummaryMessage: () => ({
168
169
  role: "user",
@@ -164,6 +164,7 @@ mock.module("../plugins/defaults/compaction/window-manager.js", () => ({
164
164
  async maybeCompact() {
165
165
  return { compacted: false };
166
166
  }
167
+ resetOverflowRecovery() {}
167
168
  },
168
169
  createContextSummaryMessage: () => ({
169
170
  role: "user",
@@ -180,6 +180,7 @@ mock.module("../plugins/defaults/compaction/window-manager.js", () => ({
180
180
  async maybeCompact() {
181
181
  return { compacted: false };
182
182
  }
183
+ resetOverflowRecovery() {}
183
184
  },
184
185
  createContextSummaryMessage: () => ({
185
186
  role: "user",