zeitlich 0.2.46 → 0.2.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +66 -6
  2. package/dist/{activities-CyeiqK_f.d.cts → activities-BlQR5gX4.d.cts} +3 -3
  3. package/dist/{activities-Bm4TLTid.d.ts → activities-DCaIPQBT.d.ts} +3 -3
  4. package/dist/adapters/thread/anthropic/index.cjs +105 -6
  5. package/dist/adapters/thread/anthropic/index.cjs.map +1 -1
  6. package/dist/adapters/thread/anthropic/index.d.cts +48 -9
  7. package/dist/adapters/thread/anthropic/index.d.ts +48 -9
  8. package/dist/adapters/thread/anthropic/index.js +104 -7
  9. package/dist/adapters/thread/anthropic/index.js.map +1 -1
  10. package/dist/adapters/thread/anthropic/workflow.cjs +38 -22
  11. package/dist/adapters/thread/anthropic/workflow.cjs.map +1 -1
  12. package/dist/adapters/thread/anthropic/workflow.d.cts +5 -4
  13. package/dist/adapters/thread/anthropic/workflow.d.ts +5 -4
  14. package/dist/adapters/thread/anthropic/workflow.js +38 -22
  15. package/dist/adapters/thread/anthropic/workflow.js.map +1 -1
  16. package/dist/adapters/thread/google-genai/index.d.cts +6 -5
  17. package/dist/adapters/thread/google-genai/index.d.ts +6 -5
  18. package/dist/adapters/thread/google-genai/workflow.cjs +38 -22
  19. package/dist/adapters/thread/google-genai/workflow.cjs.map +1 -1
  20. package/dist/adapters/thread/google-genai/workflow.d.cts +7 -5
  21. package/dist/adapters/thread/google-genai/workflow.d.ts +7 -5
  22. package/dist/adapters/thread/google-genai/workflow.js +38 -22
  23. package/dist/adapters/thread/google-genai/workflow.js.map +1 -1
  24. package/dist/adapters/thread/langchain/index.d.cts +6 -5
  25. package/dist/adapters/thread/langchain/index.d.ts +6 -5
  26. package/dist/adapters/thread/langchain/workflow.cjs +38 -22
  27. package/dist/adapters/thread/langchain/workflow.cjs.map +1 -1
  28. package/dist/adapters/thread/langchain/workflow.d.cts +5 -4
  29. package/dist/adapters/thread/langchain/workflow.d.ts +5 -4
  30. package/dist/adapters/thread/langchain/workflow.js +38 -22
  31. package/dist/adapters/thread/langchain/workflow.js.map +1 -1
  32. package/dist/{cold-store-BC5L5Z8A.d.cts → cold-store-UL13Sstw.d.cts} +8 -11
  33. package/dist/{cold-store-CFHwemBJ.d.ts → cold-store-aD4TSKlU.d.ts} +8 -11
  34. package/dist/index.cjs +311 -99
  35. package/dist/index.cjs.map +1 -1
  36. package/dist/index.d.cts +21 -9
  37. package/dist/index.d.ts +21 -9
  38. package/dist/index.js +312 -102
  39. package/dist/index.js.map +1 -1
  40. package/dist/proxy-BAty3CWM.d.cts +40 -0
  41. package/dist/proxy-mbnwBhHw.d.ts +40 -0
  42. package/dist/{thread-manager-DduoSkvJ.d.ts → thread-manager-CICj68PI.d.ts} +2 -2
  43. package/dist/{thread-manager-D33SUmZa.d.cts → thread-manager-DsXvJ5cJ.d.cts} +2 -2
  44. package/dist/{thread-manager-B-zy3xrs.d.ts → thread-manager-DtEtbUkp.d.ts} +2 -2
  45. package/dist/{thread-manager-9tezUcLW.d.cts → thread-manager-R6c3lnJy.d.cts} +2 -2
  46. package/dist/{types-oxt8GN97.d.cts → types-DDLPnxBh.d.cts} +1 -1
  47. package/dist/{types-L5bvbF-n.d.ts → types-DF4wzWQG.d.ts} +1 -1
  48. package/dist/{types-CnuN9T6t.d.cts → types-DWeyCTYK.d.cts} +47 -0
  49. package/dist/{types-CwN6_tAL.d.ts → types-DwBYd0ij.d.ts} +47 -0
  50. package/dist/{workflow-DIaIV7L2.d.cts → workflow-DVNPR7eX.d.cts} +17 -2
  51. package/dist/{workflow-B1TOcHbt.d.ts → workflow-DdaU7_j4.d.ts} +17 -2
  52. package/dist/workflow.cjs +80 -12
  53. package/dist/workflow.cjs.map +1 -1
  54. package/dist/workflow.d.cts +2 -2
  55. package/dist/workflow.d.ts +2 -2
  56. package/dist/workflow.js +80 -13
  57. package/dist/workflow.js.map +1 -1
  58. package/package.json +14 -8
  59. package/src/adapters/thread/anthropic/activities.ts +18 -11
  60. package/src/adapters/thread/anthropic/index.ts +8 -0
  61. package/src/adapters/thread/anthropic/model-invoker.test.ts +110 -0
  62. package/src/adapters/thread/anthropic/model-invoker.ts +26 -5
  63. package/src/adapters/thread/anthropic/prompt-cache.test.ts +134 -0
  64. package/src/adapters/thread/anthropic/prompt-cache.ts +163 -0
  65. package/src/adapters/thread/anthropic/proxy.ts +1 -0
  66. package/src/adapters/thread/google-genai/proxy.ts +1 -0
  67. package/src/adapters/thread/langchain/proxy.ts +1 -0
  68. package/src/index.ts +1 -1
  69. package/src/lib/lifecycle.ts +13 -1
  70. package/src/lib/session/session-edge-cases.integration.test.ts +44 -0
  71. package/src/lib/session/session.ts +15 -0
  72. package/src/lib/subagent/define.ts +1 -0
  73. package/src/lib/subagent/handler.ts +41 -6
  74. package/src/lib/subagent/subagent.integration.test.ts +178 -0
  75. package/src/lib/subagent/types.ts +16 -0
  76. package/src/lib/thread/cold-store.test.ts +33 -5
  77. package/src/lib/thread/cold-store.ts +50 -31
  78. package/src/lib/thread/proxy.ts +79 -29
  79. package/src/lib/tool-router/router-edge-cases.integration.test.ts +36 -0
  80. package/src/lib/tool-router/router.ts +21 -3
  81. package/src/lib/tool-router/types.ts +20 -0
  82. package/src/tools/edit/handler.test.ts +177 -0
  83. package/src/tools/edit/handler.ts +249 -47
  84. package/src/tools/edit/tool.ts +40 -0
  85. package/src/tools/task-create/handler.ts +1 -1
  86. package/src/tools/task-update/handler.ts +1 -1
  87. package/src/workflow.ts +2 -2
  88. package/dist/proxy-BxFyd6cg.d.cts +0 -24
  89. package/dist/proxy-Cskmj4Yx.d.ts +0 -24
