@openclaw/lobster 2026.9.8 → 2026.10.1-beta.2

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 (141) hide show
  1. package/README.md +10 -9
  2. package/dist/index.js +45 -39
  3. package/node_modules/@clawdbot/lobster/README.md +4 -0
  4. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/head.js +4 -2
  5. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/head.js.map +1 -1
  6. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/llm_invoke.js +35 -12
  7. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/llm_invoke.js.map +1 -1
  8. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/map.js +13 -1
  9. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/map.js.map +1 -1
  10. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/pick.js +1 -4
  11. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/pick.js.map +1 -1
  12. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/table.js +3 -5
  13. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/table.js.map +1 -1
  14. package/node_modules/@clawdbot/lobster/package.json +7 -7
  15. package/package.json +5 -5
  16. package/node_modules/@clawdbot/lobster/dist/test/approval_id.test.js +0 -171
  17. package/node_modules/@clawdbot/lobster/dist/test/approval_id.test.js.map +0 -1
  18. package/node_modules/@clawdbot/lobster/dist/test/approve_preview.test.js +0 -38
  19. package/node_modules/@clawdbot/lobster/dist/test/approve_preview.test.js.map +0 -1
  20. package/node_modules/@clawdbot/lobster/dist/test/bin_smoke.test.js +0 -25
  21. package/node_modules/@clawdbot/lobster/dist/test/bin_smoke.test.js.map +0 -1
  22. package/node_modules/@clawdbot/lobster/dist/test/cancellation.test.js +0 -5171
  23. package/node_modules/@clawdbot/lobster/dist/test/cancellation.test.js.map +0 -1
  24. package/node_modules/@clawdbot/lobster/dist/test/clawd_invoke.test.js +0 -146
  25. package/node_modules/@clawdbot/lobster/dist/test/clawd_invoke.test.js.map +0 -1
  26. package/node_modules/@clawdbot/lobster/dist/test/clawd_invoke_legacy.test.js +0 -63
  27. package/node_modules/@clawdbot/lobster/dist/test/clawd_invoke_legacy.test.js.map +0 -1
  28. package/node_modules/@clawdbot/lobster/dist/test/cli_run_errors.test.js +0 -53
  29. package/node_modules/@clawdbot/lobster/dist/test/cli_run_errors.test.js.map +0 -1
  30. package/node_modules/@clawdbot/lobster/dist/test/cli_run_file_args_json.test.js +0 -27
  31. package/node_modules/@clawdbot/lobster/dist/test/cli_run_file_args_json.test.js.map +0 -1
  32. package/node_modules/@clawdbot/lobster/dist/test/command_arguments.test.js +0 -82
  33. package/node_modules/@clawdbot/lobster/dist/test/command_arguments.test.js.map +0 -1
  34. package/node_modules/@clawdbot/lobster/dist/test/commands_list.test.js +0 -44
  35. package/node_modules/@clawdbot/lobster/dist/test/commands_list.test.js.map +0 -1
  36. package/node_modules/@clawdbot/lobster/dist/test/condition_comparison.test.js +0 -127
  37. package/node_modules/@clawdbot/lobster/dist/test/condition_comparison.test.js.map +0 -1
  38. package/node_modules/@clawdbot/lobster/dist/test/core_tool_runtime.test.js +0 -160
  39. package/node_modules/@clawdbot/lobster/dist/test/core_tool_runtime.test.js.map +0 -1
  40. package/node_modules/@clawdbot/lobster/dist/test/cost_tracker.test.js +0 -1993
  41. package/node_modules/@clawdbot/lobster/dist/test/cost_tracker.test.js.map +0 -1
  42. package/node_modules/@clawdbot/lobster/dist/test/dedupe.test.js +0 -48
  43. package/node_modules/@clawdbot/lobster/dist/test/dedupe.test.js.map +0 -1
  44. package/node_modules/@clawdbot/lobster/dist/test/diff_last.test.js +0 -70
  45. package/node_modules/@clawdbot/lobster/dist/test/diff_last.test.js.map +0 -1
  46. package/node_modules/@clawdbot/lobster/dist/test/doctor.test.js +0 -19
  47. package/node_modules/@clawdbot/lobster/dist/test/doctor.test.js.map +0 -1
  48. package/node_modules/@clawdbot/lobster/dist/test/dry_run.test.js +0 -502
  49. package/node_modules/@clawdbot/lobster/dist/test/dry_run.test.js.map +0 -1
  50. package/node_modules/@clawdbot/lobster/dist/test/email_triage.test.js +0 -296
  51. package/node_modules/@clawdbot/lobster/dist/test/email_triage.test.js.map +0 -1
  52. package/node_modules/@clawdbot/lobster/dist/test/exec_stdin.test.js +0 -43
  53. package/node_modules/@clawdbot/lobster/dist/test/exec_stdin.test.js.map +0 -1
  54. package/node_modules/@clawdbot/lobster/dist/test/for_each.test.js +0 -482
  55. package/node_modules/@clawdbot/lobster/dist/test/for_each.test.js.map +0 -1
  56. package/node_modules/@clawdbot/lobster/dist/test/github_pr_notify_format.test.js +0 -19
  57. package/node_modules/@clawdbot/lobster/dist/test/github_pr_notify_format.test.js.map +0 -1
  58. package/node_modules/@clawdbot/lobster/dist/test/github_pr_summary.test.js +0 -41
  59. package/node_modules/@clawdbot/lobster/dist/test/github_pr_summary.test.js.map +0 -1
  60. package/node_modules/@clawdbot/lobster/dist/test/github_pr_view.test.js +0 -176
  61. package/node_modules/@clawdbot/lobster/dist/test/github_pr_view.test.js.map +0 -1
  62. package/node_modules/@clawdbot/lobster/dist/test/gog_gmail_send.test.js +0 -100
  63. package/node_modules/@clawdbot/lobster/dist/test/gog_gmail_send.test.js.map +0 -1
  64. package/node_modules/@clawdbot/lobster/dist/test/group_by.test.js +0 -43
  65. package/node_modules/@clawdbot/lobster/dist/test/group_by.test.js.map +0 -1
  66. package/node_modules/@clawdbot/lobster/dist/test/helpers/node_shell_fixture.js +0 -17
  67. package/node_modules/@clawdbot/lobster/dist/test/helpers/node_shell_fixture.js.map +0 -1
  68. package/node_modules/@clawdbot/lobster/dist/test/helpers/wait_for_pid.js +0 -19
  69. package/node_modules/@clawdbot/lobster/dist/test/helpers/wait_for_pid.js.map +0 -1
  70. package/node_modules/@clawdbot/lobster/dist/test/http_response_body_cap.test.js +0 -318
  71. package/node_modules/@clawdbot/lobster/dist/test/http_response_body_cap.test.js.map +0 -1
  72. package/node_modules/@clawdbot/lobster/dist/test/llm_invoke.test.js +0 -1113
  73. package/node_modules/@clawdbot/lobster/dist/test/llm_invoke.test.js.map +0 -1
  74. package/node_modules/@clawdbot/lobster/dist/test/llm_task_invoke.test.js +0 -519
  75. package/node_modules/@clawdbot/lobster/dist/test/llm_task_invoke.test.js.map +0 -1
  76. package/node_modules/@clawdbot/lobster/dist/test/map.test.js +0 -41
  77. package/node_modules/@clawdbot/lobster/dist/test/map.test.js.map +0 -1
  78. package/node_modules/@clawdbot/lobster/dist/test/multi_approval_resume.test.js +0 -48
  79. package/node_modules/@clawdbot/lobster/dist/test/multi_approval_resume.test.js.map +0 -1
  80. package/node_modules/@clawdbot/lobster/dist/test/on_error.test.js +0 -151
  81. package/node_modules/@clawdbot/lobster/dist/test/on_error.test.js.map +0 -1
  82. package/node_modules/@clawdbot/lobster/dist/test/openclaw_agent.test.js +0 -169
  83. package/node_modules/@clawdbot/lobster/dist/test/openclaw_agent.test.js.map +0 -1
  84. package/node_modules/@clawdbot/lobster/dist/test/openclaw_dispatch_retry.test.js +0 -96
  85. package/node_modules/@clawdbot/lobster/dist/test/openclaw_dispatch_retry.test.js.map +0 -1
  86. package/node_modules/@clawdbot/lobster/dist/test/openclaw_invoke_alias.test.js +0 -13
  87. package/node_modules/@clawdbot/lobster/dist/test/openclaw_invoke_alias.test.js.map +0 -1
  88. package/node_modules/@clawdbot/lobster/dist/test/parallel.test.js +0 -184
  89. package/node_modules/@clawdbot/lobster/dist/test/parallel.test.js.map +0 -1
  90. package/node_modules/@clawdbot/lobster/dist/test/parser.test.js +0 -96
  91. package/node_modules/@clawdbot/lobster/dist/test/parser.test.js.map +0 -1
  92. package/node_modules/@clawdbot/lobster/dist/test/pipeline_resume_state.test.js +0 -65
  93. package/node_modules/@clawdbot/lobster/dist/test/pipeline_resume_state.test.js.map +0 -1
  94. package/node_modules/@clawdbot/lobster/dist/test/process_output_limit.test.js +0 -175
  95. package/node_modules/@clawdbot/lobster/dist/test/process_output_limit.test.js.map +0 -1
  96. package/node_modules/@clawdbot/lobster/dist/test/read_line.test.js +0 -79
  97. package/node_modules/@clawdbot/lobster/dist/test/read_line.test.js.map +0 -1
  98. package/node_modules/@clawdbot/lobster/dist/test/release_changelog.test.js +0 -39
  99. package/node_modules/@clawdbot/lobster/dist/test/release_changelog.test.js.map +0 -1
  100. package/node_modules/@clawdbot/lobster/dist/test/request_input.test.js +0 -946
  101. package/node_modules/@clawdbot/lobster/dist/test/request_input.test.js.map +0 -1
  102. package/node_modules/@clawdbot/lobster/dist/test/resume.test.js +0 -82
  103. package/node_modules/@clawdbot/lobster/dist/test/resume.test.js.map +0 -1
  104. package/node_modules/@clawdbot/lobster/dist/test/sdk_exec.test.js +0 -248
  105. package/node_modules/@clawdbot/lobster/dist/test/sdk_exec.test.js.map +0 -1
  106. package/node_modules/@clawdbot/lobster/dist/test/sdk_lobster.test.js +0 -177
  107. package/node_modules/@clawdbot/lobster/dist/test/sdk_lobster.test.js.map +0 -1
  108. package/node_modules/@clawdbot/lobster/dist/test/shell.test.js +0 -31
  109. package/node_modules/@clawdbot/lobster/dist/test/shell.test.js.map +0 -1
  110. package/node_modules/@clawdbot/lobster/dist/test/sort.test.js +0 -51
  111. package/node_modules/@clawdbot/lobster/dist/test/sort.test.js.map +0 -1
  112. package/node_modules/@clawdbot/lobster/dist/test/state.test.js +0 -919
  113. package/node_modules/@clawdbot/lobster/dist/test/state.test.js.map +0 -1
  114. package/node_modules/@clawdbot/lobster/dist/test/step_retry.test.js +0 -254
  115. package/node_modules/@clawdbot/lobster/dist/test/step_retry.test.js.map +0 -1
  116. package/node_modules/@clawdbot/lobster/dist/test/step_stdin_epipe.test.js +0 -44
  117. package/node_modules/@clawdbot/lobster/dist/test/step_stdin_epipe.test.js.map +0 -1
  118. package/node_modules/@clawdbot/lobster/dist/test/step_timeout.test.js +0 -220
  119. package/node_modules/@clawdbot/lobster/dist/test/step_timeout.test.js.map +0 -1
  120. package/node_modules/@clawdbot/lobster/dist/test/template.test.js +0 -46
  121. package/node_modules/@clawdbot/lobster/dist/test/template.test.js.map +0 -1
  122. package/node_modules/@clawdbot/lobster/dist/test/template_filters.test.js +0 -144
  123. package/node_modules/@clawdbot/lobster/dist/test/template_filters.test.js.map +0 -1
  124. package/node_modules/@clawdbot/lobster/dist/test/tool_envelope_version.test.js +0 -15
  125. package/node_modules/@clawdbot/lobster/dist/test/tool_envelope_version.test.js.map +0 -1
  126. package/node_modules/@clawdbot/lobster/dist/test/tool_mode.test.js +0 -83
  127. package/node_modules/@clawdbot/lobster/dist/test/tool_mode.test.js.map +0 -1
  128. package/node_modules/@clawdbot/lobster/dist/test/validation.test.js +0 -28
  129. package/node_modules/@clawdbot/lobster/dist/test/validation.test.js.map +0 -1
  130. package/node_modules/@clawdbot/lobster/dist/test/where.test.js +0 -94
  131. package/node_modules/@clawdbot/lobster/dist/test/where.test.js.map +0 -1
  132. package/node_modules/@clawdbot/lobster/dist/test/workflow_args_env.test.js +0 -43
  133. package/node_modules/@clawdbot/lobster/dist/test/workflow_args_env.test.js.map +0 -1
  134. package/node_modules/@clawdbot/lobster/dist/test/workflow_composition.test.js +0 -238
  135. package/node_modules/@clawdbot/lobster/dist/test/workflow_composition.test.js.map +0 -1
  136. package/node_modules/@clawdbot/lobster/dist/test/workflow_file.test.js +0 -2045
  137. package/node_modules/@clawdbot/lobster/dist/test/workflow_file.test.js.map +0 -1
  138. package/node_modules/@clawdbot/lobster/dist/test/workflow_graph.test.js +0 -107
  139. package/node_modules/@clawdbot/lobster/dist/test/workflow_graph.test.js.map +0 -1
  140. package/node_modules/@clawdbot/lobster/dist/test/workflows.test.js +0 -32
  141. package/node_modules/@clawdbot/lobster/dist/test/workflows.test.js.map +0 -1
