zeitlich 0.2.46 → 0.2.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +66 -6
  2. package/dist/{activities-CyeiqK_f.d.cts → activities-BlQR5gX4.d.cts} +3 -3
  3. package/dist/{activities-Bm4TLTid.d.ts → activities-DCaIPQBT.d.ts} +3 -3
  4. package/dist/adapters/thread/anthropic/index.cjs +105 -6
  5. package/dist/adapters/thread/anthropic/index.cjs.map +1 -1
  6. package/dist/adapters/thread/anthropic/index.d.cts +48 -9
  7. package/dist/adapters/thread/anthropic/index.d.ts +48 -9
  8. package/dist/adapters/thread/anthropic/index.js +104 -7
  9. package/dist/adapters/thread/anthropic/index.js.map +1 -1
  10. package/dist/adapters/thread/anthropic/workflow.cjs +38 -22
  11. package/dist/adapters/thread/anthropic/workflow.cjs.map +1 -1
  12. package/dist/adapters/thread/anthropic/workflow.d.cts +5 -4
  13. package/dist/adapters/thread/anthropic/workflow.d.ts +5 -4
  14. package/dist/adapters/thread/anthropic/workflow.js +38 -22
  15. package/dist/adapters/thread/anthropic/workflow.js.map +1 -1
  16. package/dist/adapters/thread/google-genai/index.d.cts +6 -5
  17. package/dist/adapters/thread/google-genai/index.d.ts +6 -5
  18. package/dist/adapters/thread/google-genai/workflow.cjs +38 -22
  19. package/dist/adapters/thread/google-genai/workflow.cjs.map +1 -1
  20. package/dist/adapters/thread/google-genai/workflow.d.cts +7 -5
  21. package/dist/adapters/thread/google-genai/workflow.d.ts +7 -5
  22. package/dist/adapters/thread/google-genai/workflow.js +38 -22
  23. package/dist/adapters/thread/google-genai/workflow.js.map +1 -1
  24. package/dist/adapters/thread/langchain/index.d.cts +6 -5
  25. package/dist/adapters/thread/langchain/index.d.ts +6 -5
  26. package/dist/adapters/thread/langchain/workflow.cjs +38 -22
  27. package/dist/adapters/thread/langchain/workflow.cjs.map +1 -1
  28. package/dist/adapters/thread/langchain/workflow.d.cts +5 -4
  29. package/dist/adapters/thread/langchain/workflow.d.ts +5 -4
  30. package/dist/adapters/thread/langchain/workflow.js +38 -22
  31. package/dist/adapters/thread/langchain/workflow.js.map +1 -1
  32. package/dist/{cold-store-BC5L5Z8A.d.cts → cold-store-UL13Sstw.d.cts} +8 -11
  33. package/dist/{cold-store-CFHwemBJ.d.ts → cold-store-aD4TSKlU.d.ts} +8 -11
  34. package/dist/index.cjs +311 -99
  35. package/dist/index.cjs.map +1 -1
  36. package/dist/index.d.cts +21 -9
  37. package/dist/index.d.ts +21 -9
  38. package/dist/index.js +312 -102
  39. package/dist/index.js.map +1 -1
  40. package/dist/proxy-BAty3CWM.d.cts +40 -0
  41. package/dist/proxy-mbnwBhHw.d.ts +40 -0
  42. package/dist/{thread-manager-DduoSkvJ.d.ts → thread-manager-CICj68PI.d.ts} +2 -2
  43. package/dist/{thread-manager-D33SUmZa.d.cts → thread-manager-DsXvJ5cJ.d.cts} +2 -2
  44. package/dist/{thread-manager-B-zy3xrs.d.ts → thread-manager-DtEtbUkp.d.ts} +2 -2
  45. package/dist/{thread-manager-9tezUcLW.d.cts → thread-manager-R6c3lnJy.d.cts} +2 -2
  46. package/dist/{types-oxt8GN97.d.cts → types-DDLPnxBh.d.cts} +1 -1
  47. package/dist/{types-L5bvbF-n.d.ts → types-DF4wzWQG.d.ts} +1 -1
  48. package/dist/{types-CnuN9T6t.d.cts → types-DWeyCTYK.d.cts} +47 -0
  49. package/dist/{types-CwN6_tAL.d.ts → types-DwBYd0ij.d.ts} +47 -0
  50. package/dist/{workflow-DIaIV7L2.d.cts → workflow-DVNPR7eX.d.cts} +17 -2
  51. package/dist/{workflow-B1TOcHbt.d.ts → workflow-DdaU7_j4.d.ts} +17 -2
  52. package/dist/workflow.cjs +80 -12
  53. package/dist/workflow.cjs.map +1 -1
  54. package/dist/workflow.d.cts +2 -2
  55. package/dist/workflow.d.ts +2 -2
  56. package/dist/workflow.js +80 -13
  57. package/dist/workflow.js.map +1 -1
  58. package/package.json +14 -8
  59. package/src/adapters/thread/anthropic/activities.ts +18 -11
  60. package/src/adapters/thread/anthropic/index.ts +8 -0
  61. package/src/adapters/thread/anthropic/model-invoker.test.ts +110 -0
  62. package/src/adapters/thread/anthropic/model-invoker.ts +26 -5
  63. package/src/adapters/thread/anthropic/prompt-cache.test.ts +134 -0
  64. package/src/adapters/thread/anthropic/prompt-cache.ts +163 -0
  65. package/src/adapters/thread/anthropic/proxy.ts +1 -0
  66. package/src/adapters/thread/google-genai/proxy.ts +1 -0
  67. package/src/adapters/thread/langchain/proxy.ts +1 -0
  68. package/src/index.ts +1 -1
  69. package/src/lib/lifecycle.ts +13 -1
  70. package/src/lib/session/session-edge-cases.integration.test.ts +44 -0
  71. package/src/lib/session/session.ts +15 -0
  72. package/src/lib/subagent/define.ts +1 -0
  73. package/src/lib/subagent/handler.ts +41 -6
  74. package/src/lib/subagent/subagent.integration.test.ts +178 -0
  75. package/src/lib/subagent/types.ts +16 -0
  76. package/src/lib/thread/cold-store.test.ts +33 -5
  77. package/src/lib/thread/cold-store.ts +50 -31
  78. package/src/lib/thread/proxy.ts +79 -29
  79. package/src/lib/tool-router/router-edge-cases.integration.test.ts +36 -0
  80. package/src/lib/tool-router/router.ts +21 -3
  81. package/src/lib/tool-router/types.ts +20 -0
  82. package/src/tools/edit/handler.test.ts +177 -0
  83. package/src/tools/edit/handler.ts +249 -47
  84. package/src/tools/edit/tool.ts +40 -0
  85. package/src/tools/task-create/handler.ts +1 -1
  86. package/src/tools/task-update/handler.ts +1 -1
  87. package/src/workflow.ts +2 -2
  88. package/dist/proxy-BxFyd6cg.d.cts +0 -24
  89. package/dist/proxy-Cskmj4Yx.d.ts +0 -24
