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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zeitlich",
3
- "version": "0.2.38",
3
+ "version": "0.2.39",
4
4
  "description": "[EXPERIMENTAL] An opinionated AI agent implementation for Temporal",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -27,6 +27,16 @@
27
27
  "default": "./dist/workflow.js"
28
28
  }
29
29
  },
30
+ "./adapters/thread": {
31
+ "import": {
32
+ "types": "./dist/adapters/thread/index.d.ts",
33
+ "default": "./dist/adapters/thread/index.js"
34
+ },
35
+ "require": {
36
+ "types": "./dist/adapters/thread/index.d.ts",
37
+ "default": "./dist/adapters/thread/index.js"
38
+ }
39
+ },
30
40
  "./adapters/thread/langchain": {
31
41
  "import": {
32
42
  "types": "./dist/adapters/thread/langchain/index.d.ts",
@@ -0,0 +1,42 @@
1
+ import { describe, it, expect, expectTypeOf } from "vitest";
2
+ import { ADAPTER_ID as LANGCHAIN } from "./langchain/adapter-id";
3
+ import { ADAPTER_ID as GOOGLE_GENAI } from "./google-genai/adapter-id";
4
+ import { ADAPTER_ID as ANTHROPIC } from "./anthropic/adapter-id";
5
+ import {
6
+ LANGCHAIN_ADAPTER_ID,
7
+ GOOGLE_GENAI_ADAPTER_ID,
8
+ ANTHROPIC_ADAPTER_ID,
9
+ type ThreadAdapterId,
10
+ } from "./index";
11
+
12
+ describe("thread adapter identity", () => {
13
+ it("langchain ADAPTER_ID is the wire-format string", () => {
14
+ expect(LANGCHAIN).toBe("langChain");
15
+ expect(LANGCHAIN_ADAPTER_ID).toBe("langChain");
16
+ });
17
+
18
+ it("google-genai ADAPTER_ID is the wire-format string", () => {
19
+ expect(GOOGLE_GENAI).toBe("googleGenAI");
20
+ expect(GOOGLE_GENAI_ADAPTER_ID).toBe("googleGenAI");
21
+ });
22
+
23
+ it("anthropic ADAPTER_ID is the wire-format string", () => {
24
+ expect(ANTHROPIC).toBe("anthropic");
25
+ expect(ANTHROPIC_ADAPTER_ID).toBe("anthropic");
26
+ });
27
+
28
+ it("ADAPTER_ID values narrow to string literals, not `string`", () => {
29
+ expectTypeOf(LANGCHAIN).toEqualTypeOf<"langChain">();
30
+ expectTypeOf(GOOGLE_GENAI).toEqualTypeOf<"googleGenAI">();
31
+ expectTypeOf(ANTHROPIC).toEqualTypeOf<"anthropic">();
32
+ });
33
+
34
+ it("ThreadAdapterId is the discriminated union of every built-in id", () => {
35
+ const allow = (_id: ThreadAdapterId): void => undefined;
36
+ allow(LANGCHAIN);
37
+ allow(GOOGLE_GENAI);
38
+ allow(ANTHROPIC);
39
+ // @ts-expect-error — arbitrary strings aren't members of the union
40
+ allow("someOtherAdapter");
41
+ });
42
+ });
@@ -1,6 +1,7 @@
1
1
  import type Redis from "ioredis";
2
2
  import type Anthropic from "@anthropic-ai/sdk";
3
3
  import type { ToolResultConfig } from "../../../lib/types";
4
+ import type { PersistedThreadState } from "../../../lib/state/types";
4
5
  import type {
5
6
  ActivityToolHandler,
6
7
  RouterContext,
@@ -22,11 +23,10 @@ import {
22
23
  createAnthropicModelInvoker,
23
24
  type AnthropicModelInvokerConfig,
24
25
  } from "./model-invoker";
25
-
26
- const ADAPTER_PREFIX = "anthropic" as const;
26
+ import { ADAPTER_ID } from "./adapter-id";
27
27
 
28
28
  export type AnthropicThreadOps<TScope extends string = ""> = PrefixedThreadOps<
29
- ScopedPrefix<TScope, typeof ADAPTER_PREFIX>,
29
+ ScopedPrefix<TScope, typeof ADAPTER_ID>,
30
30
  AnthropicContent
31
31
  >;
32
32
 
@@ -209,17 +209,43 @@ export function createAnthropicAdapter(
209
209
  redis,
210
210
  threadId: sourceThreadId,
211
211
  key: threadKey,
212
+ hooks: config.hooks,
212
213
  });
213
214
  await thread.fork(targetThreadId);
214
215
  },
215
216
 
216
217
  async truncateThread(
217
218
  threadId: string,
218
- length: number,
219
+ messageId: string,
219
220
  threadKey?: string,
220
221
  ): Promise<void> {
221
222
  const thread = createAnthropicThreadManager({ redis, threadId, key: threadKey });
222
- await thread.truncate(length);
223
+ await thread.truncateFromId(messageId);
224
+ },
225
+
226
+ async loadThreadState(
227
+ threadId: string,
228
+ threadKey?: string
229
+ ): Promise<PersistedThreadState | null> {
230
+ const thread = createAnthropicThreadManager({
231
+ redis,
232
+ threadId,
233
+ key: threadKey,
234
+ });
235
+ return thread.loadState();
236
+ },
237
+
238
+ async saveThreadState(
239
+ threadId: string,
240
+ state: PersistedThreadState,
241
+ threadKey?: string
242
+ ): Promise<void> {
243
+ const thread = createAnthropicThreadManager({
244
+ redis,
245
+ threadId,
246
+ key: threadKey,
247
+ });
248
+ await thread.saveState(state);
223
249
  },
224
250
  };
