zeitlich 0.2.45 → 0.2.47
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/README.md +137 -11
- package/dist/{activities-Coafq5zr.d.cts → activities-CPwKoUlD.d.cts} +22 -2
- package/dist/{activities-CrN-ghLo.d.ts → activities-DlaBxNID.d.ts} +22 -2
- package/dist/adapters/thread/anthropic/index.cjs +276 -71
- package/dist/adapters/thread/anthropic/index.cjs.map +1 -1
- package/dist/adapters/thread/anthropic/index.d.cts +62 -8
- package/dist/adapters/thread/anthropic/index.d.ts +62 -8
- package/dist/adapters/thread/anthropic/index.js +275 -72
- package/dist/adapters/thread/anthropic/index.js.map +1 -1
- package/dist/adapters/thread/anthropic/workflow.cjs +38 -20
- package/dist/adapters/thread/anthropic/workflow.cjs.map +1 -1
- package/dist/adapters/thread/anthropic/workflow.d.cts +5 -4
- package/dist/adapters/thread/anthropic/workflow.d.ts +5 -4
- package/dist/adapters/thread/anthropic/workflow.js +38 -20
- package/dist/adapters/thread/anthropic/workflow.js.map +1 -1
- package/dist/adapters/thread/google-genai/index.cjs +171 -69
- package/dist/adapters/thread/google-genai/index.cjs.map +1 -1
- package/dist/adapters/thread/google-genai/index.d.cts +6 -4
- package/dist/adapters/thread/google-genai/index.d.ts +6 -4
- package/dist/adapters/thread/google-genai/index.js +171 -69
- package/dist/adapters/thread/google-genai/index.js.map +1 -1
- package/dist/adapters/thread/google-genai/workflow.cjs +38 -20
- package/dist/adapters/thread/google-genai/workflow.cjs.map +1 -1
- package/dist/adapters/thread/google-genai/workflow.d.cts +7 -4
- package/dist/adapters/thread/google-genai/workflow.d.ts +7 -4
- package/dist/adapters/thread/google-genai/workflow.js +38 -20
- package/dist/adapters/thread/google-genai/workflow.js.map +1 -1
- package/dist/adapters/thread/langchain/index.cjs +170 -66
- package/dist/adapters/thread/langchain/index.cjs.map +1 -1
- package/dist/adapters/thread/langchain/index.d.cts +19 -4
- package/dist/adapters/thread/langchain/index.d.ts +19 -4
- package/dist/adapters/thread/langchain/index.js +170 -66
- package/dist/adapters/thread/langchain/index.js.map +1 -1
- package/dist/adapters/thread/langchain/workflow.cjs +38 -20
- package/dist/adapters/thread/langchain/workflow.cjs.map +1 -1
- package/dist/adapters/thread/langchain/workflow.d.cts +5 -4
- package/dist/adapters/thread/langchain/workflow.d.ts +5 -4
- package/dist/adapters/thread/langchain/workflow.js +38 -20
- package/dist/adapters/thread/langchain/workflow.js.map +1 -1
- package/dist/cold-store-BDgJpwLI.d.ts +114 -0
- package/dist/cold-store-Z2wvK2cV.d.cts +114 -0
- package/dist/index.cjs +440 -67
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +150 -8
- package/dist/index.d.ts +150 -8
- package/dist/index.js +432 -68
- package/dist/index.js.map +1 -1
- package/dist/proxy-CDh3Rsa7.d.cts +40 -0
- package/dist/proxy-Du8ggERu.d.ts +40 -0
- package/dist/{thread-manager-wRVVBFgj.d.cts → thread-manager-BjoYYXgd.d.cts} +8 -2
- package/dist/{thread-manager-BsLO3Fgc.d.cts → thread-manager-D8zKNFZ9.d.cts} +8 -2
- package/dist/{thread-manager-Bi1XlbpJ.d.ts → thread-manager-DtHYws2F.d.ts} +8 -2
- package/dist/{thread-manager-BhkOyQ1I.d.ts → thread-manager-Dw96FKH1.d.ts} +8 -2
- package/dist/{types-C66-BVBr.d.cts → types-BMJrsHo0.d.cts} +17 -1
- package/dist/{types-BkX4HLzi.d.ts → types-CtdOquo3.d.ts} +17 -1
- package/dist/{types-CdALEF3z.d.cts → types-DNEl5uxQ.d.cts} +38 -0
- package/dist/{types-ChAy_jSP.d.ts → types-qQVZfhoT.d.ts} +38 -0
- package/dist/{workflow-DMmiaw6w.d.cts → workflow-BH9ImDGq.d.cts} +48 -2
- package/dist/{workflow-BwT5EybR.d.ts → workflow-Cdw3-RNB.d.ts} +48 -2
- package/dist/workflow.cjs +47 -4
- package/dist/workflow.cjs.map +1 -1
- package/dist/workflow.d.cts +2 -2
- package/dist/workflow.d.ts +2 -2
- package/dist/workflow.js +47 -5
- package/dist/workflow.js.map +1 -1
- package/package.json +14 -3
- package/src/adapters/thread/anthropic/activities.ts +82 -39
- package/src/adapters/thread/anthropic/index.ts +8 -0
- package/src/adapters/thread/anthropic/model-invoker.test.ts +110 -0
- package/src/adapters/thread/anthropic/model-invoker.ts +26 -5
- package/src/adapters/thread/anthropic/prompt-cache.test.ts +134 -0
- package/src/adapters/thread/anthropic/prompt-cache.ts +163 -0
- package/src/adapters/thread/anthropic/proxy.ts +1 -0
- package/src/adapters/thread/anthropic/thread-manager.ts +9 -1
- package/src/adapters/thread/google-genai/activities.ts +64 -40
- package/src/adapters/thread/google-genai/proxy.ts +1 -0
- package/src/adapters/thread/google-genai/thread-manager.ts +9 -1
- package/src/adapters/thread/langchain/activities.ts +63 -36
- package/src/adapters/thread/langchain/proxy.ts +1 -0
- package/src/adapters/thread/langchain/thread-manager.ts +9 -1
- package/src/index.ts +21 -2
- package/src/lib/session/session-edge-cases.integration.test.ts +12 -0
- package/src/lib/session/session.integration.test.ts +138 -0
- package/src/lib/session/session.ts +29 -0
- package/src/lib/session/types.ts +22 -0
- package/src/lib/subagent/define.ts +1 -0
- package/src/lib/subagent/handler.ts +11 -2
- package/src/lib/subagent/subagent.integration.test.ts +139 -0
- package/src/lib/subagent/types.ts +16 -0
- package/src/lib/thread/cold-store.test.ts +221 -0
- package/src/lib/thread/cold-store.ts +269 -0
- package/src/lib/thread/index.ts +32 -0
- package/src/lib/thread/keys.ts +20 -0
- package/src/lib/thread/manager.ts +16 -27
- package/src/lib/thread/proxy.ts +79 -27
- package/src/lib/thread/snapshot.test.ts +443 -0
- package/src/lib/thread/snapshot.ts +163 -0
- package/src/lib/thread/test-utils.ts +228 -0
- package/src/lib/thread/tiered.test.ts +281 -0
- package/src/lib/thread/tiered.ts +135 -0
- package/src/lib/thread/types.ts +16 -0
- package/src/tools/edit/handler.test.ts +177 -0
- package/src/tools/edit/handler.ts +249 -47
- package/src/tools/edit/tool.ts +40 -0
- package/src/tools/task-create/handler.ts +1 -1
- package/src/tools/task-update/handler.ts +1 -1
- package/src/workflow.ts +2 -2
- package/dist/proxy-Bf7uI-Hw.d.cts +0 -24
- package/dist/proxy-COqA95FW.d.ts +0 -24
|
@@ -129,6 +129,12 @@ function createMockThreadOps() {
|
|
|
129
129
|
log.push({ op: "saveThreadState", args: [threadId, state] });
|
|
130
130
|
stateStore.set(threadId, state);
|
|
131
131
|
},
|
|
132
|
+
hydrateThread: async (threadId) => {
|
|
133
|
+
log.push({ op: "hydrateThread", args: [threadId] });
|
|
134
|
+
},
|
|
135
|
+
flushThread: async (threadId) => {
|
|
136
|
+
log.push({ op: "flushThread", args: [threadId] });
|
|
137
|
+
},
|
|
132
138
|
});
|
|
133
139
|
return { ops, log, stateStore };
|
|
134
140
|
}
|
|
@@ -824,6 +830,12 @@ describe("createSession edge cases", () => {
|
|
|
824
830
|
saveThreadState: async (threadId, state) => {
|
|
825
831
|
log.push({ op: "saveThreadState", args: [threadId, state] });
|
|
826
832
|
},
|
|
833
|
+
hydrateThread: async (threadId) => {
|
|
834
|
+
log.push({ op: "hydrateThread", args: [threadId] });
|
|
835
|
+
},
|
|
836
|
+
flushThread: async (threadId) => {
|
|
837
|
+
log.push({ op: "flushThread", args: [threadId] });
|
|
838
|
+
},
|
|
827
839
|
});
|
|
828
840
|
|
|
829
841
|
const session = await createSession<
|
|
@@ -132,6 +132,12 @@ function createMockThreadOps() {
|
|
|
132
132
|
log.push({ op: "saveThreadState", args: [threadId, state] });
|
|
133
133
|
stateStore.set(threadId, state);
|
|
134
134
|
},
|
|
135
|
+
hydrateThread: async (threadId) => {
|
|
136
|
+
log.push({ op: "hydrateThread", args: [threadId] });
|
|
137
|
+
},
|
|
138
|
+
flushThread: async (threadId) => {
|
|
139
|
+
log.push({ op: "flushThread", args: [threadId] });
|
|
140
|
+
},
|
|
135
141
|
});
|
|
136
142
|
|
|
137
143
|
return { ops, log, stateStore };
|
|
@@ -1336,4 +1342,136 @@ describe("createSession integration", () => {
|
|
|
1336
1342
|
const newThreadSlice = stateStore.get(result.threadId);
|
|
1337
1343
|
expect(newThreadSlice?.tasks).toHaveLength(1);
|
|
1338
1344
|
});
|
|
1345
|
+
|
|
1346
|
+
// --- Cold-tier lifecycle (hydrateThread + flushThread) ---
|
|
1347
|
+
|
|
1348
|
+
it("skips hydrateThread for mode:'new' threads but still flushes on exit", async () => {
|
|
1349
|
+
const { ops, log } = createMockThreadOps();
|
|
1350
|
+
|
|
1351
|
+
const session = await createSession({
|
|
1352
|
+
agentName: "TestAgent",
|
|
1353
|
+
thread: { mode: "new", threadId: "fresh-thread" },
|
|
1354
|
+
runAgent: createScriptedRunAgent([{ message: "ok", toolCalls: [] }]),
|
|
1355
|
+
threadOps: ops,
|
|
1356
|
+
buildContextMessage: () => "hi",
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
const stateManager = createAgentStateManager({
|
|
1360
|
+
initialState: { systemPrompt: "system" },
|
|
1361
|
+
});
|
|
1362
|
+
await session.runSession({ stateManager });
|
|
1363
|
+
|
|
1364
|
+
expect(log.filter((l) => l.op === "hydrateThread")).toHaveLength(0);
|
|
1365
|
+
const flushes = log.filter((l) => l.op === "flushThread");
|
|
1366
|
+
expect(flushes).toHaveLength(1);
|
|
1367
|
+
expect(at(flushes, 0).args[0]).toBe("fresh-thread");
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
it("hydrates the target thread before continuing it", async () => {
|
|
1371
|
+
const { ops, log, stateStore } = createMockThreadOps();
|
|
1372
|
+
stateStore.set("existing-thread", {
|
|
1373
|
+
tasks: [],
|
|
1374
|
+
custom: { previous: true },
|
|
1375
|
+
});
|
|
1376
|
+
|
|
1377
|
+
const session = await createSession({
|
|
1378
|
+
agentName: "TestAgent",
|
|
1379
|
+
thread: { mode: "continue", threadId: "existing-thread" },
|
|
1380
|
+
runAgent: createScriptedRunAgent([{ message: "ok", toolCalls: [] }]),
|
|
1381
|
+
threadOps: ops,
|
|
1382
|
+
buildContextMessage: () => "follow-up",
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1385
|
+
const stateManager = createAgentStateManager({
|
|
1386
|
+
initialState: { systemPrompt: "system" },
|
|
1387
|
+
});
|
|
1388
|
+
await session.runSession({ stateManager });
|
|
1389
|
+
|
|
1390
|
+
const hydrates = log.filter((l) => l.op === "hydrateThread");
|
|
1391
|
+
expect(hydrates).toHaveLength(1);
|
|
1392
|
+
expect(at(hydrates, 0).args[0]).toBe("existing-thread");
|
|
1393
|
+
|
|
1394
|
+
// hydrate runs strictly before loadThreadState so the load sees
|
|
1395
|
+
// fresh data when a cold tier is wired.
|
|
1396
|
+
const hydrateIdx = log.findIndex((l) => l.op === "hydrateThread");
|
|
1397
|
+
const loadIdx = log.findIndex((l) => l.op === "loadThreadState");
|
|
1398
|
+
expect(hydrateIdx).toBeLessThan(loadIdx);
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1401
|
+
it("hydrates the *source* thread (not the new one) on mode:'fork'", async () => {
|
|
1402
|
+
const { ops, log, stateStore } = createMockThreadOps();
|
|
1403
|
+
stateStore.set("src-thread", { tasks: [], custom: {} });
|
|
1404
|
+
|
|
1405
|
+
const session = await createSession({
|
|
1406
|
+
agentName: "TestAgent",
|
|
1407
|
+
thread: { mode: "fork", threadId: "src-thread" },
|
|
1408
|
+
runAgent: createScriptedRunAgent([{ message: "ok", toolCalls: [] }]),
|
|
1409
|
+
threadOps: ops,
|
|
1410
|
+
buildContextMessage: () => "fork me",
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
const stateManager = createAgentStateManager({
|
|
1414
|
+
initialState: { systemPrompt: "system" },
|
|
1415
|
+
});
|
|
1416
|
+
const result = await session.runSession({ stateManager });
|
|
1417
|
+
|
|
1418
|
+
const hydrates = log.filter((l) => l.op === "hydrateThread");
|
|
1419
|
+
expect(hydrates).toHaveLength(1);
|
|
1420
|
+
expect(at(hydrates, 0).args[0]).toBe("src-thread");
|
|
1421
|
+
|
|
1422
|
+
// hydrate runs before forkThread so the source is hot before
|
|
1423
|
+
// the in-Redis fork copy.
|
|
1424
|
+
const hydrateIdx = log.findIndex((l) => l.op === "hydrateThread");
|
|
1425
|
+
const forkIdx = log.findIndex((l) => l.op === "forkThread");
|
|
1426
|
+
expect(hydrateIdx).toBeLessThan(forkIdx);
|
|
1427
|
+
|
|
1428
|
+
// Flush still targets the *new* thread.
|
|
1429
|
+
const flushes = log.filter((l) => l.op === "flushThread");
|
|
1430
|
+
expect(flushes).toHaveLength(1);
|
|
1431
|
+
expect(at(flushes, 0).args[0]).toBe(result.threadId);
|
|
1432
|
+
expect(at(flushes, 0).args[0]).not.toBe("src-thread");
|
|
1433
|
+
});
|
|
1434
|
+
|
|
1435
|
+
it("flushThread runs even when the session fails", async () => {
|
|
1436
|
+
const { ops, log } = createMockThreadOps();
|
|
1437
|
+
|
|
1438
|
+
const session = await createSession({
|
|
1439
|
+
agentName: "TestAgent",
|
|
1440
|
+
thread: { mode: "new", threadId: "fail-thread" },
|
|
1441
|
+
runAgent: async () => {
|
|
1442
|
+
throw new Error("boom");
|
|
1443
|
+
},
|
|
1444
|
+
threadOps: ops,
|
|
1445
|
+
buildContextMessage: () => "hi",
|
|
1446
|
+
});
|
|
1447
|
+
|
|
1448
|
+
const stateManager = createAgentStateManager({
|
|
1449
|
+
initialState: { systemPrompt: "system" },
|
|
1450
|
+
});
|
|
1451
|
+
await expect(session.runSession({ stateManager })).rejects.toThrow(/boom/);
|
|
1452
|
+
|
|
1453
|
+
const flushes = log.filter((l) => l.op === "flushThread");
|
|
1454
|
+
expect(flushes).toHaveLength(1);
|
|
1455
|
+
expect(at(flushes, 0).args[0]).toBe("fail-thread");
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
it("flushThread runs after saveThreadState", async () => {
|
|
1459
|
+
const { ops, log } = createMockThreadOps();
|
|
1460
|
+
const session = await createSession({
|
|
1461
|
+
agentName: "TestAgent",
|
|
1462
|
+
thread: { mode: "new", threadId: "ordered-thread" },
|
|
1463
|
+
runAgent: createScriptedRunAgent([{ message: "ok", toolCalls: [] }]),
|
|
1464
|
+
threadOps: ops,
|
|
1465
|
+
buildContextMessage: () => "hi",
|
|
1466
|
+
});
|
|
1467
|
+
const stateManager = createAgentStateManager({
|
|
1468
|
+
initialState: { systemPrompt: "system" },
|
|
1469
|
+
});
|
|
1470
|
+
await session.runSession({ stateManager });
|
|
1471
|
+
|
|
1472
|
+
const saveIdx = log.findIndex((l) => l.op === "saveThreadState");
|
|
1473
|
+
const flushIdx = log.findIndex((l) => l.op === "flushThread");
|
|
1474
|
+
expect(saveIdx).toBeGreaterThanOrEqual(0);
|
|
1475
|
+
expect(flushIdx).toBeGreaterThan(saveIdx);
|
|
1476
|
+
});
|
|
1339
1477
|
});
|
|
@@ -198,6 +198,8 @@ export async function createSession<
|
|
|
198
198
|
forkThread,
|
|
199
199
|
loadThreadState,
|
|
200
200
|
saveThreadState,
|
|
201
|
+
hydrateThread,
|
|
202
|
+
flushThread,
|
|
201
203
|
} = threadOps;
|
|
202
204
|
|
|
203
205
|
const plugins: ToolMap[string][] = [];
|
|
@@ -391,10 +393,19 @@ export async function createSession<
|
|
|
391
393
|
};
|
|
392
394
|
|
|
393
395
|
if (threadMode === "fork" && sourceThreadId) {
|
|
396
|
+
// Pull the source thread from the cold tier into Redis (if a
|
|
397
|
+
// cold store is configured for this adapter) before the
|
|
398
|
+
// in-Redis fork copy runs. Hydrate is a no-op when the source
|
|
399
|
+
// is already hot or when no cold tier is wired.
|
|
400
|
+
await hydrateThread(sourceThreadId, threadKey);
|
|
394
401
|
await forkThread(sourceThreadId, threadId, threadKey);
|
|
395
402
|
const forkedSlice = await loadThreadState(threadId, threadKey);
|
|
396
403
|
if (forkedSlice) rehydrateFromSlice(forkedSlice);
|
|
397
404
|
} else if (threadMode === "continue") {
|
|
405
|
+
// Pull the thread from the cold tier into Redis before any
|
|
406
|
+
// appends. No-op when the thread is already hot or when no
|
|
407
|
+
// cold tier is wired.
|
|
408
|
+
await hydrateThread(threadId, threadKey);
|
|
398
409
|
// "continue" — thread already exists, just append the new message
|
|
399
410
|
const continuedSlice = await loadThreadState(threadId, threadKey);
|
|
400
411
|
if (continuedSlice) rehydrateFromSlice(continuedSlice);
|
|
@@ -640,6 +651,24 @@ export async function createSession<
|
|
|
640
651
|
});
|
|
641
652
|
}
|
|
642
653
|
|
|
654
|
+
// Archive the thread to the durable cold tier (S3 / R2 / etc.)
|
|
655
|
+
// after the state slice has been written. No-op when no cold
|
|
656
|
+
// store is wired into the adapter. Best-effort — failures
|
|
657
|
+
// must not mask the original exit reason and the thread is
|
|
658
|
+
// still readable from Redis via its TTL window.
|
|
659
|
+
try {
|
|
660
|
+
await flushThread(threadId, threadKey);
|
|
661
|
+
} catch (flushError) {
|
|
662
|
+
log.warn("failed to flush thread to cold tier", {
|
|
663
|
+
agentName,
|
|
664
|
+
threadId,
|
|
665
|
+
error:
|
|
666
|
+
flushError instanceof Error
|
|
667
|
+
? flushError.message
|
|
668
|
+
: String(flushError),
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
|
|
643
672
|
await callSessionEnd(exitReason, stateManager.getTurns());
|
|
644
673
|
|
|
645
674
|
if (sandboxOwned && sandboxId && sandboxOps) {
|
package/src/lib/session/types.ts
CHANGED
|
@@ -109,6 +109,28 @@ export interface ThreadOps<TContent = string> {
|
|
|
109
109
|
state: PersistedThreadState,
|
|
110
110
|
threadKey?: string
|
|
111
111
|
): Promise<void>;
|
|
112
|
+
/**
|
|
113
|
+
* Restore the thread's contents from the durable cold tier (if any)
|
|
114
|
+
* into Redis. Called once on session entry for `mode: "continue"`
|
|
115
|
+
* and `mode: "fork"` so the rest of the loop reads the freshest data.
|
|
116
|
+
*
|
|
117
|
+
* Adapters configured without a cold-store implementation treat this
|
|
118
|
+
* as a no-op. Implementations must be **idempotent** — Temporal
|
|
119
|
+
* retries the activity on transient failures, and a thread that is
|
|
120
|
+
* already hot must not be wiped.
|
|
121
|
+
*/
|
|
122
|
+
hydrateThread(threadId: string, threadKey?: string): Promise<void>;
|
|
123
|
+
/**
|
|
124
|
+
* Archive the thread's contents to the durable cold tier and
|
|
125
|
+
* (optionally) drop the hot-tier Redis keys. Called once in the
|
|
126
|
+
* session's `finally` block on every exit path, after
|
|
127
|
+
* `saveThreadState`.
|
|
128
|
+
*
|
|
129
|
+
* Adapters configured without a cold-store implementation treat this
|
|
130
|
+
* as a no-op. Implementations must be **idempotent** — the cold
|
|
131
|
+
* tier is last-writer-wins and a retried flush must converge.
|
|
132
|
+
*/
|
|
133
|
+
flushThread(threadId: string, threadKey?: string): Promise<void>;
|
|
112
134
|
}
|
|
113
135
|
|
|
114
136
|
/**
|
|
@@ -237,8 +237,17 @@ export function createSubagentHandler<
|
|
|
237
237
|
|
|
238
238
|
const threadMode = config.thread ?? "new";
|
|
239
239
|
const allowsContinuation = threadMode !== "new";
|
|
240
|
-
|
|
241
|
-
|
|
240
|
+
// The parent agent's tool call wins. When `threadId` is omitted,
|
|
241
|
+
// `newThreadSource` decides what to fall back to: `"new"` (default)
|
|
242
|
+
// starts fresh, `"from-parent"` continues/forks the parent's own
|
|
243
|
+
// thread via `context.threadId`. Both paths still require
|
|
244
|
+
// `thread: "fork" | "continue"` — `thread: "new"` always starts
|
|
245
|
+
// fresh regardless of the source.
|
|
246
|
+
const newThreadSource = config.newThreadSource ?? "new";
|
|
247
|
+
const continuationThreadId = !allowsContinuation
|
|
248
|
+
? undefined
|
|
249
|
+
: (args.threadId ??
|
|
250
|
+
(newThreadSource === "from-parent" ? context.threadId : undefined));
|
|
242
251
|
|
|
243
252
|
// --- Build thread init ---
|
|
244
253
|
let thread: ThreadInit | undefined;
|
|
@@ -635,6 +635,145 @@ describe("createSubagentHandler", () => {
|
|
|
635
635
|
expect(workflowInput.thread).toBeUndefined();
|
|
636
636
|
});
|
|
637
637
|
|
|
638
|
+
// --- newThreadSource: "from-parent" ---
|
|
639
|
+
|
|
640
|
+
it("uses the parent's threadId when fork + newThreadSource 'from-parent' and args.threadId is absent", async () => {
|
|
641
|
+
const { executeChild } = await import("@temporalio/workflow");
|
|
642
|
+
const execMock = executeChild as ReturnType<typeof vi.fn>;
|
|
643
|
+
|
|
644
|
+
const subagent: SubagentConfig = {
|
|
645
|
+
agentName: "parent-fork",
|
|
646
|
+
description: "Forks parent thread by default",
|
|
647
|
+
workflow: mockWorkflow(),
|
|
648
|
+
thread: "fork",
|
|
649
|
+
newThreadSource: "from-parent",
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
const { handler } = createSubagentHandler([subagent]);
|
|
653
|
+
|
|
654
|
+
await handler(
|
|
655
|
+
{ subagent: "parent-fork", description: "test", prompt: "test" },
|
|
656
|
+
{ threadId: "parent-t", toolCallId: "tc", toolName: "Subagent" }
|
|
657
|
+
);
|
|
658
|
+
|
|
659
|
+
const lastCall = execMock.mock.calls[execMock.mock.calls.length - 1];
|
|
660
|
+
if (!lastCall) throw new Error("expected executeChild call");
|
|
661
|
+
const workflowInput = lastCall[1].args[1] as SubagentWorkflowInput;
|
|
662
|
+
expect(workflowInput.thread).toEqual({
|
|
663
|
+
mode: "fork",
|
|
664
|
+
threadId: "parent-t",
|
|
665
|
+
});
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it("uses the parent's threadId when continue + newThreadSource 'from-parent' and args.threadId is absent", async () => {
|
|
669
|
+
const { executeChild } = await import("@temporalio/workflow");
|
|
670
|
+
const execMock = executeChild as ReturnType<typeof vi.fn>;
|
|
671
|
+
|
|
672
|
+
const subagent: SubagentConfig = {
|
|
673
|
+
agentName: "parent-continue",
|
|
674
|
+
description: "Continues parent thread by default",
|
|
675
|
+
workflow: mockWorkflow(),
|
|
676
|
+
thread: "continue",
|
|
677
|
+
newThreadSource: "from-parent",
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
const { handler } = createSubagentHandler([subagent]);
|
|
681
|
+
|
|
682
|
+
await handler(
|
|
683
|
+
{ subagent: "parent-continue", description: "test", prompt: "test" },
|
|
684
|
+
{ threadId: "parent-t", toolCallId: "tc", toolName: "Subagent" }
|
|
685
|
+
);
|
|
686
|
+
|
|
687
|
+
const lastCall = execMock.mock.calls[execMock.mock.calls.length - 1];
|
|
688
|
+
if (!lastCall) throw new Error("expected executeChild call");
|
|
689
|
+
const workflowInput = lastCall[1].args[1] as SubagentWorkflowInput;
|
|
690
|
+
expect(workflowInput.thread).toEqual({
|
|
691
|
+
mode: "continue",
|
|
692
|
+
threadId: "parent-t",
|
|
693
|
+
});
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it("prefers args.threadId over the parent source when both are available", async () => {
|
|
697
|
+
const { executeChild } = await import("@temporalio/workflow");
|
|
698
|
+
const execMock = executeChild as ReturnType<typeof vi.fn>;
|
|
699
|
+
|
|
700
|
+
const subagent: SubagentConfig = {
|
|
701
|
+
agentName: "parent-fork-explicit",
|
|
702
|
+
description: "Forks parent thread by default",
|
|
703
|
+
workflow: mockWorkflow(),
|
|
704
|
+
thread: "fork",
|
|
705
|
+
newThreadSource: "from-parent",
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
const { handler } = createSubagentHandler([subagent]);
|
|
709
|
+
|
|
710
|
+
await handler(
|
|
711
|
+
{
|
|
712
|
+
subagent: "parent-fork-explicit",
|
|
713
|
+
description: "test",
|
|
714
|
+
prompt: "test",
|
|
715
|
+
threadId: "explicit-prev",
|
|
716
|
+
},
|
|
717
|
+
{ threadId: "parent-t", toolCallId: "tc", toolName: "Subagent" }
|
|
718
|
+
);
|
|
719
|
+
|
|
720
|
+
const lastCall = execMock.mock.calls[execMock.mock.calls.length - 1];
|
|
721
|
+
if (!lastCall) throw new Error("expected executeChild call");
|
|
722
|
+
const workflowInput = lastCall[1].args[1] as SubagentWorkflowInput;
|
|
723
|
+
expect(workflowInput.thread).toEqual({
|
|
724
|
+
mode: "fork",
|
|
725
|
+
threadId: "explicit-prev",
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
it("ignores newThreadSource 'from-parent' when thread is new", async () => {
|
|
730
|
+
const { executeChild } = await import("@temporalio/workflow");
|
|
731
|
+
const execMock = executeChild as ReturnType<typeof vi.fn>;
|
|
732
|
+
|
|
733
|
+
const subagent: SubagentConfig = {
|
|
734
|
+
agentName: "new-with-source",
|
|
735
|
+
description: "Should still start fresh",
|
|
736
|
+
workflow: mockWorkflow(),
|
|
737
|
+
newThreadSource: "from-parent",
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
const { handler } = createSubagentHandler([subagent]);
|
|
741
|
+
|
|
742
|
+
await handler(
|
|
743
|
+
{ subagent: "new-with-source", description: "test", prompt: "test" },
|
|
744
|
+
{ threadId: "parent-t", toolCallId: "tc", toolName: "Subagent" }
|
|
745
|
+
);
|
|
746
|
+
|
|
747
|
+
const lastCall = execMock.mock.calls[execMock.mock.calls.length - 1];
|
|
748
|
+
if (!lastCall) throw new Error("expected executeChild call");
|
|
749
|
+
const workflowInput = lastCall[1].args[1] as SubagentWorkflowInput;
|
|
750
|
+
expect(workflowInput.thread).toBeUndefined();
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
it("preserves prior behavior when fork is set with default newThreadSource and args.threadId is absent", async () => {
|
|
754
|
+
const { executeChild } = await import("@temporalio/workflow");
|
|
755
|
+
const execMock = executeChild as ReturnType<typeof vi.fn>;
|
|
756
|
+
|
|
757
|
+
const subagent: SubagentConfig = {
|
|
758
|
+
agentName: "fork-default-source",
|
|
759
|
+
description: "Fork with default new-thread source",
|
|
760
|
+
workflow: mockWorkflow(),
|
|
761
|
+
thread: "fork",
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
const { handler } = createSubagentHandler([subagent]);
|
|
765
|
+
|
|
766
|
+
await handler(
|
|
767
|
+
{ subagent: "fork-default-source", description: "test", prompt: "test" },
|
|
768
|
+
{ threadId: "parent-t", toolCallId: "tc", toolName: "Subagent" }
|
|
769
|
+
);
|
|
770
|
+
|
|
771
|
+
const lastCall = execMock.mock.calls[execMock.mock.calls.length - 1];
|
|
772
|
+
if (!lastCall) throw new Error("expected executeChild call");
|
|
773
|
+
const workflowInput = lastCall[1].args[1] as SubagentWorkflowInput;
|
|
774
|
+
expect(workflowInput.thread).toBeUndefined();
|
|
775
|
+
});
|
|
776
|
+
|
|
638
777
|
// --- Sandbox continuation ---
|
|
639
778
|
|
|
640
779
|
it("does not pass sandbox when thread is fork (own sandbox)", async () => {
|
|
@@ -632,6 +632,22 @@ export interface SubagentConfig<TResult extends z.ZodType = z.ZodType> {
|
|
|
632
632
|
* directly to the existing thread in-place.
|
|
633
633
|
*/
|
|
634
634
|
thread?: "new" | "fork" | "continue";
|
|
635
|
+
/**
|
|
636
|
+
* Where the subagent's thread comes from when the parent's tool call
|
|
637
|
+
* omits `threadId`. Only meaningful in combination with
|
|
638
|
+
* `thread: "fork"` or `"continue"`.
|
|
639
|
+
*
|
|
640
|
+
* - `"new"` (default) — start a fresh thread (the prior behavior).
|
|
641
|
+
* - `"from-parent"` — use the parent's own `threadId` (from
|
|
642
|
+
* `RouterContext`). With `thread: "fork"` the parent's conversation
|
|
643
|
+
* is copied into a new thread; with `thread: "continue"` the
|
|
644
|
+
* subagent appends to the parent's thread in-place.
|
|
645
|
+
*
|
|
646
|
+
* Has no effect when `thread` is `"new"` (or omitted). A `threadId`
|
|
647
|
+
* supplied by the parent agent always wins — `newThreadSource` only
|
|
648
|
+
* applies when none is provided.
|
|
649
|
+
*/
|
|
650
|
+
newThreadSource?: "new" | "from-parent";
|
|
635
651
|
/**
|
|
636
652
|
* Sandbox strategy for this subagent.
|
|
637
653
|
*
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach } from "vitest";
|
|
2
|
+
import { gunzipSync, gzipSync } from "node:zlib";
|
|
3
|
+
import { createS3ColdStore, type S3LikeClient } from "./cold-store";
|
|
4
|
+
import type { ThreadSnapshot } from "./cold-store";
|
|
5
|
+
|
|
6
|
+
interface StoredObject {
|
|
7
|
+
body: Buffer;
|
|
8
|
+
contentType?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface CommandInput {
|
|
12
|
+
Bucket: string;
|
|
13
|
+
Key: string;
|
|
14
|
+
Body?: Buffer | string;
|
|
15
|
+
ContentType?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Minimal S3-shaped fake. `send()` dispatches by command constructor
|
|
20
|
+
* name; `config` provides the fields `@aws-sdk/lib-storage`'s
|
|
21
|
+
* single-part upload path inspects.
|
|
22
|
+
*/
|
|
23
|
+
function createFakeS3(): {
|
|
24
|
+
s3: S3LikeClient;
|
|
25
|
+
store: Map<string, StoredObject>;
|
|
26
|
+
calls: { get: number; put: number; delete: number };
|
|
27
|
+
} {
|
|
28
|
+
const store = new Map<string, StoredObject>();
|
|
29
|
+
const calls = { get: 0, put: 0, delete: 0 };
|
|
30
|
+
|
|
31
|
+
const compositeKey = (bucket: string, key: string): string =>
|
|
32
|
+
`${bucket}/${key}`;
|
|
33
|
+
|
|
34
|
+
const s3 = {
|
|
35
|
+
config: {
|
|
36
|
+
requestHandler: undefined,
|
|
37
|
+
forcePathStyle: false,
|
|
38
|
+
endpoint: async (): Promise<URL> => new URL("https://fake.s3.local"),
|
|
39
|
+
},
|
|
40
|
+
async send<TInput, TOutput>(
|
|
41
|
+
command: { input: TInput } & object
|
|
42
|
+
): Promise<TOutput> {
|
|
43
|
+
const name = (command as { constructor: { name: string } }).constructor
|
|
44
|
+
.name;
|
|
45
|
+
const input = command.input as unknown as CommandInput;
|
|
46
|
+
const fullKey = compositeKey(input.Bucket, input.Key);
|
|
47
|
+
if (name === "GetObjectCommand") {
|
|
48
|
+
calls.get++;
|
|
49
|
+
const obj = store.get(fullKey);
|
|
50
|
+
if (!obj) {
|
|
51
|
+
const err = new Error("NoSuchKey") as Error & { name: string };
|
|
52
|
+
err.name = "NoSuchKey";
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
return { Body: obj.body } as unknown as TOutput;
|
|
56
|
+
}
|
|
57
|
+
if (name === "PutObjectCommand") {
|
|
58
|
+
calls.put++;
|
|
59
|
+
const body =
|
|
60
|
+
typeof input.Body === "string"
|
|
61
|
+
? Buffer.from(input.Body, "utf8")
|
|
62
|
+
: (input.Body as Buffer);
|
|
63
|
+
store.set(fullKey, {
|
|
64
|
+
body,
|
|
65
|
+
...(input.ContentType && { contentType: input.ContentType }),
|
|
66
|
+
});
|
|
67
|
+
return {} as TOutput;
|
|
68
|
+
}
|
|
69
|
+
if (name === "DeleteObjectCommand") {
|
|
70
|
+
calls.delete++;
|
|
71
|
+
store.delete(fullKey);
|
|
72
|
+
return {} as TOutput;
|
|
73
|
+
}
|
|
74
|
+
throw new Error(`unknown command: ${name}`);
|
|
75
|
+
},
|
|
76
|
+
} as unknown as S3LikeClient;
|
|
77
|
+
|
|
78
|
+
return { s3, store, calls };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const sampleSnapshot: ThreadSnapshot = {
|
|
82
|
+
v: 1,
|
|
83
|
+
messages: [JSON.stringify({ id: "m1", text: "hi" })],
|
|
84
|
+
state: null,
|
|
85
|
+
dedupIds: ["m1"],
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
describe("createS3ColdStore", () => {
|
|
89
|
+
let fake: ReturnType<typeof createFakeS3>;
|
|
90
|
+
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
fake = createFakeS3();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("read returns null when no object exists for the thread", async () => {
|
|
96
|
+
const cold = createS3ColdStore({
|
|
97
|
+
s3: fake.s3,
|
|
98
|
+
bucket: "test-bucket",
|
|
99
|
+
});
|
|
100
|
+
expect(await cold.read("messages", "t-1")).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("write then read round-trips a snapshot (gzip default)", async () => {
|
|
104
|
+
const cold = createS3ColdStore({
|
|
105
|
+
s3: fake.s3,
|
|
106
|
+
bucket: "test-bucket",
|
|
107
|
+
prefix: "prod/threads",
|
|
108
|
+
});
|
|
109
|
+
await cold.write("messages", "t-1", sampleSnapshot);
|
|
110
|
+
expect(await cold.read("messages", "t-1")).toEqual(sampleSnapshot);
|
|
111
|
+
|
|
112
|
+
// Verify the on-disk format is gzip + JSON.
|
|
113
|
+
const stored = fake.store.get("test-bucket/prod/threads/messages/t-1.json.gz");
|
|
114
|
+
expect(stored).toBeDefined();
|
|
115
|
+
if (!stored) throw new Error("expected stored object");
|
|
116
|
+
expect(stored.contentType).toBe("application/gzip");
|
|
117
|
+
const decoded = JSON.parse(gunzipSync(stored.body).toString("utf8"));
|
|
118
|
+
expect(decoded).toEqual(sampleSnapshot);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("supports plain JSON when gzip is disabled", async () => {
|
|
122
|
+
const cold = createS3ColdStore({
|
|
123
|
+
s3: fake.s3,
|
|
124
|
+
bucket: "test-bucket",
|
|
125
|
+
gzip: false,
|
|
126
|
+
});
|
|
127
|
+
await cold.write("messages", "t-1", sampleSnapshot);
|
|
128
|
+
|
|
129
|
+
const stored = fake.store.get("test-bucket/threads/messages/t-1.json");
|
|
130
|
+
expect(stored).toBeDefined();
|
|
131
|
+
if (!stored) throw new Error("expected stored object");
|
|
132
|
+
expect(stored.contentType).toBe("application/json");
|
|
133
|
+
expect(JSON.parse(stored.body.toString("utf8"))).toEqual(sampleSnapshot);
|
|
134
|
+
|
|
135
|
+
expect(await cold.read("messages", "t-1")).toEqual(sampleSnapshot);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("uses 'threads' as the default prefix", async () => {
|
|
139
|
+
const cold = createS3ColdStore({
|
|
140
|
+
s3: fake.s3,
|
|
141
|
+
bucket: "test-bucket",
|
|
142
|
+
});
|
|
143
|
+
await cold.write("messages", "abc", sampleSnapshot);
|
|
144
|
+
expect(fake.store.has("test-bucket/threads/messages/abc.json.gz")).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("delete removes the underlying object", async () => {
|
|
148
|
+
const cold = createS3ColdStore({
|
|
149
|
+
s3: fake.s3,
|
|
150
|
+
bucket: "test-bucket",
|
|
151
|
+
});
|
|
152
|
+
await cold.write("messages", "t-1", sampleSnapshot);
|
|
153
|
+
await cold.delete("messages", "t-1");
|
|
154
|
+
expect(await cold.read("messages", "t-1")).toBeNull();
|
|
155
|
+
expect(fake.store.size).toBe(0);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("read treats 404 errors as null (not throw)", async () => {
|
|
159
|
+
const cold = createS3ColdStore({
|
|
160
|
+
s3: {
|
|
161
|
+
async send() {
|
|
162
|
+
const err = {
|
|
163
|
+
name: "NotFound",
|
|
164
|
+
$metadata: { httpStatusCode: 404 },
|
|
165
|
+
};
|
|
166
|
+
throw err;
|
|
167
|
+
},
|
|
168
|
+
} as unknown as S3LikeClient,
|
|
169
|
+
bucket: "test-bucket",
|
|
170
|
+
});
|
|
171
|
+
expect(await cold.read("messages", "t-1")).toBeNull();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("read rethrows non-404 errors", async () => {
|
|
175
|
+
const cold = createS3ColdStore({
|
|
176
|
+
s3: {
|
|
177
|
+
async send() {
|
|
178
|
+
throw new Error("AccessDenied");
|
|
179
|
+
},
|
|
180
|
+
} as unknown as S3LikeClient,
|
|
181
|
+
bucket: "test-bucket",
|
|
182
|
+
});
|
|
183
|
+
await expect(cold.read("messages", "t-1")).rejects.toThrow("AccessDenied");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("falls back to gunzip when the cold object is gzip-encoded but read with gzip:true", async () => {
|
|
187
|
+
// Pre-seed the fake S3 with a gzip-encoded payload, then read.
|
|
188
|
+
const compressed = gzipSync(Buffer.from(JSON.stringify(sampleSnapshot)));
|
|
189
|
+
fake.store.set("test-bucket/threads/messages/t-1.json.gz", {
|
|
190
|
+
body: compressed,
|
|
191
|
+
});
|
|
192
|
+
const cold = createS3ColdStore({
|
|
193
|
+
s3: fake.s3,
|
|
194
|
+
bucket: "test-bucket",
|
|
195
|
+
});
|
|
196
|
+
expect(await cold.read("messages", "t-1")).toEqual(sampleSnapshot);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("round-trips a large payload through the async gzip path", async () => {
|
|
200
|
+
// ~1 MB payload — regression guard that large payloads still
|
|
201
|
+
// encode/decode correctly through the promisified gzip path.
|
|
202
|
+
const big: ThreadSnapshot = {
|
|
203
|
+
v: 1,
|
|
204
|
+
messages: Array.from({ length: 500 }, (_, i) =>
|
|
205
|
+
JSON.stringify({
|
|
206
|
+
id: `m${i}`,
|
|
207
|
+
text: "x".repeat(2048),
|
|
208
|
+
})
|
|
209
|
+
),
|
|
210
|
+
state: null,
|
|
211
|
+
dedupIds: Array.from({ length: 500 }, (_, i) => `m${i}`),
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const cold = createS3ColdStore({
|
|
215
|
+
s3: fake.s3,
|
|
216
|
+
bucket: "test-bucket",
|
|
217
|
+
});
|
|
218
|
+
await cold.write("messages", "big", big);
|
|
219
|
+
expect(await cold.read("messages", "big")).toEqual(big);
|
|
220
|
+
});
|
|
221
|
+
});
|