@@ -8,55 +8,105 @@ import {
8
8
  proxyActivities,
9
9
  workflowInfo,
10
10
  type ActivityInterfaceFor,
11
+ type ActivityOptions,
11
12
  } from "@temporalio/workflow";
12
13
  import type { ThreadOps } from "../session/types";
13
14
 
15
+ type OpName = keyof ThreadOps;
16
+
17
+ /** Tight `startToCloseTimeout` so a sick Redis surfaces quickly via retry. */
18
+ const DEFAULT_OPTIONS: ActivityOptions = {
19
+ startToCloseTimeout: "10s",
20
+ retry: {
21
+ maximumAttempts: 6,
22
+ initialInterval: "5s",
23
+ maximumInterval: "15m",
24
+ backoffCoefficient: 4,
25
+ },
26
+ };
27
+
14
28
  /**
15
- * Creates a workflow-safe Temporal activity proxy for {@link ThreadOps}.
29
+ * `heartbeatTimeout` assumes the built-in S3 cold store's progress
30
+ * events (multipart `Upload` + chunked stream read). Stalls trip via
31
+ * heartbeat rather than `startToCloseTimeout`. Custom backends without
32
+ * progress events should override via `perOp`. Harmless on Redis-only
33
+ * deployments — the activities no-op.
34
+ */
35
+ const BUILTIN_PER_OP: Partial<Record<OpName, ActivityOptions>> = {
36
+ hydrateThread: { startToCloseTimeout: "60s", heartbeatTimeout: "15s" },
37
+ flushThread: { startToCloseTimeout: "60s", heartbeatTimeout: "15s" },
38
+ };
39
+
40
+ /**
41
+ * `perOp[op]` layers shallow-rightmost over `defaults` and the
42
+ * built-in cold-tier overlay (`hydrateThread` / `flushThread`).
43
+ * A bare {@link ActivityOptions} is also accepted (treated as `{ defaults }`).
16
44
  *
17
- * The proxy resolves activity names by combining the adapter prefix with
18
- * the workflow scope, so each adapter + workflow combination gets its own
19
- * namespace.
45
+ * @example
46
+ * ```typescript
47
+ * proxyAnthropicThreadOps(undefined, {
48
+ * defaults: { startToCloseTimeout: "5s" },
49
+ * perOp: {
50
+ * flushThread: { startToCloseTimeout: "180s" }, // heartbeatTimeout still inherited
51
+ * },
52
+ * });
53
+ * ```
54
+ */
55
+ export interface ThreadOpsProxyOptions {
56
+ defaults?: ActivityOptions;
57
+ perOp?: Partial<Record<OpName, ActivityOptions>>;
58
+ }
59
+
60
+ function isProxyOptionsShape(o: object): o is ThreadOpsProxyOptions {
61
+ return "defaults" in o || "perOp" in o;
62
+ }
63
+
64
+ /**
65
+ * Creates a workflow-safe Temporal activity proxy for {@link ThreadOps}.
20
66
  *
21
67
  * @param adapterPrefix - Adapter identifier (e.g. "anthropic", "googleGenAI", "langChain")
22
- * @param scope - Optional workflow scope override. Defaults to `workflowInfo().workflowType`.
23
- * @param options - Optional Temporal `proxyActivities` options.
68
+ * @param scope - Workflow scope. Defaults to `workflowInfo().workflowType`.
69
+ * @param options - {@link ThreadOpsProxyOptions} or a bare {@link ActivityOptions}.
24
70
  */
