@plateforme-ai/lobster 2026.6.12

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 (219) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +353 -0
  3. package/VISION.md +249 -0
  4. package/bin/clawd.invoke.js +18 -0
  5. package/bin/lobster.js +24 -0
  6. package/bin/openclaw.invoke.js +21 -0
  7. package/dist/src/cli.js +793 -0
  8. package/dist/src/cli.js.map +1 -0
  9. package/dist/src/commands/commands_list.js +49 -0
  10. package/dist/src/commands/commands_list.js.map +1 -0
  11. package/dist/src/commands/registry.js +66 -0
  12. package/dist/src/commands/registry.js.map +1 -0
  13. package/dist/src/commands/stdlib/approve.js +77 -0
  14. package/dist/src/commands/stdlib/approve.js.map +1 -0
  15. package/dist/src/commands/stdlib/ask.js +171 -0
  16. package/dist/src/commands/stdlib/ask.js.map +1 -0
  17. package/dist/src/commands/stdlib/dedupe.js +55 -0
  18. package/dist/src/commands/stdlib/dedupe.js.map +1 -0
  19. package/dist/src/commands/stdlib/diff_last.js +35 -0
  20. package/dist/src/commands/stdlib/diff_last.js.map +1 -0
  21. package/dist/src/commands/stdlib/email_triage.js +279 -0
  22. package/dist/src/commands/stdlib/email_triage.js.map +1 -0
  23. package/dist/src/commands/stdlib/exec.js +130 -0
  24. package/dist/src/commands/stdlib/exec.js.map +1 -0
  25. package/dist/src/commands/stdlib/gog_gmail_search.js +94 -0
  26. package/dist/src/commands/stdlib/gog_gmail_search.js.map +1 -0
  27. package/dist/src/commands/stdlib/gog_gmail_send.js +104 -0
  28. package/dist/src/commands/stdlib/gog_gmail_send.js.map +1 -0
  29. package/dist/src/commands/stdlib/group_by.js +59 -0
  30. package/dist/src/commands/stdlib/group_by.js.map +1 -0
  31. package/dist/src/commands/stdlib/head.js +34 -0
  32. package/dist/src/commands/stdlib/head.js.map +1 -0
  33. package/dist/src/commands/stdlib/json.js +20 -0
  34. package/dist/src/commands/stdlib/json.js.map +1 -0
  35. package/dist/src/commands/stdlib/llm_invoke.js +758 -0
  36. package/dist/src/commands/stdlib/llm_invoke.js.map +1 -0
  37. package/dist/src/commands/stdlib/llm_task_invoke.js +2 -0
  38. package/dist/src/commands/stdlib/llm_task_invoke.js.map +1 -0
  39. package/dist/src/commands/stdlib/map.js +104 -0
  40. package/dist/src/commands/stdlib/map.js.map +1 -0
  41. package/dist/src/commands/stdlib/openclaw_invoke.js +136 -0
  42. package/dist/src/commands/stdlib/openclaw_invoke.js.map +1 -0
  43. package/dist/src/commands/stdlib/pick.js +45 -0
  44. package/dist/src/commands/stdlib/pick.js.map +1 -0
  45. package/dist/src/commands/stdlib/sort.js +86 -0
  46. package/dist/src/commands/stdlib/sort.js.map +1 -0
  47. package/dist/src/commands/stdlib/state.js +76 -0
  48. package/dist/src/commands/stdlib/state.js.map +1 -0
  49. package/dist/src/commands/stdlib/table.js +57 -0
  50. package/dist/src/commands/stdlib/table.js.map +1 -0
  51. package/dist/src/commands/stdlib/template.js +126 -0
  52. package/dist/src/commands/stdlib/template.js.map +1 -0
  53. package/dist/src/commands/stdlib/where.js +81 -0
  54. package/dist/src/commands/stdlib/where.js.map +1 -0
  55. package/dist/src/commands/types.js +2 -0
  56. package/dist/src/commands/types.js.map +1 -0
  57. package/dist/src/commands/workflows/workflows_list.js +24 -0
  58. package/dist/src/commands/workflows/workflows_list.js.map +1 -0
  59. package/dist/src/commands/workflows/workflows_run.js +74 -0
  60. package/dist/src/commands/workflows/workflows_run.js.map +1 -0
  61. package/dist/src/core/cost_tracker.js +119 -0
  62. package/dist/src/core/cost_tracker.js.map +1 -0
  63. package/dist/src/core/filters.js +102 -0
  64. package/dist/src/core/filters.js.map +1 -0
  65. package/dist/src/core/index.js +7 -0
  66. package/dist/src/core/index.js.map +1 -0
  67. package/dist/src/core/retry.js +89 -0
  68. package/dist/src/core/retry.js.map +1 -0
  69. package/dist/src/core/tool_runtime.js +289 -0
  70. package/dist/src/core/tool_runtime.js.map +1 -0
  71. package/dist/src/input_request.js +430 -0
  72. package/dist/src/input_request.js.map +1 -0
  73. package/dist/src/parser.js +145 -0
  74. package/dist/src/parser.js.map +1 -0
  75. package/dist/src/pipeline_resume_state.js +186 -0
  76. package/dist/src/pipeline_resume_state.js.map +1 -0
  77. package/dist/src/read_line.js +50 -0
  78. package/dist/src/read_line.js.map +1 -0
  79. package/dist/src/recipes/github/index.js +16 -0
  80. package/dist/src/recipes/github/index.js.map +1 -0
  81. package/dist/src/recipes/github/pr-monitor.js +248 -0
  82. package/dist/src/recipes/github/pr-monitor.js.map +1 -0
  83. package/dist/src/recipes/github/stages/pr-view.js +107 -0
  84. package/dist/src/recipes/github/stages/pr-view.js.map +1 -0
  85. package/dist/src/recipes/index.js +7 -0
  86. package/dist/src/recipes/index.js.map +1 -0
  87. package/dist/src/recipes/registry.js +30 -0
  88. package/dist/src/recipes/registry.js.map +1 -0
  89. package/dist/src/renderers/json.js +13 -0
  90. package/dist/src/renderers/json.js.map +1 -0
  91. package/dist/src/resume.js +179 -0
  92. package/dist/src/resume.js.map +1 -0
  93. package/dist/src/runtime.js +230 -0
  94. package/dist/src/runtime.js.map +1 -0
  95. package/dist/src/sdk/Lobster.js +402 -0
  96. package/dist/src/sdk/Lobster.js.map +1 -0
  97. package/dist/src/sdk/index.js +25 -0
  98. package/dist/src/sdk/index.js.map +1 -0
  99. package/dist/src/sdk/primitives/approve.js +47 -0
  100. package/dist/src/sdk/primitives/approve.js.map +1 -0
  101. package/dist/src/sdk/primitives/diff.js +156 -0
  102. package/dist/src/sdk/primitives/diff.js.map +1 -0
  103. package/dist/src/sdk/primitives/exec.js +167 -0
  104. package/dist/src/sdk/primitives/exec.js.map +1 -0
  105. package/dist/src/sdk/primitives/state.js +203 -0
  106. package/dist/src/sdk/primitives/state.js.map +1 -0
  107. package/dist/src/sdk/runtime.js +131 -0
  108. package/dist/src/sdk/runtime.js.map +1 -0
  109. package/dist/src/sdk/token.js +9 -0
  110. package/dist/src/sdk/token.js.map +1 -0
  111. package/dist/src/shell.js +39 -0
  112. package/dist/src/shell.js.map +1 -0
  113. package/dist/src/state/store.js +337 -0
  114. package/dist/src/state/store.js.map +1 -0
  115. package/dist/src/token.js +15 -0
  116. package/dist/src/token.js.map +1 -0
  117. package/dist/src/validation.js +38 -0
  118. package/dist/src/validation.js.map +1 -0
  119. package/dist/src/workflows/file.js +2405 -0
  120. package/dist/src/workflows/file.js.map +1 -0
  121. package/dist/src/workflows/github_pr_monitor.js +167 -0
  122. package/dist/src/workflows/github_pr_monitor.js.map +1 -0
  123. package/dist/src/workflows/graph.js +234 -0
  124. package/dist/src/workflows/graph.js.map +1 -0
  125. package/dist/src/workflows/registry.js +57 -0
  126. package/dist/src/workflows/registry.js.map +1 -0
  127. package/dist/test/approval_id.test.js +171 -0
  128. package/dist/test/approval_id.test.js.map +1 -0
  129. package/dist/test/approve_preview.test.js +38 -0
  130. package/dist/test/approve_preview.test.js.map +1 -0
  131. package/dist/test/clawd_invoke.test.js +124 -0
  132. package/dist/test/clawd_invoke.test.js.map +1 -0
  133. package/dist/test/clawd_invoke_legacy.test.js +63 -0
  134. package/dist/test/clawd_invoke_legacy.test.js.map +1 -0
  135. package/dist/test/cli_run_file_args_json.test.js +27 -0
  136. package/dist/test/cli_run_file_args_json.test.js.map +1 -0
  137. package/dist/test/commands_list.test.js +44 -0
  138. package/dist/test/commands_list.test.js.map +1 -0
  139. package/dist/test/condition_comparison.test.js +127 -0
  140. package/dist/test/condition_comparison.test.js.map +1 -0
  141. package/dist/test/core_tool_runtime.test.js +160 -0
  142. package/dist/test/core_tool_runtime.test.js.map +1 -0
  143. package/dist/test/cost_tracker.test.js +231 -0
  144. package/dist/test/cost_tracker.test.js.map +1 -0
  145. package/dist/test/dedupe.test.js +48 -0
  146. package/dist/test/dedupe.test.js.map +1 -0
  147. package/dist/test/diff_last.test.js +70 -0
  148. package/dist/test/diff_last.test.js.map +1 -0
  149. package/dist/test/doctor.test.js +19 -0
  150. package/dist/test/doctor.test.js.map +1 -0
  151. package/dist/test/dry_run.test.js +502 -0
  152. package/dist/test/dry_run.test.js.map +1 -0
  153. package/dist/test/email_triage.test.js +296 -0
  154. package/dist/test/email_triage.test.js.map +1 -0
  155. package/dist/test/exec_stdin.test.js +43 -0
  156. package/dist/test/exec_stdin.test.js.map +1 -0
  157. package/dist/test/for_each.test.js +228 -0
  158. package/dist/test/for_each.test.js.map +1 -0
  159. package/dist/test/github_pr_notify_format.test.js +19 -0
  160. package/dist/test/github_pr_notify_format.test.js.map +1 -0
  161. package/dist/test/github_pr_summary.test.js +41 -0
  162. package/dist/test/github_pr_summary.test.js.map +1 -0
  163. package/dist/test/group_by.test.js +43 -0
  164. package/dist/test/group_by.test.js.map +1 -0
  165. package/dist/test/llm_invoke.test.js +166 -0
  166. package/dist/test/llm_invoke.test.js.map +1 -0
  167. package/dist/test/llm_task_invoke.test.js +416 -0
  168. package/dist/test/llm_task_invoke.test.js.map +1 -0
  169. package/dist/test/map.test.js +41 -0
  170. package/dist/test/map.test.js.map +1 -0
  171. package/dist/test/multi_approval_resume.test.js +48 -0
  172. package/dist/test/multi_approval_resume.test.js.map +1 -0
  173. package/dist/test/on_error.test.js +151 -0
  174. package/dist/test/on_error.test.js.map +1 -0
  175. package/dist/test/openclaw_invoke_alias.test.js +13 -0
  176. package/dist/test/openclaw_invoke_alias.test.js.map +1 -0
  177. package/dist/test/parallel.test.js +184 -0
  178. package/dist/test/parallel.test.js.map +1 -0
  179. package/dist/test/parser.test.js +39 -0
  180. package/dist/test/parser.test.js.map +1 -0
  181. package/dist/test/read_line.test.js +25 -0
  182. package/dist/test/read_line.test.js.map +1 -0
  183. package/dist/test/request_input.test.js +946 -0
  184. package/dist/test/request_input.test.js.map +1 -0
  185. package/dist/test/resume.test.js +82 -0
  186. package/dist/test/resume.test.js.map +1 -0
  187. package/dist/test/sdk_lobster.test.js +177 -0
  188. package/dist/test/sdk_lobster.test.js.map +1 -0
  189. package/dist/test/shell.test.js +31 -0
  190. package/dist/test/shell.test.js.map +1 -0
  191. package/dist/test/sort.test.js +51 -0
  192. package/dist/test/sort.test.js.map +1 -0
  193. package/dist/test/state.test.js +336 -0
  194. package/dist/test/state.test.js.map +1 -0
  195. package/dist/test/step_retry.test.js +254 -0
  196. package/dist/test/step_retry.test.js.map +1 -0
  197. package/dist/test/step_timeout.test.js +154 -0
  198. package/dist/test/step_timeout.test.js.map +1 -0
  199. package/dist/test/template.test.js +46 -0
  200. package/dist/test/template.test.js.map +1 -0
  201. package/dist/test/template_filters.test.js +107 -0
  202. package/dist/test/template_filters.test.js.map +1 -0
  203. package/dist/test/tool_envelope_version.test.js +15 -0
  204. package/dist/test/tool_envelope_version.test.js.map +1 -0
  205. package/dist/test/tool_mode.test.js +83 -0
  206. package/dist/test/tool_mode.test.js.map +1 -0
  207. package/dist/test/validation.test.js +28 -0
  208. package/dist/test/validation.test.js.map +1 -0
  209. package/dist/test/workflow_args_env.test.js +41 -0
  210. package/dist/test/workflow_args_env.test.js.map +1 -0
  211. package/dist/test/workflow_composition.test.js +238 -0
  212. package/dist/test/workflow_composition.test.js.map +1 -0
  213. package/dist/test/workflow_file.test.js +1399 -0
  214. package/dist/test/workflow_file.test.js.map +1 -0
  215. package/dist/test/workflow_graph.test.js +97 -0
  216. package/dist/test/workflow_graph.test.js.map +1 -0
  217. package/dist/test/workflows.test.js +32 -0
  218. package/dist/test/workflows.test.js.map +1 -0
  219. package/package.json +75 -0
