@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.
- package/package.json +1 -1
- package/src/__tests__/background-workers-disk-pressure.test.ts +1 -1
- package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +1 -1
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +37 -75
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -1
- package/src/__tests__/conversation-crud-inference-profile.test.ts +7 -9
- package/src/__tests__/conversation-runtime-assembly.test.ts +239 -37
- package/src/__tests__/conversation-workspace-injection.test.ts +1 -1
- package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -1
- package/src/__tests__/inference-profile-reaper.test.ts +62 -0
- package/src/__tests__/inference-profile-session-handler.test.ts +86 -0
- package/src/__tests__/injector-background-turn.test.ts +12 -22
- package/src/__tests__/injector-chain.test.ts +6 -0
- package/src/__tests__/injector-disk-pressure.test.ts +6 -0
- package/src/__tests__/injector-document-comments.test.ts +96 -113
- package/src/__tests__/memory-v2-static-injector.test.ts +1 -2
- package/src/agent/loop.ts +15 -0
- package/src/daemon/conversation-agent-loop.ts +31 -48
- package/src/daemon/conversation-runtime-assembly.ts +66 -102
- package/src/daemon/conversation.ts +36 -8
- package/src/memory/conversation-crud.ts +27 -19
- package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +15 -0
- package/src/plugins/defaults/memory-retrieval/injectors.ts +30 -47
- package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +5 -2
- package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +5 -2
- package/src/plugins/defaults/memory-v3-shadow/__tests__/router.test.ts +7 -2
- package/src/plugins/defaults/memory-v3-shadow/__tests__/selector.test.ts +2 -2
- package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +5 -6
- package/src/plugins/defaults/memory-v3-shadow/__tests__/types.test.ts +2 -2
- package/src/plugins/defaults/memory-v3-shadow/assign.ts +5 -1
- package/src/plugins/defaults/memory-v3-shadow/capabilities.ts +4 -1
- package/src/plugins/defaults/memory-v3-shadow/injector.ts +2 -2
- package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +9 -2
- package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +2 -2
- package/src/plugins/defaults/memory-v3-shadow/reconcile.ts +5 -1
- package/src/plugins/defaults/memory-v3-shadow/router.ts +2 -2
- package/src/plugins/defaults/memory-v3-shadow/selector.ts +3 -3
- package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +4 -4
- package/src/plugins/defaults/memory-v3-shadow/types.ts +1 -1
- package/src/plugins/types.ts +39 -56
- package/src/runtime/routes/conversation-routes.ts +5 -0
- package/src/runtime/routes/inference-profile-session-handler.ts +11 -0
- package/src/runtime/routes/inference-profile-session-reaper.ts +6 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
153
|
+
resolveOverrideProfile: (
|
|
154
|
+
fields: {
|
|
155
|
+
conversationType?: string | null;
|
|
156
|
+
inferenceProfile?: string | null;
|
|
157
|
+
inferenceProfileExpiresAt?: number | null;
|
|
158
|
+
} | null,
|
|
155
159
|
) => {
|
|
156
|
-
if (
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
540
|
-
|
|
553
|
+
const captured: CapturedAgentLoopRun[] = [];
|
|
554
|
+
const ctx = makeCtx(captured, {
|
|
541
555
|
conversationType: "standard",
|
|
542
556
|
inferenceProfile: "quality-optimized",
|
|
543
|
-
|
|
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
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
598
|
+
// though the subagent's own conversation is `background` (which
|
|
616
599
|
// would otherwise zero out the override per the rule above).
|
|
617
|
-
|
|
618
|
-
|
|
600
|
+
const captured: CapturedAgentLoopRun[] = [];
|
|
601
|
+
const ctx = makeCtx(captured, {
|
|
619
602
|
conversationType: "background",
|
|
620
603
|
inferenceProfile: null,
|
|
621
|
-
|
|
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
|
-
|
|
644
|
-
|
|
617
|
+
const captured: CapturedAgentLoopRun[] = [];
|
|
618
|
+
const ctx = makeCtx(captured, {
|
|
645
619
|
conversationType: "standard",
|
|
646
620
|
inferenceProfile: null,
|
|
647
|
-
|
|
648
|
-
contextCompactedMessageCount: 0,
|
|
649
|
-
totalInputTokens: 0,
|
|
650
|
-
totalOutputTokens: 0,
|
|
651
|
-
totalEstimatedCost: 0,
|
|
652
|
-
title: null,
|
|
653
|
-
};
|
|
621
|
+
});
|
|
654
622
|
mutateBeforeResolveOverrideProfile = () => {
|
|
655
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
3825
|
+
"runtime-assembly-fallback",
|
|
3742
3826
|
slackChannelCaps,
|
|
3743
|
-
{
|
|
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
|
|
3976
|
-
//
|
|
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
|
|
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
|
|
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("
|
|
4239
|
+
).toContain("Reply in thread A");
|
|
4034
4240
|
});
|
|
4035
4241
|
|
|
4036
|
-
test("non-slack conversations
|
|
4037
|
-
//
|
|
4038
|
-
// `isSlackChannelConversation`).
|
|
4039
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
144
|
+
resolveOverrideProfile: () => undefined,
|
|
145
145
|
updateMessageMetadata: () => {},
|
|
146
146
|
reserveMessage: mock(async () => ({ id: "msg-reserve" })),
|
|
147
147
|
updateMessageContent: mock(() => {}),
|