225
251
 
@@ -227,8 +253,8 @@ export function createAnthropicAdapter(
227
253
  scope?: S
228
254
  ): AnthropicThreadOps<S> {
229
255
  const prefix = scope
230
- ? `${ADAPTER_PREFIX}${scope.charAt(0).toUpperCase()}${scope.slice(1)}`
231
- : ADAPTER_PREFIX;
256
+ ? `${ADAPTER_ID}${scope.charAt(0).toUpperCase()}${scope.slice(1)}`
257
+ : ADAPTER_ID;
232
258
  const cap = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);
233
259
  return Object.fromEntries(
234
260
  Object.entries(threadOps).map(([k, v]) => [`${prefix}${cap(k)}`, v])
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Public adapter identity for the Anthropic thread adapter.
3
+ *
4
+ * This value is wire format — it appears as the prefix for Temporal
5
+ * activity names (e.g. `anthropicCodingAgentInitializeThread`) and must
6
+ * never change, since renaming it would orphan existing persisted
7
+ * threads and break in-flight workflows.
8
+ *
9
+ * Re-exported from `zeitlich/adapters/thread/anthropic` so downstream
10
+ * consumers can use the exact same literal the adapter uses internally,
11
+ * typed as the narrow string literal `"anthropic"`.
12
+ */
13
+ export const ADAPTER_ID = "anthropic" as const;
14
+
15
+ /** Narrow string-literal type for {@link ADAPTER_ID}. */
16
+ export type AdapterId = typeof ADAPTER_ID;
@@ -0,0 +1,291 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { StoredMessage } from "./thread-manager";
3
+ import { createAnthropicThreadManager } from "./thread-manager";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Stateful in-memory Redis mock sufficient for fork / replaceAll flows.
7
+ // Only the commands used by createThreadManager are implemented.
8
+ // ---------------------------------------------------------------------------
9
+
10
+ function createStatefulRedis() {
11
+ const lists = new Map<string, string[]>();
12
+ const strings = new Map<string, string>();
13
+
14
+ return {
15
+ exists: vi.fn(async (...keys: string[]) =>
16
+ keys.reduce(
17
+ (acc, k) => acc + (lists.has(k) || strings.has(k) ? 1 : 0),
18
+ 0
19
+ )
20
+ ),
21
+ lrange: vi.fn(async (key: string, start: number, stop: number) => {
22
+ const list = lists.get(key) ?? [];
23
+ const end = stop === -1 ? list.length : stop + 1;
24
+ return list.slice(start, end);
25
+ }),
26
+ rpush: vi.fn(async (key: string, ...values: string[]) => {
27
+ const list = lists.get(key) ?? [];
28
+ list.push(...values);
29
+ lists.set(key, list);
30
+ return list.length;
31
+ }),
32
+ ltrim: vi.fn(async (key: string, start: number, stop: number) => {
33
+ const list = lists.get(key) ?? [];
34
+ const end = stop === -1 ? list.length : stop + 1;
35
+ lists.set(key, list.slice(start, end));
36
+ return "OK";
37
+ }),
38
+ del: vi.fn(async (...keys: string[]) => {
39
+ let removed = 0;
40
+ for (const k of keys) {
41
+ if (lists.delete(k)) removed++;
42
+ if (strings.delete(k)) removed++;
43
+ }
44
+ return removed;
45
+ }),
46
+ set: vi.fn(async (key: string, value: string) => {
47
+ strings.set(key, value);
48
+ return "OK";
49
+ }),
50
+ get: vi.fn(async (key: string) => strings.get(key) ?? null),
51
+ expire: vi.fn(async (_key: string, _ttl: number) => 1),
52
+ llen: vi.fn(async (key: string) => (lists.get(key) ?? []).length),
53
+ eval: vi.fn(
54
+ async (_script: string, _numKeys: number, ...args: string[]) => {
55
+ const [dedupKey, listKey, , ...serialised] = args;
56
+ if (!dedupKey || !listKey) return 0;
57
+ if (strings.has(dedupKey)) return 0;
58
+ const list = lists.get(listKey) ?? [];
59
+ list.push(...serialised);
60
+ lists.set(listKey, list);
61
+ strings.set(dedupKey, "1");
62
+ return 1;
63
+ }
64
+ ),
65
+ __peek: {
66
+ list: (key: string): string[] => [...(lists.get(key) ?? [])],
67
+ strings,
68
+ },
69
+ };
70
+ }
71
+
72
+ const userMsg: StoredMessage = {
73
+ id: "msg-1",
74
+ message: { role: "user", content: [{ type: "text", text: "Hello" }] },
75
+ };
76
+
77
+ const assistantMsg: StoredMessage = {
78
+ id: "msg-2",
79
+ message: {
80
+ role: "assistant",
81
+ content: [{ type: "text", text: "Hi there!" }],
82
+ },
83
+ };
84
+
85
+ const userMsg2: StoredMessage = {
86
+ id: "msg-3",
87
+ message: { role: "user", content: [{ type: "text", text: "Again please" }] },
88
+ };
89
+
90
+ async function seedSource(
91
+ redis: ReturnType<typeof createStatefulRedis>,
92
+ threadId: string,
93
+ messages: StoredMessage[]
94
+ ): Promise<void> {
95
+ const tm = createAnthropicThreadManager({
96
+ redis: redis as never,
97
+ threadId,
98
+ });
99
+ await tm.initialize();
100
+ await tm.append(messages);
101
+ }
102
+
103
+ describe("Anthropic fork + transform hooks", () => {
104
+ it("behaves like fork when neither onFork hook is configured", async () => {
105
+ const redis = createStatefulRedis();
106
+ await seedSource(redis, "src", [userMsg, assistantMsg]);
107
+
108
+ const tm = createAnthropicThreadManager({
109
+ redis: redis as never,
110
+ threadId: "src",
111
+ });
112
+ const forked = await tm.fork("dst");
113
+ const loaded = await forked.load();
114
+
115
+ expect(loaded).toEqual([userMsg, assistantMsg]);
116
+
117
+ // Source is untouched
118
+ const srcLoaded = await tm.load();
119
+ expect(srcLoaded).toEqual([userMsg, assistantMsg]);
120
+ });
121
+
122
+ it("applies onForkTransform alone as a per-message map", async () => {
123
+ const redis = createStatefulRedis();
124
+ await seedSource(redis, "src", [userMsg, assistantMsg, userMsg2]);
125
+
126
+ const calls: Array<{
127
+ idx: number;
128
+ id: string;
129
+ total: number;
130
+ }> = [];
131
+ const onForkTransform = vi.fn(
132
+ (msg: StoredMessage, index: number, messages: readonly StoredMessage[]) => {
133
+ calls.push({ idx: index, id: msg.id, total: messages.length });
134
+ const firstBlock = (msg.message.content as Array<{ text?: string }>)[0];
135
+ return {
136
+ ...msg,
137
+ message: {
138
+ ...msg.message,
139
+ content: [
140
+ {
141
+ type: "text" as const,
142
+ text: `[T${index}] ${firstBlock?.text ?? ""}`,
143
+ },
144
+ ],
145
+ },
146
+ };
147
+ }
148
+ );
149
+
150
+ const tm = createAnthropicThreadManager({
151
+ redis: redis as never,
152
+ threadId: "src",
153
+ hooks: { onForkTransform },
154
+ });
155
+ const forked = await tm.fork("dst");
156
+ const loaded = await forked.load();
157
+
158
+ expect(onForkTransform).toHaveBeenCalledTimes(3);
159
+ expect(calls).toEqual([
160
+ { idx: 0, id: "msg-1", total: 3 },
161
+ { idx: 1, id: "msg-2", total: 3 },
162
+ { idx: 2, id: "msg-3", total: 3 },
163
+ ]);
164
+ expect(loaded).toHaveLength(3);
165
+ expect(loaded[0]?.message.content).toEqual([
166
+ { type: "text", text: "[T0] Hello" },
167
+ ]);
168
+ expect(loaded[1]?.message.content).toEqual([
169
+ { type: "text", text: "[T1] Hi there!" },
170
+ ]);
171
+ expect(loaded[2]?.message.content).toEqual([
172
+ { type: "text", text: "[T2] Again please" },
173
+ ]);
174
+
175
+ // Source is unchanged.
176
+ const srcLoaded = await tm.load();
177
+ expect(srcLoaded.map((m) => m.id)).toEqual(["msg-1", "msg-2", "msg-3"]);
178
+ });
179
+
180
+ it("applies onForkPrepareThread alone and may change list length", async () => {
181
+ const redis = createStatefulRedis();
182
+ await seedSource(redis, "src", [userMsg, assistantMsg, userMsg2]);
183
+
184
+ const onForkPrepareThread = vi.fn(
185
+ async (messages: readonly StoredMessage[]) =>
186
+ // Drop first message and prepend a summary.
187
+ [
188
+ {
189
+ id: "summary-1",
190
+ message: {
191
+ role: "user" as const,
192
+ content: [{ type: "text" as const, text: "[summary]" }],
193
+ },
194
+ },
195
+ ...messages.slice(1),
196
+ ]
197
+ );
198
+
199
+ const tm = createAnthropicThreadManager({
200
+ redis: redis as never,
201
+ threadId: "src",
202
+ hooks: { onForkPrepareThread },
203
+ });
204
+ const forked = await tm.fork("dst");
205
+ const loaded = await forked.load();
206
+
207
+ expect(onForkPrepareThread).toHaveBeenCalledTimes(1);
208
+ expect(loaded.map((m) => m.id)).toEqual(["summary-1", "msg-2", "msg-3"]);
209
+ });
210
+
211
+ it("runs onForkPrepareThread before onForkTransform and passes prepared list as messages", async () => {
212
+ const redis = createStatefulRedis();
213
+ await seedSource(redis, "src", [userMsg, assistantMsg, userMsg2]);
214
+
215
+ const order: string[] = [];
216
+ const indicesSeen: Array<{ idx: number; total: number; id: string }> = [];
217
+
218
+ const onForkPrepareThread = vi.fn(
219
+ async (messages: readonly StoredMessage[]) => {
220
+ order.push("prepare");
221
+ // Drop the last message (length changes).
222
+ return messages.slice(0, -1);
223
+ }
224
+ );
225
+
226
+ const onForkTransform = vi.fn(
227
+ (
228
+ msg: StoredMessage,
229
+ index: number,
230
+ messages: readonly StoredMessage[]
231
+ ) => {
232
+ order.push("transform");
233
+ indicesSeen.push({ idx: index, total: messages.length, id: msg.id });
234
+ return {
235
+ ...msg,
236
+ message: {
237
+ ...msg.message,
238
+ content: [{ type: "text" as const, text: `[x${index}]` }],
239
+ },
240
+ };
241
+ }
242
+ );
243
+
244
+ const tm = createAnthropicThreadManager({
245
+ redis: redis as never,
246
+ threadId: "src",
247
+ hooks: { onForkPrepareThread, onForkTransform },
248
+ });
249
+ const forked = await tm.fork("dst");
250
+ const loaded = await forked.load();
251
+
252
+ // prepare runs once, transform once per survivor.
253
+ expect(order).toEqual(["prepare", "transform", "transform"]);
254
+ expect(indicesSeen).toEqual([
255
+ { idx: 0, total: 2, id: "msg-1" },
256
+ { idx: 1, total: 2, id: "msg-2" },
257
+ ]);
258
+ expect(loaded).toHaveLength(2);
259
+ expect(loaded[0]?.message.content).toEqual([{ type: "text", text: "[x0]" }]);
260
+ expect(loaded[1]?.message.content).toEqual([{ type: "text", text: "[x1]" }]);
261
+ });
262
+
263
+ it("leaves dedup markers cleared so the transformed thread can accept replays", async () => {
264
+ const redis = createStatefulRedis();
265
+ await seedSource(redis, "src", [userMsg, assistantMsg]);
266
+
267
+ const onForkTransform = vi.fn(
268
+ (msg: StoredMessage) => ({
269
+ ...msg,
270
+ message: {
271
+ ...msg.message,
272
+ content: [{ type: "text" as const, text: "[replaced]" }],
273
+ },
274
+ })
275
+ );
276
+
277
+ const tm = createAnthropicThreadManager({
278
+ redis: redis as never,
279
+ threadId: "src",
280
+ hooks: { onForkTransform },
281
+ });
282
+ await tm.fork("dst");
283
+
284
+ // After replaceAll, dedup markers from the pre-replacement writes must be
285
+ // gone — otherwise an append with the same id would be silently skipped.
286
+ const lingering = Array.from(redis.__peek.strings.keys()).filter((k) =>
287
+ k.startsWith("messages:thread:dst:dedup:")
288
+ );
289
+ expect(lingering).toEqual([]);
290
+ });
291
+ });
@@ -17,6 +17,9 @@
17
17
  * ```
18
18
  */
19
19
 
20
+ // Adapter identity (wire format — matches Temporal activity-name prefix)
21
+ export { ADAPTER_ID, type AdapterId } from "./adapter-id";
22
+
20
23
  // Adapter (primary API)
21
24
  export {
22
25
  createAnthropicAdapter,
@@ -61,7 +61,7 @@ export function createAnthropicModelInvoker({
61
61
  return async function invokeAnthropicModel(
62
62
  config: ModelInvokerConfig
63
63
  ): Promise<AgentResponse<Anthropic.Messages.Message>> {
64
- const { threadId, threadKey, state } = config;
64
+ const { threadId, threadKey, state, assistantMessageId } = config;
65
65
  const { heartbeat, signal } = getActivityContext();
66
66
 
67
67
  const thread = createAnthropicThreadManager({
@@ -70,8 +70,13 @@ export function createAnthropicModelInvoker({
70
70
  key: threadKey,
71
71
  hooks,
72
72
  });
73
- const { messages, system, storedLength } =
74
- await thread.prepareForInvocation();
73
+ // Truncate the thread starting at the id the assistant message
74
+ // will be stored under. On the happy path this is a no-op; on a
75
+ // rewind retry or a Temporal workflow reset it wipes the prior
76
+ // attempt's assistant + tool results so the LLM sees the same
77
+ // pre-call state that it saw originally.
78
+ await thread.truncateFromId(assistantMessageId);
79
+ const { messages, system } = await thread.prepareForInvocation();
75
80
 
76
81
  const anthropicTools = toAnthropicTools(state.tools);
77
82
  const tools = anthropicTools.length > 0 ? anthropicTools : undefined;
@@ -111,7 +116,6 @@ export function createAnthropicModelInvoker({
111
116
  response.usage.cache_creation_input_tokens ?? undefined,
112
117
  cachedReadTokens: response.usage.cache_read_input_tokens ?? undefined,
113
118
  },
114
- threadLengthAtCall: storedLength,
115
119
  };
116
120
  };
117
121
  }
@@ -22,15 +22,16 @@ import { type ActivityInterfaceFor } from "@temporalio/workflow";
22
22
  import type { ThreadOps } from "../../../lib/session/types";
23
23
  import type { AnthropicContent } from "./thread-manager";
24
24
  import { createThreadOpsProxy } from "../../../lib/thread/proxy";
25
+ import { ADAPTER_ID } from "./adapter-id";
25
26
 
26
- const ADAPTER_PREFIX = "anthropic";
27
+ export { ADAPTER_ID, type AdapterId } from "./adapter-id";
27
28
 
28
29
  export function proxyAnthropicThreadOps(
29
30
  scope?: string,
30
31
  options?: Parameters<typeof createThreadOpsProxy>[2]
31
32
  ): ActivityInterfaceFor<ThreadOps<AnthropicContent>> {
32
33
  return createThreadOpsProxy(
33
- ADAPTER_PREFIX,
34
+ ADAPTER_ID,
34
35
  scope,
35
36
  options
36
37
  ) as ActivityInterfaceFor<ThreadOps<AnthropicContent>>;
@@ -41,8 +41,6 @@ export interface AnthropicThreadManagerConfig {
41
41
  export interface AnthropicInvocationPayload {
42
42
  messages: Anthropic.Messages.MessageParam[];
43
43
  system?: string | Anthropic.Messages.TextBlockParam[];
44
- /** Number of stored messages loaded from Redis before preparation. */
45
- storedLength: number;
46
44
  }
47
45
 
48
46
  /** Thread manager with Anthropic MessageParam convenience helpers */
@@ -222,10 +220,35 @@ export function createAnthropicThreadManager(
222
220
  ? messages.map((msg, i) => onPreparedMessage(msg, i, messages))
223
221
  : messages,
224
222
  ...(system ? { system } : {}),
225
- storedLength: stored.length,
226
223
  };
227
224
  },
228
225
  };
229
226
 
230
- return Object.assign(base, helpers);
227
+ const manager = Object.assign(base, helpers);
228
+
229
+ const originalFork = manager.fork.bind(manager);
230
+ manager.fork = async (
231
+ newThreadId: string
232
+ ): Promise<AnthropicThreadManager> => {
233
+ await originalFork(newThreadId);
234
+ const forked = createAnthropicThreadManager({
235
+ ...config,
236
+ threadId: newThreadId,
237
+ });
238
+ const { onForkPrepareThread, onForkTransform } = config.hooks ?? {};
239
+ if (!onForkPrepareThread && !onForkTransform) {
240
+ return forked;
241
+ }
242
+ let next = await forked.load();
243
+ if (onForkPrepareThread) {
244
+ next = await onForkPrepareThread(next);
245
+ }
246
+ if (onForkTransform) {
247
+ next = next.map((msg, i) => onForkTransform(msg, i, next));
248
+ }
249
+ await forked.replaceAll(next);
250
+ return forked;
251
+ };
252
+
253
+ return manager;
231
254
  }
@@ -1,6 +1,7 @@
1
1
  import type Redis from "ioredis";
2
2
  import type { GoogleGenAI, Content, Part } from "@google/genai";
3
3
  import type { ToolResultConfig } from "../../../lib/types";
4
+ import type { PersistedThreadState } from "../../../lib/state/types";
4
5
  import type {
5
6
  ActivityToolHandler,
6
7
  RouterContext,
@@ -19,12 +20,11 @@ import {
19
20
  type GoogleGenAIThreadManagerHooks,
20
21
  } from "./thread-manager";
21
22
  import { createGoogleGenAIModelInvoker } from "./model-invoker";
22
-
23
- const ADAPTER_PREFIX = "googleGenAI" as const;
23
+ import { ADAPTER_ID } from "./adapter-id";
24
24
 
25
25
  export type GoogleGenAIThreadOps<TScope extends string = ""> =
26
26
  PrefixedThreadOps<
27
- ScopedPrefix<TScope, typeof ADAPTER_PREFIX>,
27
+ ScopedPrefix<TScope, typeof ADAPTER_ID>,
28
28
  GoogleGenAIContent
29
29
  >;
30
30
 
@@ -219,13 +219,14 @@ export function createGoogleGenAIAdapter(
219
219
  redis,
220
220
  threadId: sourceThreadId,
221
221
  key: threadKey,
222
+ hooks: config.hooks,
222
223
  });
223
224
  await thread.fork(targetThreadId);
224
225
  },
225
226
 
226
227
  async truncateThread(
227
228
  threadId: string,
228
- length: number,
229
+ messageId: string,
229
230
  threadKey?: string,
230
231
  ): Promise<void> {
231
232
  const thread = createGoogleGenAIThreadManager({
@@ -233,7 +234,32 @@ export function createGoogleGenAIAdapter(
233
234
  threadId,
234
235
  key: threadKey,
235
236
  });
236
- await thread.truncate(length);
237
+ await thread.truncateFromId(messageId);
238
+ },
239
+
240
+ async loadThreadState(
241
+ threadId: string,
242
+ threadKey?: string
243
+ ): Promise<PersistedThreadState | null> {
244
+ const thread = createGoogleGenAIThreadManager({
245
+ redis,
246
+ threadId,
247
+ key: threadKey,
248
+ });
249
+ return thread.loadState();
250
+ },
251
+
252
+ async saveThreadState(
253
+ threadId: string,
254
+ state: PersistedThreadState,
255
+ threadKey?: string
256
+ ): Promise<void> {
257
+ const thread = createGoogleGenAIThreadManager({
258
+ redis,
259
+ threadId,
260
+ key: threadKey,
261
+ });
262
+ await thread.saveState(state);
237
263
  },
238
264
  };
239
265
 
@@ -241,8 +267,8 @@ export function createGoogleGenAIAdapter(
241
267
  scope?: S
242
268
  ): GoogleGenAIThreadOps<S> {
243
269
  const prefix = scope
244
- ? `${ADAPTER_PREFIX}${scope.charAt(0).toUpperCase()}${scope.slice(1)}`
245
- : ADAPTER_PREFIX;
270
+ ? `${ADAPTER_ID}${scope.charAt(0).toUpperCase()}${scope.slice(1)}`
271
+ : ADAPTER_ID;
246
272
  const cap = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);
247
273
  return Object.fromEntries(
248
274
  Object.entries(threadOps).map(([k, v]) => [`${prefix}${cap(k)}`, v])
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Public adapter identity for the Google GenAI thread adapter.
3
+ *
4
+ * This value is wire format — it appears as the prefix for Temporal
5
+ * activity names (e.g. `googleGenAICodingAgentInitializeThread`) and
6
+ * must never change, since renaming it would orphan existing persisted
7
+ * threads and break in-flight workflows.
8
+ *
9
+ * Re-exported from `zeitlich/adapters/thread/google-genai` so downstream
10
+ * consumers can use the exact same literal the adapter uses internally,
11
+ * typed as the narrow string literal `"googleGenAI"`.
12
+ */
13
+ export const ADAPTER_ID = "googleGenAI" as const;
14
+
15
+ /** Narrow string-literal type for {@link ADAPTER_ID}. */
16
+ export type AdapterId = typeof ADAPTER_ID;