zeitlich 0.2.38 → 0.2.39

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 (125) hide show
  1. package/README.md +18 -0
  2. package/dist/{activities-BKhMtKDd.d.ts → activities-Bmu7XnaG.d.ts} +4 -6
  3. package/dist/{activities-CDcwkRZs.d.cts → activities-ByBFLvm2.d.cts} +4 -6
  4. package/dist/adapter-id-BB-mmrts.d.cts +17 -0
  5. package/dist/adapter-id-BB-mmrts.d.ts +17 -0
  6. package/dist/adapter-id-CMwVrVqv.d.cts +17 -0
  7. package/dist/adapter-id-CMwVrVqv.d.ts +17 -0
  8. package/dist/adapter-id-CbY2zeSt.d.cts +17 -0
  9. package/dist/adapter-id-CbY2zeSt.d.ts +17 -0
  10. package/dist/adapters/thread/anthropic/index.cjs +140 -23
  11. package/dist/adapters/thread/anthropic/index.cjs.map +1 -1
  12. package/dist/adapters/thread/anthropic/index.d.cts +8 -7
  13. package/dist/adapters/thread/anthropic/index.d.ts +8 -7
  14. package/dist/adapters/thread/anthropic/index.js +140 -24
  15. package/dist/adapters/thread/anthropic/index.js.map +1 -1
  16. package/dist/adapters/thread/anthropic/workflow.cjs +8 -3
  17. package/dist/adapters/thread/anthropic/workflow.cjs.map +1 -1
  18. package/dist/adapters/thread/anthropic/workflow.d.cts +5 -4
  19. package/dist/adapters/thread/anthropic/workflow.d.ts +5 -4
  20. package/dist/adapters/thread/anthropic/workflow.js +8 -4
  21. package/dist/adapters/thread/anthropic/workflow.js.map +1 -1
  22. package/dist/adapters/thread/google-genai/index.cjs +140 -23
  23. package/dist/adapters/thread/google-genai/index.cjs.map +1 -1
  24. package/dist/adapters/thread/google-genai/index.d.cts +5 -4
  25. package/dist/adapters/thread/google-genai/index.d.ts +5 -4
  26. package/dist/adapters/thread/google-genai/index.js +140 -24
  27. package/dist/adapters/thread/google-genai/index.js.map +1 -1
  28. package/dist/adapters/thread/google-genai/workflow.cjs +8 -3
  29. package/dist/adapters/thread/google-genai/workflow.cjs.map +1 -1
  30. package/dist/adapters/thread/google-genai/workflow.d.cts +5 -4
  31. package/dist/adapters/thread/google-genai/workflow.d.ts +5 -4
  32. package/dist/adapters/thread/google-genai/workflow.js +8 -4
  33. package/dist/adapters/thread/google-genai/workflow.js.map +1 -1
  34. package/dist/adapters/thread/index.cjs +16 -0
  35. package/dist/adapters/thread/index.cjs.map +1 -0
  36. package/dist/adapters/thread/index.d.cts +34 -0
  37. package/dist/adapters/thread/index.d.ts +34 -0
  38. package/dist/adapters/thread/index.js +12 -0
  39. package/dist/adapters/thread/index.js.map +1 -0
  40. package/dist/adapters/thread/langchain/index.cjs +139 -24
  41. package/dist/adapters/thread/langchain/index.cjs.map +1 -1
  42. package/dist/adapters/thread/langchain/index.d.cts +8 -7
  43. package/dist/adapters/thread/langchain/index.d.ts +8 -7
  44. package/dist/adapters/thread/langchain/index.js +139 -25
  45. package/dist/adapters/thread/langchain/index.js.map +1 -1
  46. package/dist/adapters/thread/langchain/workflow.cjs +8 -3
  47. package/dist/adapters/thread/langchain/workflow.cjs.map +1 -1
  48. package/dist/adapters/thread/langchain/workflow.d.cts +5 -4
  49. package/dist/adapters/thread/langchain/workflow.d.ts +5 -4
  50. package/dist/adapters/thread/langchain/workflow.js +8 -4
  51. package/dist/adapters/thread/langchain/workflow.js.map +1 -1
  52. package/dist/index.cjs +266 -48
  53. package/dist/index.cjs.map +1 -1
  54. package/dist/index.d.cts +6 -6
  55. package/dist/index.d.ts +6 -6
  56. package/dist/index.js +263 -49
  57. package/dist/index.js.map +1 -1
  58. package/dist/{proxy-D_3x7RN4.d.cts → proxy-BAKzNGRq.d.cts} +1 -1
  59. package/dist/{proxy-CUlKSvZS.d.ts → proxy-DO_MXbY4.d.ts} +1 -1
  60. package/dist/{thread-manager-CVu7o2cs.d.ts → thread-manager-CcRXasqs.d.ts} +2 -4
  61. package/dist/{thread-manager-HSwyh28L.d.cts → thread-manager-ClwSaUnj.d.cts} +2 -4
  62. package/dist/{thread-manager-c1gPopAG.d.ts → thread-manager-D-7lp1JK.d.ts} +2 -4
  63. package/dist/{thread-manager-wGi-LqIP.d.cts → thread-manager-Y8Ucf0Tf.d.cts} +2 -4
  64. package/dist/{types-C06FwR96.d.cts → types-Bcbiq8iv.d.cts} +162 -44
  65. package/dist/{types-BH_IRryz.d.ts → types-DpHTX-iO.d.ts} +54 -6
  66. package/dist/{types-DNr31FzL.d.ts → types-Dt8-HBBT.d.ts} +162 -44
  67. package/dist/{types-BaOw4hKI.d.cts → types-hFFi-Zd9.d.cts} +54 -6
  68. package/dist/{workflow-CSCkpwAL.d.ts → workflow-Bmf9EtDW.d.ts} +82 -2
  69. package/dist/{workflow-DuvMZ8Vm.d.cts → workflow-Bx9utBwb.d.cts} +82 -2
  70. package/dist/workflow.cjs +188 -37
  71. package/dist/workflow.cjs.map +1 -1
  72. package/dist/workflow.d.cts +2 -2
  73. package/dist/workflow.d.ts +2 -2
  74. package/dist/workflow.js +185 -38
  75. package/dist/workflow.js.map +1 -1
  76. package/package.json +11 -1
  77. package/src/adapters/thread/adapter-id.test.ts +42 -0
  78. package/src/adapters/thread/anthropic/activities.ts +33 -7
  79. package/src/adapters/thread/anthropic/adapter-id.ts +16 -0
  80. package/src/adapters/thread/anthropic/fork-transform.test.ts +291 -0
  81. package/src/adapters/thread/anthropic/index.ts +3 -0
  82. package/src/adapters/thread/anthropic/model-invoker.ts +8 -4
  83. package/src/adapters/thread/anthropic/proxy.ts +3 -2
  84. package/src/adapters/thread/anthropic/thread-manager.ts +27 -4
  85. package/src/adapters/thread/google-genai/activities.ts +33 -7
  86. package/src/adapters/thread/google-genai/adapter-id.ts +16 -0
  87. package/src/adapters/thread/google-genai/fork-transform.test.ts +149 -0
  88. package/src/adapters/thread/google-genai/index.ts +3 -0
  89. package/src/adapters/thread/google-genai/model-invoker.ts +7 -3
  90. package/src/adapters/thread/google-genai/proxy.ts +3 -2
  91. package/src/adapters/thread/google-genai/thread-manager.ts +27 -4
  92. package/src/adapters/thread/index.ts +39 -0
  93. package/src/adapters/thread/langchain/activities.ts +33 -7
  94. package/src/adapters/thread/langchain/adapter-id.ts +16 -0
  95. package/src/adapters/thread/langchain/fork-transform.test.ts +142 -0
  96. package/src/adapters/thread/langchain/index.ts +3 -0
  97. package/src/adapters/thread/langchain/model-invoker.ts +8 -3
  98. package/src/adapters/thread/langchain/proxy.ts +3 -2
  99. package/src/adapters/thread/langchain/thread-manager.ts +27 -4
  100. package/src/lib/lifecycle.ts +3 -1
  101. package/src/lib/model/types.ts +7 -10
  102. package/src/lib/session/session-edge-cases.integration.test.ts +131 -63
  103. package/src/lib/session/session.integration.test.ts +174 -5
  104. package/src/lib/session/session.ts +68 -28
  105. package/src/lib/session/types.ts +60 -9
  106. package/src/lib/state/index.ts +1 -0
  107. package/src/lib/state/manager.integration.test.ts +109 -0
  108. package/src/lib/state/manager.ts +38 -8
  109. package/src/lib/state/types.ts +25 -0
  110. package/src/lib/subagent/handler.ts +124 -11
  111. package/src/lib/subagent/index.ts +5 -1
  112. package/src/lib/subagent/subagent.integration.test.ts +528 -0
  113. package/src/lib/subagent/types.ts +63 -14
  114. package/src/lib/subagent/workflow.ts +29 -2
  115. package/src/lib/thread/index.ts +5 -0
  116. package/src/lib/thread/keys.test.ts +101 -0
  117. package/src/lib/thread/keys.ts +94 -0
  118. package/src/lib/thread/manager.test.ts +139 -0
  119. package/src/lib/thread/manager.ts +92 -14
  120. package/src/lib/thread/proxy.ts +2 -0
  121. package/src/lib/thread/types.ts +60 -6
  122. package/src/lib/tool-router/types.ts +16 -8
  123. package/src/lib/types.ts +12 -0
  124. package/src/workflow.ts +12 -1
  125. package/tsup.config.ts +1 -0