@@ -11,11 +11,23 @@
11
11
  * continue there. When the adapter has `onForkPrepareThread` and/or
12
12
  * `onForkTransform` hooks configured, they are applied once to the forked
13
13
  * thread before the session starts.
14
+ *
15
+ * The optional `truncateAfterFork.fromMessageId` directs the session to
16
+ * call `truncateThread` on the freshly forked thread immediately after
17
+ * the fork, dropping that message and everything after. Used by
18
+ * subagents that fork their parent's thread mid-tool-call to strip the
19
+ * orphan assistant `tool_use` block (the one whose `tool_result` will
20
+ * never arrive in the child's thread) so the first model call doesn't
21
+ * reject on an unmatched tool-use/tool-result pair.
14
22
  */
15
23
  export type ThreadInit =
16
24
  | { mode: "new"; threadId?: string }
17
25
  | { mode: "continue"; threadId: string }
18
- | { mode: "fork"; threadId: string };
26
+ | {
27
+ mode: "fork";
28
+ threadId: string;
29
+ truncateAfterFork?: { fromMessageId: string };
30
+ };
19
31
 
20
32
  // ============================================================================
21
33
  // Sandbox lifecycle
@@ -660,6 +660,50 @@ describe("createSession edge cases", () => {
660
660
  const forkOp = forkOps[0];
661
661
  if (!forkOp) throw new Error("expected fork op");
662
662
  expect(forkOp.args[0]).toBe("original-thread");
663
+
664
+ // No truncateAfterFork directive ⇒ no truncate call.
665
+ const truncateOps = log.filter((l) => l.op === "truncateThread");
666
+ expect(truncateOps).toHaveLength(0);
667
+ });
668
+
669
+ it("fork thread mode truncates the forked thread at fromMessageId when truncateAfterFork is set", async () => {
670
+ const { ops, log } = createMockThreadOps();
671
+
672
+ const session = await createSession({
673
+ agentName: "TestAgent",
674
+ thread: {
675
+ mode: "fork",
676
+ threadId: "parent-thread",
677
+ truncateAfterFork: { fromMessageId: "parent-asst-msg-1" },
678
+ },
679
+ runAgent: createScriptedRunAgent([
680
+ { message: "forked & continued", toolCalls: [] },
681
+ ]),
682
+ threadOps: ops,
683
+ buildContextMessage: () => "continue",
684
+ });
685
+
686
+ const stateManager = createAgentStateManager({
687
+ initialState: { systemPrompt: "test" },
688
+ });
689
+
690
+ const result = await session.runSession({ stateManager });
691
+
692
+ expect(result.exitReason).toBe("completed");
693
+ const forkedThreadId = result.threadId;
694
+ expect(forkedThreadId).not.toBe("parent-thread");
695
+
696
+ // Order matters: fork must happen before truncate, otherwise the
697
+ // truncate would no-op against an empty thread.
698
+ const forkIdx = log.findIndex((l) => l.op === "forkThread");
699
+ const truncIdx = log.findIndex((l) => l.op === "truncateThread");
700
+ expect(forkIdx).toBeGreaterThanOrEqual(0);
701
+ expect(truncIdx).toBeGreaterThan(forkIdx);
702
+
703
+ const truncOp = log[truncIdx];
704
+ if (!truncOp) throw new Error("expected truncate op");
705
+ expect(truncOp.args[0]).toBe(forkedThreadId);
706
+ expect(truncOp.args[1]).toBe("parent-asst-msg-1");
663
707
  });
664
708
 
665
709
  // --- maxTurns of 1 ---
@@ -196,6 +196,7 @@ export async function createSession<
196
196
  appendSystemMessage,
197
197
  appendAgentMessage,
198
198
  forkThread,
199
+ truncateThread,
199
200
  loadThreadState,
200
201
  saveThreadState,
201
202
  hydrateThread,
@@ -399,6 +400,17 @@ export async function createSession<
399
400
  // is already hot or when no cold tier is wired.
400
401
  await hydrateThread(sourceThreadId, threadKey);
401
402
  await forkThread(sourceThreadId, threadId, threadKey);
403
+ // If the caller asked to drop the tail of the forked thread
404
+ // (e.g. subagent forking its parent mid-tool-call needs to
405
+ // strip the orphan assistant `tool_use`), do it now — before
406
+ // any rehydration / state load so the truncated thread is
407
+ // what subsequent reads see.
408
+ const truncate = (
409
+ threadInit as { mode: "fork"; truncateAfterFork?: { fromMessageId: string } }
410
+ ).truncateAfterFork;
411
+ if (truncate?.fromMessageId) {
412
+ await truncateThread(threadId, truncate.fromMessageId, threadKey);
413
+ }
402
414
  const forkedSlice = await loadThreadState(threadId, threadKey);
403
415
  if (forkedSlice) rehydrateFromSlice(forkedSlice);
404
416
  } else if (threadMode === "continue") {
@@ -570,6 +582,9 @@ export async function createSession<
570
582
  {
571
583
  turn: currentTurn,
572
584
  ...(sandboxId !== undefined && { sandboxId }),
585
+ ...(assistantId !== undefined && {
586
+ assistantMessageId: assistantId,
587
+ }),
573
588
  }
574
589
  );