@@ -0,0 +1,946 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { spawnSync } from "node:child_process";
4
+ import { promises as fsp } from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { resumeToolRequest, runToolRequest } from "../src/core/tool_runtime.js";
8
+ import { runPipeline } from "../src/runtime.js";
9
+ import { decodeResumeToken } from "../src/resume.js";
10
+ import { readStateJson, writeStateJson } from "../src/state/store.js";
11
+ const responseSchema = {
12
+ type: "object",
13
+ properties: { choice: { type: "string", enum: ["red", "blue"] } },
14
+ required: ["choice"],
15
+ };
16
+ function registry(commands) {
17
+ return {
18
+ get(name) {
19
+ return commands[name];
20
+ },
21
+ list() {
22
+ return Object.keys(commands);
23
+ },
24
+ };
25
+ }
26
+ function streamOf(items) {
27
+ return (async function* () {
28
+ for (const item of items)
29
+ yield item;
30
+ })();
31
+ }
32
+ function runCli(args, env) {
33
+ const bin = path.join(process.cwd(), "bin", "lobster.js");
34
+ return spawnSync("node", [bin, ...args], {
35
+ encoding: "utf8",
36
+ env: { ...process.env, ...env },
37
+ });
38
+ }
39
+ test("ctx.requestInput suspends and resumes the same tool command with state-backed metadata", async () => {
40
+ const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-request-input-"));
41
+ const env = { LOBSTER_STATE_DIR: path.join(tmpDir, "state") };
42
+ let calls = 0;
43
+ const choose = {
44
+ name: "choose",
45
+ async run({ ctx }) {
46
+ calls += 1;
47
+ assert.equal("resumeInput" in ctx, false);
48
+ assert.equal("requestInputResume" in ctx, false);
49
+ const response = await ctx.requestInput({ prompt: "Pick one", responseSchema });
50
+ return { output: streamOf([{ choice: response.choice, calls }]) };
51
+ },
52
+ };
53
+ const first = await runToolRequest({
54
+ pipeline: "choose",
55
+ ctx: { env, registry: registry({ choose }) },
56
+ });
57
+ assert.equal(first.status, "needs_input");
58
+ assert.ok(first.requiresInput?.resumeToken);
59
+ const payload = decodeResumeToken(first.requiresInput.resumeToken);
60
+ assert.deepEqual(Object.keys(payload).sort(), ["kind", "protocolVersion", "stateKey", "v"]);
61
+ const state = (await readStateJson({ env, key: payload.stateKey }));
62
+ assert.equal(state.resumeMode, "same_stage");
63
+ assert.equal(state.resumeAtIndex, 0);
64
+ assert.deepEqual(state.items, []);
65
+ assert.equal(state.commandInput.pending.requestIndex, 0);
66
+ const resumed = await resumeToolRequest({
67
+ token: first.requiresInput.resumeToken,
68
+ response: { choice: "blue" },
69
+ ctx: { env, registry: registry({ choose }) },
70
+ });
71
+ assert.equal(resumed.status, "ok");
72
+ assert.deepEqual(resumed.output, [{ choice: "blue", calls: 2 }]);
73
+ });
74
+ test("ctx.requestInput carries bounded prior responses across multiple suspensions", async () => {
75
+ const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-request-input-chain-"));
76
+ const env = { LOBSTER_STATE_DIR: path.join(tmpDir, "state") };
77
+ const choose = {
78
+ name: "choose",
79
+ async run({ ctx }) {
80
+ const first = await ctx.requestInput({ prompt: "First", responseSchema });
81
+ const second = await ctx.requestInput({
82
+ prompt: `Second after ${first.choice}`,
83
+ responseSchema,
84
+ });
85
+ return { output: streamOf([{ first: first.choice, second: second.choice }]) };
86
+ },
87
+ };
88
+ const first = await runToolRequest({
89
+ pipeline: "choose",
90
+ ctx: { env, registry: registry({ choose }) },
91
+ });
92
+ assert.equal(first.status, "needs_input");
93
+ const second = await resumeToolRequest({
94
+ token: first.requiresInput.resumeToken,
95
+ response: { choice: "red" },
96
+ ctx: { env, registry: registry({ choose }) },
97
+ });
98
+ assert.equal(second.status, "needs_input");
99
+ const payload = decodeResumeToken(second.requiresInput.resumeToken);
100
+ const state = (await readStateJson({ env, key: payload.stateKey }));
101
+ assert.equal(state.commandInput.pending.requestIndex, 1);
102
+ assert.equal(state.commandInput.history.length, 1);
103
+ const done = await resumeToolRequest({
104
+ token: second.requiresInput.resumeToken,
105
+ response: { choice: "blue" },
106
+ ctx: { env, registry: registry({ choose }) },
107
+ });
108
+ assert.equal(done.status, "ok");
109
+ assert.deepEqual(done.output, [{ first: "red", second: "blue" }]);
110
+ });
111
+ test("ctx.requestInput does not leak consumed suspended state into later requests", async () => {
112
+ const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-request-input-state-leak-"));
113
+ const env = { LOBSTER_STATE_DIR: path.join(tmpDir, "state") };
114
+ const choose = {
115
+ name: "choose",
116
+ async run({ ctx }) {
117
+ const firstState = ctx.requestInput.getSuspendedState?.() ?? { phase: "first" };
118
+ const first = await ctx.requestInput({
119
+ prompt: "First",
120
+ responseSchema,
121
+ suspendedState: firstState,
122
+ });
123
+ assert.equal(ctx.requestInput.getSuspendedState?.(), undefined);
124
+ const second = await ctx.requestInput({
125
+ prompt: `Second after ${first.choice}`,
126
+ responseSchema,
127
+ });
128
+ return { output: streamOf([{ first: first.choice, second: second.choice }]) };
129
+ },
130
+ };
131
+ const first = await runToolRequest({
132
+ pipeline: "choose",
133
+ ctx: { env, registry: registry({ choose }) },
134
+ });
135
+ const second = await resumeToolRequest({
136
+ token: first.requiresInput.resumeToken,
137
+ response: { choice: "red" },
138
+ ctx: { env, registry: registry({ choose }) },
139
+ });
140
+ assert.equal(second.status, "needs_input");
141
+ const payload = decodeResumeToken(second.requiresInput.resumeToken);
142
+ const state = (await readStateJson({ env, key: payload.stateKey }));
143
+ assert.equal(state.commandInput.pending.suspendedState, undefined);
144
+ });
145
+ test("ctx.requestInput snapshots response history before command mutation", async () => {
146
+ const countSchema = {
147
+ type: "object",
148
+ properties: { count: { type: "number" } },
149
+ required: ["count"],
150
+ };
151
+ const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-request-input-response-copy-"));
152
+ const env = { LOBSTER_STATE_DIR: path.join(tmpDir, "state") };
153
+ const choose = {
154
+ name: "choose",
155
+ async run({ ctx }) {
156
+ const first = await ctx.requestInput({ prompt: "First", responseSchema: countSchema });
157
+ first.count += 1;
158
+ const second = await ctx.requestInput({
159
+ prompt: `Second after ${first.count}`,
160
+ responseSchema,
161
+ });
162
+ return { output: streamOf([{ count: first.count, choice: second.choice }]) };
163
+ },
164
+ };
165
+ const first = await runToolRequest({
166
+ pipeline: "choose",
167
+ ctx: { env, registry: registry({ choose }) },
168
+ });
169
+ const second = await resumeToolRequest({
170
+ token: first.requiresInput.resumeToken,
171
+ response: { count: 0 },
172
+ ctx: { env, registry: registry({ choose }) },
173
+ });
174
+ assert.equal(second.status, "needs_input");
175
+ const done = await resumeToolRequest({
176
+ token: second.requiresInput.resumeToken,
177
+ response: { choice: "blue" },
178
+ ctx: { env, registry: registry({ choose }) },
179
+ });
180
+ assert.equal(done.status, "ok");
181
+ assert.deepEqual(done.output, [{ count: 1, choice: "blue" }]);
182
+ });
183
+ test("ctx.requestInput rejects a response rebound to changed request metadata", async () => {
184
+ const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-request-input-rebind-"));
185
+ const env = { LOBSTER_STATE_DIR: path.join(tmpDir, "state") };
186
+ let prompt = "Pick one";
187
+ const choose = {
188
+ name: "choose",
189
+ async run({ ctx }) {
190
+ const response = await ctx.requestInput({ prompt, responseSchema });
191
+ return { output: streamOf([response]) };
192
+ },
193
+ };
194
+ const first = await runToolRequest({
195
+ pipeline: "choose",
196
+ ctx: { env, registry: registry({ choose }) },
197
+ });
198
+ assert.equal(first.status, "needs_input");
199
+ prompt = "Different prompt";
200
+ const resumed = await resumeToolRequest({
201
+ token: first.requiresInput.resumeToken,
202
+ response: { choice: "red" },
203
+ ctx: { env, registry: registry({ choose }) },
204
+ });
205
+ assert.equal(resumed.ok, false);
206
+ assert.match(resumed.error?.message ?? "", /does not match suspended request/);
207
+ });
208
+ test("malformed same-stage requestInput state is rejected before resume execution", async () => {
209
+ const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-request-input-corrupt-"));
210
+ const env = { LOBSTER_STATE_DIR: path.join(tmpDir, "state") };
211
+ let calls = 0;
212
+ const choose = {
213
+ name: "choose",
214
+ async run({ ctx }) {
215
+ calls += 1;
216
+ await ctx.requestInput({ prompt: "Pick", responseSchema });
217
+ return { output: streamOf([]) };
218
+ },
219
+ };
220
+ const first = await runToolRequest({
221
+ pipeline: "choose",
222
+ ctx: { env, registry: registry({ choose }) },
223
+ });
224
+ const payload = decodeResumeToken(first.requiresInput.resumeToken);
225
+ const state = (await readStateJson({ env, key: payload.stateKey }));
226
+ state.commandInput.pending.requestIndex = 5;
227
+ await writeStateJson({ env, key: payload.stateKey, value: state });
228
+ const resumed = await resumeToolRequest({
229
+ token: first.requiresInput.resumeToken,
230
+ response: { choice: "red" },
231
+ ctx: { env, registry: registry({ choose }) },
232
+ });
233
+ assert.equal(resumed.ok, false);
234
+ assert.match(resumed.error?.message ?? "", /Invalid pipeline resume state/);
235
+ assert.equal(calls, 1);
236
+ });
237
+ test("unconsumed requestInput resume fails before downstream side effects", async () => {
238
+ const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-request-input-side-effect-"));
239
+ const env = { LOBSTER_STATE_DIR: path.join(tmpDir, "state") };
240
+ let calls = 0;
241
+ let sideEffects = 0;
242
+ const choose = {
243
+ name: "choose",
244
+ async run({ ctx }) {
245
+ calls += 1;
246
+ if (calls > 1)
247
+ return { output: streamOf([{ skipped: true }]) };
248
+ await ctx.requestInput({ prompt: "Pick", responseSchema });
249
+ return { output: streamOf([]) };
250
+ },
251
+ };
252
+ const side = {
253
+ name: "side",
254
+ async run({ input }) {
255
+ for await (const _ of input)
256
+ sideEffects += 1;
257
+ return { output: streamOf([{ sideEffects }]) };
258
+ },
259
+ };
260
+ const first = await runToolRequest({
261
+ pipeline: "choose | side",
262
+ ctx: { env, registry: registry({ choose, side }) },
263
+ });
264
+ const resumed = await resumeToolRequest({
265
+ token: first.requiresInput.resumeToken,
266
+ response: { choice: "red" },
267
+ ctx: { env, registry: registry({ choose, side }) },
268
+ });
269
+ assert.equal(resumed.ok, false);
270
+ assert.match(resumed.error?.message ?? "", /not consumed/);
271
+ assert.equal(sideEffects, 0);
272
+ });
273
+ test("unconsumed requestInput resume wins over rerun errors", async () => {
274
+ const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-request-input-rerun-error-"));
275
+ const env = { LOBSTER_STATE_DIR: path.join(tmpDir, "state") };
276
+ let calls = 0;
277
+ const choose = {
278
+ name: "choose",
279
+ async run({ ctx }) {
280
+ calls += 1;
281
+ if (calls > 1)
282
+ throw new Error("boom before request");
283
+ await ctx.requestInput({ prompt: "Pick", responseSchema });
284
+ return { output: streamOf([]) };
285
+ },
286
+ };
287
+ const first = await runToolRequest({
288
+ pipeline: "choose",
289
+ ctx: { env, registry: registry({ choose }) },
290
+ });
291
+ const resumed = await resumeToolRequest({
292
+ token: first.requiresInput.resumeToken,
293
+ response: { choice: "red" },
294
+ ctx: { env, registry: registry({ choose }) },
295
+ });
296
+ assert.equal(resumed.ok, false);
297
+ assert.match(resumed.error?.message ?? "", /not consumed/);
298
+ });
299
+ test("unconsumed requestInput resume wins over lazy output errors", async () => {
300
+ const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-request-input-lazy-error-"));
301
+ const env = { LOBSTER_STATE_DIR: path.join(tmpDir, "state") };
302
+ let calls = 0;
303
+ const choose = {
304
+ name: "choose",
305
+ async run({ ctx }) {
306
+ calls += 1;
307
+ if (calls > 1) {
308
+ return {
309
+ output: {
310
+ [Symbol.asyncIterator]() {
311
+ return {
312
+ async next() {
313
+ throw new Error("boom before request");
314
+ },
315
+ };
316
+ },
317
+ },
318
+ };
319
+ }
320
+ await ctx.requestInput({ prompt: "Pick", responseSchema });
321
+ return { output: streamOf([]) };
322
+ },
323
+ };
324
+ const first = await runToolRequest({
325
+ pipeline: "choose",
326
+ ctx: { env, registry: registry({ choose }) },
327
+ });
328
+ const resumed = await resumeToolRequest({
329
+ token: first.requiresInput.resumeToken,
330
+ response: { choice: "red" },
331
+ ctx: { env, registry: registry({ choose }) },
332
+ });
333
+ assert.equal(resumed.ok, false);
334
+ assert.match(resumed.error?.message ?? "", /not consumed/);
335
+ });
336
+ test("consumed requestInput resume token is invalid after downstream failure", async () => {
337
+ const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-request-input-consumed-"));
338
+ const env = { LOBSTER_STATE_DIR: path.join(tmpDir, "state") };
339
+ let sideEffects = 0;
340
+ const choose = {
341
+ name: "choose",
342
+ async run({ ctx }) {
343
+ const response = await ctx.requestInput({ prompt: "Pick", responseSchema });
344
+ sideEffects += 1;
345
+ return { output: streamOf([{ choice: response.choice }]) };
346
+ },
347
+ };
348
+ const fail = {
349
+ name: "fail",
350
+ async run() {
351
+ throw new Error("downstream failed");
352
+ },
353
+ };
354
+ const first = await runToolRequest({
355
+ pipeline: "choose | fail",
356
+ ctx: { env, registry: registry({ choose, fail }) },
357
+ });
358
+ const resumed = await resumeToolRequest({
359
+ token: first.requiresInput.resumeToken,
360
+ response: { choice: "red" },
361
+ ctx: { env, registry: registry({ choose, fail }) },
362
+ });
363
+ assert.equal(resumed.ok, false);
364
+ assert.match(resumed.error?.message ?? "", /downstream failed/);
365
+ assert.equal(sideEffects, 1);
366
+ const replay = await resumeToolRequest({
367
+ token: first.requiresInput.resumeToken,
368
+ response: { choice: "red" },
369
+ ctx: { env, registry: registry({ choose, fail }) },
370
+ });
371
+ assert.equal(replay.ok, false);
372
+ assert.match(replay.error?.message ?? "", /Pipeline resume state not found/);
373
+ assert.equal(sideEffects, 1);
374
+ });
375
+ test("ctx.requestInput rejects lazy input replay without suspended state and closes it", async () => {
376
+ let closed = false;
377
+ const input = {
378
+ async *[Symbol.asyncIterator]() {
379
+ try {
380
+ yield { value: 1 };
381
+ yield { value: 2 };
382
+ }
383
+ finally {
384
+ closed = true;
385
+ }
386
+ },
387
+ };
388
+ const choose = {
389
+ name: "choose",
390
+ async run({ input, ctx }) {
391
+ const iterator = input[Symbol.asyncIterator]();
392
+ await iterator.next();
393
+ await ctx.requestInput({ prompt: "Partial", responseSchema });
394
+ return { output: streamOf([]) };
395
+ },
396
+ };
397
+ await assert.rejects(() => runPipeline({
398
+ pipeline: [{ name: "choose", args: {}, raw: "choose" }],
399
+ registry: registry({ choose }),
400
+ stdin: process.stdin,
401
+ stdout: process.stdout,
402
+ stderr: process.stderr,
403
+ env: process.env,
404
+ mode: "tool",
405
+ input,
406
+ }), /suspendedState when command input is streaming/);
407
+ assert.equal(closed, true);
408
+ });
409
+ test("ctx.requestInput propagates cleanup errors on normal early close", async () => {
410
+ const input = {
411
+ [Symbol.asyncIterator]() {
412
+ let index = 0;
413
+ return {
414
+ async next() {
415
+ index += 1;
416
+ return index === 1 ? { done: false, value: { value: 1 } } : { done: true };
417
+ },
418
+ async return() {
419
+ throw new Error("cleanup failed");
420
+ },
421
+ };
422
+ },
423
+ };
424
+ const take = {
425
+ name: "take",
426
+ async run({ input }) {
427
+ for await (const item of input) {
428
+ return { output: streamOf([item]) };
429
+ }
430
+ return { output: streamOf([]) };
431
+ },
432
+ };
433
+ await assert.rejects(runPipeline({
434
+ pipeline: [{ name: "take", args: {}, raw: "take" }],
435
+ registry: registry({ take }),
436
+ stdin: process.stdin,
437
+ stdout: process.stdout,
438
+ stderr: process.stderr,
439
+ env: process.env,
440
+ mode: "tool",
441
+ input,
442
+ }), /cleanup failed/);
443
+ });
444
+ test("ctx.requestInput closes early-consumed input only once", async () => {
445
+ let closeCount = 0;
446
+ const input = {
447
+ [Symbol.asyncIterator]() {
448
+ let index = 0;
449
+ return {
450
+ async next() {
451
+ index += 1;
452
+ return index === 1 ? { done: false, value: { value: 1 } } : { done: true };
453
+ },
454
+ async return() {
455
+ closeCount += 1;
456
+ if (closeCount > 1)
457
+ throw new Error("closed twice");
458
+ return { done: true, value: undefined };
459
+ },
460
+ };
461
+ },
462
+ };
463
+ const take = {
464
+ name: "take",
465
+ async run({ input }) {
466
+ for await (const item of input) {
467
+ return { output: [item] };
468
+ }
469
+ return { output: [] };
470
+ },
471
+ };
472
+ const result = await runPipeline({
473
+ pipeline: [{ name: "take", args: {}, raw: "take" }],
474
+ registry: registry({ take }),
475
+ stdin: process.stdin,
476
+ stdout: process.stdout,
477
+ stderr: process.stderr,
478
+ env: process.env,
479
+ mode: "tool",
480
+ input,
481
+ });
482
+ assert.deepEqual(result.items, [{ value: 1 }]);
483
+ assert.equal(closeCount, 1);
484
+ });
485
+ test("ctx.requestInput snapshots array replay input before command mutation", async () => {
486
+ const choose = {
487
+ name: "choose",
488
+ async run({ input, ctx }) {
489
+ const items = [];
490
+ for await (const item of input)
491
+ items.push(item);
492
+ items[0].count += 1;
493
+ const response = await ctx.requestInput({ prompt: "Pick", responseSchema });
494
+ return { output: streamOf([{ count: items[0].count, choice: response.choice }]) };
495
+ },
496
+ };
497
+ const pipeline = [{ name: "choose", args: {}, raw: "choose" }];
498
+ const first = await runPipeline({
499
+ pipeline,
500
+ registry: registry({ choose }),
501
+ stdin: process.stdin,
502
+ stdout: process.stdout,
503
+ stderr: process.stderr,
504
+ env: process.env,
505
+ mode: "tool",
506
+ input: [{ count: 0 }],
507
+ });
508
+ assert.equal(first.halted, true);
509
+ const request = first.items[0];
510
+ assert.deepEqual(request.items, [{ count: 0 }]);
511
+ const resumed = await runPipeline({
512
+ pipeline,
513
+ registry: registry({ choose }),
514
+ stdin: process.stdin,
515
+ stdout: process.stdout,
516
+ stderr: process.stderr,
517
+ env: process.env,
518
+ mode: "tool",
519
+ input: request.items,
520
+ requestInputResume: {
521
+ state: request.commandInput,
522
+ response: { choice: "red" },
523
+ },
524
+ });
525
+ assert.deepEqual(resumed.items, [{ count: 1, choice: "red" }]);
526
+ });
527
+ test("ctx.requestInput preserves array replay when suspended state is supplied", async () => {
528
+ const input = [{ value: 1 }];
529
+ const choose = {
530
+ name: "choose",
531
+ async run({ input, ctx }) {
532
+ const items = [];
533
+ for await (const item of input)
534
+ items.push(item);
535
+ const response = await ctx.requestInput({
536
+ prompt: "Pick",
537
+ responseSchema,
538
+ suspendedState: { seen: items.length },
539
+ });
540
+ return { output: streamOf([{ items, choice: response.choice }]) };
541
+ },
542
+ };
543
+ const pipeline = [{ name: "choose", args: {}, raw: "choose" }];
544
+ const first = await runPipeline({
545
+ pipeline,
546
+ registry: registry({ choose }),
547
+ stdin: process.stdin,
548
+ stdout: process.stdout,
549
+ stderr: process.stderr,
550
+ env: process.env,
551
+ mode: "tool",
552
+ input,
553
+ });
554
+ assert.equal(first.halted, true);
555
+ const request = first.items[0];
556
+ assert.deepEqual(request.items, [{ value: 1 }]);
557
+ assert.deepEqual(request.commandInput.pending.suspendedState, { seen: 1 });
558
+ const resumed = await runPipeline({
559
+ pipeline,
560
+ registry: registry({ choose }),
561
+ stdin: process.stdin,
562
+ stdout: process.stdout,
563
+ stderr: process.stderr,
564
+ env: process.env,
565
+ mode: "tool",
566
+ input: request.items,
567
+ requestInputResume: {
568
+ state: request.commandInput,
569
+ response: { choice: "red" },
570
+ },
571
+ });
572
+ assert.deepEqual(resumed.items, [{ items: [{ value: 1 }], choice: "red" }]);
573
+ });
574
+ test("ctx.requestInput preserves eager array output as replayable input", async () => {
575
+ const produce = {
576
+ name: "produce",
577
+ async run() {
578
+ return { output: [{ value: 1 }] };
579
+ },
580
+ };
581
+ const choose = {
582
+ name: "choose",
583
+ async run({ ctx }) {
584
+ await ctx.requestInput({ prompt: "Pick", responseSchema });
585
+ return { output: streamOf([]) };
586
+ },
587
+ };
588
+ const result = await runPipeline({
589
+ pipeline: [
590
+ { name: "produce", args: {}, raw: "produce" },
591
+ { name: "choose", args: {}, raw: "choose" },
592
+ ],
593
+ registry: registry({ produce, choose }),
594
+ stdin: process.stdin,
595
+ stdout: process.stdout,
596
+ stderr: process.stderr,
597
+ env: process.env,
598
+ mode: "tool",
599
+ });
600
+ assert.equal(result.halted, true);
601
+ assert.deepEqual(result.items[0].items, [{ value: 1 }]);
602
+ });
603
+ test("ctx.requestInput treats omitted pipeline input as replayable empty input", async () => {
604
+ const choose = {
605
+ name: "choose",
606
+ async run({ ctx }) {
607
+ await ctx.requestInput({ prompt: "Pick", responseSchema });
608
+ return { output: streamOf([]) };
609
+ },
610
+ };
611
+ const result = await runPipeline({
612
+ pipeline: [{ name: "choose", args: {}, raw: "choose" }],
613
+ registry: registry({ choose }),
614
+ stdin: process.stdin,
615
+ stdout: process.stdout,
616
+ stderr: process.stderr,
617
+ env: process.env,
618
+ mode: "tool",
619
+ });
620
+ assert.equal(result.halted, true);
621
+ assert.deepEqual(result.items[0].items, []);
622
+ });
623
+ test("ctx.requestInput accepts compact suspended state without buffering unread lazy input", async () => {
624
+ let yielded = 0;
625
+ let closed = false;
626
+ const input = {
627
+ async *[Symbol.asyncIterator]() {
628
+ try {
629
+ yielded += 1;
630
+ yield { value: 1 };
631
+ yielded += 1;
632
+ yield { value: 2 };
633
+ }
634
+ finally {
635
+ closed = true;
636
+ }
637
+ },
638
+ };
639
+ const choose = {
640
+ name: "choose",
641
+ async run({ input, ctx }) {
642
+ const iterator = input[Symbol.asyncIterator]();
643
+ const first = await iterator.next();
644
+ await ctx.requestInput({
645
+ prompt: "Partial",
646
+ responseSchema,
647
+ suspendedState: { first: first.value },
648
+ });
649
+ return { output: streamOf([]) };
650
+ },
651
+ };
652
+ const result = await runPipeline({
653
+ pipeline: [{ name: "choose", args: {}, raw: "choose" }],
654
+ registry: registry({ choose }),
655
+ stdin: process.stdin,
656
+ stdout: process.stdout,
657
+ stderr: process.stderr,
658
+ env: process.env,
659
+ mode: "tool",
660
+ input,
661
+ });
662
+ assert.equal(result.halted, true);
663
+ assert.equal(yielded, 1);
664
+ assert.equal(closed, true);
665
+ assert.deepEqual(result.items[0].items, []);
666
+ assert.deepEqual(result.items[0].commandInput.pending.suspendedState, {
667
+ first: { value: 1 },
668
+ });
669
+ });
670
+ test("ctx.requestInput restores compact suspended state before lazy input is read on resume", async () => {
671
+ const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-request-input-restore-"));
672
+ const env = { LOBSTER_STATE_DIR: path.join(tmpDir, "state") };
673
+ let produced = 0;
674
+ let chooseRuns = 0;
675
+ const produce = {
676
+ name: "produce",
677
+ async run() {
678
+ return {
679
+ output: (async function* () {
680
+ produced += 1;
681
+ yield { value: 1 };
682
+ produced += 1;
683
+ yield { value: 2 };
684
+ })(),
685
+ };
686
+ },
687
+ };
688
+ const choose = {
689
+ name: "choose",
690
+ async run({ input, ctx }) {
691
+ chooseRuns += 1;
692
+ let state = ctx.requestInput.getSuspendedState?.();
693
+ if (!state) {
694
+ const iterator = input[Symbol.asyncIterator]();
695
+ const first = await iterator.next();
696
+ state = { first: first.value };
697
+ }
698
+ const response = await ctx.requestInput({
699
+ prompt: "Pick",
700
+ responseSchema,
701
+ suspendedState: state,
702
+ });
703
+ return { output: streamOf([{ first: state.first, choice: response.choice, chooseRuns }]) };
704
+ },
705
+ };
706
+ const first = await runToolRequest({
707
+ pipeline: "produce | choose",
708
+ ctx: { env, registry: registry({ produce, choose }) },
709
+ });
710
+ assert.equal(first.status, "needs_input");
711
+ assert.equal(produced, 1);
712
+ const payload = decodeResumeToken(first.requiresInput.resumeToken);
713
+ const state = (await readStateJson({ env, key: payload.stateKey }));
714
+ assert.equal(state.resumeMode, "same_stage");
715
+ assert.equal(state.resumeAtIndex, 1);
716
+ assert.deepEqual(state.items, []);
717
+ assert.deepEqual(state.commandInput.pending.suspendedState, { first: { value: 1 } });
718
+ const resumed = await resumeToolRequest({
719
+ token: first.requiresInput.resumeToken,
720
+ response: { choice: "blue" },
721
+ ctx: { env, registry: registry({ produce, choose }) },
722
+ });
723
+ assert.equal(resumed.status, "ok");
724
+ assert.equal(produced, 1);
725
+ assert.deepEqual(resumed.output, [{ first: { value: 1 }, choice: "blue", chooseRuns: 2 }]);
726
+ });
727
+ test("ctx.requestInput cleanup accepts direct async iterator return results", async () => {
728
+ let closed = false;
729
+ const input = {
730
+ [Symbol.asyncIterator]() {
731
+ let index = 0;
732
+ return {
733
+ async next() {
734
+ index += 1;
735
+ if (index === 1)
736
+ return { done: false, value: { value: 1 } };
737
+ return { done: false, value: { value: 2 } };
738
+ },
739
+ return() {
740
+ closed = true;
741
+ return { done: true, value: undefined };
742
+ },
743
+ };
744
+ },
745
+ };
746
+ const choose = {
747
+ name: "choose",
748
+ async run({ input, ctx }) {
749
+ const iterator = input[Symbol.asyncIterator]();
750
+ const first = await iterator.next();
751
+ await ctx.requestInput({
752
+ prompt: "Partial",
753
+ responseSchema,
754
+ suspendedState: { first: first.value },
755
+ });
756
+ return { output: streamOf([]) };
757
+ },
758
+ };
759
+ const result = await runPipeline({
760
+ pipeline: [{ name: "choose", args: {}, raw: "choose" }],
761
+ registry: registry({ choose }),
762
+ stdin: process.stdin,
763
+ stdout: process.stdout,
764
+ stderr: process.stderr,
765
+ env: process.env,
766
+ mode: "tool",
767
+ input,
768
+ });
769
+ assert.equal(result.halted, true);
770
+ assert.equal(closed, true);
771
+ });
772
+ test("ctx.requestInput rejects suspension after command stdout output", async () => {
773
+ const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-request-input-output-"));
774
+ const env = { LOBSTER_STATE_DIR: path.join(tmpDir, "state") };
775
+ const choose = {
776
+ name: "choose",
777
+ async run({ ctx }) {
778
+ ctx.stdout.write("already wrote\n");
779
+ await ctx.requestInput({ prompt: "Pick", responseSchema });
780
+ return { output: streamOf([]) };
781
+ },
782
+ };
783
+ const result = await runToolRequest({
784
+ pipeline: "choose",
785
+ ctx: { env, registry: registry({ choose }) },
786
+ });
787
+ assert.equal(result.ok, false);
788
+ assert.match(result.error?.message ?? "", /cannot suspend after this command has produced output/);
789
+ });
790
+ test("ctx.requestInput rejects suspension after an earlier stage wrote stdout", async () => {
791
+ const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-request-input-prior-output-"));
792
+ const env = { LOBSTER_STATE_DIR: path.join(tmpDir, "state") };
793
+ const write = {
794
+ name: "write",
795
+ async run({ ctx }) {
796
+ ctx.stdout.write("already wrote\n");
797
+ return { output: streamOf([{ ok: true }]) };
798
+ },
799
+ };
800
+ const choose = {
801
+ name: "choose",
802
+ async run({ ctx }) {
803
+ await ctx.requestInput({ prompt: "Pick", responseSchema });
804
+ return { output: streamOf([]) };
805
+ },
806
+ };
807
+ const result = await runToolRequest({
808
+ pipeline: "write | choose",
809
+ ctx: { env, registry: registry({ write, choose }) },
810
+ });
811
+ assert.equal(result.ok, false);
812
+ assert.match(result.error?.message ?? "", /cannot suspend after this command has produced output/);
813
+ });
814
+ test("ctx.requestInput suspends from terminal lazy output", async () => {
815
+ const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-request-input-lazy-output-"));
816
+ const env = { LOBSTER_STATE_DIR: path.join(tmpDir, "state") };
817
+ const lazy = {
818
+ name: "lazy",
819
+ async run({ ctx }) {
820
+ return {
821
+ output: (async function* () {
822
+ const response = await ctx.requestInput({ prompt: "Pick", responseSchema });
823
+ yield { choice: response.choice };
824
+ })(),
825
+ };
826
+ },
827
+ };
828
+ const first = await runToolRequest({
829
+ pipeline: "lazy",
830
+ ctx: { env, registry: registry({ lazy }) },
831
+ });
832
+ assert.equal(first.status, "needs_input");
833
+ const payload = decodeResumeToken(first.requiresInput.resumeToken);
834
+ const state = (await readStateJson({ env, key: payload.stateKey }));
835
+ assert.equal(state.resumeAtIndex, 0);
836
+ const resumed = await resumeToolRequest({
837
+ token: first.requiresInput.resumeToken,
838
+ response: { choice: "blue" },
839
+ ctx: { env, registry: registry({ lazy }) },
840
+ });
841
+ assert.equal(resumed.status, "ok");
842
+ assert.deepEqual(resumed.output, [{ choice: "blue" }]);
843
+ });
844
+ test("ctx.requestInput rejects non-terminal lazy output suspension", async () => {
845
+ const lazy = {
846
+ name: "lazy",
847
+ async run({ ctx }) {
848
+ return {
849
+ output: (async function* () {
850
+ await ctx.requestInput({ prompt: "Pick", responseSchema });
851
+ yield { ok: true };
852
+ })(),
853
+ };
854
+ },
855
+ };
856
+ const pass = {
857
+ name: "pass",
858
+ async run({ input }) {
859
+ return { output: input };
860
+ },
861
+ };
862
+ const result = await runToolRequest({
863
+ pipeline: "lazy | pass",
864
+ ctx: { registry: registry({ lazy, pass }) },
865
+ });
866
+ assert.equal(result.ok, false);
867
+ assert.match(result.error?.message ?? "", /lazy output before downstream stages/);
868
+ });
869
+ test("ctx.requestInput rejects lazy suspension after pipeline item output", async () => {
870
+ const late = {
871
+ name: "late",
872
+ async run({ ctx }) {
873
+ return {
874
+ output: (async function* () {
875
+ yield { choice: "red" };
876
+ await ctx.requestInput({ prompt: "Pick", responseSchema });
877
+ })(),
878
+ };
879
+ },
880
+ };
881
+ const run = runPipeline({
882
+ pipeline: [{ name: "late", args: {} }],
883
+ registry: registry({ late }),
884
+ stdin: process.stdin,
885
+ stdout: process.stdout,
886
+ stderr: process.stderr,
887
+ env: {},
888
+ input: [],
889
+ mode: "tool",
890
+ });
891
+ await assert.rejects(run, /requestInput cannot suspend after this command has produced output/);
892
+ });
893
+ test("built CLI ask restores subject state across processes", async () => {
894
+ const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-request-input-cli-"));
895
+ const stateDir = path.join(tmpDir, "state");
896
+ const schema = JSON.stringify({
897
+ type: "object",
898
+ properties: { decision: { type: "string", enum: ["approve", "reject"] } },
899
+ required: ["decision"],
900
+ });
901
+ const producer = `node -e 'process.stdout.write(JSON.stringify([{draft:"hello"}]))'`;
902
+ const pipeline = `exec --json --shell ${JSON.stringify(producer)} | ask --subject-from-stdin --prompt 'Review?' --schema ${JSON.stringify(schema)} | pick decision`;
903
+ const first = runCli(["run", "--mode", "tool", pipeline], { LOBSTER_STATE_DIR: stateDir });
904
+ assert.equal(first.status, 0, first.stderr);
905
+ const firstJson = JSON.parse(first.stdout);
906
+ assert.equal(firstJson.status, "needs_input");
907
+ assert.ok(firstJson.requiresInput.resumeToken);
908
+ assert.deepEqual(firstJson.requiresInput.subject, { text: '{"draft":"hello"}' });
909
+ const payload = decodeResumeToken(firstJson.requiresInput.resumeToken);
910
+ const state = (await readStateJson({
911
+ env: { LOBSTER_STATE_DIR: stateDir },
912
+ key: payload.stateKey,
913
+ }));
914
+ assert.equal(state.resumeMode, "same_stage");
915
+ assert.equal(state.resumeAtIndex, 1);
916
+ assert.deepEqual(state.items, []);
917
+ assert.deepEqual(state.commandInput.pending.suspendedState, {
918
+ type: "ask",
919
+ subject: { text: '{"draft":"hello"}' },
920
+ });
921
+ const resumed = runCli([
922
+ "resume",
923
+ "--token",
924
+ firstJson.requiresInput.resumeToken,
925
+ "--response-json",
926
+ '{"decision":"approve"}',
927
+ ], { LOBSTER_STATE_DIR: stateDir });
928
+ assert.equal(resumed.status, 0, resumed.stderr);
929
+ const resumedJson = JSON.parse(resumed.stdout);
930
+ assert.equal(resumedJson.status, "ok");
931
+ assert.deepEqual(resumedJson.output, [{ decision: "approve" }]);
932
+ });
933
+ test("human CLI ask --emit prints public input request", async () => {
934
+ const schema = JSON.stringify({
935
+ type: "object",
936
+ properties: { decision: { type: "string" } },
937
+ required: ["decision"],
938
+ });
939
+ const result = runCli(["run", `ask --emit --prompt 'Review?' --schema ${JSON.stringify(schema)}`], {});
940
+ assert.equal(result.status, 0, result.stderr);
941
+ const output = JSON.parse(result.stdout);
942
+ assert.equal(output[0].type, "input_request");
943
+ assert.equal(output[0].prompt, "Review?");
944
+ assert.equal(output[0].commandInput, undefined);
945
+ });
946
+ //# sourceMappingURL=request_input.test.js.map