@@ -12,6 +12,7 @@ import type {
12
12
  SubagentSessionInput,
13
13
  } from "./types";
14
14
  import type { SubagentSandboxShutdown } from "../lifecycle";
15
+ import type { SandboxSnapshot } from "../sandbox/types";
15
16
  import { childSandboxReadySignal } from "./signals";
16
17
 
17
18
  /**
@@ -50,6 +51,8 @@ import { childSandboxReadySignal } from "./signals";
50
51
  * });
51
52
  *
52
53
  * const { finalMessage, threadId } = await session.runSession({ stateManager });
54
+ * // `sandboxId`, `snapshot`, and `baseSnapshot` are auto-forwarded
55
+ * // from the session — no need to thread them through manually.
53
56
  * return { toolResponse: finalMessage ?? "No response", data: null, threadId };
54
57
  * },
55
58
  * );
@@ -125,23 +128,47 @@ export function defineSubagentWorkflow(
125
128
  }
126
129
  const parentHandle = getExternalWorkflowHandle(parent.workflowId);
127
130
 
131
+ let capturedSandboxId: string | undefined;
132
+ let capturedSnapshot: SandboxSnapshot | undefined;
133
+ let capturedBaseSnapshot: SandboxSnapshot | undefined;
134
+ let capturedThreadId: string | undefined;
128
135
  const sessionInput: SubagentSessionInput = {
129
136
  agentName: config.name,
130
137
  sandboxShutdown: effectiveShutdown,
131
138
  ...(workflowInput.thread && { thread: workflowInput.thread }),
132
139
  ...(workflowInput.sandbox && { sandbox: workflowInput.sandbox }),
133
- onSandboxReady: (sandboxId: string) => {
140
+ onSandboxReady: ({ sandboxId, baseSnapshot }) => {
141
+ capturedBaseSnapshot = baseSnapshot;
134
142
  const isReuse = workflowInput.sandbox?.mode === "continue";
135
143
  if (!isReuse) {
136
144
  void parentHandle.signal(childSandboxReadySignal, {
137
145
  childWorkflowId: workflowInfo().workflowId,
138
146
  sandboxId,
147
+ ...(baseSnapshot && { baseSnapshot }),
139
148
  });
140
149
  }
141
150
  },
151
+ onSessionExit: ({ sandboxId, snapshot, threadId }) => {
152
+ capturedSandboxId = sandboxId;
153
+ capturedSnapshot = snapshot;
154
+ capturedThreadId = threadId;
155
+ },
142
156
  };
143
157
 
144
- return fn(prompt, sessionInput, context ?? {});
158
+ const result = await fn(prompt, sessionInput, context ?? {});
159
+
160
+ // Auto-forward sandbox outputs captured from the session so user code
161
+ // never has to thread them through manually. Explicit values on the fn
162
+ // result take precedence.
163
+ return {
164
+ ...result,
165
+ ...(capturedThreadId !== undefined && { threadId: capturedThreadId }),
166
+ ...(capturedSandboxId !== undefined && { sandboxId: capturedSandboxId }),
167
+ ...(capturedSnapshot !== undefined && { snapshot: capturedSnapshot }),
168
+ ...(capturedBaseSnapshot !== undefined && {
169
+ baseSnapshot: capturedBaseSnapshot,
170
+ }),
171
+ };
145
172
  };
146
173
 
147
174
  // for temporal workflow name
@@ -1,5 +1,10 @@
1
1
  export { createThreadManager } from "./manager";
2
2
  export { createThreadOpsProxy } from "./proxy";
3
+ export {
4
+ THREAD_TTL_SECONDS,
5
+ getThreadListKey,
6
+ getThreadMetaKey,
7
+ } from "./keys";
3
8
 
4
9
  export type {
5
10
  ThreadManagerConfig,
@@ -0,0 +1,101 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import {
3
+ getThreadListKey,
4
+ getThreadMetaKey,
5
+ THREAD_TTL_SECONDS,
6
+ } from "./keys";
7
+ import { createThreadManager } from "./manager";
8
+
9
+ describe("thread keys (public helpers)", () => {
10
+ it("getThreadListKey matches the internal list-key format", () => {
11
+ expect(getThreadListKey("messages", "abc")).toBe("messages:thread:abc");
12
+ expect(getThreadListKey("myScope", "xyz")).toBe("myScope:thread:xyz");
13
+ });
14
+
15
+ it("getThreadMetaKey matches the internal meta-key format", () => {
16
+ expect(getThreadMetaKey("messages", "abc")).toBe(
17
+ "messages:meta:thread:abc"
18
+ );
19
+ expect(getThreadMetaKey("myScope", "xyz")).toBe("myScope:meta:thread:xyz");
20
+ });
21
+
22
+ it("THREAD_TTL_SECONDS is 90 days", () => {
23
+ expect(THREAD_TTL_SECONDS).toBe(60 * 60 * 24 * 90);
24
+ });
25
+ });
26
+
27
+ describe("createThreadManager ↔ public key helpers round-trip", () => {
28
+ it("writes and reads via the exact keys returned by the public helpers", async () => {
29
+ const store = new Map<string, string[]>();
30
+ const meta = new Map<string, string>();
31
+
32
+ const writtenListExpires = new Map<string, number>();
33
+ const writtenMetaExpires = new Map<string, number>();
34
+
35
+ const redis = {
36
+ exists: vi.fn(async (k: string) => (meta.has(k) ? 1 : 0)),
37
+ set: vi.fn(
38
+ async (k: string, v: string, _ex: string, ttl: number) => {
39
+ meta.set(k, v);
40
+ writtenMetaExpires.set(k, ttl);
41
+ return "OK";
42
+ }
43
+ ),
44
+ del: vi.fn(async (...keys: string[]) => {
45
+ let n = 0;
46
+ for (const k of keys) {
47
+ if (store.delete(k)) n++;
48
+ if (meta.delete(k)) n++;
49
+ }
50
+ return n;
51
+ }),
52
+ rpush: vi.fn(async (k: string, ...values: string[]) => {
53
+ const list = store.get(k) ?? [];
54
+ list.push(...values);
55
+ store.set(k, list);
56
+ return list.length;
57
+ }),
58
+ lrange: vi.fn(async (k: string) => store.get(k) ?? []),
59
+ llen: vi.fn(async (k: string) => (store.get(k) ?? []).length),
60
+ ltrim: vi.fn(async () => "OK"),
61
+ expire: vi.fn(async (k: string, ttl: number) => {
62
+ if (store.has(k)) writtenListExpires.set(k, ttl);
63
+ if (meta.has(k)) writtenMetaExpires.set(k, ttl);
64
+ return 1;
65
+ }),
66
+ eval: vi.fn(async () => 1),
67
+ };
68
+
69
+ const threadKey = "messages";
70
+ const threadId = "t1";
71
+ const tm = createThreadManager<{ id: string }>({
72
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
73
+ redis: redis as any,
74
+ threadId,
75
+ key: threadKey,
76
+ idOf: (m) => m.id,
77
+ });
78
+
79
+ await tm.initialize();
80
+
81
+ const expectedList = getThreadListKey(threadKey, threadId);
82
+ const expectedMeta = getThreadMetaKey(threadKey, threadId);
83
+
84
+ expect(meta.has(expectedMeta)).toBe(true);
85
+ expect(writtenMetaExpires.get(expectedMeta)).toBe(THREAD_TTL_SECONDS);
86
+
87
+ // Append uses rpush (idOf set → eval path; bypass eval for this check by
88
+ // calling rpush path via no-idOf manager)
89
+ const tm2 = createThreadManager<{ id: string }>({
90
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
91
+ redis: redis as any,
92
+ threadId,
93
+ key: threadKey,
94
+ });
95
+ await tm2.append([{ id: "m1" }]);
96
+
97
+ expect(store.has(expectedList)).toBe(true);
98
+ expect(store.get(expectedList)).toEqual([JSON.stringify({ id: "m1" })]);
99
+ expect(writtenListExpires.get(expectedList)).toBe(THREAD_TTL_SECONDS);
100
+ });
101
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Public helpers for zeitlich's Redis thread storage layout.
3
+ *
4
+ * These are the exact string-building primitives zeitlich's internal thread
5
+ * manager uses for every adapter. Downstream consumers that need to read a
6
+ * persisted thread (for evaluation, observability, admin tooling, etc.)
7
+ * should use these helpers rather than reconstructing the key layout by
8
+ * hand — the layout is versioned with this module, so upgrading zeitlich
9
+ * keeps the consumer in sync.
10
+ *
11
+ * The layout is adapter-agnostic: every thread adapter stores messages the
12
+ * same way.
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * import {
17
+ * getThreadListKey,
18
+ * getThreadMetaKey,
19
+ * THREAD_TTL_SECONDS,
20
+ * } from 'zeitlich';
21
+ *
22
+ * const listKey = getThreadListKey('messages', threadId);
23
+ * const metaKey = getThreadMetaKey('messages', threadId);
24
+ * const ttl = await redis.ttl(listKey); // <= THREAD_TTL_SECONDS
25
+ * ```
26
+ */
27
+
28
+ /**
29
+ * TTL (in seconds) applied to every thread list and thread meta key that
30
+ * zeitlich's {@link createThreadManager} writes. Exposed so downstream
31
+ * consumers can size their Redis retention / query windows to match.
32
+ *
33
+ * Current value: 90 days.
34
+ */
35
+ export const THREAD_TTL_SECONDS = 60 * 60 * 24 * 90;
36
+
37
+ /**
38
+ * Build the Redis list key that holds a thread's serialized messages.
39
+ *
40
+ * Mirrors the exact key used internally by zeitlich's thread manager,
41
+ * so a consumer calling `redis.lrange(getThreadListKey(key, id), 0, -1)`
42
+ * sees the same data the writer wrote.
43
+ *
44
+ * @param threadKey - Thread key (defaults to `"messages"` inside the
45
+ * thread manager, but downstream adapters may pass
46
+ * their own value).
47
+ * @param threadId - Thread id as provided to the thread manager.
48
+ */
49
+ export function getThreadListKey(
50
+ threadKey: string,
51
+ threadId: string
52
+ ): string {
53
+ return `${threadKey}:thread:${threadId}`;
54
+ }
55
+
56
+ /**
57
+ * Build the Redis key that stores a thread's existence marker / metadata.
58
+ *
59
+ * Zeitlich treats the presence of this key as "thread has been
60
+ * initialized"; append/load/fork/truncate operations fail when it is
61
+ * missing. Consumers can use it as a cheap existence probe without
62
+ * scanning the message list.
63
+ *
64
+ * @param threadKey - Thread key (defaults to `"messages"` inside the
65
+ * thread manager, but downstream adapters may pass
66
+ * their own value).
67
+ * @param threadId - Thread id as provided to the thread manager.
68
+ */
69
+ export function getThreadMetaKey(
70
+ threadKey: string,
71
+ threadId: string
72
+ ): string {
73
+ return `${threadKey}:meta:thread:${threadId}`;
74
+ }
75
+
76
+ /**
77
+ * Build the Redis key that stores a thread's persisted state slice
78
+ * (tasks + custom state) written by zeitlich's session loop on every
79
+ * exit path.
80
+ *
81
+ * Consumers can read this key with `redis.get(getThreadStateKey(key, id))`
82
+ * and `JSON.parse` the result into a {@link PersistedThreadState}.
83
+ *
84
+ * @param threadKey - Thread key (defaults to `"messages"` inside the
85
+ * thread manager, but downstream adapters may pass
86
+ * their own value).
87
+ * @param threadId - Thread id as provided to the thread manager.
88
+ */
89
+ export function getThreadStateKey(
90
+ threadKey: string,
91
+ threadId: string
92
+ ): string {
93
+ return `${threadKey}:state:thread:${threadId}`;
94
+ }
@@ -0,0 +1,139 @@
1
+ import { describe, expect, it, beforeEach } from "vitest";
2
+ import type Redis from "ioredis";
3
+ import { createThreadManager } from "./manager";
4
+ import type { PersistedThreadState } from "../state/types";
5
+
6
+ /**
7
+ * Minimal in-memory Redis stub exposing just the commands used by
8
+ * `createThreadManager`'s state methods (get/set/del/exists/expire) plus
9
+ * the list helpers needed for `initialize`.
10
+ */
11
+ function createFakeRedis(): Redis {
12
+ const store = new Map<string, string>();
13
+
14
+ const redis = {
15
+ async get(key: string): Promise<string | null> {
16
+ return store.has(key) ? (store.get(key) as string) : null;
17
+ },
18
+ async set(key: string, value: string): Promise<"OK"> {
19
+ store.set(key, String(value));
20
+ return "OK";
21
+ },
22
+ async del(...keys: string[]): Promise<number> {
23
+ let removed = 0;
24
+ for (const k of keys) {
25
+ if (store.delete(k)) removed++;
26
+ }
27
+ return removed;
28
+ },
29
+ async exists(...keys: string[]): Promise<number> {
30
+ return keys.reduce((acc, k) => acc + (store.has(k) ? 1 : 0), 0);
31
+ },
32
+ async expire(_key: string, _ttl: number): Promise<number> {
33
+ return 1;
34
+ },
35
+ async lrange(): Promise<string[]> {
36
+ return [];
37
+ },
38
+ async rpush(): Promise<number> {
39
+ return 0;
40
+ },
41
+ _store: store,
42
+ } as unknown as Redis & { _store: Map<string, string> };
43
+
44
+ return redis;
45
+ }
46
+
47
+ const baseSlice: PersistedThreadState = {
48
+ tasks: [
49
+ [
50
+ "t1",
51
+ {
52
+ id: "t1",
53
+ subject: "do a thing",
54
+ description: "do it",
55
+ activeForm: "doing a thing",
56
+ status: "pending",
57
+ metadata: {},
58
+ blockedBy: [],
59
+ blocks: [],
60
+ },
61
+ ],
62
+ ],
63
+ custom: { counter: 7, label: "hello" },
64
+ };
65
+
66
+ describe("createThreadManager state persistence", () => {
67
+ let redis: Redis & { _store: Map<string, string> };
68
+
69
+ beforeEach(() => {
70
+ redis = createFakeRedis() as Redis & { _store: Map<string, string> };
71
+ });
72
+
73
+ async function initThread(threadId: string): Promise<void> {
74
+ const tm = createThreadManager({ redis, threadId });
75
+ await tm.initialize();
76
+ }
77
+
78
+ it("loadState returns null when nothing has been saved", async () => {
79
+ await initThread("thread-1");
80
+ const tm = createThreadManager({ redis, threadId: "thread-1" });
81
+ expect(await tm.loadState()).toBeNull();
82
+ });
83
+
84
+ it("saveState writes a round-trippable JSON slice", async () => {
85
+ await initThread("thread-1");
86
+ const tm = createThreadManager({ redis, threadId: "thread-1" });
87
+ await tm.saveState(baseSlice);
88
+
89
+ const loaded = await tm.loadState();
90
+ expect(loaded).toEqual(baseSlice);
91
+ });
92
+
93
+ it("saveState throws if the thread was never initialized", async () => {
94
+ const tm = createThreadManager({ redis, threadId: "missing" });
95
+ await expect(tm.saveState(baseSlice)).rejects.toThrow(/does not exist/);
96
+ });
97
+
98
+ it("fork copies the persisted state slice to the new thread", async () => {
99
+ await initThread("source");
100
+ const src = createThreadManager({ redis, threadId: "source" });
101
+ await src.saveState(baseSlice);
102
+
103
+ const dst = await src.fork("target");
104
+
105
+ expect(await dst.loadState()).toEqual(baseSlice);
106
+ });
107
+
108
+ it("fork leaves the new thread's slice null when source has none", async () => {
109
+ await initThread("source");
110
+ const src = createThreadManager({ redis, threadId: "source" });
111
+
112
+ const dst = await src.fork("target");
113
+
114
+ expect(await dst.loadState()).toBeNull();
115
+ });
116
+
117
+ it("deleteState removes only the state key", async () => {
118
+ await initThread("thread-1");
119
+ const tm = createThreadManager({ redis, threadId: "thread-1" });
120
+ await tm.saveState(baseSlice);
121
+
122
+ await tm.deleteState();
123
+
124
+ expect(await tm.loadState()).toBeNull();
125
+ const keys = Array.from(redis._store.keys());
126
+ expect(keys.some((k) => k.includes(":meta:"))).toBe(true);
127
+ expect(keys.some((k) => k.includes(":state"))).toBe(false);
128
+ });
129
+
130
+ it("delete removes messages + meta + state together", async () => {
131
+ await initThread("thread-1");
132
+ const tm = createThreadManager({ redis, threadId: "thread-1" });
133
+ await tm.saveState(baseSlice);
134
+
135
+ await tm.delete();
136
+
137
+ expect(redis._store.size).toBe(0);
138
+ });
139
+ });
@@ -1,6 +1,11 @@
1
+ import type { PersistedThreadState } from "../state/types";
1
2
  import type { ThreadManagerConfig, BaseThreadManager } from "./types";