25
71
  export function createThreadOpsProxy(
26
72
  adapterPrefix: string,
27
73
  scope?: string,
28
- options?: Parameters<typeof proxyActivities>[0]
74
+ options?: ActivityOptions | ThreadOpsProxyOptions
29
75
  ): ActivityInterfaceFor<ThreadOps> {
30
76
  const resolvedScope = scope ?? workflowInfo().workflowType;
31
77
 
78
+ const opts: ThreadOpsProxyOptions =
79
+ options && isProxyOptionsShape(options) ? options : { defaults: options };
80
+
81
+ const base = opts.defaults ?? DEFAULT_OPTIONS;
32
82
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
33
- const acts = proxyActivities<Record<string, (...args: any[]) => any>>(
34
- options ?? {
35
- startToCloseTimeout: "10s",
36
- retry: {
37
- maximumAttempts: 6,
38
- initialInterval: "5s",
39
- maximumInterval: "15m",
40
- backoffCoefficient: 4,
41
- },
42
- }
43
- );
83
+ const baseActs = proxyActivities<Record<string, (...args: any[]) => any>>(base);
44
84
 
45
85
  const prefix = `${adapterPrefix}${resolvedScope.charAt(0).toUpperCase()}${resolvedScope.slice(1)}`;
46
86
  const p = (key: string): string =>
47
87
  `${prefix}${key.charAt(0).toUpperCase()}${key.slice(1)}`;
48
88
 
89
+ const pick = (op: OpName): unknown => {
90
+ const overlay = { ...BUILTIN_PER_OP[op], ...opts.perOp?.[op] };
91
+ if (Object.keys(overlay).length === 0) return baseActs[p(op)];
92
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
93
+ return proxyActivities<Record<string, (...args: any[]) => any>>({
94
+ ...base,
95
+ ...overlay,
96
+ })[p(op)];
97
+ };
98
+
49
99
  return {
50
- initializeThread: acts[p("initializeThread")],
51
- appendHumanMessage: acts[p("appendHumanMessage")],
52
- appendToolResult: acts[p("appendToolResult")],
53
- appendAgentMessage: acts[p("appendAgentMessage")],
54
- appendSystemMessage: acts[p("appendSystemMessage")],
55
- forkThread: acts[p("forkThread")],
56
- truncateThread: acts[p("truncateThread")],
57
- loadThreadState: acts[p("loadThreadState")],
58
- saveThreadState: acts[p("saveThreadState")],
59
- hydrateThread: acts[p("hydrateThread")],
60
- flushThread: acts[p("flushThread")],
100
+ initializeThread: pick("initializeThread"),
101
+ appendHumanMessage: pick("appendHumanMessage"),
102
+ appendToolResult: pick("appendToolResult"),
103
+ appendAgentMessage: pick("appendAgentMessage"),
104
+ appendSystemMessage: pick("appendSystemMessage"),
105
+ forkThread: pick("forkThread"),
106
+ truncateThread: pick("truncateThread"),
107
+ loadThreadState: pick("loadThreadState"),
108
+ saveThreadState: pick("saveThreadState"),
109
+ hydrateThread: pick("hydrateThread"),
110
+ flushThread: pick("flushThread"),
61
111
  } as ActivityInterfaceFor<ThreadOps>;
62
112
  }
