zeitlich 0.2.38 → 0.2.40

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-CULxRzJ1.d.ts} +4 -6
  3. package/dist/{activities-CDcwkRZs.d.cts → activities-CvUrG3YG.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 +267 -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 +264 -49
  57. package/dist/index.js.map +1 -1
  58. package/dist/{proxy-D_3x7RN4.d.cts → proxy-5EbwzaY4.d.cts} +1 -1
  59. package/dist/{proxy-CUlKSvZS.d.ts → proxy-wZufFfBh.d.ts} +1 -1
  60. package/dist/{thread-manager-CVu7o2cs.d.ts → thread-manager-BNiIt5r8.d.ts} +2 -4
  61. package/dist/{thread-manager-c1gPopAG.d.ts → thread-manager-BoN5DOvG.d.cts} +2 -4
  62. package/dist/{thread-manager-wGi-LqIP.d.cts → thread-manager-BqBAIsED.d.ts} +2 -4
  63. package/dist/{thread-manager-HSwyh28L.d.cts → thread-manager-DF8WuCRs.d.cts} +2 -4
  64. package/dist/{types-BH_IRryz.d.ts → types-C7OoY7h8.d.ts} +54 -6
  65. package/dist/{types-C06FwR96.d.cts → types-Cn2r3ol3.d.cts} +163 -44
  66. package/dist/{types-BaOw4hKI.d.cts → types-CuISs0Ub.d.cts} +54 -6
  67. package/dist/{types-DNr31FzL.d.ts → types-DeQH84C_.d.ts} +163 -44
  68. package/dist/{workflow-CSCkpwAL.d.ts → workflow-C2MZZj5K.d.ts} +82 -2
  69. package/dist/{workflow-DuvMZ8Vm.d.cts → workflow-DhplIN65.d.cts} +82 -2
  70. package/dist/workflow.cjs +189 -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 +186 -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 +69 -28
  105. package/src/lib/session/types.ts +61 -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
@@ -0,0 +1,149 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { StoredContent } from "./thread-manager";
3
+ import { createGoogleGenAIThreadManager } from "./thread-manager";
4
+
5
+ function createStatefulRedis() {
6
+ const lists = new Map<string, string[]>();
7
+ const strings = new Map<string, string>();
8
+
9
+ return {
10
+ exists: vi.fn(async (...keys: string[]) =>
11
+ keys.reduce(
12
+ (acc, k) => acc + (lists.has(k) || strings.has(k) ? 1 : 0),
13
+ 0
14
+ )
15
+ ),
16
+ lrange: vi.fn(async (key: string, start: number, stop: number) => {
17
+ const list = lists.get(key) ?? [];
18
+ const end = stop === -1 ? list.length : stop + 1;
19
+ return list.slice(start, end);
20
+ }),
21
+ rpush: vi.fn(async (key: string, ...values: string[]) => {
22
+ const list = lists.get(key) ?? [];
23
+ list.push(...values);
24
+ lists.set(key, list);
25
+ return list.length;
26
+ }),
27
+ ltrim: vi.fn(async () => "OK"),
28
+ del: vi.fn(async (...keys: string[]) => {
29
+ let removed = 0;
30
+ for (const k of keys) {
31
+ if (lists.delete(k)) removed++;
32
+ if (strings.delete(k)) removed++;
33
+ }
34
+ return removed;
35
+ }),
36
+ set: vi.fn(async (key: string, value: string) => {
37
+ strings.set(key, value);
38
+ return "OK";
39
+ }),
40
+ get: vi.fn(async (key: string) => strings.get(key) ?? null),
41
+ expire: vi.fn(async () => 1),
42
+ llen: vi.fn(async (key: string) => (lists.get(key) ?? []).length),
43
+ eval: vi.fn(
44
+ async (_script: string, _numKeys: number, ...args: string[]) => {
45
+ const [dedupKey, listKey, , ...serialised] = args;
46
+ if (!dedupKey || !listKey) return 0;
47
+ if (strings.has(dedupKey)) return 0;
48
+ const list = lists.get(listKey) ?? [];
49
+ list.push(...serialised);
50
+ lists.set(listKey, list);
51
+ strings.set(dedupKey, "1");
52
+ return 1;
53
+ }
54
+ ),
55
+ };
56
+ }
57
+
58
+ const userContent: StoredContent = {
59
+ id: "msg-1",
60
+ content: { role: "user", parts: [{ text: "Hello" }] },
61
+ };
62
+
63
+ const modelContent: StoredContent = {
64
+ id: "msg-2",
65
+ content: { role: "model", parts: [{ text: "Hi there!" }] },
66
+ };
67
+
68
+ const userContent2: StoredContent = {
69
+ id: "msg-3",
70
+ content: { role: "user", parts: [{ text: "Again please" }] },
71
+ };
72
+
73
+ async function seed(
74
+ redis: ReturnType<typeof createStatefulRedis>,
75
+ threadId: string,
76
+ messages: StoredContent[]
77
+ ): Promise<void> {
78
+ const tm = createGoogleGenAIThreadManager({
79
+ redis: redis as never,
80
+ threadId,
81
+ });
82
+ await tm.initialize();
83
+ await tm.append(messages);
84
+ }
85
+
86
+ describe("Google GenAI fork + transform hooks", () => {
87
+ it("falls back to plain fork when no hooks are set", async () => {
88
+ const redis = createStatefulRedis();
89
+ await seed(redis, "src", [userContent, modelContent]);
90
+
91
+ const tm = createGoogleGenAIThreadManager({
92
+ redis: redis as never,
93
+ threadId: "src",
94
+ });
95
+ const forked = await tm.fork("dst");
96
+ expect(await forked.load()).toEqual([userContent, modelContent]);
97
+ });
98
+
99
+ it("applies onForkPrepareThread then onForkTransform in order", async () => {
100
+ const redis = createStatefulRedis();
101
+ await seed(redis, "src", [userContent, modelContent, userContent2]);
102
+
103
+ const order: string[] = [];
104
+ const tm = createGoogleGenAIThreadManager({
105
+ redis: redis as never,
106
+ threadId: "src",
107
+ hooks: {
108
+ onForkPrepareThread: async (messages) => {
109
+ order.push("prepare");
110
+ return messages.slice(0, -1);
111
+ },
112
+ onForkTransform: (msg, i) => {
113
+ order.push("transform");
114
+ return {
115
+ ...msg,
116
+ content: { ...msg.content, parts: [{ text: `[x${i}]` }] },
117
+ };
118
+ },
119
+ },
120
+ });
121
+
122
+ const forked = await tm.fork("dst");
123
+ const loaded = await forked.load();
124
+
125
+ expect(order).toEqual(["prepare", "transform", "transform"]);
126
+ expect(loaded).toHaveLength(2);
127
+ expect(loaded[0]?.content.parts).toEqual([{ text: "[x0]" }]);
128
+ expect(loaded[1]?.content.parts).toEqual([{ text: "[x1]" }]);
129
+ });
130
+
131
+ it("keeps the source thread untouched", async () => {
132
+ const redis = createStatefulRedis();
133
+ await seed(redis, "src", [userContent, modelContent]);
134
+
135
+ const tm = createGoogleGenAIThreadManager({
136
+ redis: redis as never,
137
+ threadId: "src",
138
+ hooks: {
139
+ onForkTransform: (msg) => ({
140
+ ...msg,
141
+ content: { ...msg.content, parts: [{ text: "mutated" }] },
142
+ }),
143
+ },
144
+ });
145
+
146
+ await tm.fork("dst");
147
+ expect(await tm.load()).toEqual([userContent, modelContent]);
148
+ });
149
+ });
@@ -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
  createGoogleGenAIAdapter,
@@ -64,7 +64,7 @@ export function createGoogleGenAIModelInvoker({
64
64
  return async function invokeGoogleGenAIModel(
65
65
  config: ModelInvokerConfig
66
66
  ): Promise<AgentResponse<Content>> {
67
- const { threadId, threadKey, state } = config;
67
+ const { threadId, threadKey, state, assistantMessageId } = config;
68
68
  const { heartbeat, signal } = getActivityContext();
69
69
 
70
70
  const thread = createGoogleGenAIThreadManager({
@@ -73,7 +73,12 @@ export function createGoogleGenAIModelInvoker({
73
73
  key: threadKey,
74
74
  hooks,
75
75
  });
76
- const { contents, systemInstruction, storedLength } =
76
+ // Truncate the thread starting at the id the assistant message
77
+ // will be stored under. No-op on the first attempt; on rewind
78
+ // retry / Temporal reset it wipes the prior attempt's assistant
79
+ // + tool results so the LLM sees the original pre-call state.
80
+ await thread.truncateFromId(assistantMessageId);
81
+ const { contents, systemInstruction } =
77
82
  await thread.prepareForInvocation();
78
83
 
79
84
  const functionDeclarations = toFunctionDeclarations(state.tools);
@@ -117,7 +122,6 @@ export function createGoogleGenAIModelInvoker({
117
122
  outputTokens: lastChunk.usageMetadata?.candidatesTokenCount,
118
123
  cachedReadTokens: lastChunk.usageMetadata?.cachedContentTokenCount,
119
124
  },
120
- threadLengthAtCall: storedLength,
121
125
  };
122
126
  };
123
127
  }
@@ -22,15 +22,16 @@ import { type ActivityInterfaceFor } from "@temporalio/workflow";
22
22
  import type { ThreadOps } from "../../../lib/session/types";
23
23
  import type { GoogleGenAIContent } from "./thread-manager";
24
24
  import { createThreadOpsProxy } from "../../../lib/thread/proxy";
25
+ import { ADAPTER_ID } from "./adapter-id";
25
26
 
26
- const ADAPTER_PREFIX = "googleGenAI";
27
+ export { ADAPTER_ID, type AdapterId } from "./adapter-id";
27
28
 
28
29
  export function proxyGoogleGenAIThreadOps(
29
30
  scope?: string,
30
31
  options?: Parameters<typeof createThreadOpsProxy>[2]
31
32
  ): ActivityInterfaceFor<ThreadOps<GoogleGenAIContent>> {
32
33
  return createThreadOpsProxy(
33
- ADAPTER_PREFIX,
34
+ ADAPTER_ID,
34
35
  scope,
35
36
  options
36
37
  ) as ActivityInterfaceFor<ThreadOps<GoogleGenAIContent>>;
@@ -37,8 +37,6 @@ export interface GoogleGenAIThreadManagerConfig {
37
37
  export interface GoogleGenAIInvocationPayload {
38
38
  contents: Content[];
39
39
  systemInstruction?: Part[];
40
- /** Number of stored messages loaded from Redis before preparation. */
41
- storedLength: number;
42
40
  }
43
41
 
44
42
  /** Thread manager with Google GenAI Content convenience helpers */
@@ -198,10 +196,35 @@ export function createGoogleGenAIThreadManager(
198
196
  ...(systemInstruction && systemInstruction.length > 0
199
197
  ? { systemInstruction }
200
198
  : {}),
201
- storedLength: stored.length,
202
199
  };
203
200
  },
204
201
  };
205
202
 
206
- return Object.assign(base, helpers);
203
+ const manager = Object.assign(base, helpers);
204
+
205
+ const originalFork = manager.fork.bind(manager);
206
+ manager.fork = async (
207
+ newThreadId: string
208
+ ): Promise<GoogleGenAIThreadManager> => {
209
+ await originalFork(newThreadId);
210
+ const forked = createGoogleGenAIThreadManager({
211
+ ...config,
212
+ threadId: newThreadId,
213
+ });
214
+ const { onForkPrepareThread, onForkTransform } = config.hooks ?? {};
215
+ if (!onForkPrepareThread && !onForkTransform) {
216
+ return forked;
217
+ }
218
+ let next = await forked.load();
219
+ if (onForkPrepareThread) {
220
+ next = await onForkPrepareThread(next);
221
+ }
222
+ if (onForkTransform) {
223
+ next = next.map((msg, i) => onForkTransform(msg, i, next));
224
+ }
225
+ await forked.replaceAll(next);
226
+ return forked;
227
+ };
228
+
229
+ return manager;
207
230
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Barrel re-exports for every built-in thread adapter's public identity.
3
+ *
4
+ * Downstream consumers reading persisted threads can import a narrow
5
+ * discriminated union of adapter identifiers without pulling the full
6
+ * adapter implementation (Redis, provider SDKs, etc.) as a dependency —
7
+ * each individual re-export resolves to an `adapter-id.ts` module with
8
+ * no runtime dependencies.
9
+ *
10
+ * @example
11
+ * ```typescript
12
+ * import {
13
+ * LANGCHAIN_ADAPTER_ID,
14
+ * GOOGLE_GENAI_ADAPTER_ID,
15
+ * ANTHROPIC_ADAPTER_ID,
16
+ * type ThreadAdapterId,
17
+ * } from 'zeitlich/adapters/thread';
18
+ *
19
+ * interface ThreadIdentity {
20
+ * adapter: ThreadAdapterId;
21
+ * threadKey: string;
22
+ * threadId: string;
23
+ * }
24
+ * ```
25
+ */
26
+
27
+ export { ADAPTER_ID as LANGCHAIN_ADAPTER_ID } from "./langchain/adapter-id";
28
+ export { ADAPTER_ID as GOOGLE_GENAI_ADAPTER_ID } from "./google-genai/adapter-id";
29
+ export { ADAPTER_ID as ANTHROPIC_ADAPTER_ID } from "./anthropic/adapter-id";
30
+
31
+ import type { ADAPTER_ID as LANGCHAIN } from "./langchain/adapter-id";
32
+ import type { ADAPTER_ID as GOOGLE_GENAI } from "./google-genai/adapter-id";
33
+ import type { ADAPTER_ID as ANTHROPIC } from "./anthropic/adapter-id";
34
+
35
+ /** Narrow discriminated union of every built-in thread adapter id. */
36
+ export type ThreadAdapterId =
37
+ | typeof LANGCHAIN
38
+ | typeof GOOGLE_GENAI
39
+ | typeof ANTHROPIC;
@@ -1,5 +1,6 @@
1
1
  import type Redis from "ioredis";
2
2
  import type { ToolResultConfig } from "../../../lib/types";
3
+ import type { PersistedThreadState } from "../../../lib/state/types";
3
4
  import type { MessageContent } from "@langchain/core/messages";
4
5
  import type {
5
6
  ActivityToolHandler,
@@ -21,11 +22,10 @@ import {
21
22
  type LangChainThreadManagerHooks,
22
23
  } from "./thread-manager";
23
24
  import { createLangChainModelInvoker } from "./model-invoker";
24
-
25
- const ADAPTER_PREFIX = "langChain" as const;
25
+ import { ADAPTER_ID } from "./adapter-id";
26
26
 
27
27
  export type LangChainThreadOps<TScope extends string = ""> = PrefixedThreadOps<
28
- ScopedPrefix<TScope, typeof ADAPTER_PREFIX>,
28
+ ScopedPrefix<TScope, typeof ADAPTER_ID>,
29
29
  LangChainContent
30
30
  >;
31
31
 
@@ -192,17 +192,43 @@ export function createLangChainAdapter(
192
192
  redis,
193
193
  threadId: sourceThreadId,
194
194
  key: threadKey,
195
+ hooks: config.hooks,
195
196
  });
196
197
  await thread.fork(targetThreadId);
197
198
  },
198
199
 
199
200
  async truncateThread(
200
201
  threadId: string,
201
- length: number,
202
+ messageId: string,
202
203
  threadKey?: string,
203
204
  ): Promise<void> {
204
205
  const thread = createLangChainThreadManager({ redis, threadId, key: threadKey });
205
- await thread.truncate(length);
206
+ await thread.truncateFromId(messageId);
207
+ },
208
+
209
+ async loadThreadState(
210
+ threadId: string,
211
+ threadKey?: string
212
+ ): Promise<PersistedThreadState | null> {
213
+ const thread = createLangChainThreadManager({
214
+ redis,
215
+ threadId,
216
+ key: threadKey,
217
+ });
218
+ return thread.loadState();
219
+ },
220
+
221
+ async saveThreadState(
222
+ threadId: string,
223
+ state: PersistedThreadState,
224
+ threadKey?: string
225
+ ): Promise<void> {
226
+ const thread = createLangChainThreadManager({
227
+ redis,
228
+ threadId,
229
+ key: threadKey,
230
+ });
231
+ await thread.saveState(state);
206
232
  },
207
233
  };
208
234
 
@@ -210,8 +236,8 @@ export function createLangChainAdapter(
210
236
  scope?: S
211
237
  ): LangChainThreadOps<S> {
212
238
  const prefix = scope
213
- ? `${ADAPTER_PREFIX}${scope.charAt(0).toUpperCase()}${scope.slice(1)}`
214
- : ADAPTER_PREFIX;
239
+ ? `${ADAPTER_ID}${scope.charAt(0).toUpperCase()}${scope.slice(1)}`
240
+ : ADAPTER_ID;
215
241
  const cap = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);
216
242
  return Object.fromEntries(
217
243
  Object.entries(threadOps).map(([k, v]) => [`${prefix}${cap(k)}`, v])
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Public adapter identity for the LangChain thread adapter.
3
+ *
4
+ * This value is wire format — it appears as the prefix for Temporal
5
+ * activity names (e.g. `langChainCodingAgentInitializeThread`) 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/langchain` so downstream
10
+ * consumers can use the exact same literal the adapter uses internally,
11
+ * typed as the narrow string literal `"langChain"`.
12
+ */
13
+ export const ADAPTER_ID = "langChain" as const;
14
+
15
+ /** Narrow string-literal type for {@link ADAPTER_ID}. */
16
+ export type AdapterId = typeof ADAPTER_ID;
@@ -0,0 +1,142 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import {
3
+ HumanMessage,
4
+ AIMessage,
5
+ type StoredMessage,
6
+ } from "@langchain/core/messages";
7
+ import { createLangChainThreadManager } from "./thread-manager";
8
+
9
+ function createStatefulRedis() {
10
+ const lists = new Map<string, string[]>();
11
+ const strings = new Map<string, string>();
12
+
13
+ return {
14
+ exists: vi.fn(async (...keys: string[]) =>
15
+ keys.reduce(
16
+ (acc, k) => acc + (lists.has(k) || strings.has(k) ? 1 : 0),
17
+ 0
18
+ )
19
+ ),
20
+ lrange: vi.fn(async (key: string, start: number, stop: number) => {
21
+ const list = lists.get(key) ?? [];
22
+ const end = stop === -1 ? list.length : stop + 1;
23
+ return list.slice(start, end);
24
+ }),
25
+ rpush: vi.fn(async (key: string, ...values: string[]) => {
26
+ const list = lists.get(key) ?? [];
27
+ list.push(...values);
28
+ lists.set(key, list);
29
+ return list.length;
30
+ }),
31
+ ltrim: vi.fn(async () => "OK"),
32
+ del: vi.fn(async (...keys: string[]) => {
33
+ let removed = 0;
34
+ for (const k of keys) {
35
+ if (lists.delete(k)) removed++;
36
+ if (strings.delete(k)) removed++;
37
+ }
38
+ return removed;
39
+ }),
40
+ set: vi.fn(async (key: string, value: string) => {
41
+ strings.set(key, value);
42
+ return "OK";
43
+ }),
44
+ get: vi.fn(async (key: string) => strings.get(key) ?? null),
45
+ expire: vi.fn(async () => 1),
46
+ llen: vi.fn(async (key: string) => (lists.get(key) ?? []).length),
47
+ eval: vi.fn(
48
+ async (_script: string, _numKeys: number, ...args: string[]) => {
49
+ const [dedupKey, listKey, , ...serialised] = args;
50
+ if (!dedupKey || !listKey) return 0;
51
+ if (strings.has(dedupKey)) return 0;
52
+ const list = lists.get(listKey) ?? [];
53
+ list.push(...serialised);
54
+ lists.set(listKey, list);
55
+ strings.set(dedupKey, "1");
56
+ return 1;
57
+ }
58
+ ),
59
+ };
60
+ }
61
+
62
+ const humanMsg = new HumanMessage({ id: "msg-1", content: "Hello" }).toDict();
63
+ const aiMsg = new AIMessage({ id: "msg-2", content: "Hi there!" }).toDict();
64
+ const humanMsg2 = new HumanMessage({
65
+ id: "msg-3",
66
+ content: "Again please",
67
+ }).toDict();
68
+
69
+ async function seed(
70
+ redis: ReturnType<typeof createStatefulRedis>,
71
+ threadId: string,
72
+ messages: StoredMessage[]
73
+ ): Promise<void> {
74
+ const tm = createLangChainThreadManager({
75
+ redis: redis as never,
76
+ threadId,
77
+ });
78
+ await tm.initialize();
79
+ await tm.append(messages);
80
+ }
81
+
82
+ describe("LangChain fork + transform hooks", () => {
83
+ it("falls back to plain fork when no hooks are set", async () => {
84
+ const redis = createStatefulRedis();
85
+ await seed(redis, "src", [humanMsg, aiMsg]);
86
+
87
+ const tm = createLangChainThreadManager({
88
+ redis: redis as never,
89
+ threadId: "src",
90
+ });
91
+ const forked = await tm.fork("dst");
92
+ expect(await forked.load()).toEqual([humanMsg, aiMsg]);
93
+ });
94
+
95
+ it("applies onForkPrepareThread then onForkTransform in order", async () => {
96
+ const redis = createStatefulRedis();
97
+ await seed(redis, "src", [humanMsg, aiMsg, humanMsg2]);
98
+
99
+ const order: string[] = [];
100
+ const tm = createLangChainThreadManager({
101
+ redis: redis as never,
102
+ threadId: "src",
103
+ hooks: {
104
+ onForkPrepareThread: async (messages) => {
105
+ order.push("prepare");
106
+ return messages.slice(0, -1);
107
+ },
108
+ onForkTransform: (msg, i) => {
109
+ order.push("transform");
110
+ return { ...msg, data: { ...msg.data, content: `[x${i}]` } };
111
+ },
112
+ },
113
+ });
114
+
115
+ const forked = await tm.fork("dst");
116
+ const loaded = await forked.load();
117
+
118
+ expect(order).toEqual(["prepare", "transform", "transform"]);
119
+ expect(loaded).toHaveLength(2);
120
+ expect(loaded[0]?.data.content).toBe("[x0]");
121
+ expect(loaded[1]?.data.content).toBe("[x1]");
122
+ });
123
+
124
+ it("keeps the source thread untouched", async () => {
125
+ const redis = createStatefulRedis();
126
+ await seed(redis, "src", [humanMsg, aiMsg]);
127
+
128
+ const tm = createLangChainThreadManager({
129
+ redis: redis as never,
130
+ threadId: "src",
131
+ hooks: {
132
+ onForkTransform: (msg) => ({
133
+ ...msg,
134
+ data: { ...msg.data, content: "mutated" },
135
+ }),
136
+ },
137
+ });
138
+
139
+ await tm.fork("dst");
140
+ expect(await tm.load()).toEqual([humanMsg, aiMsg]);
141
+ });
142
+ });
@@ -15,6 +15,9 @@
15
15
  * ```
16
16
  */
17
17
 
18
+ // Adapter identity (wire format — matches Temporal activity-name prefix)
19
+ export { ADAPTER_ID, type AdapterId } from "./adapter-id";
20
+
18
21
  // Adapter (primary API)
19
22
  export {
20
23
  createLangChainAdapter,
@@ -47,7 +47,8 @@ export function createLangChainModelInvoker<
47
47
  return async function invokeLangChainModel(
48
48
  config: ModelInvokerConfig
49
49
  ): Promise<AgentResponse<StoredMessage>> {
50
- const { threadId, threadKey, agentName, state, metadata } = config;
50
+ const { threadId, threadKey, agentName, state, metadata, assistantMessageId } =
51
+ config;
51
52
  const { heartbeat, signal } = getActivityContext();
52
53
 
53
54
  const thread = createLangChainThreadManager({
@@ -58,7 +59,12 @@ export function createLangChainModelInvoker<
58
59
  });
59
60
  const runId = uuidv4();
60
61
 
61
- const { messages, storedLength } = await thread.prepareForInvocation();
62
+ // Truncate the thread starting at the id the assistant message
63
+ // will be stored under. No-op on the first attempt; on rewind
64
+ // retry / Temporal reset it wipes the prior attempt's assistant
65
+ // + tool results so the LLM sees the original pre-call state.
66
+ await thread.truncateFromId(assistantMessageId);
67
+ const { messages } = await thread.prepareForInvocation();
62
68
 
63
69
  const heartbeatInterval = heartbeat
64
70
  ? setInterval(() => heartbeat(), 30_000)
@@ -97,7 +103,6 @@ export function createLangChainModelInvoker<
97
103
  response.usage_metadata?.input_token_details?.cache_read ||
98
104
  (providerUsage.cacheReadInputTokens as number | undefined),
99
105
  },
100
- threadLengthAtCall: storedLength,
101
106
  };
102
107
  } finally {
103
108
  if (heartbeatInterval) clearInterval(heartbeatInterval);
@@ -22,15 +22,16 @@ import { type ActivityInterfaceFor } from "@temporalio/workflow";
22
22
  import type { ThreadOps } from "../../../lib/session/types";
23
23
  import type { LangChainContent } from "./thread-manager";
24
24
  import { createThreadOpsProxy } from "../../../lib/thread/proxy";
25
+ import { ADAPTER_ID } from "./adapter-id";
25
26
 
26
- const ADAPTER_PREFIX = "langChain";
27
+ export { ADAPTER_ID, type AdapterId } from "./adapter-id";
27
28
 
28
29
  export function proxyLangChainThreadOps(
29
30
  scope?: string,
30
31
  options?: Parameters<typeof createThreadOpsProxy>[2]
31
32
  ): ActivityInterfaceFor<ThreadOps<LangChainContent>> {
32
33
  return createThreadOpsProxy(
33
- ADAPTER_PREFIX,
34
+ ADAPTER_ID,
34
35
  scope,
35
36
  options
36
37
  ) as ActivityInterfaceFor<ThreadOps<LangChainContent>>;
@@ -39,8 +39,6 @@ export interface LangChainThreadManagerConfig {
39
39
  /** Prepared payload ready to send to a LangChain chat model */
40
40
  export interface LangChainInvocationPayload {
41
41
  messages: BaseMessage[];
42
- /** Number of stored messages loaded from Redis before preparation. */
43
- storedLength: number;
44
42
  }
45
43
 
46
44
  /** Thread manager with LangChain StoredMessage convenience helpers */
@@ -141,10 +139,35 @@ export function createLangChainThreadManager(
141
139
  messages: onPreparedMessage
142
140
  ? messages.map((msg, i) => onPreparedMessage(msg, i, messages))
143
141
  : messages,
144
- storedLength: stored.length,
145
142
  };
146
143
  },
147
144
  };
148
145
 
149
- return Object.assign(base, helpers);
146
+ const manager = Object.assign(base, helpers);
147
+
148
+ const originalFork = manager.fork.bind(manager);
149
+ manager.fork = async (
150
+ newThreadId: string
151
+ ): Promise<LangChainThreadManager> => {
152
+ await originalFork(newThreadId);
153
+ const forked = createLangChainThreadManager({
154
+ ...config,
155
+ threadId: newThreadId,
156
+ });
157
+ const { onForkPrepareThread, onForkTransform } = config.hooks ?? {};
158
+ if (!onForkPrepareThread && !onForkTransform) {
159
+ return forked;
160
+ }
161
+ let next = await forked.load();
162
+ if (onForkPrepareThread) {
163
+ next = await onForkPrepareThread(next);
164
+ }
165
+ if (onForkTransform) {
166
+ next = next.map((msg, i) => onForkTransform(msg, i, next));
167
+ }
168
+ await forked.replaceAll(next);
169
+ return forked;
170
+ };
171
+
172
+ return manager;
150
173
  }
@@ -8,7 +8,9 @@
8
8
  * - `"new"` — start a fresh thread (optionally specify its ID).
9
9
  * - `"continue"` — append directly to an existing thread in-place.
10
10
  * - `"fork"` — copy all messages from an existing thread into a new one and
11
- * continue there.
11
+ * continue there. When the adapter has `onForkPrepareThread` and/or
12
+ * `onForkTransform` hooks configured, they are applied once to the forked
13
+ * thread before the session starts.
12
14
  */
13
15
  export type ThreadInit =
14
16
  | { mode: "new"; threadId?: string }
@@ -8,16 +8,6 @@ export interface AgentResponse<M = unknown> {
8
8
  message: M;
9
9
  rawToolCalls: RawToolCall[];
10
10
  usage?: TokenUsage;
11
- /**
12
- * Number of stored messages in the thread at the moment the LLM was
13
- * invoked — i.e. *before* the assistant message is appended. The
14
- * session uses this as a rewind snapshot so it can roll the thread
15
- * back to this exact state if a tool requests a rewind.
16
- *
17
- * Adapters compute this for free from the array of stored messages
18
- * they load when preparing the payload.
19
- */
20
- threadLengthAtCall?: number;
21
11
  }
22
12
 
23
13
  /**
@@ -39,6 +29,13 @@ export interface ModelInvokerConfig {
39
29
  agentName: string;
40
30
  state: BaseAgentState;
41
31
  metadata?: Record<string, unknown>;
32
+ /**
33
+ * The id the assistant message produced by this call will be stored
34
+ * under. Invokers truncate the thread from this id on entry so that
35
+ * rewind retries and Temporal workflow resets restore the pre-call
36
+ * state before re-invoking the LLM. See {@link RunAgentConfig}.
37
+ */
38
+ assistantMessageId: string;
42
39
  }
43
40
 
44
41
  /**