2
-
3
- const THREAD_TTL_SECONDS = 60 * 60 * 24 * 90; // 90 days
3
+ import {
4
+ THREAD_TTL_SECONDS,
5
+ getThreadListKey,
6
+ getThreadMetaKey,
7
+ getThreadStateKey,
8
+ } from "./keys";
4
9
 
5
10
  /**
6
11
  * Lua script for atomic idempotent append.
@@ -23,8 +28,8 @@ redis.call('SET', KEYS[1], '1', 'EX', tonumber(ARGV[1]))
23
28
  return 1
24
29
  `;
25
30
 
26
- function getThreadKey(threadId: string, key: string): string {
27
- return `${key}:thread:${threadId}`;
31
+ function getDedupKey(threadId: string, id: string): string {
32
+ return `dedup:${id}:thread:${threadId}`;
28
33
  }
29
34
 
30
35
  /**
@@ -42,8 +47,9 @@ export function createThreadManager<T>(
42
47
  deserialize = (raw: string): T => JSON.parse(raw) as T,
43
48
  idOf,
44
49
  } = config;
45
- const redisKey = getThreadKey(threadId, key);
46
- const metaKey = getThreadKey(threadId, `${key}:meta`);
50
+ const redisKey = getThreadListKey(key, threadId);
51
+ const metaKey = getThreadMetaKey(key, threadId);
52
+ const stateKey = getThreadStateKey(key, threadId);
47
53
 
48
54
  async function assertThreadExists(): Promise<void> {
49
55
  const exists = await redis.exists(metaKey);
@@ -70,7 +76,7 @@ export function createThreadManager<T>(
70
76
 
71
77
  if (idOf) {
72
78
  const dedupId = messages.map(idOf).join(":");
73
- const dedupKey = getThreadKey(threadId, `dedup:${dedupId}`);
79
+ const dedupKey = getDedupKey(threadId, dedupId);
74
80
  await redis.eval(
75
81
  APPEND_IDEMPOTENT_SCRIPT,
76
82
  2,
@@ -88,21 +94,70 @@ export function createThreadManager<T>(
88
94
  async fork(newThreadId: string): Promise<BaseThreadManager<T>> {
89
95
  await assertThreadExists();
90
96
  const data = await redis.lrange(redisKey, 0, -1);
97
+ const stateRaw = await redis.get(stateKey);
91
98
  const forked = createThreadManager({
92
99
  ...config,
93
100
  threadId: newThreadId,
94
101
  });
95
102
  await forked.initialize();
96
103
  if (data.length > 0) {
97
- const newKey = getThreadKey(newThreadId, key);
104
+ const newKey = getThreadListKey(key, newThreadId);
98
105
  await redis.rpush(newKey, ...data);
99
106
  await redis.expire(newKey, THREAD_TTL_SECONDS);
100
107
  }
108
+ if (stateRaw != null) {
109
+ const newStateKey = getThreadStateKey(key, newThreadId);
110
+ await redis.set(newStateKey, stateRaw, "EX", THREAD_TTL_SECONDS);
111
+ }
101
112
  return forked;
102
113
  },
103
114
 
115
+ async replaceAll(messages: T[]): Promise<void> {
116
+ await assertThreadExists();
117
+ if (!idOf) {
118
+ throw new Error(
119
+ "replaceAll requires the thread manager to be configured with `idOf`"
120
+ );
121
+ }
122
+ const existing = await redis.lrange(redisKey, 0, -1);
123
+ const existingIds = existing
124
+ .map((raw) => idOf(deserialize(raw)))
125
+ .filter((id): id is string => typeof id === "string");
126
+ await redis.del(redisKey);
127
+ if (existingIds.length > 0) {
128
+ await redis.del(
129
+ ...existingIds.map((id) => getDedupKey(threadId, id))
130
+ );
131
+ }
132
+ if (messages.length > 0) {
133
+ await redis.rpush(redisKey, ...messages.map(serialize));
134
+ await redis.expire(redisKey, THREAD_TTL_SECONDS);
135
+ }
136
+ await redis.expire(metaKey, THREAD_TTL_SECONDS);
137
+ },
138
+
104
139
  async delete(): Promise<void> {
105
- await redis.del(redisKey, metaKey);
140
+ await redis.del(redisKey, metaKey, stateKey);
141
+ },
142
+
143
+ async loadState(): Promise<PersistedThreadState | null> {
144
+ const raw = await redis.get(stateKey);
145
+ if (raw == null) return null;
146
+ return JSON.parse(raw) as PersistedThreadState;
147
+ },
148
+
149
+ async saveState(state: PersistedThreadState): Promise<void> {
150
+ await assertThreadExists();
151
+ await redis.set(
152
+ stateKey,
153
+ JSON.stringify(state),
154
+ "EX",
155
+ THREAD_TTL_SECONDS
156
+ );
157
+ },
158
+
159
+ async deleteState(): Promise<void> {
160
+ await redis.del(stateKey);
106
161
  },
107
162
 
108
163
  async length(): Promise<number> {
@@ -110,17 +165,40 @@ export function createThreadManager<T>(
110
165
  return redis.llen(redisKey);
111
166
  },
112
167
 
113
- async truncate(length: number): Promise<void> {
168
+ async truncateFromId(messageId: string): Promise<void> {
114
169
  await assertThreadExists();
115
- if (length <= 0) {
170
+ if (!idOf) {
171
+ throw new Error(
172
+ "truncateFromId requires the thread manager to be configured with `idOf`"
173
+ );
174
+ }
175
+ const data = await redis.lrange(redisKey, 0, -1);
176
+ let idx = -1;
177
+ const removedIds: string[] = [];
178
+ for (let i = 0; i < data.length; i++) {
179
+ const raw = data[i];
180
+ if (raw === undefined) continue;
181
+ const id = idOf(deserialize(raw));
182
+ if (idx === -1 && id === messageId) idx = i;
183
+ if (idx !== -1) removedIds.push(id);
184
+ }
185
+ if (idx === -1) return;
186
+ if (idx === 0) {
116
187
  await redis.del(redisKey);
117
188
  await redis.expire(metaKey, THREAD_TTL_SECONDS);
118
189
  } else {
119
- await redis.ltrim(redisKey, 0, length - 1);
190
+ await redis.ltrim(redisKey, 0, idx - 1);
120
191
  await redis.expire(redisKey, THREAD_TTL_SECONDS);
121
192
  }
122
- // Dedup keys for removed messages are left to expire via their TTL.
123
- // Post-truncate appends use fresh ids so collisions do not occur in practice.
193
+ // Clear dedup markers for the removed messages so that a rewind
194
+ // retry which reuses the same ids (e.g. the same assistantId) can
195
+ // re-append without the idempotent-append Lua script treating it
196
+ // as a duplicate.
197
+ if (removedIds.length > 0) {
198
+ await redis.del(
199
+ ...removedIds.map((id) => getDedupKey(threadId, id))
200
+ );
201
+ }
124
202
  },
125
203
  };
126
204
  }
@@ -54,5 +54,7 @@ export function createThreadOpsProxy(
54
54
  appendSystemMessage: acts[p("appendSystemMessage")],
55
55
  forkThread: acts[p("forkThread")],
56
56
  truncateThread: acts[p("truncateThread")],
57
+ loadThreadState: acts[p("loadThreadState")],
58
+ saveThreadState: acts[p("saveThreadState")],
57
59
  } as ActivityInterfaceFor<ThreadOps>;
58
60
  }
@@ -1,5 +1,5 @@
1
1
  import type Redis from "ioredis";
2
- import type { JsonValue } from "../state/types";
2
+ import type { JsonValue, PersistedThreadState } from "../state/types";
3
3
  export interface ThreadManagerConfig<T> {
4
4
  redis: Redis;
5
5
  threadId: string;
@@ -34,17 +34,50 @@ export interface BaseThreadManager<T> {
34
34
  * forks — each call creates an independent copy.
35
35
  */