@@ -134,6 +134,42 @@ describe("createToolRouter edge cases", () => {
134
134
  expect(appendSpy.calls).toHaveLength(0);
135
135
  });
136
136
 
137
+ // --- assistantMessageId propagation into RouterContext ---
138
+
139
+ it("forwards assistantMessageId from ProcessToolCallsContext into RouterContext", async () => {
140
+ let capturedAssistantMessageId: string | undefined;
141
+ const captureTool = defineTool({
142
+ name: "Capture" as const,
143
+ description: "captures router context",
144
+ schema: z.object({}),
145
+ handler: async (
146
+ _args: Record<string, never>,
147
+ ctx: { assistantMessageId?: string }
148
+ ): Promise<ToolHandlerResponse<null>> => {
149
+ capturedAssistantMessageId = ctx.assistantMessageId;
150
+ return { toolResponse: "ok", data: null };
151
+ },
152
+ });
153
+
154
+ const router = createToolRouter({
155
+ tools: { Capture: captureTool } as const,
156
+ threadId: "t-1",
157
+ appendToolResult: appendSpy.fn,
158
+ });
159
+
160
+ const parsed = router.parseToolCall({
161
+ id: "tc-1",
162
+ name: "Capture",
163
+ args: {},
164
+ });
165
+ await router.processToolCalls([parsed], {
166
+ turn: 1,
167
+ assistantMessageId: "asst-msg-42",
168
+ });
169
+
170
+ expect(capturedAssistantMessageId).toBe("asst-msg-42");
171
+ });
172
+
137
173
  // --- Both global and per-tool pre-hooks run in order ---
138
174
 