575
590
 
@@ -44,6 +44,7 @@ export function defineSubagent<
44
44
  enabled?: boolean | (() => boolean);
45
45
  taskQueue?: string;
46
46
  thread?: "new" | "fork" | "continue";
47
+ newThreadSource?: "new" | "from-parent";
47
48
  sandbox?: SubagentSandboxConfig;
48
49
  }
49
50
  ): SubagentConfig<TResult> {
@@ -237,16 +237,51 @@ export function createSubagentHandler<
237
237
 
238
238
  const threadMode = config.thread ?? "new";
239
239
  const allowsContinuation = threadMode !== "new";
240
- const continuationThreadId =
241
- args.threadId && allowsContinuation ? args.threadId : undefined;
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 usingParentFallback =
248
+ allowsContinuation &&
249
+ !args.threadId &&
250
+ newThreadSource === "from-parent";
251
+ const continuationThreadId = !allowsContinuation
252
+ ? undefined
253
+ : (args.threadId ?? (usingParentFallback ? context.threadId : undefined));
242
254
 
243
255
  // --- Build thread init ---
244
256
  let thread: ThreadInit | undefined;
245
257
  if (continuationThreadId) {
246
- thread = {
247
- mode: threadMode as "fork" | "continue",
248
- threadId: continuationThreadId,
249
- };
258
+ if (threadMode === "fork") {
259
+ // When falling back to the parent's thread for a fork, the
260
+ // parent is mid-tool-call: its assistant message containing the
261
+ // `Subagent` tool_use has already been persisted, but its
262
+ // matching tool_result hasn't. Forking verbatim would leave an
263
+ // orphan tool_use at the tail of the child thread, which most
264
+ // model APIs reject on the very next call. Have the child's
265
+ // session truncate that assistant message (and anything after)
266
+ // immediately after the fork so the first model call sees a
267
+ // well-formed history.
268
+ thread = {
269
+ mode: "fork",
270
+ threadId: continuationThreadId,
271
+ ...(usingParentFallback && context.assistantMessageId
272
+ ? {
273
+ truncateAfterFork: {
274
+ fromMessageId: context.assistantMessageId,
275
+ },
276
+ }
277
+ : {}),
278
+ };
279
+ } else {
280
+ thread = {
281
+ mode: "continue",
282
+ threadId: continuationThreadId,
283
+ };
284
+ }
250
285
  }
251
286
 
252
287
  // --- Build sandbox init ---
@@ -635,6 +635,184 @@ 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
+ {
657
+ threadId: "parent-t",
658
+ toolCallId: "tc",
659
+ toolName: "Subagent",
660
+ assistantMessageId: "parent-asst-msg-1",
661
+ }
662
+ );
663
+
664
+ const lastCall = execMock.mock.calls[execMock.mock.calls.length - 1];
665
+ if (!lastCall) throw new Error("expected executeChild call");
666
+ const workflowInput = lastCall[1].args[1] as SubagentWorkflowInput;
667
+ expect(workflowInput.thread).toEqual({
668
+ mode: "fork",
669
+ threadId: "parent-t",
670
+ truncateAfterFork: { fromMessageId: "parent-asst-msg-1" },
671
+ });
672
+ });
673
+
674
+ it("omits truncateAfterFork on parent-fallback fork when assistantMessageId is absent from context", async () => {
675
+ const { executeChild } = await import("@temporalio/workflow");
676
+ const execMock = executeChild as ReturnType<typeof vi.fn>;
677
+
678
+ const subagent: SubagentConfig = {
679
+ agentName: "parent-fork-no-asst",
680
+ description: "Forks parent thread by default",
681
+ workflow: mockWorkflow(),
682
+ thread: "fork",
683
+ newThreadSource: "from-parent",
684
+ };
685
+
686
+ const { handler } = createSubagentHandler([subagent]);
687
+
688
+ await handler(
689
+ { subagent: "parent-fork-no-asst", description: "test", prompt: "test" },
690
+ { threadId: "parent-t", toolCallId: "tc", toolName: "Subagent" }
691
+ );
692
+
693
+ const lastCall = execMock.mock.calls[execMock.mock.calls.length - 1];
694
+ if (!lastCall) throw new Error("expected executeChild call");
695
+ const workflowInput = lastCall[1].args[1] as SubagentWorkflowInput;
696
+ expect(workflowInput.thread).toEqual({
697
+ mode: "fork",
698
+ threadId: "parent-t",
699
+ });
700
+ });
701
+
702
+ it("uses the parent's threadId when continue + newThreadSource 'from-parent' and args.threadId is absent", async () => {
703
+ const { executeChild } = await import("@temporalio/workflow");
704
+ const execMock = executeChild as ReturnType<typeof vi.fn>;
705
+
706
+ const subagent: SubagentConfig = {
707
+ agentName: "parent-continue",
708
+ description: "Continues parent thread by default",
709
+ workflow: mockWorkflow(),
710
+ thread: "continue",
711
+ newThreadSource: "from-parent",
712
+ };
713
+
714
+ const { handler } = createSubagentHandler([subagent]);
715
+
716
+ await handler(
717
+ { subagent: "parent-continue", description: "test", prompt: "test" },
718
+ { threadId: "parent-t", toolCallId: "tc", toolName: "Subagent" }
719
+ );
720
+
721
+ const lastCall = execMock.mock.calls[execMock.mock.calls.length - 1];
722
+ if (!lastCall) throw new Error("expected executeChild call");
723
+ const workflowInput = lastCall[1].args[1] as SubagentWorkflowInput;
724
+ expect(workflowInput.thread).toEqual({
725
+ mode: "continue",
726
+ threadId: "parent-t",
727
+ });
728
+ });
729
+
730
+ it("prefers args.threadId over the parent source when both are available and skips truncateAfterFork", async () => {
731
+ const { executeChild } = await import("@temporalio/workflow");
732
+ const execMock = executeChild as ReturnType<typeof vi.fn>;
733
+
734
+ const subagent: SubagentConfig = {
735
+ agentName: "parent-fork-explicit",
736
+ description: "Forks parent thread by default",
737
+ workflow: mockWorkflow(),
738
+ thread: "fork",
739
+ newThreadSource: "from-parent",
740
+ };
741
+
742
+ const { handler } = createSubagentHandler([subagent]);
743
+
744
+ await handler(
745
+ {
746
+ subagent: "parent-fork-explicit",
747
+ description: "test",
748
+ prompt: "test",
749
+ threadId: "explicit-prev",
750
+ },
751
+ {
752
+ threadId: "parent-t",
753
+ toolCallId: "tc",
754
+ toolName: "Subagent",
755
+ assistantMessageId: "parent-asst-msg-1",
756
+ }
757
+ );
758
+
759
+ const lastCall = execMock.mock.calls[execMock.mock.calls.length - 1];
760
+ if (!lastCall) throw new Error("expected executeChild call");
761
+ const workflowInput = lastCall[1].args[1] as SubagentWorkflowInput;
762
+ expect(workflowInput.thread).toEqual({
763
+ mode: "fork",
764
+ threadId: "explicit-prev",
765
+ });
766
+ });
767
+
768
+ it("ignores newThreadSource 'from-parent' when thread is new", async () => {
769
+ const { executeChild } = await import("@temporalio/workflow");
770
+ const execMock = executeChild as ReturnType<typeof vi.fn>;
771
+
772
+ const subagent: SubagentConfig = {
773
+ agentName: "new-with-source",
774
+ description: "Should still start fresh",
775
+ workflow: mockWorkflow(),
776
+ newThreadSource: "from-parent",
777
+ };
778
+
779
+ const { handler } = createSubagentHandler([subagent]);
780
+
781
+ await handler(
782
+ { subagent: "new-with-source", description: "test", prompt: "test" },
783
+ { threadId: "parent-t", toolCallId: "tc", toolName: "Subagent" }
784
+ );
785
+
786
+ const lastCall = execMock.mock.calls[execMock.mock.calls.length - 1];
787
+ if (!lastCall) throw new Error("expected executeChild call");
788
+ const workflowInput = lastCall[1].args[1] as SubagentWorkflowInput;
789
+ expect(workflowInput.thread).toBeUndefined();
790
+ });
791
+
792
+ it("preserves prior behavior when fork is set with default newThreadSource and args.threadId is absent", async () => {
793
+ const { executeChild } = await import("@temporalio/workflow");
794
+ const execMock = executeChild as ReturnType<typeof vi.fn>;
795
+
796
+ const subagent: SubagentConfig = {
797
+ agentName: "fork-default-source",
798
+ description: "Fork with default new-thread source",
799
+ workflow: mockWorkflow(),
800
+ thread: "fork",
801
+ };
802
+
803
+ const { handler } = createSubagentHandler([subagent]);
804
+
805
+ await handler(
806
+ { subagent: "fork-default-source", description: "test", prompt: "test" },
807
+ { threadId: "parent-t", toolCallId: "tc", toolName: "Subagent" }
808
+ );
809
+
810
+ const lastCall = execMock.mock.calls[execMock.mock.calls.length - 1];
811
+ if (!lastCall) throw new Error("expected executeChild call");
812
+ const workflowInput = lastCall[1].args[1] as SubagentWorkflowInput;
813
+ expect(workflowInput.thread).toBeUndefined();
814
+ });
815
+
638
816
  // --- Sandbox continuation ---