36
36
  fork(newThreadId: string): Promise<BaseThreadManager<T>>;
37
+ /**
38
+ * Atomically replace the entire contents of the thread with `messages`.
39
+ * The existing list is cleared, the new messages are appended in order,
40
+ * and dedup markers from prior appends are cleared so future idempotent
41
+ * appends with ids that were removed aren't silently skipped.
42
+ *
43
+ * Requires the thread manager to be configured with `idOf`.
44
+ */
45
+ replaceAll(messages: T[]): Promise<void>;
37
46
  /** Delete the thread */
38
47
  delete(): Promise<void>;
39
48
  /** Get the number of stored messages currently in the thread */
40
49
  length(): Promise<number>;
41
50
  /**
42
- * Truncate the thread to the given length (inclusive). Any messages
43
- * beyond `length` are removed. When `length` is 0 the thread ends up
44
- * empty (but still exists). Also clears any dedup markers so that
45
- * subsequent appends with the same ids replay correctly.
51
+ * Truncate the thread starting at the message with id `messageId`.
52
+ * That message and every message after it are removed. If `messageId`
53
+ * is not present in the thread this is a no-op — useful as the
54
+ * "truncate on entry" step of the `runAgent` activity, which becomes a
55
+ * no-op on the first attempt and a cleanup on Temporal workflow reset
56
+ * or in-workflow rewind retries.
57
+ *
58
+ * Dedup markers for removed single-message appends are also cleared so
59
+ * that appending the same id again (e.g. the same assistant message id
60
+ * on a rewind retry) is not silently skipped.
61
+ *
62
+ * Requires the thread manager to be configured with `idOf`.
63
+ */
64
+ truncateFromId(messageId: string): Promise<void>;
65
+ /**
66
+ * Load the persisted state slice associated with this thread, or
67
+ * `null` if none has been saved yet. Safe to call on any thread —
68
+ * treats a missing slice as a non-error.
46
69
  */