139
175
  it("global pre-hook runs before per-tool pre-hook", async () => {
@@ -220,7 +220,8 @@ export function createToolRouter<T extends ToolMap>(
220
220
  toolCall: ParsedToolCallUnion<T>,
221
221
  turn: number,
222
222
  sandboxId?: string,
223
- onRewindRequested?: (signal: RewindSignal) => void
223
+ onRewindRequested?: (signal: RewindSignal) => void,
224
+ assistantMessageId?: string
224
225
  ): Promise<ProcessedToolCall> {
225
226
  const startTime = Date.now();
226
227
  const tool = toolMap.get(toolCall.name);
@@ -263,6 +264,7 @@ export function createToolRouter<T extends ToolMap>(
263
264
  toolCallId: toolCall.id,
264
265
  toolName: toolCall.name,
265
266
  ...(sandboxId !== undefined && { sandboxId }),
267
+ ...(assistantMessageId !== undefined && { assistantMessageId }),
266
268
  };
267
269
  const response = await tool.handler(
268
270
  effectiveArgs as Parameters<typeof tool.handler>[0],
@@ -419,6 +421,7 @@ export function createToolRouter<T extends ToolMap>(
419
421
 
420
422
  const turn = context?.turn ?? 0;
421
423
  const sandboxId = context?.sandboxId;
424
+ const assistantMessageId = context?.assistantMessageId;
422
425
 
423
426
  let rewindSignal: RewindSignal | undefined;
424
427
 
@@ -435,7 +438,13 @@ export function createToolRouter<T extends ToolMap>(
435
438
  const outcomes = await scope.run(async () =>
436
439
  Promise.allSettled(
437
440
  toolCalls.map((tc) =>
438
- processToolCall(tc, turn, sandboxId, onRewindRequested)
441
+ processToolCall(
442
+ tc,
443
+ turn,
444
+ sandboxId,
445
+ onRewindRequested,
446
+ assistantMessageId
447
+ )
439
448
  )
440
449
  )
441
450
  );
@@ -457,7 +466,13 @@ export function createToolRouter<T extends ToolMap>(
457
466
 
458
467
  const results: ToolCallResultUnion<TResults>[] = [];
459
468
  for (const toolCall of toolCalls) {
460
- const outcome = await processToolCall(toolCall, turn, sandboxId);
469
+ const outcome = await processToolCall(
470
+ toolCall,
471
+ turn,
472
+ sandboxId,
473
+ undefined,
474
+ assistantMessageId
475
+ );
461
476
  if (outcome.kind === "rewind") {
462
477
  rewindSignal = outcome.signal;
463
478
  break;
@@ -492,6 +507,9 @@ export function createToolRouter<T extends ToolMap>(
492
507
  ...(context?.sandboxId !== undefined && {
493
508
  sandboxId: context.sandboxId,
494
509
  }),
510
+ ...(context?.assistantMessageId !== undefined && {
511
+ assistantMessageId: context.assistantMessageId,
512
+ }),
495
513
  };
496
514
  const response = await handler(
497
515
  toolCall.args as ToolArgs<T, TName>,
@@ -178,6 +178,18 @@ export interface RouterContext {
178
178
  toolCallId: string;
179
179
  toolName: string;
180
180
  sandboxId?: string;
181
+ /**
182
+ * Id of the assistant message that issued this tool call (the message
183
+ * the session passed as `assistantMessageId` into `runAgent`). Present
184
+ * for any tool call processed through `processToolCalls` from a
185
+ * session; may be absent when the router is driven manually (e.g.
186
+ * tests, custom orchestrators).
187
+ *
188
+ * Subagent handlers that fork the parent's thread mid-call use this
189
+ * to truncate the orphan trailing assistant message from the forked
190
+ * thread so the child's first model call sees a well-formed history.
191
+ */
192
+ assistantMessageId?: string;
181
193
  }
182
194
 
183
195
  /**
@@ -294,6 +306,14 @@ export interface ProcessToolCallsContext {
294
306
  turn?: number;
295
307
  /** Active sandbox ID (when a sandbox is configured for this session) */
296
308
  sandboxId?: string;
309
+ /**
310
+ * Id of the assistant message that produced these tool calls. The
311
+ * router forwards it into every handler's {@link RouterContext} so
312
+ * handlers can reference the message they were issued from (e.g.
313
+ * subagent forks that need to truncate the orphan assistant message
314
+ * out of a parent-forked thread).
315
+ */
316
+ assistantMessageId?: string;
297
317
  }
298
318
 
299
319
  /**
@@ -0,0 +1,177 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { InMemorySandboxProvider } from "../../adapters/sandbox/inmemory/index";
3
+ import type { Sandbox, SandboxCreateOptions } from "../../lib/sandbox";
4
+ import { SandboxManager } from "../../lib/sandbox/manager";
5
+ import type { RouterContext } from "../../lib/tool-router/types";
6
+ import { withSandbox } from "../../lib/tool-router/with-sandbox";
7
+ import { applyEditPlan, editHandler, multiEditHandler } from "./handler";
8
+
9
+ describe("edit handlers", () => {
10
+ let manager: SandboxManager<SandboxCreateOptions, Sandbox, "inMemory">;
11
+ let sandboxId: string;
12
+
13
+ const ctx = (id: string): RouterContext => ({
14
+ sandboxId: id,
15
+ threadId: "test-thread",
16
+ toolCallId: "test-call",
17
+ toolName: "FileEdit",
18
+ });
19
+
20
+ beforeEach(async () => {
21
+ manager = new SandboxManager(new InMemorySandboxProvider());
22
+ const result = await manager.create({
23
+ initialFiles: {
24
+ "/src/app.ts": [
25
+ "export function greet(name: string) {",
26
+ ' return "hello " + name;',
27
+ "}",
28
+ "",
29
+ "export const status = 'draft';",
30
+ "export const repeated = 'draft';",
31
+ "",
32
+ ].join("\n"),
33
+ },
34
+ });
35
+ expect(result).not.toBeNull();
36
+ sandboxId = (result as NonNullable<typeof result>).sandboxId;
37
+ });
38
+
39
+ it("applies one unique exact replacement", async () => {
40
+ const handler = withSandbox(manager, editHandler);
41
+
42
+ const response = await handler(
43
+ {
44
+ file_path: "/src/app.ts",
45
+ old_string: ' return "hello " + name;',
46
+ new_string: " return `hello ${name}`;",
47
+ },
48
+ ctx(sandboxId)
49
+ );
50
+
51
+ const sandbox = await manager.getSandbox(sandboxId);
52
+ await expect(sandbox.fs.readFile("/src/app.ts")).resolves.toContain(
53
+ "return `hello ${name}`;"
54
+ );
55
+ expect(response.data?.success).toBe(true);
56
+ expect(response.data?.replacements).toBe(1);
57
+ expect(response.data?.hunks?.[0]).toMatchObject({
58
+ oldStartLine: 2,
59
+ newStartLine: 2,
60
+ oldLines: [' return "hello " + name;'],
61
+ newLines: [" return `hello ${name}`;"],
62
+ });
63
+ });
64
+
65
+ it("refuses ambiguous single edits without replace_all", async () => {
66
+ const handler = withSandbox(manager, editHandler);
67
+
68
+ const response = await handler(
69
+ {
70
+ file_path: "/src/app.ts",
71
+ old_string: "draft",
72
+ new_string: "ready",
73
+ },
74
+ ctx(sandboxId)
75
+ );
76
+
77
+ const sandbox = await manager.getSandbox(sandboxId);
78
+ await expect(sandbox.fs.readFile("/src/app.ts")).resolves.toContain(
79
+ "status = 'draft'"
80
+ );
81
+ expect(response.data?.success).toBe(false);
82
+ expect(response.toolResponse).toContain("appears 2 times");
83
+ });
84
+
85
+ it("supports replace_all for one edit", async () => {
86
+ const handler = withSandbox(manager, editHandler);
87
+
88
+ const response = await handler(
89
+ {
90
+ file_path: "/src/app.ts",
91
+ old_string: "draft",
92
+ new_string: "ready",
93
+ replace_all: true,
94
+ },
95
+ ctx(sandboxId)
96
+ );
97
+
98
+ const sandbox = await manager.getSandbox(sandboxId);
99
+ const content = await sandbox.fs.readFile("/src/app.ts");
100
+ expect(content).toContain("status = 'ready'");
101
+ expect(content).toContain("repeated = 'ready'");
102
+ expect(response.data?.success).toBe(true);
103
+ expect(response.data?.replacements).toBe(2);
104
+ });
105
+
106
+ it("applies multiple edits sequentially and atomically", async () => {
107
+ const handler = withSandbox(manager, multiEditHandler);
108
+
109
+ const response = await handler(
110
+ {
111
+ file_path: "/src/app.ts",
112
+ edits: [
113
+ {
114
+ old_string: ' return "hello " + name;',
115
+ new_string: " return `hello ${name}`;",
116
+ },
117
+ { old_string: "draft", new_string: "ready", replace_all: true },
118
+ ],
119
+ },
120
+ { ...ctx(sandboxId), toolName: "FileMultiEdit" }
121
+ );
122
+
123
+ const sandbox = await manager.getSandbox(sandboxId);
124
+ const content = await sandbox.fs.readFile("/src/app.ts");
125
+ expect(content).toContain("return `hello ${name}`;");
126
+ expect(content).toContain("status = 'ready'");
127
+ expect(content).toContain("repeated = 'ready'");
128
+ expect(response.data?.success).toBe(true);
129
+ expect(response.data?.replacements).toBe(3);
130
+ expect(response.data?.hunks).toHaveLength(3);
131
+ });
132
+
133
+ it("leaves the file unchanged when a later multi-edit fails", async () => {
134
+ const handler = withSandbox(manager, multiEditHandler);
135
+ const sandbox = await manager.getSandbox(sandboxId);
136
+ const before = await sandbox.fs.readFile("/src/app.ts");
137
+
138
+ const response = await handler(
139
+ {
140
+ file_path: "/src/app.ts",
141
+ edits: [
142
+ {
143
+ old_string: ' return "hello " + name;',
144
+ new_string: " return `hello ${name}`;",
145
+ },
146
+ { old_string: "missing text", new_string: "replacement" },
147
+ ],
148
+ },
149
+ { ...ctx(sandboxId), toolName: "FileMultiEdit" }
150
+ );
151
+
152
+ await expect(sandbox.fs.readFile("/src/app.ts")).resolves.toBe(before);
153
+ expect(response.data?.success).toBe(false);
154
+ expect(response.toolResponse).toContain("edit 1");
155
+ });
156
+ });
157
+
158
+ describe("applyEditPlan", () => {
159
+ it("rejects empty old_string before mutating content", () => {
160
+ const result = applyEditPlan("abc", [{ old_string: "", new_string: "x" }]);
161
+
162
+ expect(result).toMatchObject({ ok: false, editIndex: 0 });
163
+ });
164
+
165
+ it("treats replacement text literally", () => {
166
+ const result = applyEditPlan("a.$^ b.$^", [
167
+ { old_string: ".$^", new_string: "literal" },
168
+ ]);
169
+
170
+ expect(result).toMatchObject({ ok: false });
171
+
172
+ const unique = applyEditPlan("a.$^", [
173
+ { old_string: ".$^", new_string: "literal" },
174
+ ]);
175
+ expect(unique).toMatchObject({ ok: true, content: "aliteral" });
176
+ });
177
+ });