639
817
 
640
818
  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
  *
@@ -16,9 +16,9 @@ interface CommandInput {
16
16
  }
17
17
 
18
18
  /**
19
- * Minimal in-memory S3-shaped client. The `send()` method dispatches
20
- * by the command's constructor name, mirroring the contract that
21
- * `@aws-sdk/client-s3` exposes.
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
22
  */
23
23
  function createFakeS3(): {
24
24
  s3: S3LikeClient;
@@ -31,7 +31,12 @@ function createFakeS3(): {
31
31
  const compositeKey = (bucket: string, key: string): string =>
32
32
  `${bucket}/${key}`;
33
33
 
34
- const s3: S3LikeClient = {
34
+ const s3 = {
35
+ config: {
36
+ requestHandler: undefined,
37
+ forcePathStyle: false,
38
+ endpoint: async (): Promise<URL> => new URL("https://fake.s3.local"),
39
+ },
35
40
  async send<TInput, TOutput>(
36
41
  command: { input: TInput } & object
37
42
  ): Promise<TOutput> {
@@ -68,7 +73,7 @@ function createFakeS3(): {
68
73
  }
69
74
  throw new Error(`unknown command: ${name}`);
70
75
  },
71
- };
76
+ } as unknown as S3LikeClient;
72
77
 
73
78
  return { s3, store, calls };
74
79
  }
@@ -190,4 +195,27 @@ describe("createS3ColdStore", () => {
190
195
  });
191
196
  expect(await cold.read("messages", "t-1")).toEqual(sampleSnapshot);
192
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
+ });
193
221
  });