47
- truncate(length: number): Promise<void>;
70
+ loadState(): Promise<PersistedThreadState | null>;
71
+ /**
72
+ * Overwrite the persisted state slice for this thread. The thread
73
+ * itself must already exist (same TTL as the message list).
74
+ *
75
+ * Note: {@link BaseThreadManager.fork} already copies the slice to
76
+ * the new thread, so there's no separate `forkState` method.
77
+ */
78
+ saveState(state: PersistedThreadState): Promise<void>;
79
+ /** Delete just the persisted state slice, leaving messages intact. */
80
+ deleteState(): Promise<void>;
48
81
  }
49
82
 
50
83
  /**
@@ -81,6 +114,27 @@ export interface ThreadManagerHooks<TStored, TPrepared = TStored> {
81
114
  index: number,
82
115
  messages: readonly TPrepared[]
83
116
  ) => TPrepared;
117
+ /**
118
+ * One-shot list-level pre-pass applied once when a thread is forked with
119
+ * `transform: true`. Runs before {@link onForkTransform}. May filter,
120
+ * compact, prepend, or otherwise rewrite the whole forked thread — so the
121
+ * returned length need not match the input length. Async, so implementations
122
+ * may call an LLM or other I/O.
123
+ */
124
+ onForkPrepareThread?: (
125
+ messages: readonly TStored[]
126
+ ) => TStored[] | Promise<TStored[]>;
127
+ /**
128
+ * Per-message final pass applied once when a thread is forked with
129
+ * `transform: true`. Runs after {@link onForkPrepareThread}. Pure 1:1 map —
130
+ * must return a value for every input message; length cannot change. Same
131
+ * shape as {@link onPreparedMessage}.
132
+ */
133
+ onForkTransform?: (
134
+ message: TStored,
135
+ index: number,
136
+ messages: readonly TStored[]
137
+ ) => TStored;
84
138
  }
85
139
 
86
140
  export interface ProviderThreadManager<