@vellumai/assistant 0.10.0-dev.202606192330.7061672 → 0.10.0-dev.202606200318.c052d10
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__/conversation-lifecycle.test.ts +117 -0
- package/src/__tests__/conversation-runtime-assembly.test.ts +27 -0
- package/src/daemon/conversation-runtime-assembly.ts +72 -15
- package/src/daemon/conversation.ts +33 -0
- package/src/memory/__tests__/memory-retrospective-job.test.ts +6 -33
- package/src/memory/conversation-crud.ts +7 -0
- package/src/memory/memory-retrospective-job.ts +7 -9
- package/src/memory/v2/__tests__/backfill-jobs.test.ts +7 -4
- package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit.ts +14 -0
- package/src/runtime/agent-wake.ts +0 -10
package/package.json
CHANGED
|
@@ -860,6 +860,123 @@ describe("loadFromDb metadata injection rehydration", () => {
|
|
|
860
860
|
]);
|
|
861
861
|
});
|
|
862
862
|
|
|
863
|
+
test("rehydration order matches live assembly for background-turn / channel-capabilities / non-interactive-context", async () => {
|
|
864
|
+
// A background/scheduled source's live turns inject three extra blocks that
|
|
865
|
+
// the metadata persist layer now captures so a reloaded or forked
|
|
866
|
+
// conversation (memory retrospective) reproduces them byte-for-byte.
|
|
867
|
+
// Live assembly lands them at:
|
|
868
|
+
// - `<background_turn>` — prepend-user-tail injector order 15, between
|
|
869
|
+
// `<workspace>` (10) and `<turn_context>` (20).
|
|
870
|
+
// - `<channel_capabilities>` — Step-3 prepend, just below `<turn_context>`
|
|
871
|
+
// and above the after-memory region.
|
|
872
|
+
// - `<non_interactive_context>` — Step-3 APPEND, the very last block.
|
|
873
|
+
// Expected layout (cf. live `applyRuntimeInjections`):
|
|
874
|
+
// [<workspace>, <background_turn>, <turn_context>, <channel_capabilities>,
|
|
875
|
+
// <memory>dynamic</memory>, <info>v2static</info>, <memory>v3</memory>,
|
|
876
|
+
// <NOW.md>, <system_reminder>, <knowledge_base>, ...original,
|
|
877
|
+
// <non_interactive_context>]
|
|
878
|
+
// Rehydration must reproduce this or a background-source fork busts the
|
|
879
|
+
// message-tier prefix cache at the first divergent block.
|
|
880
|
+
mockConversation = defaultConv();
|
|
881
|
+
mockDbMessages = [
|
|
882
|
+
{
|
|
883
|
+
id: "m1",
|
|
884
|
+
role: "user",
|
|
885
|
+
content: JSON.stringify([{ type: "text", text: "First turn" }]),
|
|
886
|
+
metadata: JSON.stringify({
|
|
887
|
+
workspaceBlock: "<workspace>\nworkspace body\n</workspace>",
|
|
888
|
+
backgroundTurnBlock: "<background_turn>\nbg body\n</background_turn>",
|
|
889
|
+
turnContextBlock: "<turn_context>\nctx payload\n</turn_context>",
|
|
890
|
+
channelCapabilitiesBlock:
|
|
891
|
+
"<channel_capabilities>\nchannel: vellum\n</channel_capabilities>",
|
|
892
|
+
memoryInjectedBlock: "mem payload",
|
|
893
|
+
memoryV2StaticBlock:
|
|
894
|
+
"<info>\n## Essentials\n\nAlice prefers VS Code.\n</info>",
|
|
895
|
+
memoryV3InjectedBlock:
|
|
896
|
+
"header line\n\n# memory/concepts/page-a.md\nhead a",
|
|
897
|
+
nowScratchpadBlock: "<NOW.md>\nnow body\n</NOW.md>",
|
|
898
|
+
pkbSystemReminderBlock:
|
|
899
|
+
"<system_reminder>\npkb reminder body\n</system_reminder>",
|
|
900
|
+
pkbContextBlock: "<knowledge_base>\nkb body\n</knowledge_base>",
|
|
901
|
+
nonInteractiveContextBlock:
|
|
902
|
+
"<non_interactive_context>\nno human present\n</non_interactive_context>",
|
|
903
|
+
}),
|
|
904
|
+
},
|
|
905
|
+
{
|
|
906
|
+
id: "m2",
|
|
907
|
+
role: "assistant",
|
|
908
|
+
content: JSON.stringify([{ type: "text", text: "Reply" }]),
|
|
909
|
+
},
|
|
910
|
+
{
|
|
911
|
+
id: "m3",
|
|
912
|
+
role: "user",
|
|
913
|
+
content: JSON.stringify([{ type: "text", text: "Tail" }]),
|
|
914
|
+
},
|
|
915
|
+
];
|
|
916
|
+
|
|
917
|
+
const conversation = makeConversation();
|
|
918
|
+
await conversation.loadFromDb();
|
|
919
|
+
const messages = conversation.getMessages();
|
|
920
|
+
|
|
921
|
+
expect(messages).toHaveLength(3);
|
|
922
|
+
expect(messages[0].content).toEqual([
|
|
923
|
+
{ type: "text", text: "<workspace>\nworkspace body\n</workspace>" },
|
|
924
|
+
{ type: "text", text: "<background_turn>\nbg body\n</background_turn>" },
|
|
925
|
+
{ type: "text", text: "<turn_context>\nctx payload\n</turn_context>" },
|
|
926
|
+
{
|
|
927
|
+
type: "text",
|
|
928
|
+
text: "<channel_capabilities>\nchannel: vellum\n</channel_capabilities>",
|
|
929
|
+
},
|
|
930
|
+
{ type: "text", text: "<memory>\nmem payload\n</memory>" },
|
|
931
|
+
{
|
|
932
|
+
type: "text",
|
|
933
|
+
text: "<info>\n## Essentials\n\nAlice prefers VS Code.\n</info>",
|
|
934
|
+
},
|
|
935
|
+
{
|
|
936
|
+
type: "text",
|
|
937
|
+
text: "<memory>\nheader line\n\n# memory/concepts/page-a.md\nhead a\n</memory>",
|
|
938
|
+
},
|
|
939
|
+
{ type: "text", text: "<NOW.md>\nnow body\n</NOW.md>" },
|
|
940
|
+
{
|
|
941
|
+
type: "text",
|
|
942
|
+
text: "<system_reminder>\npkb reminder body\n</system_reminder>",
|
|
943
|
+
},
|
|
944
|
+
{ type: "text", text: "<knowledge_base>\nkb body\n</knowledge_base>" },
|
|
945
|
+
{ type: "text", text: "First turn" },
|
|
946
|
+
{
|
|
947
|
+
type: "text",
|
|
948
|
+
text: "<non_interactive_context>\nno human present\n</non_interactive_context>",
|
|
949
|
+
},
|
|
950
|
+
]);
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
test("tail user row skips background-turn / channel-capabilities / non-interactive-context", async () => {
|
|
954
|
+
// The tail row re-injects these fresh next turn, so rehydration must skip
|
|
955
|
+
// them on the tail — mirroring `<turn_context>` / `<workspace>`.
|
|
956
|
+
mockConversation = defaultConv();
|
|
957
|
+
mockDbMessages = [
|
|
958
|
+
{
|
|
959
|
+
id: "m1",
|
|
960
|
+
role: "user",
|
|
961
|
+
content: JSON.stringify([{ type: "text", text: "Tail" }]),
|
|
962
|
+
metadata: JSON.stringify({
|
|
963
|
+
backgroundTurnBlock: "<background_turn>\nbg body\n</background_turn>",
|
|
964
|
+
channelCapabilitiesBlock:
|
|
965
|
+
"<channel_capabilities>\nchannel: vellum\n</channel_capabilities>",
|
|
966
|
+
nonInteractiveContextBlock:
|
|
967
|
+
"<non_interactive_context>\nno human present\n</non_interactive_context>",
|
|
968
|
+
}),
|
|
969
|
+
},
|
|
970
|
+
];
|
|
971
|
+
|
|
972
|
+
const conversation = makeConversation();
|
|
973
|
+
await conversation.loadFromDb();
|
|
974
|
+
const messages = conversation.getMessages();
|
|
975
|
+
|
|
976
|
+
expect(messages).toHaveLength(1);
|
|
977
|
+
expect(messages[0].content).toEqual([{ type: "text", text: "Tail" }]);
|
|
978
|
+
});
|
|
979
|
+
|
|
863
980
|
test("untrusted-actor view does not rehydrate memoryV2StaticBlock", async () => {
|
|
864
981
|
mockConversation = defaultConv();
|
|
865
982
|
// Rows with `trusted_contact` / `unknown` provenance survive the
|
|
@@ -88,6 +88,7 @@ import {
|
|
|
88
88
|
loadSlackActiveThreadFocusBlock,
|
|
89
89
|
loadSlackChronologicalContext,
|
|
90
90
|
loadSlackChronologicalMessages,
|
|
91
|
+
NON_INTERACTIVE_CONTEXT_BLOCK,
|
|
91
92
|
resolveChannelCapabilities,
|
|
92
93
|
resolveTurnInboundActorContext,
|
|
93
94
|
resolveTurnModelProfileLabel,
|
|
@@ -941,6 +942,32 @@ describe("applyRuntimeInjections — injection mode", () => {
|
|
|
941
942
|
expect(allText).toContain("<knowledge_base>");
|
|
942
943
|
});
|
|
943
944
|
|
|
945
|
+
test("captures background-turn / channel-capabilities / non-interactive-context for metadata persistence", async () => {
|
|
946
|
+
// These three blocks must be captured into `blocks` so persistInjectionBlocks
|
|
947
|
+
// writes them to message metadata and loadFromDb rehydrates them on a
|
|
948
|
+
// reload/fork (background-source memory retrospective cache parity — see the
|
|
949
|
+
// rehydration-order tests in conversation-lifecycle.test.ts). background-turn
|
|
950
|
+
// fires only for a background/scheduled live conversation, so register one
|
|
951
|
+
// under the turn's conversation id.
|
|
952
|
+
setConversation("injection-mode-conv", {
|
|
953
|
+
conversationId: "injection-mode-conv",
|
|
954
|
+
workingDir: "/sandbox",
|
|
955
|
+
workspaceTopLevelContext: "",
|
|
956
|
+
workspaceTopLevelDirty: false,
|
|
957
|
+
surfaceState: new Map(),
|
|
958
|
+
channelCapabilities,
|
|
959
|
+
conversationType: "background",
|
|
960
|
+
} as never);
|
|
961
|
+
|
|
962
|
+
const { blocks } = await applyRuntimeInjections(baseMessages, fullOptions);
|
|
963
|
+
|
|
964
|
+
expect(blocks.backgroundTurnBlock).toContain("<background_turn>");
|
|
965
|
+
expect(blocks.channelCapabilitiesBlock).toContain("<channel_capabilities>");
|
|
966
|
+
expect(blocks.nonInteractiveContextBlock).toBe(
|
|
967
|
+
NON_INTERACTIVE_CONTEXT_BLOCK,
|
|
968
|
+
);
|
|
969
|
+
});
|
|
970
|
+
|
|
944
971
|
test("explicit mode: 'full' behaves the same as default", async () => {
|
|
945
972
|
const { messages: result } = await applyRuntimeInjections(baseMessages, {
|
|
946
973
|
...fullOptions,
|
|
@@ -681,13 +681,14 @@ export function stripNowScratchpad(messages: Message[]): Message[] {
|
|
|
681
681
|
}
|
|
682
682
|
|
|
683
683
|
/**
|
|
684
|
-
*
|
|
685
|
-
*
|
|
684
|
+
* Build the `<channel_capabilities>` block text for the given capabilities, or
|
|
685
|
+
* `null` on the happy path (desktop, full capabilities, no special context)
|
|
686
|
+
* where no block is injected. Split from {@link injectChannelCapabilityContext}
|
|
687
|
+
* so callers can capture the exact injected text for metadata persistence.
|
|
686
688
|
*/
|
|
687
|
-
export function
|
|
688
|
-
message: Message,
|
|
689
|
+
export function buildChannelCapabilityBlock(
|
|
689
690
|
caps: ChannelCapabilities,
|
|
690
|
-
):
|
|
691
|
+
): string | null {
|
|
691
692
|
// Happy path: desktop with full capabilities and no special context — skip injection.
|
|
692
693
|
if (
|
|
693
694
|
caps.dashboardCapable &&
|
|
@@ -696,7 +697,7 @@ export function injectChannelCapabilityContext(
|
|
|
696
697
|
!isGroupChatType(caps.chatType) &&
|
|
697
698
|
caps.clientOS !== "macos"
|
|
698
699
|
) {
|
|
699
|
-
return
|
|
700
|
+
return null;
|
|
700
701
|
}
|
|
701
702
|
|
|
702
703
|
const lines: string[] = ["<channel_capabilities>"];
|
|
@@ -769,7 +770,16 @@ export function injectChannelCapabilityContext(
|
|
|
769
770
|
|
|
770
771
|
lines.push("</channel_capabilities>");
|
|
771
772
|
|
|
772
|
-
|
|
773
|
+
return lines.join("\n");
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** Prepend the `<channel_capabilities>` block (if any) to a user message. */
|
|
777
|
+
export function injectChannelCapabilityContext(
|
|
778
|
+
message: Message,
|
|
779
|
+
caps: ChannelCapabilities,
|
|
780
|
+
): Message {
|
|
781
|
+
const block = buildChannelCapabilityBlock(caps);
|
|
782
|
+
if (block === null) return message;
|
|
773
783
|
return {
|
|
774
784
|
...message,
|
|
775
785
|
content: [{ type: "text", text: block }, ...message.content],
|
|
@@ -1512,6 +1522,14 @@ export function loadSlackActiveThreadFocusBlock(
|
|
|
1512
1522
|
*/
|
|
1513
1523
|
export type InjectionMode = "full" | "minimal";
|
|
1514
1524
|
|
|
1525
|
+
/**
|
|
1526
|
+
* The `<non_interactive_context>` block appended on non-interactive turns.
|
|
1527
|
+
* Module-scoped so `applyRuntimeInjections` both injects it and captures the
|
|
1528
|
+
* exact text for metadata persistence from a single source of truth.
|
|
1529
|
+
*/
|
|
1530
|
+
export const NON_INTERACTIVE_CONTEXT_BLOCK =
|
|
1531
|
+
"<non_interactive_context>\nNon-interactive scheduled task — do not ask for clarification or confirmation. Follow the instructions exactly using your best judgment. If recalled memory contains conflicting notes, prefer the explicit instruction in this message.\n</non_interactive_context>";
|
|
1532
|
+
|
|
1515
1533
|
/**
|
|
1516
1534
|
* Per-turn injection bytes captured so `loadFromDb` can rehydrate historical
|
|
1517
1535
|
* user messages byte-for-byte after a daemon restart or conversation
|
|
@@ -1527,6 +1545,27 @@ export interface RuntimeInjectionBlocks {
|
|
|
1527
1545
|
nowScratchpadBlock?: string;
|
|
1528
1546
|
pkbContextBlock?: string;
|
|
1529
1547
|
memoryV2StaticBlock?: string;
|
|
1548
|
+
/**
|
|
1549
|
+
* The `<background_turn>` block the `background-turn` default injector
|
|
1550
|
+
* attached this turn (background/scheduled non-interactive turns only).
|
|
1551
|
+
* Persisted under `metadata.backgroundTurnBlock` so `loadFromDb` rehydrates
|
|
1552
|
+
* it byte-for-byte on reload/fork — without it, a fork of a background
|
|
1553
|
+
* source (memory retrospective) drops the block and breaks message-tier
|
|
1554
|
+
* prefix-cache parity with the source's live turns.
|
|
1555
|
+
*/
|
|
1556
|
+
backgroundTurnBlock?: string;
|
|
1557
|
+
/**
|
|
1558
|
+
* The `<channel_capabilities>` block injected this turn (non-default channel
|
|
1559
|
+
* capabilities). Persisted under `metadata.channelCapabilitiesBlock` for the
|
|
1560
|
+
* same reload/fork cache-parity reason as {@link backgroundTurnBlock}.
|
|
1561
|
+
*/
|
|
1562
|
+
channelCapabilitiesBlock?: string;
|
|
1563
|
+
/**
|
|
1564
|
+
* The `<non_interactive_context>` block injected this turn (non-interactive
|
|
1565
|
+
* turns only). Persisted under `metadata.nonInteractiveContextBlock` for the
|
|
1566
|
+
* same reload/fork cache-parity reason as {@link backgroundTurnBlock}.
|
|
1567
|
+
*/
|
|
1568
|
+
nonInteractiveContextBlock?: string;
|
|
1530
1569
|
/**
|
|
1531
1570
|
* UNWRAPPED inner text of the memory-v3 frozen net-new card block the v3
|
|
1532
1571
|
* injector attached this turn, mirroring v2's unwrapped `memoryInjectedBlock`
|
|
@@ -2108,6 +2147,9 @@ export async function applyRuntimeInjections(
|
|
|
2108
2147
|
let pkbSystemReminderCaptured: string | undefined;
|
|
2109
2148
|
let memoryV2StaticCaptured: string | undefined;
|
|
2110
2149
|
let memoryV3Captured: string | undefined;
|
|
2150
|
+
let backgroundTurnCaptured: string | undefined;
|
|
2151
|
+
let channelCapabilitiesCaptured: string | undefined;
|
|
2152
|
+
let nonInteractiveContextCaptured: string | undefined;
|
|
2111
2153
|
const initialTail = runMessages[runMessages.length - 1];
|
|
2112
2154
|
const initialTailIsUser = !!initialTail && initialTail.role === "user";
|
|
2113
2155
|
if (initialTailIsUser) {
|
|
@@ -2131,6 +2173,9 @@ export async function applyRuntimeInjections(
|
|
|
2131
2173
|
case "memory-v2-static":
|
|
2132
2174
|
memoryV2StaticCaptured = block.text;
|
|
2133
2175
|
break;
|
|
2176
|
+
case "background-turn":
|
|
2177
|
+
backgroundTurnCaptured = block.text;
|
|
2178
|
+
break;
|
|
2134
2179
|
case MEMORY_V3_BLOCK_ID: {
|
|
2135
2180
|
// The v3 frozen card block is persisted UNWRAPPED (the v2
|
|
2136
2181
|
// `memoryInjectedBlock` contract — rehydration re-wraps on use).
|
|
@@ -2260,16 +2305,14 @@ export async function applyRuntimeInjections(
|
|
|
2260
2305
|
if (isNonInteractive) {
|
|
2261
2306
|
const userTail = result[result.length - 1];
|
|
2262
2307
|
if (userTail && userTail.role === "user") {
|
|
2308
|
+
nonInteractiveContextCaptured = NON_INTERACTIVE_CONTEXT_BLOCK;
|
|
2263
2309
|
result = [
|
|
2264
2310
|
...result.slice(0, -1),
|
|
2265
2311
|
{
|
|
2266
2312
|
...userTail,
|
|
2267
2313
|
content: [
|
|
2268
2314
|
...userTail.content,
|
|
2269
|
-
{
|
|
2270
|
-
type: "text" as const,
|
|
2271
|
-
text: "<non_interactive_context>\nNon-interactive scheduled task — do not ask for clarification or confirmation. Follow the instructions exactly using your best judgment. If recalled memory contains conflicting notes, prefer the explicit instruction in this message.\n</non_interactive_context>",
|
|
2272
|
-
},
|
|
2315
|
+
{ type: "text" as const, text: NON_INTERACTIVE_CONTEXT_BLOCK },
|
|
2273
2316
|
],
|
|
2274
2317
|
},
|
|
2275
2318
|
];
|
|
@@ -2310,10 +2353,21 @@ export async function applyRuntimeInjections(
|
|
|
2310
2353
|
if (channelCapabilities) {
|
|
2311
2354
|
const userTail = result[result.length - 1];
|
|
2312
2355
|
if (userTail && userTail.role === "user") {
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2356
|
+
const channelCapabilityBlock =
|
|
2357
|
+
buildChannelCapabilityBlock(channelCapabilities);
|
|
2358
|
+
if (channelCapabilityBlock !== null) {
|
|
2359
|
+
channelCapabilitiesCaptured = channelCapabilityBlock;
|
|
2360
|
+
result = [
|
|
2361
|
+
...result.slice(0, -1),
|
|
2362
|
+
{
|
|
2363
|
+
...userTail,
|
|
2364
|
+
content: [
|
|
2365
|
+
{ type: "text" as const, text: channelCapabilityBlock },
|
|
2366
|
+
...userTail.content,
|
|
2367
|
+
],
|
|
2368
|
+
},
|
|
2369
|
+
];
|
|
2370
|
+
}
|
|
2317
2371
|
}
|
|
2318
2372
|
}
|
|
2319
2373
|
|
|
@@ -2372,6 +2426,9 @@ export async function applyRuntimeInjections(
|
|
|
2372
2426
|
pkbContextBlock: pkbContextCaptured,
|
|
2373
2427
|
memoryV2StaticBlock: memoryV2StaticCaptured,
|
|
2374
2428
|
memoryV3InjectedBlock: memoryV3Captured,
|
|
2429
|
+
backgroundTurnBlock: backgroundTurnCaptured,
|
|
2430
|
+
channelCapabilitiesBlock: channelCapabilitiesCaptured,
|
|
2431
|
+
nonInteractiveContextBlock: nonInteractiveContextCaptured,
|
|
2375
2432
|
memoryV3Active,
|
|
2376
2433
|
injectorChainBlock,
|
|
2377
2434
|
},
|
|
@@ -976,6 +976,18 @@ export class Conversation {
|
|
|
976
976
|
const meta = JSON.parse(m.metadata);
|
|
977
977
|
const isTail = index === arr.length - 1;
|
|
978
978
|
|
|
979
|
+
// `<non_interactive_context>` is the only rehydrated block that
|
|
980
|
+
// APPENDS to the tail (live injection appends it in Step 3), so it
|
|
981
|
+
// must land after the original content. Apply it first — before the
|
|
982
|
+
// prepends below — so the prepends stack in front of it and it stays
|
|
983
|
+
// last, matching the live layout.
|
|
984
|
+
if (!isTail && typeof meta.nonInteractiveContextBlock === "string") {
|
|
985
|
+
content = [
|
|
986
|
+
...content,
|
|
987
|
+
{ type: "text" as const, text: meta.nonInteractiveContextBlock },
|
|
988
|
+
];
|
|
989
|
+
}
|
|
990
|
+
|
|
979
991
|
// Rehydrate in reverse injection order (innermost block first)
|
|
980
992
|
// so the resulting layout matches `applyRuntimeInjections`'s
|
|
981
993
|
// after-memory-prefix splices in ascending injector order
|
|
@@ -1086,6 +1098,17 @@ export class Conversation {
|
|
|
1086
1098
|
];
|
|
1087
1099
|
}
|
|
1088
1100
|
|
|
1101
|
+
// `<channel_capabilities>` lands just below `<turn_context>`: live
|
|
1102
|
+
// injection prepends it (Step 3) before the prepend-user-tail chain
|
|
1103
|
+
// blocks (Step 4), so it must be prepended BEFORE turnContextBlock
|
|
1104
|
+
// here to land one slot deeper than `<turn_context>`.
|
|
1105
|
+
if (!isTail && typeof meta.channelCapabilitiesBlock === "string") {
|
|
1106
|
+
content = [
|
|
1107
|
+
{ type: "text" as const, text: meta.channelCapabilitiesBlock },
|
|
1108
|
+
...content,
|
|
1109
|
+
];
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1089
1112
|
if (!isTail && typeof meta.turnContextBlock === "string") {
|
|
1090
1113
|
content = [
|
|
1091
1114
|
{ type: "text" as const, text: meta.turnContextBlock },
|
|
@@ -1093,6 +1116,16 @@ export class Conversation {
|
|
|
1093
1116
|
];
|
|
1094
1117
|
}
|
|
1095
1118
|
|
|
1119
|
+
// `<background_turn>` lands between `<workspace>` and `<turn_context>`
|
|
1120
|
+
// (injector order 15, between workspace 10 and unified-turn-context
|
|
1121
|
+
// 20), so prepend it AFTER turnContextBlock and BEFORE workspaceBlock.
|
|
1122
|
+
if (!isTail && typeof meta.backgroundTurnBlock === "string") {
|
|
1123
|
+
content = [
|
|
1124
|
+
{ type: "text" as const, text: meta.backgroundTurnBlock },
|
|
1125
|
+
...content,
|
|
1126
|
+
];
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1096
1129
|
if (!isTail && typeof meta.workspaceBlock === "string") {
|
|
1097
1130
|
content = [
|
|
1098
1131
|
{ type: "text" as const, text: meta.workspaceBlock },
|
|
@@ -685,39 +685,12 @@ describe("memoryRetrospectiveJob", () => {
|
|
|
685
685
|
expect(wakeCalls[0]!.opts.toolGateMode).toBe("execution");
|
|
686
686
|
});
|
|
687
687
|
|
|
688
|
-
//
|
|
689
|
-
//
|
|
690
|
-
//
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
source: "heartbeat",
|
|
695
|
-
forkParentMessageId: null,
|
|
696
|
-
title: "Heartbeat",
|
|
697
|
-
conversationType: "background",
|
|
698
|
-
};
|
|
699
|
-
|
|
700
|
-
const outcome = await memoryRetrospectiveJob(makeJob(), stubConfig);
|
|
701
|
-
|
|
702
|
-
expect(outcome.kind).toBe("invoked");
|
|
703
|
-
expect(wakeCalls).toHaveLength(1);
|
|
704
|
-
expect(wakeCalls[0]!.opts.isNonInteractive).toBe(true);
|
|
705
|
-
});
|
|
706
|
-
|
|
707
|
-
test("standard source → wake stays interactive (no spurious <background_turn>)", async () => {
|
|
708
|
-
conversationOverrides["src-conv-1"] = {
|
|
709
|
-
source: "user",
|
|
710
|
-
forkParentMessageId: null,
|
|
711
|
-
title: "Source conversation",
|
|
712
|
-
conversationType: "standard",
|
|
713
|
-
};
|
|
714
|
-
|
|
715
|
-
const outcome = await memoryRetrospectiveJob(makeJob(), stubConfig);
|
|
716
|
-
|
|
717
|
-
expect(outcome.kind).toBe("invoked");
|
|
718
|
-
expect(wakeCalls).toHaveLength(1);
|
|
719
|
-
expect(wakeCalls[0]!.opts.isNonInteractive).toBe(false);
|
|
720
|
-
});
|
|
688
|
+
// Source background-turn cache parity is no longer a wake-time concern: the
|
|
689
|
+
// fork reproduces the source's `<background_turn>` / `<channel_capabilities>`
|
|
690
|
+
// / `<non_interactive_context>` blocks via metadata rehydration in
|
|
691
|
+
// `Conversation.loadFromDb` (the wake never re-runs runtime injection). That
|
|
692
|
+
// round-trip is covered by the byte-parity test in
|
|
693
|
+
// `conversation-runtime-assembly.test.ts`.
|
|
721
694
|
|
|
722
695
|
// -------------------------------------------------------------------------
|
|
723
696
|
// Source-derived persona override
|
|
@@ -148,6 +148,13 @@ export const messageMetadataSchema = z
|
|
|
148
148
|
nowScratchpadBlock: z.string().optional(),
|
|
149
149
|
pkbContextBlock: z.string().optional(),
|
|
150
150
|
memoryV2StaticBlock: z.string().optional(),
|
|
151
|
+
/** `<background_turn>` block (background/scheduled non-interactive turns),
|
|
152
|
+
* rehydrated by `loadFromDb` for reload/fork prefix-cache parity. */
|
|
153
|
+
backgroundTurnBlock: z.string().optional(),
|
|
154
|
+
/** `<channel_capabilities>` block, rehydrated for the same reason. */
|
|
155
|
+
channelCapabilitiesBlock: z.string().optional(),
|
|
156
|
+
/** `<non_interactive_context>` block, rehydrated for the same reason. */
|
|
157
|
+
nonInteractiveContextBlock: z.string().optional(),
|
|
151
158
|
})
|
|
152
159
|
.passthrough();
|
|
153
160
|
|
|
@@ -65,7 +65,6 @@ import {
|
|
|
65
65
|
getMessagesAfter,
|
|
66
66
|
resolveOverrideProfile,
|
|
67
67
|
} from "./conversation-crud.js";
|
|
68
|
-
import { isBackgroundConversationType } from "./conversation-types.js";
|
|
69
68
|
import {
|
|
70
69
|
enqueueMemoryJob,
|
|
71
70
|
type MemoryJob,
|
|
@@ -314,14 +313,13 @@ async function runForkBasedRetrospective(
|
|
|
314
313
|
// {@link WakeToolContextPin}.
|
|
315
314
|
toolGateMode: "execution" as const,
|
|
316
315
|
toolContextPin,
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
// the fork
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
),
|
|
316
|
+
// Message-tier cache-prefix parity — reproducing the source's
|
|
317
|
+
// `<background_turn>` / `<channel_capabilities>` / `<non_interactive_context>`
|
|
318
|
+
// blocks — is handled by metadata rehydration, not by re-running runtime
|
|
319
|
+
// injection on the fork: the source's live turns persist those blocks onto
|
|
320
|
+
// message metadata, the fork copies that metadata, and
|
|
321
|
+
// `Conversation.loadFromDb` rehydrates them byte-for-byte. The wake never
|
|
322
|
+
// re-runs the injection pipeline, so it needs no interactivity hint here.
|
|
325
323
|
// Profile forcing (model/thinking/effort parity) is a separate concern
|
|
326
324
|
// and stays keyed on `matchConversationProfile` via `matchedProfile`.
|
|
327
325
|
...(matchedProfile !== undefined
|
|
@@ -203,9 +203,8 @@ afterAll(() => {
|
|
|
203
203
|
});
|
|
204
204
|
|
|
205
205
|
const { getDb } = await import("../../db-connection.js");
|
|
206
|
-
const { resetDbForTesting } =
|
|
207
|
-
"../../../__tests__/db-test-helpers.js"
|
|
208
|
-
);
|
|
206
|
+
const { resetDbForTesting } =
|
|
207
|
+
await import("../../../__tests__/db-test-helpers.js");
|
|
209
208
|
const { initializeDb } = await import("../../db-init.js");
|
|
210
209
|
const { rawExec } = await import("../../raw-query.js");
|
|
211
210
|
const { conversations, memoryJobs, messages } = await import("../../schema.js");
|
|
@@ -249,6 +248,10 @@ function makeJob(
|
|
|
249
248
|
};
|
|
250
249
|
}
|
|
251
250
|
|
|
251
|
+
// The first `initializeDb()` in a fresh test process builds the migration
|
|
252
|
+
// template by running every step; later calls restore it by file copy. That
|
|
253
|
+
// cold build can exceed bun's default 5s hook timeout under CI load, so give
|
|
254
|
+
// the hook generous headroom.
|
|
252
255
|
beforeEach(() => {
|
|
253
256
|
resetDbForTesting();
|
|
254
257
|
initializeDb();
|
|
@@ -282,7 +285,7 @@ beforeEach(() => {
|
|
|
282
285
|
|
|
283
286
|
migrationCalls.length = 0;
|
|
284
287
|
migrationOutcome = { type: "ok" };
|
|
285
|
-
});
|
|
288
|
+
}, 30_000);
|
|
286
289
|
|
|
287
290
|
// ---------------------------------------------------------------------------
|
|
288
291
|
// memoryV2MigrateJob
|
|
@@ -194,6 +194,9 @@ function persistInjectionBlocks(
|
|
|
194
194
|
!blocks.pkbContextBlock &&
|
|
195
195
|
!blocks.memoryV2StaticBlock &&
|
|
196
196
|
!blocks.memoryV3InjectedBlock &&
|
|
197
|
+
!blocks.backgroundTurnBlock &&
|
|
198
|
+
!blocks.channelCapabilitiesBlock &&
|
|
199
|
+
!blocks.nonInteractiveContextBlock &&
|
|
197
200
|
!removeV2Block
|
|
198
201
|
) {
|
|
199
202
|
return;
|
|
@@ -229,6 +232,17 @@ function persistInjectionBlocks(
|
|
|
229
232
|
if (blocks.memoryV2StaticBlock) {
|
|
230
233
|
metadataUpdates.memoryV2StaticBlock = blocks.memoryV2StaticBlock;
|
|
231
234
|
}
|
|
235
|
+
if (blocks.backgroundTurnBlock) {
|
|
236
|
+
metadataUpdates.backgroundTurnBlock = blocks.backgroundTurnBlock;
|
|
237
|
+
}
|
|
238
|
+
if (blocks.channelCapabilitiesBlock) {
|
|
239
|
+
metadataUpdates.channelCapabilitiesBlock =
|
|
240
|
+
blocks.channelCapabilitiesBlock;
|
|
241
|
+
}
|
|
242
|
+
if (blocks.nonInteractiveContextBlock) {
|
|
243
|
+
metadataUpdates.nonInteractiveContextBlock =
|
|
244
|
+
blocks.nonInteractiveContextBlock;
|
|
245
|
+
}
|
|
232
246
|
updateMessageMetadata(ctx.userMessageId, metadataUpdates);
|
|
233
247
|
} catch (err) {
|
|
234
248
|
ctx.logger.warn(
|
|
@@ -196,14 +196,6 @@ export interface WakeOptions {
|
|
|
196
196
|
* retrospectives whose conversation title already says "(Retrospective)").
|
|
197
197
|
*/
|
|
198
198
|
suppressWakeSurface?: boolean;
|
|
199
|
-
/**
|
|
200
|
-
* Run the wake's turn as non-interactive (no client present). Threads to the
|
|
201
|
-
* agent loop's `isNonInteractive`, which gates the `<non_interactive_context>`
|
|
202
|
-
* and `<background_turn>` injectors. Fork-based memory retrospectives set this
|
|
203
|
-
* to the source conversation's background-turn state so the fork reproduces
|
|
204
|
-
* the source's injected turn block (prompt-cache prefix parity). Default false.
|
|
205
|
-
*/
|
|
206
|
-
isNonInteractive?: boolean;
|
|
207
199
|
/**
|
|
208
200
|
* Optional exact tool allowlist for this wake. Used by internal maintenance
|
|
209
201
|
* jobs that need the assistant's judgment but must not execute arbitrary
|
|
@@ -596,7 +588,6 @@ export async function wakeAgentForOpportunity(
|
|
|
596
588
|
opts.forceOverrideProfile ??
|
|
597
589
|
getConversationOverrideProfile(conversationId);
|
|
598
590
|
const callSite = opts.callSite ?? "mainAgent";
|
|
599
|
-
const isNonInteractive = opts.isNonInteractive ?? false;
|
|
600
591
|
const config = getConfig();
|
|
601
592
|
const effectiveContextWindow = resolveEffectiveContextWindow({
|
|
602
593
|
llm: config.llm,
|
|
@@ -1144,7 +1135,6 @@ export async function wakeAgentForOpportunity(
|
|
|
1144
1135
|
trust: wakeTrust,
|
|
1145
1136
|
overrideProfile,
|
|
1146
1137
|
forceOverrideProfile,
|
|
1147
|
-
isNonInteractive,
|
|
1148
1138
|
// The wake's compaction lives in the pre-run gate above
|
|
1149
1139
|
// (`conversation.maybeCompact()`), never in the loop: the in-loop
|
|
1150
1140
|
// budget gate and overflow-recovery ladder stay disabled because
|