@@ -18,9 +18,17 @@
18
18
  * last-writer-wins; no compare-and-swap is required.
19
19
  */
20
20
 
21
- import { gzipSync, gunzipSync } from "node:zlib";
21
+ import { gunzip as gunzipCb, gzip as gzipCb } from "node:zlib";
22
+ import { promisify } from "node:util";
22
23
  import type { PersistedThreadState } from "../state/types";
23
- import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
24
+ import { DeleteObjectCommand, GetObjectCommand, type S3Client } from "@aws-sdk/client-s3";
25
+ import { Upload } from "@aws-sdk/lib-storage";
26
+ import { getActivityContext } from "../activity";
27
+
28
+ // Async zlib so gzip/gunzip don't block the worker's event loop
29
+ // during compression of large snapshots.
30
+ const gzipAsync = promisify(gzipCb);
31
+ const gunzipAsync = promisify(gunzipCb);
24
32
 
25
33
  /**
26
34
  * Serialized form of a thread that can be written to and read from a
@@ -69,20 +77,16 @@ export interface ColdThreadStore {
69
77
  }
70
78
 
71
79
  /**
72
- * Compact, duck-typed shape of an S3 client. Zeitlich only needs the
73
- * `send(...)` method; declaring this locally avoids forcing
74
- * `@aws-sdk/client-s3` to be installed when the consumer is using a
75
- * different cold-store backend.
80
+ * Alias for `@aws-sdk/client-s3`'s `S3Client`. The built-in store
81
+ * calls `send(...)` and accesses `client.config` (read by
82
+ * `@aws-sdk/lib-storage`'s `Upload`) a duck-type with just `send`
83
+ * is not sufficient.
76
84
  */