@@ -1,1113 +0,0 @@
1
- import test from "node:test";
2
- import assert from "node:assert/strict";
3
- import { createHash } from "node:crypto";
4
- import http from "node:http";
5
- import { promises as fsp } from "node:fs";
6
- import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
7
- import { tmpdir } from "node:os";
8
- import path from "node:path";
9
- import { createDefaultRegistry } from "../src/commands/registry.js";
10
- import { keyToPath, stableStringify } from "../src/state/store.js";
11
- function streamOf(items) {
12
- return (async function* () {
13
- for (const item of items)
14
- yield item;
15
- })();
16
- }
17
- async function collect(iterable) {
18
- const items = [];
19
- for await (const item of iterable)
20
- items.push(item);
21
- return items;
22
- }
23
- test("llm.invoke auto-detects OpenClaw provider and normalizes output", async () => {
24
- const registry = createDefaultRegistry();
25
- const cmd = registry.get("llm.invoke");
26
- assert.ok(cmd, "llm.invoke should be registered");
27
- const cacheDir = await mkdtemp(path.join(tmpdir(), "lobster-cache-"));
28
- const bodyLog = [];
29
- const server = http.createServer((req, res) => {
30
- if (req.method !== "POST" || req.url !== "/tools/invoke") {
31
- res.writeHead(404);
32
- res.end("nope");
33
- return;
34
- }
35
- let buf = "";
36
- req.setEncoding("utf8");
37
- req.on("data", (d) => (buf += d));
38
- req.on("end", () => {
39
- const parsed = JSON.parse(buf || "{}");
40
- bodyLog.push(parsed);
41
- res.writeHead(200, { "content-type": "application/json" });
42
- res.end(JSON.stringify({
43
- ok: true,
44
- result: {
45
- ok: true,
46
- result: {
47
- runId: "invoke_1",
48
- model: parsed.args?.model,
49
- prompt: parsed.args?.prompt,
50
- output: { data: { summary: "hello" } },
51
- },
52
- },
53
- }));
54
- });
55
- });
56
- await new Promise((resolve) => server.listen(0, resolve));
57
- const addr = server.address();
58
- const port = typeof addr === "object" && addr ? addr.port : 0;
59
- try {
60
- const result = await cmd.run({
61
- input: streamOf([{ kind: "text", text: "doc" }]),
62
- args: {
63
- _: [],
64
- model: "claude-3-sonnet",
65
- prompt: "Summarize",
66
- },
67
- ctx: baseCtx({ OPENCLAW_URL: `http://localhost:${port}`, LOBSTER_CACHE_DIR: cacheDir }, registry),
68
- });
69
- const items = await collect(result.output);
70
- assert.equal(items.length, 1);
71
- assert.equal(items[0].kind, "llm.invoke");
72
- assert.equal(items[0].source, "openclaw");
73
- assert.equal(items[0].runId, "invoke_1");
74
- assert.equal(items[0].output.data.summary, "hello");
75
- assert.equal(bodyLog.length, 1);
76
- assert.equal(bodyLog[0].tool, "llm-task");
77
- assert.equal(bodyLog[0].args.prompt, "Summarize");
78
- }
79
- finally {
80
- await rm(cacheDir, { recursive: true, force: true });
81
- await closeServer(server);
82
- }
83
- });
84
- test("llm.invoke re-invokes the adapter when --temperature changes", async () => {
85
- const registry = createDefaultRegistry();
86
- const cmd = registry.get("llm.invoke");
87
- assert.ok(cmd);
88
- const cacheDir = await mkdtemp(path.join(tmpdir(), "lobster-cache-"));
89
- const requestLog = [];
90
- const server = http.createServer((req, res) => {
91
- if (req.method !== "POST" || req.url !== "/invoke") {
92
- res.writeHead(404);
93
- res.end("nope");
94
- return;
95
- }
96
- let buf = "";
97
- req.setEncoding("utf8");
98
- req.on("data", (d) => (buf += d));
99
- req.on("end", () => {
100
- const parsed = JSON.parse(buf || "{}");
101
- requestLog.push(parsed);
102
- res.writeHead(200, { "content-type": "application/json" });
103
- res.end(JSON.stringify({
104
- ok: true,
105
- result: {
106
- runId: `pi_${requestLog.length}`,
107
- model: parsed.model,
108
- prompt: parsed.prompt,
109
- output: { format: "text", text: `temperature=${parsed.temperature}` },
110
- },
111
- }));
112
- });
113
- });
114
- await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
115
- const addr = server.address();
116
- const port = typeof addr === "object" && addr ? addr.port : 0;
117
- const ctxEnv = {
118
- LOBSTER_PI_LLM_ADAPTER_URL: `http://127.0.0.1:${port}`,
119
- LOBSTER_CACHE_DIR: cacheDir,
120
- };
121
- try {
122
- const runWith = async (temperature) => {
123
- const result = await cmd.run({
124
- input: streamOf([]),
125
- args: {
126
- _: [],
127
- provider: "pi",
128
- model: "test-model",
129
- prompt: "Sampling parameters matter",
130
- "schema-version": "v1",
131
- temperature,
132
- },
133
- ctx: baseCtx(ctxEnv, registry),
134
- });
135
- return collect(result.output);
136
- };
137
- const cold = await runWith(0.1);
138
- assert.equal(cold[0].source, "pi");
139
- assert.equal(requestLog.length, 1);
140
- assert.equal(requestLog[0].temperature, 0.1);
141
- const changed = await runWith(0.9);
142
- assert.equal(changed[0].source, "pi");
143
- assert.equal(requestLog.length, 2);
144
- assert.equal(requestLog[1].temperature, 0.9);
145
- assert.notEqual(changed[0].cacheKey, cold[0].cacheKey);
146
- const replay = await runWith(0.1);
147
- assert.equal(replay[0].source, "cache");
148
- assert.equal(replay[0].cacheKey, cold[0].cacheKey);
149
- assert.equal(requestLog.length, 2);
150
- }
151
- finally {
152
- await rm(cacheDir, { recursive: true, force: true });
153
- await closeServer(server);
154
- }
155
- });
156
- test("llm.invoke re-invokes the adapter when --max-output-tokens changes", async () => {
157
- const registry = createDefaultRegistry();
158
- const cmd = registry.get("llm.invoke");
159
- assert.ok(cmd);
160
- const cacheDir = await mkdtemp(path.join(tmpdir(), "lobster-cache-"));
161
- const requestLog = [];
162
- const server = http.createServer((req, res) => {
163
- if (req.method !== "POST" || req.url !== "/invoke") {
164
- res.writeHead(404);
165
- res.end("nope");
166
- return;
167
- }
168
- let buf = "";
169
- req.setEncoding("utf8");
170
- req.on("data", (d) => (buf += d));
171
- req.on("end", () => {
172
- const parsed = JSON.parse(buf || "{}");
173
- requestLog.push(parsed);
174
- res.writeHead(200, { "content-type": "application/json" });
175
- res.end(JSON.stringify({
176
- ok: true,
177
- result: {
178
- runId: `pi_${requestLog.length}`,
179
- output: { format: "text", text: `budget=${parsed.maxOutputTokens}` },
180
- },
181
- }));
182
- });
183
- });
184
- await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
185
- const addr = server.address();
186
- const port = typeof addr === "object" && addr ? addr.port : 0;
187
- const ctxEnv = {
188
- LOBSTER_PI_LLM_ADAPTER_URL: `http://127.0.0.1:${port}`,
189
- LOBSTER_CACHE_DIR: cacheDir,
190
- };
191
- try {
192
- const runWith = async (maxOutputTokens) => {
193
- const result = await cmd.run({
194
- input: streamOf([]),
195
- args: {
196
- _: [],
197
- provider: "pi",
198
- model: "test-model",
199
- prompt: "Token budget matters",
200
- "schema-version": "v1",
201
- "max-output-tokens": maxOutputTokens,
202
- },
203
- ctx: baseCtx(ctxEnv, registry),
204
- });
205
- return collect(result.output);
206
- };
207
- const cold = await runWith(64);
208
- assert.equal(cold[0].source, "pi");
209
- assert.equal(requestLog.length, 1);
210
- assert.equal(requestLog[0].maxOutputTokens, 64);
211
- const changed = await runWith(4096);
212
- assert.equal(changed[0].source, "pi");
213
- assert.equal(requestLog.length, 2);
214
- assert.equal(requestLog[1].maxOutputTokens, 4096);
215
- assert.notEqual(changed[0].cacheKey, cold[0].cacheKey);
216
- }
217
- finally {
218
- await rm(cacheDir, { recursive: true, force: true });
219
- await closeServer(server);
220
- }
221
- });
222
- test("llm.invoke does not replay a cache entry written before sampling was keyed", async () => {
223
- const registry = createDefaultRegistry();
224
- const cmd = registry.get("llm.invoke");
225
- assert.ok(cmd);
226
- const cacheDir = await mkdtemp(path.join(tmpdir(), "lobster-cache-"));
227
- const args = {
228
- _: [],
229
- provider: "pi",
230
- model: "test-model",
231
- prompt: "Cache key stability",
232
- "schema-version": "v1",
233
- };
234
- // The identity earlier releases hashed: sampling parameters were absent from the payload,
235
- // so an answer sampled at temperature 0.9 was stored under the same key a request that
236
- // omits sampling computes. Reading that entry back would serve sampling nobody asked for.
237
- const legacyKey = createHash("sha256")
238
- .update(stableStringify({
239
- provider: "pi",
240
- prompt: "Cache key stability",
241
- model: "test-model",
242
- schemaVersion: "v1",
243
- artifactHashes: [],
244
- outputSchema: null,
245
- }))
246
- .digest("hex");
247
- const requestLog = [];
248
- const server = http.createServer((req, res) => {
249
- if (req.method !== "POST" || req.url !== "/invoke") {
250
- res.writeHead(404);
251
- res.end("nope");
252
- return;
253
- }
254
- let buf = "";
255
- req.setEncoding("utf8");
256
- req.on("data", (d) => (buf += d));
257
- req.on("end", () => {
258
- requestLog.push(JSON.parse(buf || "{}"));
259
- res.writeHead(200, { "content-type": "application/json" });
260
- res.end(JSON.stringify({
261
- ok: true,
262
- result: { runId: "pi_1", output: { format: "text", text: "fresh" } },
263
- }));
264
- });
265
- });
266
- await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
267
- const addr = server.address();
268
- const port = typeof addr === "object" && addr ? addr.port : 0;
269
- try {
270
- await mkdir(path.join(cacheDir, "llm.invoke"), { recursive: true });
271
- await writeFile(path.join(cacheDir, "llm.invoke", `${legacyKey}.json`), JSON.stringify({
272
- cacheKey: legacyKey,
273
- storedAt: "2026-08-01T00:00:00.000Z",
274
- items: [
275
- {
276
- kind: "llm.invoke",
277
- cacheKey: legacyKey,
278
- status: "completed",
279
- source: "pi",
280
- cached: false,
281
- output: { format: "text", text: "sampled at 0.9" },
282
- },
283
- ],
284
- }), "utf8");
285
- const result = await cmd.run({
286
- input: streamOf([]),
287
- args,
288
- ctx: baseCtx({
289
- LOBSTER_PI_LLM_ADAPTER_URL: `http://127.0.0.1:${port}`,
290
- LOBSTER_CACHE_DIR: cacheDir,
291
- }, registry),
292
- });
293
- const items = await collect(result.output);
294
- assert.equal(requestLog.length, 1);
295
- assert.equal(items[0].source, "pi");
296
- assert.equal(items[0].output.text, "fresh");
297
- assert.notEqual(items[0].cacheKey, legacyKey);
298
- }
299
- finally {
300
- await rm(cacheDir, { recursive: true, force: true });
301
- await closeServer(server);
302
- }
303
- });
304
- test("llm.invoke uses Pi adapter over local HTTP bridge", async () => {
305
- const registry = createDefaultRegistry();
306
- const cmd = registry.get("llm.invoke");
307
- assert.ok(cmd);
308
- const cacheDir = await mkdtemp(path.join(tmpdir(), "lobster-cache-"));
309
- const requestLog = [];
310
- const server = http.createServer((req, res) => {
311
- if (req.method !== "POST" || req.url !== "/invoke") {
312
- res.writeHead(404);
313
- res.end("nope");
314
- return;
315
- }
316
- let buf = "";
317
- req.setEncoding("utf8");
318
- req.on("data", (d) => (buf += d));
319
- req.on("end", () => {
320
- const parsed = JSON.parse(buf || "{}");
321
- requestLog.push(parsed);
322
- res.writeHead(200, { "content-type": "application/json" });
323
- res.end(JSON.stringify({
324
- ok: true,
325
- result: {
326
- runId: "pi_1",
327
- model: parsed.model,
328
- prompt: parsed.prompt,
329
- output: {
330
- format: "json",
331
- text: '{"decision":"reply"}',
332
- data: { decision: "reply" },
333
- },
334
- diagnostics: { adapter: "pi" },
335
- },
336
- }));
337
- });
338
- });
339
- await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
340
- const addr = server.address();
341
- const port = typeof addr === "object" && addr ? addr.port : 0;
342
- try {
343
- const result = await cmd.run({
344
- input: streamOf([{ kind: "text", text: "draft this" }]),
345
- args: {
346
- _: [],
347
- provider: "pi",
348
- prompt: "Decide",
349
- "output-schema": '{"type":"object","required":["decision"]}',
350
- },
351
- ctx: baseCtx({
352
- LOBSTER_PI_LLM_ADAPTER_URL: `http://127.0.0.1:${port}`,
353
- LOBSTER_LLM_MODEL: "anthropic/claude-sonnet-4-5",
354
- LOBSTER_CACHE_DIR: cacheDir,
355
- }, registry),
356
- });
357
- const items = await collect(result.output);
358
- assert.equal(items.length, 1);
359
- assert.equal(items[0].kind, "llm.invoke");
360
- assert.equal(items[0].source, "pi");
361
- assert.equal(items[0].model, "anthropic/claude-sonnet-4-5");
362
- assert.equal(items[0].output.data.decision, "reply");
363
- assert.equal(requestLog.length, 1);
364
- assert.equal(requestLog[0].prompt, "Decide");
365
- assert.equal(requestLog[0].model, "anthropic/claude-sonnet-4-5");
366
- assert.equal(requestLog[0].artifacts.length, 1);
367
- }
368
- finally {
369
- await rm(cacheDir, { recursive: true, force: true });
370
- await closeServer(server);
371
- }
372
- });
373
- test("llm.invoke aborts the in-flight adapter request when ctx.signal aborts", { timeout: 20_000 }, async () => {
374
- const registry = createDefaultRegistry();
375
- const cmd = registry.get("llm.invoke");
376
- assert.ok(cmd);
377
- const stalled = createStalledAdapter();
378
- await stalled.listen();
379
- const controller = new AbortController();
380
- try {
381
- const pending = cmd.run({
382
- input: streamOf([]),
383
- args: { _: [], provider: "http", prompt: "Summarize", "disable-cache": true },
384
- ctx: {
385
- ...baseCtx({ LOBSTER_LLM_ADAPTER_URL: `http://127.0.0.1:${stalled.port}/invoke` }, registry),
386
- signal: controller.signal,
387
- },
388
- });
389
- await stalled.requestReceived;
390
- controller.abort();
391
- const err = await pending.then(() => null, (e) => e);
392
- assert.ok(err, "llm.invoke should reject once the run is aborted");
393
- assert.ok(err.name === "AbortError" || err.code === "ABORT_ERR", `expected an abort error, got ${err.name}: ${err.message}`);
394
- // The abort must stay recognizable; wrapping it hides the cancellation from
395
- // workflow timeout and abort handling.
396
- assert.doesNotMatch(String(err.message), /request failed/);
397
- await stalled.requestClosed;
398
- }
399
- finally {
400
- controller.abort();
401
- await stalled.close();
402
- }
403
- });
404
- test("llm.invoke stops waiting for a direct adapter that ignores ctx.signal", { timeout: 20_000 }, async () => {
405
- const registry = createDefaultRegistry();
406
- const cmd = registry.get("llm.invoke");
407
- assert.ok(cmd);
408
- let invoked = () => { };
409
- const adapterInvoked = new Promise((resolve) => (invoked = resolve));
410
- // A supported ctx.llmAdapters adapter that never resolves and never looks
411
- // at ctx.signal.
412
- const stubborn = {
413
- invoke() {
414
- invoked();
415
- return new Promise(() => { });
416
- },
417
- };
418
- const controller = new AbortController();
419
- const pending = cmd.run({
420
- input: streamOf([]),
421
- args: { _: [], provider: "stubborn", prompt: "Summarize", "disable-cache": true },
422
- ctx: {
423
- ...baseCtx({}, registry),
424
- llmAdapters: { stubborn },
425
- signal: controller.signal,
426
- },
427
- });
428
- await adapterInvoked;
429
- controller.abort();
430
- const err = await pending.then(() => null, (e) => e);
431
- assert.ok(err, "llm.invoke should reject once the run is aborted");
432
- assert.ok(err.name === "AbortError" || err.code === "ABORT_ERR", `expected an abort error, got ${err.name}: ${err.message}`);
433
- assert.doesNotMatch(String(err.message), /request failed/);
434
- });
435
- test("llm.invoke observes a direct adapter that rejects after aborting the run", async () => {
436
- const registry = createDefaultRegistry();
437
- const cmd = registry.get("llm.invoke");
438
- assert.ok(cmd);
439
- const controller = new AbortController();
440
- // An adapter that cancels the run from inside its own invoke and then fails:
441
- // the shape of an SDK client that tears itself down on a fatal error.
442
- const selfaborting = {
443
- invoke() {
444
- controller.abort();
445
- return Promise.reject(new Error("adapter tore down its client"));
446
- },
447
- };
448
- const unhandled = [];
449
- const onUnhandled = (reason) => unhandled.push(reason);
450
- process.on("unhandledRejection", onUnhandled);
451
- try {
452
- const err = await cmd
453
- .run({
454
- input: streamOf([]),
455
- args: { _: [], provider: "selfaborting", prompt: "Summarize", "disable-cache": true },
456
- ctx: {
457
- ...baseCtx({}, registry),
458
- llmAdapters: { selfaborting },
459
- signal: controller.signal,
460
- },
461
- })
462
- .then(() => null, (e) => e);
463
- assert.ok(err?.name === "AbortError" || err?.code === "ABORT_ERR", `expected an abort error, got ${err?.name}: ${err?.message}`);
464
- // The adapter's own rejection must not outlive the cancelled step. Nothing is
465
- // waiting on it once the abort has won the race, and an unobserved rejection
466
- // ends the process under Node's default handling.
467
- await new Promise((resolve) => setTimeout(resolve, 50));
468
- assert.equal(unhandled.length, 0, `adapter rejection went unhandled: ${unhandled[0]?.message ?? unhandled[0]}`);
469
- }
470
- finally {
471
- process.off("unhandledRejection", onUnhandled);
472
- }
473
- });
474
- test("llm.invoke does not complete from cache when ctx.signal is already aborted", async () => {
475
- const registry = createDefaultRegistry();
476
- const cmd = registry.get("llm.invoke");
477
- assert.ok(cmd);
478
- const cacheDir = await mkdtemp(path.join(tmpdir(), "lobster-cache-"));
479
- await mkdir(path.join(cacheDir, "llm.invoke"), { recursive: true });
480
- let requests = 0;
481
- const server = http.createServer((req, res) => {
482
- requests += 1;
483
- req.resume();
484
- req.on("end", () => {
485
- res.writeHead(200, { "content-type": "application/json" });
486
- res.end(JSON.stringify({
487
- ok: true,
488
- result: { runId: "cached_1", output: { text: "hello", data: null } },
489
- }));
490
- });
491
- });
492
- await new Promise((resolve) => server.listen(0, resolve));
493
- const addr = server.address();
494
- const port = typeof addr === "object" && addr ? addr.port : 0;
495
- const env = {
496
- LOBSTER_LLM_ADAPTER_URL: `http://127.0.0.1:${port}/invoke`,
497
- LOBSTER_CACHE_DIR: cacheDir,
498
- };
499
- const args = { _: [], provider: "http", prompt: "Summarize" };
500
- try {
501
- // Populate the cache, then repeat the same call on a cancelled run.
502
- const warm = await cmd.run({
503
- input: streamOf([]),
504
- args,
505
- ctx: baseCtx(env, registry),
506
- });
507
- assert.equal((await collect(warm.output))[0].cached, false);
508
- assert.equal(requests, 1);
509
- const controller = new AbortController();
510
- controller.abort();
511
- await assert.rejects(() => cmd.run({
512
- input: streamOf([]),
513
- args,
514
- ctx: { ...baseCtx(env, registry), signal: controller.signal },
515
- }), (err) => err?.name === "AbortError" || err?.code === "ABORT_ERR");
516
- assert.equal(requests, 1, "the cached run must not reach the adapter either");
517
- }
518
- finally {
519
- await rm(cacheDir, { recursive: true, force: true });
520
- await closeServer(server);
521
- }
522
- });
523
- test("llm.invoke does not replay a cache hit when ctx.signal aborts while input is drained", async () => {
524
- const registry = createDefaultRegistry();
525
- const cmd = registry.get("llm.invoke");
526
- assert.ok(cmd);
527
- const cacheDir = await mkdtemp(path.join(tmpdir(), "lobster-cache-"));
528
- await mkdir(path.join(cacheDir, "llm.invoke"), { recursive: true });
529
- let requests = 0;
530
- const server = http.createServer((req, res) => {
531
- requests += 1;
532
- req.resume();
533
- req.on("end", () => {
534
- res.writeHead(200, { "content-type": "application/json" });
535
- res.end(JSON.stringify({
536
- ok: true,
537
- result: { runId: "cached_1", output: { text: "hello", data: null } },
538
- }));
539
- });
540
- });
541
- await new Promise((resolve) => server.listen(0, resolve));
542
- const addr = server.address();
543
- const port = typeof addr === "object" && addr ? addr.port : 0;
544
- const env = {
545
- LOBSTER_LLM_ADAPTER_URL: `http://127.0.0.1:${port}/invoke`,
546
- LOBSTER_CACHE_DIR: cacheDir,
547
- };
548
- const args = { _: [], provider: "http", prompt: "Summarize" };
549
- try {
550
- const warm = await cmd.run({
551
- input: streamOf([]),
552
- args,
553
- ctx: baseCtx(env, registry),
554
- });
555
- assert.equal((await collect(warm.output))[0].cached, false);
556
- assert.equal(requests, 1);
557
- // The step timeout fires while the upstream step is still producing input,
558
- // which is after the entry check and before the cache lookup.
559
- const controller = new AbortController();
560
- // oxlint-disable-next-line require-yield -- Exercise cancellation before the first item.
561
- const abortingInput = (async function* () {
562
- await new Promise((resolve) => setTimeout(resolve, 5));
563
- controller.abort();
564
- })();
565
- await assert.rejects(() => cmd.run({
566
- input: abortingInput,
567
- args,
568
- ctx: { ...baseCtx(env, registry), signal: controller.signal },
569
- }), (err) => err?.name === "AbortError" || err?.code === "ABORT_ERR");
570
- assert.equal(requests, 1, "a cancelled run must not reach the adapter either");
571
- }
572
- finally {
573
- await rm(cacheDir, { recursive: true, force: true });
574
- await closeServer(server);
575
- }
576
- });
577
- test("llm.invoke does not replay run state when ctx.signal aborts while input is drained", async () => {
578
- const registry = createDefaultRegistry();
579
- const cmd = registry.get("llm.invoke");
580
- assert.ok(cmd);
581
- const stateDir = await mkdtemp(path.join(tmpdir(), "lobster-state-"));
582
- let requests = 0;
583
- const server = http.createServer((req, res) => {
584
- requests += 1;
585
- req.resume();
586
- req.on("end", () => {
587
- res.writeHead(200, { "content-type": "application/json" });
588
- res.end(JSON.stringify({
589
- ok: true,
590
- result: { runId: "state_1", output: { text: "hello", data: null } },
591
- }));
592
- });
593
- });
594
- await new Promise((resolve) => server.listen(0, resolve));
595
- const addr = server.address();
596
- const port = typeof addr === "object" && addr ? addr.port : 0;
597
- const env = {
598
- LOBSTER_LLM_ADAPTER_URL: `http://127.0.0.1:${port}/invoke`,
599
- LOBSTER_STATE_DIR: stateDir,
600
- };
601
- const args = {
602
- _: [],
603
- provider: "http",
604
- prompt: "Summarize",
605
- "state-key": "step-1",
606
- "disable-cache": true,
607
- };
608
- try {
609
- const warm = await cmd.run({
610
- input: streamOf([]),
611
- args,
612
- ctx: baseCtx(env, registry),
613
- });
614
- assert.equal((await collect(warm.output))[0].cached, false);
615
- assert.equal(requests, 1);
616
- const controller = new AbortController();
617
- // oxlint-disable-next-line require-yield -- Exercise cancellation before the first item.
618
- const abortingInput = (async function* () {
619
- await new Promise((resolve) => setTimeout(resolve, 5));
620
- controller.abort();
621
- })();
622
- await assert.rejects(() => cmd.run({
623
- input: abortingInput,
624
- args,
625
- ctx: { ...baseCtx(env, registry), signal: controller.signal },
626
- }), (err) => err?.name === "AbortError" || err?.code === "ABORT_ERR");
627
- assert.equal(requests, 1, "a cancelled run must not reach the adapter either");
628
- }
629
- finally {
630
- await rm(stateDir, { recursive: true, force: true });
631
- await closeServer(server);
632
- }
633
- });
634
- test("llm.invoke does not call the adapter when ctx.signal is already aborted", async () => {
635
- const registry = createDefaultRegistry();
636
- const cmd = registry.get("llm.invoke");
637
- assert.ok(cmd);
638
- let requests = 0;
639
- const server = http.createServer((req, res) => {
640
- requests += 1;
641
- req.resume();
642
- res.writeHead(200, { "content-type": "application/json" });
643
- res.end(JSON.stringify({ ok: true, result: { runId: "r1", output: { data: {} } } }));
644
- });
645
- await new Promise((resolve) => server.listen(0, resolve));
646
- const addr = server.address();
647
- const port = typeof addr === "object" && addr ? addr.port : 0;
648
- try {
649
- const controller = new AbortController();
650
- controller.abort();
651
- await assert.rejects(() => cmd.run({
652
- input: streamOf([]),
653
- args: { _: [], provider: "http", prompt: "Summarize", "disable-cache": true },
654
- ctx: {
655
- ...baseCtx({ LOBSTER_LLM_ADAPTER_URL: `http://127.0.0.1:${port}/invoke` }, registry),
656
- signal: controller.signal,
657
- },
658
- }), (err) => err?.name === "AbortError" || err?.code === "ABORT_ERR");
659
- assert.equal(requests, 0, "an already-cancelled run must not reach the adapter");
660
- }
661
- finally {
662
- await closeServer(server);
663
- }
664
- });
665
- test("llm.invoke does not retry schema validation after adapter cancellation", async () => {
666
- const registry = createDefaultRegistry();
667
- const cmd = registry.get("llm.invoke");
668
- assert.ok(cmd);
669
- const controller = new AbortController();
670
- let calls = 0;
671
- await assert.rejects(cmd.run({
672
- input: streamOf([]),
673
- args: {
674
- _: [],
675
- provider: "cancel-test",
676
- prompt: "Decide",
677
- "output-schema": '{"type":"object","required":["decision"]}',
678
- "max-validation-retries": 2,
679
- },
680
- ctx: {
681
- ...baseCtx({}, registry),
682
- signal: controller.signal,
683
- llmAdapters: {
684
- "cancel-test": {
685
- source: "cancel-test",
686
- async invoke() {
687
- calls += 1;
688
- controller.abort(new Error("adapter cancelled during validation"));
689
- return {
690
- ok: true,
691
- result: {
692
- runId: "cancelled-attempt",
693
- output: { data: { unexpected: true } },
694
- },
695
- };
696
- },
697
- },
698
- },
699
- },
700
- }), /adapter cancelled during validation/);
701
- assert.equal(calls, 1, "cancellation after an invalid response must suppress retries");
702
- });
703
- test("llm.invoke aborts while waiting for its reusable run-state lock", async () => {
704
- const registry = createDefaultRegistry();
705
- const cmd = registry.get("llm.invoke");
706
- assert.ok(cmd);
707
- const cacheDir = await mkdtemp(path.join(tmpdir(), "lobster-llm-state-lock-abort-"));
708
- const stateDir = path.join(cacheDir, "state");
709
- const stateKey = "blocked-run-state";
710
- const lockPath = `${keyToPath(stateDir, stateKey)}.lock`;
711
- await fsp.mkdir(lockPath, { recursive: true });
712
- await fsp.writeFile(path.join(lockPath, "owner"), `${process.pid}::live-writer\n`, "utf8");
713
- try {
714
- const controller = new AbortController();
715
- const pending = cmd.run({
716
- input: streamOf([]),
717
- args: {
718
- _: [],
719
- provider: "state-lock-abort-test",
720
- prompt: "Decide",
721
- "state-key": stateKey,
722
- },
723
- ctx: {
724
- ...baseCtx({ LOBSTER_CACHE_DIR: cacheDir, LOBSTER_STATE_DIR: stateDir }, registry),
725
- signal: controller.signal,
726
- llmAdapters: {
727
- "state-lock-abort-test": {
728
- source: "state-lock-abort-test",
729
- async invoke() {
730
- throw new Error("adapter must not run while the state read is locked");
731
- },
732
- },
733
- },
734
- },
735
- });
736
- const completion = pending.then(() => ({ kind: "success" }), (error) => ({ kind: "error", error }));
737
- await new Promise((resolve) => setImmediate(resolve));
738
- controller.abort(new Error("LLM state read cancelled"));
739
- const early = await Promise.race([
740
- completion,
741
- new Promise((resolve) => setTimeout(() => resolve({ kind: "timeout" }), 75)),
742
- ]);
743
- if (early.kind === "timeout")
744
- await fsp.rm(lockPath, { recursive: true, force: true });
745
- const settled = early.kind === "timeout" ? await completion : early;
746
- assert.notEqual(early.kind, "timeout", "state-key reads must observe cancellation while locked");
747
- assert.equal(settled.kind, "error");
748
- if (settled.kind === "error") {
749
- assert.match(settled.error?.message ?? "", /LLM state read cancelled/);
750
- }
751
- }
752
- finally {
753
- await fsp.rm(cacheDir, { recursive: true, force: true });
754
- }
755
- });
756
- test("llm.invoke does not publish a reusable cache entry when cancellation races cache commit", async () => {
757
- const registry = createDefaultRegistry();
758
- const cmd = registry.get("llm.invoke");
759
- assert.ok(cmd);
760
- const cacheDir = await mkdtemp(path.join(tmpdir(), "lobster-cache-cancel-publication-"));
761
- const stateDir = path.join(cacheDir, "state");
762
- const controller = new AbortController();
763
- const originalRename = fsp.rename;
764
- let cacheCommitAborted = false;
765
- let calls = 0;
766
- const adapter = {
767
- source: "cache-cancel-test",
768
- async invoke() {
769
- calls += 1;
770
- return {
771
- ok: true,
772
- result: {
773
- runId: `call-${calls}`,
774
- output: { data: { call: calls } },
775
- },
776
- };
777
- },
778
- };
779
- const args = {
780
- _: [],
781
- provider: "cache-cancel-test",
782
- prompt: "Decide",
783
- "state-key": "cancelled-cache-publication",
784
- };
785
- try {
786
- Object.defineProperty(fsp, "rename", {
787
- configurable: true,
788
- writable: true,
789
- async value(from, to) {
790
- const result = await originalRename(from, to);
791
- if (!cacheCommitAborted &&
792
- String(to).startsWith(`${path.join(cacheDir, "llm.invoke")}${path.sep}`) &&
793
- String(to).endsWith(".json")) {
794
- cacheCommitAborted = true;
795
- controller.abort(new Error("cancelled during cache publication"));
796
- }
797
- return result;
798
- },
799
- });
800
- await assert.rejects(cmd.run({
801
- input: streamOf([]),
802
- args,
803
- ctx: {
804
- ...baseCtx({ LOBSTER_CACHE_DIR: cacheDir, LOBSTER_STATE_DIR: stateDir }, registry),
805
- signal: controller.signal,
806
- llmAdapters: { "cache-cancel-test": adapter },
807
- },
808
- }), /cancelled during cache publication/);
809
- }
810
- finally {
811
- Object.defineProperty(fsp, "rename", {
812
- configurable: true,
813
- writable: true,
814
- value: originalRename,
815
- });
816
- }
817
- const retried = await cmd.run({
818
- input: streamOf([]),
819
- args,
820
- ctx: {
821
- ...baseCtx({ LOBSTER_CACHE_DIR: cacheDir, LOBSTER_STATE_DIR: stateDir }, registry),
822
- llmAdapters: { "cache-cancel-test": adapter },
823
- },
824
- });
825
- const retriedItems = await collect(retried.output);
826
- assert.equal(calls, 2, "a cancelled invocation must not satisfy a later request from cache or run state");
827
- assert.equal(retriedItems[0]?.source, "cache-cancel-test");
828
- await rm(cacheDir, { recursive: true, force: true });
829
- });
830
- test("llm.invoke restores the previous cache entry when a refresh is cancelled after commit", async () => {
831
- const registry = createDefaultRegistry();
832
- const cmd = registry.get("llm.invoke");
833
- assert.ok(cmd);
834
- const cacheDir = await mkdtemp(path.join(tmpdir(), "lobster-cache-refresh-cancel-"));
835
- const stateDir = path.join(cacheDir, "state");
836
- let calls = 0;
837
- const adapter = {
838
- source: "cache-refresh-cancel-test",
839
- async invoke() {
840
- calls += 1;
841
- return {
842
- ok: true,
843
- result: {
844
- runId: `call-${calls}`,
845
- output: { data: { call: calls } },
846
- },
847
- };
848
- },
849
- };
850
- const args = {
851
- _: [],
852
- provider: "cache-refresh-cancel-test",
853
- prompt: "Decide",
854
- "state-key": "refresh-cache-publication",
855
- };
856
- try {
857
- const first = await cmd.run({
858
- input: streamOf([]),
859
- args,
860
- ctx: {
861
- ...baseCtx({ LOBSTER_CACHE_DIR: cacheDir, LOBSTER_STATE_DIR: stateDir }, registry),
862
- llmAdapters: { "cache-refresh-cancel-test": adapter },
863
- },
864
- });
865
- assert.deepEqual((await collect(first.output))[0]?.output.data, { call: 1 });
866
- const controller = new AbortController();
867
- const originalRename = fsp.rename;
868
- let cacheCommitAborted = false;
869
- try {
870
- Object.defineProperty(fsp, "rename", {
871
- configurable: true,
872
- writable: true,
873
- async value(from, to) {
874
- const result = await originalRename(from, to);
875
- if (!cacheCommitAborted &&
876
- String(to).startsWith(`${path.join(cacheDir, "llm.invoke")}${path.sep}`)) {
877
- cacheCommitAborted = true;
878
- controller.abort(new Error("cancelled during cache refresh publication"));
879
- }
880
- return result;
881
- },
882
- });
883
- await assert.rejects(cmd.run({
884
- input: streamOf([]),
885
- args: { ...args, refresh: true },
886
- ctx: {
887
- ...baseCtx({ LOBSTER_CACHE_DIR: cacheDir, LOBSTER_STATE_DIR: stateDir }, registry),
888
- signal: controller.signal,
889
- llmAdapters: { "cache-refresh-cancel-test": adapter },
890
- },
891
- }), /cancelled during cache refresh publication/);
892
- }
893
- finally {
894
- Object.defineProperty(fsp, "rename", {
895
- configurable: true,
896
- writable: true,
897
- value: originalRename,
898
- });
899
- }
900
- const recovered = await cmd.run({
901
- input: streamOf([]),
902
- args,
903
- ctx: {
904
- ...baseCtx({ LOBSTER_CACHE_DIR: cacheDir, LOBSTER_STATE_DIR: stateDir }, registry),
905
- llmAdapters: { "cache-refresh-cancel-test": adapter },
906
- },
907
- });
908
- const recoveredItems = await collect(recovered.output);
909
- assert.equal(calls, 2, "the cancelled refresh must restore the existing run state");
910
- assert.equal(recoveredItems[0]?.source, "run_state");
911
- assert.deepEqual(recoveredItems[0]?.output.data, { call: 1 });
912
- const recoveredCache = await cmd.run({
913
- input: streamOf([]),
914
- args: { _: [], provider: "cache-refresh-cancel-test", prompt: "Decide" },
915
- ctx: {
916
- ...baseCtx({ LOBSTER_CACHE_DIR: cacheDir, LOBSTER_STATE_DIR: stateDir }, registry),
917
- llmAdapters: { "cache-refresh-cancel-test": adapter },
918
- },
919
- });
920
- const recoveredCacheItems = await collect(recoveredCache.output);
921
- assert.equal(recoveredCacheItems[0]?.source, "cache");
922
- assert.deepEqual(recoveredCacheItems[0]?.output.data, { call: 1 });
923
- }
924
- finally {
925
- await rm(cacheDir, { recursive: true, force: true });
926
- }
927
- });
928
- test("llm.invoke rolls back cache and run-state publications after a cache directory sync failure", async () => {
929
- const registry = createDefaultRegistry();
930
- const cmd = registry.get("llm.invoke");
931
- assert.ok(cmd);
932
- const cacheDir = await mkdtemp(path.join(tmpdir(), "lobster-cache-dir-sync-failure-"));
933
- const stateDir = path.join(cacheDir, "state");
934
- const cacheNamespaceDir = path.join(cacheDir, "llm.invoke");
935
- const originalOpen = fsp.open;
936
- const fault = Object.assign(new Error("cache directory sync failed"), { code: "EIO" });
937
- let failNextCacheDirectorySync = true;
938
- let calls = 0;
939
- const adapter = {
940
- source: "cache-dir-sync-test",
941
- async invoke() {
942
- calls += 1;
943
- return { ok: true, result: { runId: `call-${calls}`, output: { data: { call: calls } } } };
944
- },
945
- };
946
- const args = {
947
- _: [],
948
- provider: "cache-dir-sync-test",
949
- prompt: "Decide",
950
- "state-key": "cache-directory-sync-failure",
951
- };
952
- try {
953
- await fsp.mkdir(cacheNamespaceDir, { recursive: true });
954
- Object.defineProperty(fsp, "open", {
955
- configurable: true,
956
- writable: true,
957
- async value(...openArgs) {
958
- const handle = await originalOpen(...openArgs);
959
- if (failNextCacheDirectorySync &&
960
- String(openArgs[0]) === cacheNamespaceDir &&
961
- openArgs[1] === "r") {
962
- failNextCacheDirectorySync = false;
963
- return new Proxy(handle, {
964
- get(target, property, receiver) {
965
- if (property === "sync")
966
- return async () => Promise.reject(fault);
967
- const value = Reflect.get(target, property, receiver);
968
- return typeof value === "function" ? value.bind(target) : value;
969
- },
970
- });
971
- }
972
- return handle;
973
- },
974
- });
975
- await assert.rejects(cmd.run({
976
- input: streamOf([]),
977
- args,
978
- ctx: {
979
- ...baseCtx({ LOBSTER_CACHE_DIR: cacheDir, LOBSTER_STATE_DIR: stateDir }, registry),
980
- llmAdapters: { "cache-dir-sync-test": adapter },
981
- },
982
- }), /cache directory sync failed/);
983
- }
984
- finally {
985
- Object.defineProperty(fsp, "open", {
986
- configurable: true,
987
- writable: true,
988
- value: originalOpen,
989
- });
990
- }
991
- const retried = await cmd.run({
992
- input: streamOf([]),
993
- args,
994
- ctx: {
995
- ...baseCtx({ LOBSTER_CACHE_DIR: cacheDir, LOBSTER_STATE_DIR: stateDir }, registry),
996
- llmAdapters: { "cache-dir-sync-test": adapter },
997
- },
998
- });
999
- const items = await collect(retried.output);
1000
- assert.equal(calls, 2, "a failed publication must not be reused from cache or run state");
1001
- assert.equal(items[0]?.source, "cache-dir-sync-test");
1002
- await rm(cacheDir, { recursive: true, force: true });
1003
- });
1004
- test("llm.invoke reads a populated cache when lock creation is forbidden", async () => {
1005
- const registry = createDefaultRegistry();
1006
- const cmd = registry.get("llm.invoke");
1007
- assert.ok(cmd);
1008
- const cacheDir = await mkdtemp(path.join(tmpdir(), "lobster-readonly-cache-"));
1009
- const originalMkdir = fsp.mkdir;
1010
- let calls = 0;
1011
- const adapter = {
1012
- source: "readonly-cache-test",
1013
- async invoke() {
1014
- calls += 1;
1015
- return { ok: true, result: { runId: `call-${calls}`, output: { data: { call: calls } } } };
1016
- },
1017
- };
1018
- const args = { _: [], provider: "readonly-cache-test", prompt: "Decide" };
1019
- try {
1020
- const first = await cmd.run({
1021
- input: streamOf([]),
1022
- args,
1023
- ctx: {
1024
- ...baseCtx({ LOBSTER_CACHE_DIR: cacheDir }, registry),
1025
- llmAdapters: { "readonly-cache-test": adapter },
1026
- },
1027
- });
1028
- await collect(first.output);
1029
- Object.defineProperty(fsp, "mkdir", {
1030
- configurable: true,
1031
- writable: true,
1032
- async value(filePath, options) {
1033
- if (String(filePath).endsWith(".lock")) {
1034
- throw Object.assign(new Error("read-only cache directory"), { code: "EACCES" });
1035
- }
1036
- return originalMkdir(filePath, options);
1037
- },
1038
- });
1039
- const cached = await cmd.run({
1040
- input: streamOf([]),
1041
- args,
1042
- ctx: {
1043
- ...baseCtx({ LOBSTER_CACHE_DIR: cacheDir }, registry),
1044
- llmAdapters: { "readonly-cache-test": adapter },
1045
- },
1046
- });
1047
- const items = await collect(cached.output);
1048
- assert.equal(calls, 1);
1049
- assert.equal(items[0]?.source, "cache");
1050
- }
1051
- finally {
1052
- Object.defineProperty(fsp, "mkdir", {
1053
- configurable: true,
1054
- writable: true,
1055
- value: originalMkdir,
1056
- });
1057
- await rm(cacheDir, { recursive: true, force: true });
1058
- }
1059
- });
1060
- function baseCtx(envOverrides, registry) {
1061
- return {
1062
- stdin: process.stdin,
1063
- stdout: process.stdout,
1064
- stderr: process.stderr,
1065
- env: { ...process.env, ...envOverrides },
1066
- registry: registry ?? null,
1067
- mode: "tool",
1068
- render: { json() { }, lines() { } },
1069
- };
1070
- }
1071
- // An adapter that accepts the request and never answers, so the only thing that
1072
- // can end the call is the caller cancelling it.
1073
- function createStalledAdapter() {
1074
- const sockets = new Set();
1075
- let markReceived = () => { };
1076
- let markClosed = () => { };
1077
- const requestReceived = new Promise((resolve) => (markReceived = resolve));
1078
- const requestClosed = new Promise((resolve) => (markClosed = resolve));
1079
- const server = http.createServer((req, res) => {
1080
- req.resume();
1081
- req.on("end", () => {
1082
- res.on("close", () => markClosed());
1083
- markReceived();
1084
- });
1085
- });
1086
- server.on("connection", (socket) => {
1087
- sockets.add(socket);
1088
- socket.on("close", () => sockets.delete(socket));
1089
- });
1090
- return {
1091
- requestReceived,
1092
- requestClosed,
1093
- port: 0,
1094
- async listen() {
1095
- await new Promise((resolve) => server.listen(0, resolve));
1096
- const addr = server.address();
1097
- this.port = typeof addr === "object" && addr ? addr.port : 0;
1098
- },
1099
- async close() {
1100
- for (const socket of sockets)
1101
- socket.destroy();
1102
- if (!server.listening)
1103
- return;
1104
- await new Promise((resolve) => server.close(() => resolve()));
1105
- },
1106
- };
1107
- }
1108
- async function closeServer(server) {
1109
- if (!server.listening)
1110
- return;
1111
- await new Promise((resolve) => server.close(() => resolve()));
1112
- }
1113
- //# sourceMappingURL=llm_invoke.test.js.map