77
- export interface S3LikeClient {
78
- send<TInput, TOutput>(
79
- command: { input: TInput } & object
80
- ): Promise<TOutput>;
81
- }
85
+ export type S3LikeClient = S3Client;
82
86
 
83
87
  /** Configuration for the built-in S3 cold store. */
84
88
  export interface S3ColdStoreConfig {
85
- /** An `@aws-sdk/client-s3` `S3Client` (or duck-typed equivalent). */
89
+ /** An `@aws-sdk/client-s3` `S3Client`. */
86
90
  s3: S3LikeClient;
87
91
  /** S3 bucket that holds the archive. */
88
92
  bucket: string;
@@ -126,10 +130,26 @@ function buildKey(
126
130
  }
127
131
 
128
132
  async function streamToBuffer(
129
- body: unknown
133
+ body: unknown,
134
+ onChunk?: () => void
130
135
  ): Promise<Buffer> {
131
136
  if (body == null) return Buffer.alloc(0);
132
137
  if (body instanceof Uint8Array) return Buffer.from(body);
138
+ // Prefer async iteration so `onChunk` fires per chunk. Node S3
139
+ // bodies (`SdkStream<Readable>`) iterate; bulk-read fallbacks
140
+ // below cover browser body shapes.
141
+ if (
142
+ typeof (body as { [Symbol.asyncIterator]?: unknown })[
143
+ Symbol.asyncIterator
144
+ ] === "function"
145
+ ) {
146
+ const chunks: Buffer[] = [];
147
+ for await (const chunk of body as AsyncIterable<Buffer | Uint8Array>) {
148
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
149
+ onChunk?.();
150
+ }
151
+ return Buffer.concat(chunks);
152
+ }
133
153
  if (typeof (body as { transformToByteArray?: () => Promise<Uint8Array> })
134
154
  .transformToByteArray === "function") {
135
155
  const bytes = await (
@@ -144,12 +164,7 @@ async function streamToBuffer(
144
164
  ).arrayBuffer();
145
165
  return Buffer.from(ab);
146
166
  }
147
- // Node.js Readable stream fallback
148
- const chunks: Buffer[] = [];
149
- for await (const chunk of body as AsyncIterable<Buffer | Uint8Array>) {
150
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
151
- }
152
- return Buffer.concat(chunks);
167
+ return Buffer.alloc(0);
153
168
  }
154
169
 
155
170
  /**
@@ -191,9 +206,10 @@ export function createS3ColdStore(
191
206
  const resp = (await s3.send(
192
207
  new GetObjectCommand({ Bucket: bucket, Key })
193
208
  )) as { Body?: unknown };
194
- const buf = await streamToBuffer(resp.Body);
209
+ const { heartbeat } = getActivityContext();
210
+ const buf = await streamToBuffer(resp.Body, heartbeat);
195
211
  const json = gzip
196
- ? gunzipSync(buf).toString("utf8")
212
+ ? (await gunzipAsync(buf)).toString("utf8")
197
213
  : buf.toString("utf8");
198
214
  return JSON.parse(json) as ThreadSnapshot;
199
215
  } catch (err) {
@@ -209,16 +225,19 @@ export function createS3ColdStore(
209
225
  ): Promise<void> {
210
226
  const Key = buildKey(prefix, threadKey, threadId, gzip);
211
227
  const json = JSON.stringify(snapshot);
212
- const body = gzip ? gzipSync(Buffer.from(json, "utf8")) : json;
213
-
214
- await s3.send(
215
- new PutObjectCommand({
216
- Bucket: bucket,
217
- Key,
218
- Body: body,
219
- ContentType: contentType,
220
- })
221
- );
228
+ const body = gzip ? await gzipAsync(Buffer.from(json, "utf8")) : json;
229
+
230
+ const upload = new Upload({
231
+ client: s3,
232
+ params: { Bucket: bucket, Key, Body: body, ContentType: contentType },
233
+ });
234
+
235
+ // Heartbeat per S3 part completion so a stalled upload trips
236
+ // `heartbeatTimeout` instead of `startToCloseTimeout`.
237
+ const { heartbeat } = getActivityContext();
238
+ if (heartbeat) upload.on("httpUploadProgress", heartbeat);
239
+
240
+ await upload.done();
222
241
  },
223
242
 
224
243
  async delete(