@openparachute/agent 0.2.2 → 0.2.3-rc.11
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.
- package/.parachute/module.json +3 -3
- package/package.json +4 -1
- package/src/agent-defs.ts +9 -0
- package/src/auth.ts +182 -14
- package/src/backends/programmatic.ts +35 -2
- package/src/backends/registry.ts +159 -40
- package/src/backends/types.ts +44 -0
- package/src/daemon.ts +317 -12
- package/src/def-vault-triggers.ts +317 -0
- package/src/preflight.ts +139 -0
- package/src/spawn-agent.ts +16 -0
- package/src/step-up.ts +316 -0
- package/src/terminal-ui.ts +73 -0
- package/src/transports/http-ui.ts +10 -8
- package/src/transports/vault.ts +48 -27
- package/src/ui-kit.ts +6 -3
- package/src/ui-ticket.ts +121 -0
- package/web/ui/dist/assets/index-Dhr5Kl_d.css +1 -0
- package/web/ui/dist/assets/index-Di5MmFZR.js +60 -0
- package/web/ui/dist/index.html +2 -2
- package/src/_parked/interactive-spawn.test.ts +0 -324
- package/src/_parked/interactive-spawn.ts +0 -701
- package/src/agent-defs.test.ts +0 -1504
- package/src/agent-mcp-config.test.ts +0 -115
- package/src/agents.test.ts +0 -360
- package/src/auth.test.ts +0 -46
- package/src/backends/attached-queue.test.ts +0 -376
- package/src/backends/programmatic.test.ts +0 -1715
- package/src/backends/registry.test.ts +0 -1494
- package/src/backends/stream-json.test.ts +0 -570
- package/src/channel-backend-wiring.test.ts +0 -237
- package/src/credentials.test.ts +0 -274
- package/src/cron.test.ts +0 -342
- package/src/daemon-agent-def-api.test.ts +0 -166
- package/src/daemon-agent-defs-api.test.ts +0 -953
- package/src/daemon-agent-env-api.test.ts +0 -338
- package/src/daemon-attached-queue-store.test.ts +0 -65
- package/src/daemon-config-api.test.ts +0 -962
- package/src/daemon-jobs-api.test.ts +0 -271
- package/src/daemon-vault-chat.test.ts +0 -250
- package/src/daemon.test.ts +0 -746
- package/src/def-vaults.test.ts +0 -136
- package/src/delivery-state.test.ts +0 -110
- package/src/effective-env.test.ts +0 -114
- package/src/grants.test.ts +0 -638
- package/src/hub-jwt.test.ts +0 -161
- package/src/jobs.test.ts +0 -245
- package/src/mcp-http.test.ts +0 -265
- package/src/mint-token.test.ts +0 -152
- package/src/module-manifest.test.ts +0 -158
- package/src/programmatic-wiring.test.ts +0 -838
- package/src/registry.test.ts +0 -227
- package/src/resolve-port.test.ts +0 -64
- package/src/routing.test.ts +0 -184
- package/src/runner.test.ts +0 -506
- package/src/sandbox/config.test.ts +0 -150
- package/src/sandbox/egress.test.ts +0 -113
- package/src/sandbox/live-seatbelt.test.ts +0 -277
- package/src/sandbox/mounts.test.ts +0 -154
- package/src/sandbox/sandbox.test.ts +0 -168
- package/src/services-manifest.test.ts +0 -106
- package/src/spa-serve.test.ts +0 -116
- package/src/spawn-agent-cli.test.ts +0 -172
- package/src/spawn-agent.test.ts +0 -1218
- package/src/spawn-deps.test.ts +0 -54
- package/src/terminal-assets.test.ts +0 -50
- package/src/terminal.test.ts +0 -530
- package/src/transports/http-ui.test.ts +0 -455
- package/src/transports/telegram.test.ts +0 -174
- package/src/transports/vault.test.ts +0 -2011
- package/src/ui-kit.test.ts +0 -178
- package/web/ui/dist/assets/index-C-iWdFFV.css +0 -1
- package/web/ui/dist/assets/index-VFETBk0a.js +0 -60
- package/web/ui/tsconfig.json +0 -21
|
@@ -1,570 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* parseStreamJson tests — the `claude -p --output-format stream-json --verbose`
|
|
3
|
-
* NDJSON parser. Built to the VERIFIED event shapes (claude 2.1.179).
|
|
4
|
-
*
|
|
5
|
-
* Covered:
|
|
6
|
-
* - a success turn: session_id from init, reply from result, success/subtype, usage, cost;
|
|
7
|
-
* - apiKeySource "none" surfaced (the subscription-auth signal);
|
|
8
|
-
* - an error turn (is_error / non-success subtype) → success=false, error message;
|
|
9
|
-
* - session_id captured from the FIRST event (init precedes result);
|
|
10
|
-
* - robustness: interleaved hook / rate_limit_event lines + a trailing PARTIAL line;
|
|
11
|
-
* - no result event at all → success undefined (a truncated/crashed turn);
|
|
12
|
-
* - blank/garbage input → an empty parse, never a throw.
|
|
13
|
-
*/
|
|
14
|
-
import { describe, test, expect } from "bun:test";
|
|
15
|
-
import {
|
|
16
|
-
parseStreamJson,
|
|
17
|
-
parseStreamJsonStream,
|
|
18
|
-
MAX_TOOL_INPUT_CHARS,
|
|
19
|
-
MAX_TOOL_RESULT_CHARS,
|
|
20
|
-
type InterimTurnEvent,
|
|
21
|
-
} from "./stream-json.ts";
|
|
22
|
-
|
|
23
|
-
/** Join NDJSON event objects into the line-delimited blob claude emits. */
|
|
24
|
-
function ndjson(...events: unknown[]): string {
|
|
25
|
-
return events.map((e) => JSON.stringify(e)).join("\n") + "\n";
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** A ReadableStream<Uint8Array> that emits the given byte chunks in order (then closes). */
|
|
29
|
-
function streamOf(...chunks: string[]): ReadableStream<Uint8Array> {
|
|
30
|
-
const enc = new TextEncoder();
|
|
31
|
-
return new ReadableStream<Uint8Array>({
|
|
32
|
-
start(controller) {
|
|
33
|
-
for (const c of chunks) controller.enqueue(enc.encode(c));
|
|
34
|
-
controller.close();
|
|
35
|
-
},
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Collect the interim events parseStreamJsonStream emits while parsing `chunks`. */
|
|
40
|
-
async function collectInterim(
|
|
41
|
-
...chunks: string[]
|
|
42
|
-
): Promise<{ events: InterimTurnEvent[]; turn: Awaited<ReturnType<typeof parseStreamJsonStream>> }> {
|
|
43
|
-
const events: InterimTurnEvent[] = [];
|
|
44
|
-
const turn = await parseStreamJsonStream(streamOf(...chunks), (e) => events.push(e));
|
|
45
|
-
return { events, turn };
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
describe("parseStreamJson — success turn", () => {
|
|
49
|
-
test("captures session_id (from init), reply (from result), subtype, usage, cost", () => {
|
|
50
|
-
const blob = ndjson(
|
|
51
|
-
{ type: "system", subtype: "init", session_id: "sess-123", apiKeySource: "none", mcp_servers: [] },
|
|
52
|
-
{ type: "assistant", message: { content: [{ type: "text", text: "hi there" }] }, session_id: "sess-123" },
|
|
53
|
-
{
|
|
54
|
-
type: "result",
|
|
55
|
-
subtype: "success",
|
|
56
|
-
is_error: false,
|
|
57
|
-
result: "Here is the final reply.",
|
|
58
|
-
session_id: "sess-123",
|
|
59
|
-
usage: { input_tokens: 42, output_tokens: 7 },
|
|
60
|
-
total_cost_usd: 0.0012,
|
|
61
|
-
},
|
|
62
|
-
);
|
|
63
|
-
const t = parseStreamJson(blob);
|
|
64
|
-
expect(t.sessionId).toBe("sess-123");
|
|
65
|
-
expect(t.reply).toBe("Here is the final reply.");
|
|
66
|
-
expect(t.success).toBe(true);
|
|
67
|
-
expect(t.subtype).toBe("success");
|
|
68
|
-
expect(t.isError).toBe(false);
|
|
69
|
-
expect(t.usage).toEqual({ input_tokens: 42, output_tokens: 7 });
|
|
70
|
-
expect(t.totalCostUsd).toBe(0.0012);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test("surfaces apiKeySource 'none' — the subscription-auth signal (design §1)", () => {
|
|
74
|
-
const blob = ndjson(
|
|
75
|
-
{ type: "system", subtype: "init", session_id: "s", apiKeySource: "none" },
|
|
76
|
-
{ type: "result", subtype: "success", is_error: false, result: "ok", session_id: "s" },
|
|
77
|
-
);
|
|
78
|
-
expect(parseStreamJson(blob).apiKeySource).toBe("none");
|
|
79
|
-
});
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
describe("parseStreamJson — error turn", () => {
|
|
83
|
-
test("is_error true → success=false, error message captured from result", () => {
|
|
84
|
-
const blob = ndjson(
|
|
85
|
-
{ type: "system", subtype: "init", session_id: "sess-err", apiKeySource: "none" },
|
|
86
|
-
{
|
|
87
|
-
type: "result",
|
|
88
|
-
subtype: "error_during_execution",
|
|
89
|
-
is_error: true,
|
|
90
|
-
result: "something went wrong",
|
|
91
|
-
session_id: "sess-err",
|
|
92
|
-
},
|
|
93
|
-
);
|
|
94
|
-
const t = parseStreamJson(blob);
|
|
95
|
-
expect(t.success).toBe(false);
|
|
96
|
-
expect(t.isError).toBe(true);
|
|
97
|
-
expect(t.subtype).toBe("error_during_execution");
|
|
98
|
-
expect(t.errorMessage).toBe("something went wrong");
|
|
99
|
-
// The session id is still captured (a turn can fail AFTER establishing a session).
|
|
100
|
-
expect(t.sessionId).toBe("sess-err");
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
test("a non-success subtype with is_error false is STILL not success", () => {
|
|
104
|
-
const blob = ndjson(
|
|
105
|
-
{ type: "system", subtype: "init", session_id: "s" },
|
|
106
|
-
{ type: "result", subtype: "error_max_turns", is_error: false, result: "partial", session_id: "s" },
|
|
107
|
-
);
|
|
108
|
-
const t = parseStreamJson(blob);
|
|
109
|
-
expect(t.success).toBe(false);
|
|
110
|
-
expect(t.subtype).toBe("error_max_turns");
|
|
111
|
-
});
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
describe("parseStreamJson — session_id capture", () => {
|
|
115
|
-
test("captured from the FIRST event that carries one (init precedes result)", () => {
|
|
116
|
-
const blob = ndjson(
|
|
117
|
-
{ type: "system", subtype: "init", session_id: "first-id" },
|
|
118
|
-
{ type: "result", subtype: "success", is_error: false, result: "ok", session_id: "first-id" },
|
|
119
|
-
);
|
|
120
|
-
expect(parseStreamJson(blob).sessionId).toBe("first-id");
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
test("falls through to the result event's session_id when there's no init", () => {
|
|
124
|
-
const blob = ndjson({ type: "result", subtype: "success", is_error: false, result: "ok", session_id: "from-result" });
|
|
125
|
-
expect(parseStreamJson(blob).sessionId).toBe("from-result");
|
|
126
|
-
});
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
describe("parseStreamJson — robustness", () => {
|
|
130
|
-
test("interleaved hook / rate_limit_event lines + a trailing PARTIAL line still parse the result", () => {
|
|
131
|
-
const blob =
|
|
132
|
-
// a non-JSON hook line (plain text from a user hook)
|
|
133
|
-
"running PreToolUse hook...\n" +
|
|
134
|
-
ndjson({ type: "system", subtype: "init", session_id: "sess-robust", apiKeySource: "none" }).trimEnd() +
|
|
135
|
-
"\n" +
|
|
136
|
-
// a rate_limit_event interleaved (the subscription five_hour pool signal)
|
|
137
|
-
ndjson({ type: "system", subtype: "rate_limit_event", rate_limit: { five_hour: { overageStatus: "rejected" } } }).trimEnd() +
|
|
138
|
-
"\n" +
|
|
139
|
-
ndjson({ type: "assistant", message: { content: [{ type: "text", text: "thinking" }] }, session_id: "sess-robust" }).trimEnd() +
|
|
140
|
-
"\n" +
|
|
141
|
-
JSON.stringify({
|
|
142
|
-
type: "result",
|
|
143
|
-
subtype: "success",
|
|
144
|
-
is_error: false,
|
|
145
|
-
result: "robust reply",
|
|
146
|
-
session_id: "sess-robust",
|
|
147
|
-
usage: { input_tokens: 1, output_tokens: 2 },
|
|
148
|
-
}) +
|
|
149
|
-
"\n" +
|
|
150
|
-
// a TRAILING PARTIAL line — a JSON object cut off mid-stream (no closing brace)
|
|
151
|
-
'{"type":"system","subtype":"in';
|
|
152
|
-
const t = parseStreamJson(blob);
|
|
153
|
-
expect(t.sessionId).toBe("sess-robust");
|
|
154
|
-
expect(t.reply).toBe("robust reply");
|
|
155
|
-
expect(t.success).toBe(true);
|
|
156
|
-
expect(t.apiKeySource).toBe("none");
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
test("blank lines + a leading non-{ line are skipped", () => {
|
|
160
|
-
const blob =
|
|
161
|
-
"\n\nsome banner text\n" +
|
|
162
|
-
JSON.stringify({ type: "result", subtype: "success", is_error: false, result: "ok", session_id: "s" }) +
|
|
163
|
-
"\n\n";
|
|
164
|
-
const t = parseStreamJson(blob);
|
|
165
|
-
expect(t.reply).toBe("ok");
|
|
166
|
-
expect(t.success).toBe(true);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
test("no result event at all → success is undefined (a truncated/crashed turn)", () => {
|
|
170
|
-
const blob = ndjson(
|
|
171
|
-
{ type: "system", subtype: "init", session_id: "sess-trunc", apiKeySource: "none" },
|
|
172
|
-
{ type: "assistant", message: { content: [{ type: "text", text: "started…" }] }, session_id: "sess-trunc" },
|
|
173
|
-
);
|
|
174
|
-
const t = parseStreamJson(blob);
|
|
175
|
-
expect(t.success).toBeUndefined();
|
|
176
|
-
expect(t.reply).toBeUndefined();
|
|
177
|
-
expect(t.sessionId).toBe("sess-trunc"); // id still available for a resume
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
test("fully blank / garbage input → an empty parse, never a throw", () => {
|
|
181
|
-
expect(parseStreamJson("")).toEqual({});
|
|
182
|
-
expect(parseStreamJson(" \n \n")).toEqual({});
|
|
183
|
-
expect(parseStreamJson("not json at all\nmore noise")).toEqual({});
|
|
184
|
-
});
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
describe("parseStreamJsonStream — interim events + final turn", () => {
|
|
188
|
-
test("emits init (session) + text chunks + tool_use, and returns the same final turn", async () => {
|
|
189
|
-
const blob = ndjson(
|
|
190
|
-
{ type: "system", subtype: "init", session_id: "sess-1", apiKeySource: "none", mcp_servers: [] },
|
|
191
|
-
{ type: "assistant", message: { content: [{ type: "text", text: "let me check" }] }, session_id: "sess-1" },
|
|
192
|
-
{
|
|
193
|
-
type: "assistant",
|
|
194
|
-
message: { content: [{ type: "tool_use", name: "Read", input: {} }] },
|
|
195
|
-
session_id: "sess-1",
|
|
196
|
-
},
|
|
197
|
-
{ type: "assistant", message: { content: [{ type: "text", text: " — done." }] }, session_id: "sess-1" },
|
|
198
|
-
{
|
|
199
|
-
type: "result",
|
|
200
|
-
subtype: "success",
|
|
201
|
-
is_error: false,
|
|
202
|
-
result: "let me check — done.",
|
|
203
|
-
session_id: "sess-1",
|
|
204
|
-
usage: { input_tokens: 9, output_tokens: 4 },
|
|
205
|
-
total_cost_usd: 0.0009,
|
|
206
|
-
},
|
|
207
|
-
);
|
|
208
|
-
const { events, turn } = await collectInterim(blob);
|
|
209
|
-
|
|
210
|
-
expect(events).toEqual([
|
|
211
|
-
{ kind: "init", sessionId: "sess-1" },
|
|
212
|
-
{ kind: "text", text: "let me check" },
|
|
213
|
-
// empty input `{}` still stringifies to "{}" and rides as the input field
|
|
214
|
-
{ kind: "tool", tool: "Read", input: "{}" },
|
|
215
|
-
{ kind: "text", text: " — done." },
|
|
216
|
-
]);
|
|
217
|
-
// The FINAL turn is exactly what the blob parser would compute — durable path intact.
|
|
218
|
-
expect(turn).toEqual(parseStreamJson(blob));
|
|
219
|
-
expect(turn.reply).toBe("let me check — done.");
|
|
220
|
-
expect(turn.success).toBe(true);
|
|
221
|
-
expect(turn.sessionId).toBe("sess-1");
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
test("a SINGLE assistant event with multiple blocks emits each in order", async () => {
|
|
225
|
-
const blob = ndjson(
|
|
226
|
-
{ type: "system", subtype: "init", session_id: "s" },
|
|
227
|
-
{
|
|
228
|
-
type: "assistant",
|
|
229
|
-
message: {
|
|
230
|
-
content: [
|
|
231
|
-
{ type: "text", text: "first" },
|
|
232
|
-
{ type: "tool_use", name: "Bash" },
|
|
233
|
-
{ type: "text", text: "second" },
|
|
234
|
-
],
|
|
235
|
-
},
|
|
236
|
-
session_id: "s",
|
|
237
|
-
},
|
|
238
|
-
{ type: "result", subtype: "success", is_error: false, result: "ok", session_id: "s" },
|
|
239
|
-
);
|
|
240
|
-
const { events } = await collectInterim(blob);
|
|
241
|
-
expect(events).toEqual([
|
|
242
|
-
{ kind: "init", sessionId: "s" },
|
|
243
|
-
{ kind: "text", text: "first" },
|
|
244
|
-
{ kind: "tool", tool: "Bash" },
|
|
245
|
-
{ kind: "text", text: "second" },
|
|
246
|
-
]);
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
test("chunk boundaries SPLIT mid-line still parse + emit correctly", async () => {
|
|
250
|
-
const blob = ndjson(
|
|
251
|
-
{ type: "system", subtype: "init", session_id: "split-1" },
|
|
252
|
-
{ type: "assistant", message: { content: [{ type: "text", text: "streamed" }] }, session_id: "split-1" },
|
|
253
|
-
{ type: "result", subtype: "success", is_error: false, result: "streamed", session_id: "split-1" },
|
|
254
|
-
);
|
|
255
|
-
// Split the blob at an arbitrary mid-line byte offset into two chunks.
|
|
256
|
-
const cut = Math.floor(blob.length / 2);
|
|
257
|
-
const { events, turn } = await collectInterim(blob.slice(0, cut), blob.slice(cut));
|
|
258
|
-
expect(events).toEqual([
|
|
259
|
-
{ kind: "init", sessionId: "split-1" },
|
|
260
|
-
{ kind: "text", text: "streamed" },
|
|
261
|
-
]);
|
|
262
|
-
expect(turn).toEqual(parseStreamJson(blob));
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
test("a final result line with NO trailing newline is still folded (pipe close)", async () => {
|
|
266
|
-
// The result line is emitted WITHOUT a terminating \n (the pipe closed) — it must
|
|
267
|
-
// still be parsed (folded once at end-of-stream).
|
|
268
|
-
const init = JSON.stringify({ type: "system", subtype: "init", session_id: "no-nl" }) + "\n";
|
|
269
|
-
const result = JSON.stringify({
|
|
270
|
-
type: "result",
|
|
271
|
-
subtype: "success",
|
|
272
|
-
is_error: false,
|
|
273
|
-
result: "tail reply",
|
|
274
|
-
session_id: "no-nl",
|
|
275
|
-
});
|
|
276
|
-
const { turn } = await collectInterim(init, result);
|
|
277
|
-
expect(turn.reply).toBe("tail reply");
|
|
278
|
-
expect(turn.success).toBe(true);
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
test("interim sink is NEVER called for the result event (final-only), and tolerates noise", async () => {
|
|
282
|
-
const blob =
|
|
283
|
-
"PreToolUse hook running...\n" +
|
|
284
|
-
ndjson({ type: "system", subtype: "init", session_id: "noise" }).trimEnd() +
|
|
285
|
-
"\n" +
|
|
286
|
-
ndjson({ type: "assistant", message: { content: [{ type: "text", text: "hi" }] }, session_id: "noise" }).trimEnd() +
|
|
287
|
-
"\n" +
|
|
288
|
-
JSON.stringify({ type: "result", subtype: "success", is_error: false, result: "hi", session_id: "noise" }) +
|
|
289
|
-
"\n";
|
|
290
|
-
const { events, turn } = await collectInterim(blob);
|
|
291
|
-
// init + the one text chunk only — the result event produces no interim event.
|
|
292
|
-
expect(events).toEqual([
|
|
293
|
-
{ kind: "init", sessionId: "noise" },
|
|
294
|
-
{ kind: "text", text: "hi" },
|
|
295
|
-
]);
|
|
296
|
-
expect(turn.reply).toBe("hi");
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
test("a null stream yields an empty turn with no events (no stdout)", async () => {
|
|
300
|
-
const events: InterimTurnEvent[] = [];
|
|
301
|
-
const turn = await parseStreamJsonStream(null, (e) => events.push(e));
|
|
302
|
-
expect(turn).toEqual({});
|
|
303
|
-
expect(events).toHaveLength(0);
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
test("an error turn streams nothing-but-init then the final turn carries the failure", async () => {
|
|
307
|
-
const blob = ndjson(
|
|
308
|
-
{ type: "system", subtype: "init", session_id: "err-1" },
|
|
309
|
-
{ type: "result", subtype: "error_during_execution", is_error: true, result: "kaboom", session_id: "err-1" },
|
|
310
|
-
);
|
|
311
|
-
const { events, turn } = await collectInterim(blob);
|
|
312
|
-
expect(events).toEqual([{ kind: "init", sessionId: "err-1" }]);
|
|
313
|
-
expect(turn.success).toBe(false);
|
|
314
|
-
expect(turn.errorMessage).toBe("kaboom");
|
|
315
|
-
expect(turn.sessionId).toBe("err-1");
|
|
316
|
-
});
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
describe("parseStreamJsonStream — enriched tool events (inputs + result previews)", () => {
|
|
320
|
-
test("a tool_use carries its input JSON-stringified on the tool event", async () => {
|
|
321
|
-
const blob = ndjson(
|
|
322
|
-
{ type: "system", subtype: "init", session_id: "tin-1" },
|
|
323
|
-
{
|
|
324
|
-
type: "assistant",
|
|
325
|
-
message: {
|
|
326
|
-
content: [{ type: "tool_use", id: "tu_1", name: "Read", input: { file_path: "/etc/hosts" } }],
|
|
327
|
-
},
|
|
328
|
-
session_id: "tin-1",
|
|
329
|
-
},
|
|
330
|
-
{ type: "result", subtype: "success", is_error: false, result: "done", session_id: "tin-1" },
|
|
331
|
-
);
|
|
332
|
-
const { events } = await collectInterim(blob);
|
|
333
|
-
expect(events).toEqual([
|
|
334
|
-
{ kind: "init", sessionId: "tin-1" },
|
|
335
|
-
{ kind: "tool", tool: "Read", input: JSON.stringify({ file_path: "/etc/hosts" }) },
|
|
336
|
-
]);
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
test("an OVERSIZE tool input is truncated with the … marker", async () => {
|
|
340
|
-
const bigCmd = "x".repeat(MAX_TOOL_INPUT_CHARS + 500);
|
|
341
|
-
const blob = ndjson(
|
|
342
|
-
{ type: "system", subtype: "init", session_id: "tin-big" },
|
|
343
|
-
{
|
|
344
|
-
type: "assistant",
|
|
345
|
-
message: { content: [{ type: "tool_use", id: "tu_b", name: "Bash", input: { command: bigCmd } }] },
|
|
346
|
-
session_id: "tin-big",
|
|
347
|
-
},
|
|
348
|
-
{ type: "result", subtype: "success", is_error: false, result: "done", session_id: "tin-big" },
|
|
349
|
-
);
|
|
350
|
-
const { events } = await collectInterim(blob);
|
|
351
|
-
const toolEvent = events.find((e) => e.kind === "tool");
|
|
352
|
-
expect(toolEvent).toBeDefined();
|
|
353
|
-
const input = (toolEvent as { input?: string }).input!;
|
|
354
|
-
// exactly MAX chars + the 1-char marker, and it ends with the marker
|
|
355
|
-
expect(input.length).toBe(MAX_TOOL_INPUT_CHARS + 1);
|
|
356
|
-
expect(input.endsWith("…")).toBe(true);
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
test("an input at EXACTLY the cap is NOT truncated (no marker)", async () => {
|
|
360
|
-
// A JSON string whose serialized form is exactly MAX_TOOL_INPUT_CHARS chars: the
|
|
361
|
-
// `> max` (strictly-greater) guard must leave it untouched, no `…`.
|
|
362
|
-
const padTo = MAX_TOOL_INPUT_CHARS - JSON.stringify({ command: "" }).length;
|
|
363
|
-
const exactCmd = "z".repeat(padTo);
|
|
364
|
-
const inputObj = { command: exactCmd };
|
|
365
|
-
const expected = JSON.stringify(inputObj);
|
|
366
|
-
expect(expected.length).toBe(MAX_TOOL_INPUT_CHARS); // sanity: we built it right
|
|
367
|
-
const blob = ndjson(
|
|
368
|
-
{ type: "system", subtype: "init", session_id: "tin-exact" },
|
|
369
|
-
{
|
|
370
|
-
type: "assistant",
|
|
371
|
-
message: { content: [{ type: "tool_use", id: "tu_x", name: "Bash", input: inputObj }] },
|
|
372
|
-
session_id: "tin-exact",
|
|
373
|
-
},
|
|
374
|
-
{ type: "result", subtype: "success", is_error: false, result: "done", session_id: "tin-exact" },
|
|
375
|
-
);
|
|
376
|
-
const { events } = await collectInterim(blob);
|
|
377
|
-
expect(events).toContainEqual({ kind: "tool", tool: "Bash", input: expected });
|
|
378
|
-
expect((events.find((e) => e.kind === "tool") as { input?: string }).input!.endsWith("…")).toBe(false);
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
test("the id→name map accumulates MULTIPLE tool_use ids before their results arrive", async () => {
|
|
382
|
-
// Two tool_use blocks fire, THEN two tool_results — each result must be labeled
|
|
383
|
-
// with the right tool via the per-parse id→name map (no cross-talk, map survives).
|
|
384
|
-
const blob = ndjson(
|
|
385
|
-
{ type: "system", subtype: "init", session_id: "multi" },
|
|
386
|
-
{
|
|
387
|
-
type: "assistant",
|
|
388
|
-
message: {
|
|
389
|
-
content: [
|
|
390
|
-
{ type: "tool_use", id: "tu_a", name: "Read", input: { file_path: "/a" } },
|
|
391
|
-
{ type: "tool_use", id: "tu_b", name: "Bash", input: { command: "ls" } },
|
|
392
|
-
],
|
|
393
|
-
},
|
|
394
|
-
session_id: "multi",
|
|
395
|
-
},
|
|
396
|
-
{
|
|
397
|
-
type: "user",
|
|
398
|
-
message: {
|
|
399
|
-
content: [
|
|
400
|
-
{ type: "tool_result", tool_use_id: "tu_b", is_error: false, content: "bash out" },
|
|
401
|
-
{ type: "tool_result", tool_use_id: "tu_a", is_error: false, content: "read out" },
|
|
402
|
-
],
|
|
403
|
-
},
|
|
404
|
-
session_id: "multi",
|
|
405
|
-
},
|
|
406
|
-
{ type: "result", subtype: "success", is_error: false, result: "done", session_id: "multi" },
|
|
407
|
-
);
|
|
408
|
-
const { events } = await collectInterim(blob);
|
|
409
|
-
// tu_b → Bash, tu_a → Read (results arrived in reverse order; labels still correct)
|
|
410
|
-
expect(events).toContainEqual({ kind: "tool_result", tool: "Bash", ok: true, preview: "bash out" });
|
|
411
|
-
expect(events).toContainEqual({ kind: "tool_result", tool: "Read", ok: true, preview: "read out" });
|
|
412
|
-
});
|
|
413
|
-
|
|
414
|
-
test("a tool_use with NO input omits the input field (back-compat shape)", async () => {
|
|
415
|
-
const blob = ndjson(
|
|
416
|
-
{ type: "system", subtype: "init", session_id: "tin-none" },
|
|
417
|
-
{
|
|
418
|
-
type: "assistant",
|
|
419
|
-
message: { content: [{ type: "tool_use", id: "tu_n", name: "Glob" }] },
|
|
420
|
-
session_id: "tin-none",
|
|
421
|
-
},
|
|
422
|
-
{ type: "result", subtype: "success", is_error: false, result: "done", session_id: "tin-none" },
|
|
423
|
-
);
|
|
424
|
-
const { events } = await collectInterim(blob);
|
|
425
|
-
expect(events).toEqual([
|
|
426
|
-
{ kind: "init", sessionId: "tin-none" },
|
|
427
|
-
{ kind: "tool", tool: "Glob" },
|
|
428
|
-
]);
|
|
429
|
-
});
|
|
430
|
-
|
|
431
|
-
test("a tool_result → {kind:'tool_result', tool, ok, preview} labeled via the tool_use_id map", async () => {
|
|
432
|
-
const blob = ndjson(
|
|
433
|
-
{ type: "system", subtype: "init", session_id: "tr-1" },
|
|
434
|
-
{
|
|
435
|
-
type: "assistant",
|
|
436
|
-
message: { content: [{ type: "tool_use", id: "tu_42", name: "Read", input: { file_path: "/a" } }] },
|
|
437
|
-
session_id: "tr-1",
|
|
438
|
-
},
|
|
439
|
-
{
|
|
440
|
-
type: "user",
|
|
441
|
-
message: {
|
|
442
|
-
content: [{ type: "tool_result", tool_use_id: "tu_42", is_error: false, content: "file contents here" }],
|
|
443
|
-
},
|
|
444
|
-
session_id: "tr-1",
|
|
445
|
-
},
|
|
446
|
-
{ type: "result", subtype: "success", is_error: false, result: "done", session_id: "tr-1" },
|
|
447
|
-
);
|
|
448
|
-
const { events } = await collectInterim(blob);
|
|
449
|
-
expect(events).toContainEqual({
|
|
450
|
-
kind: "tool_result",
|
|
451
|
-
tool: "Read", // labeled via the tool_use_id → name map
|
|
452
|
-
ok: true,
|
|
453
|
-
preview: "file contents here",
|
|
454
|
-
});
|
|
455
|
-
});
|
|
456
|
-
|
|
457
|
-
test("a tool_result with array content parts joins the text parts into the preview", async () => {
|
|
458
|
-
const blob = ndjson(
|
|
459
|
-
{ type: "system", subtype: "init", session_id: "tr-arr" },
|
|
460
|
-
{
|
|
461
|
-
type: "assistant",
|
|
462
|
-
message: { content: [{ type: "tool_use", id: "tu_arr", name: "Bash", input: { command: "ls" } }] },
|
|
463
|
-
session_id: "tr-arr",
|
|
464
|
-
},
|
|
465
|
-
{
|
|
466
|
-
type: "user",
|
|
467
|
-
message: {
|
|
468
|
-
content: [
|
|
469
|
-
{
|
|
470
|
-
type: "tool_result",
|
|
471
|
-
tool_use_id: "tu_arr",
|
|
472
|
-
is_error: false,
|
|
473
|
-
content: [
|
|
474
|
-
{ type: "text", text: "line1\n" },
|
|
475
|
-
{ type: "text", text: "line2" },
|
|
476
|
-
],
|
|
477
|
-
},
|
|
478
|
-
],
|
|
479
|
-
},
|
|
480
|
-
session_id: "tr-arr",
|
|
481
|
-
},
|
|
482
|
-
{ type: "result", subtype: "success", is_error: false, result: "done", session_id: "tr-arr" },
|
|
483
|
-
);
|
|
484
|
-
const { events } = await collectInterim(blob);
|
|
485
|
-
expect(events).toContainEqual({ kind: "tool_result", tool: "Bash", ok: true, preview: "line1\nline2" });
|
|
486
|
-
});
|
|
487
|
-
|
|
488
|
-
test("an OVERSIZE tool_result preview is truncated with the … marker", async () => {
|
|
489
|
-
const bigOut = "y".repeat(MAX_TOOL_RESULT_CHARS + 500);
|
|
490
|
-
const blob = ndjson(
|
|
491
|
-
{ type: "system", subtype: "init", session_id: "tr-big" },
|
|
492
|
-
{
|
|
493
|
-
type: "assistant",
|
|
494
|
-
message: { content: [{ type: "tool_use", id: "tu_big", name: "Bash", input: {} }] },
|
|
495
|
-
session_id: "tr-big",
|
|
496
|
-
},
|
|
497
|
-
{
|
|
498
|
-
type: "user",
|
|
499
|
-
message: { content: [{ type: "tool_result", tool_use_id: "tu_big", is_error: false, content: bigOut }] },
|
|
500
|
-
session_id: "tr-big",
|
|
501
|
-
},
|
|
502
|
-
{ type: "result", subtype: "success", is_error: false, result: "done", session_id: "tr-big" },
|
|
503
|
-
);
|
|
504
|
-
const { events } = await collectInterim(blob);
|
|
505
|
-
const resultEvent = events.find((e) => e.kind === "tool_result");
|
|
506
|
-
expect(resultEvent).toBeDefined();
|
|
507
|
-
const preview = (resultEvent as { preview?: string }).preview!;
|
|
508
|
-
expect(preview.length).toBe(MAX_TOOL_RESULT_CHARS + 1);
|
|
509
|
-
expect(preview.endsWith("…")).toBe(true);
|
|
510
|
-
});
|
|
511
|
-
|
|
512
|
-
test("is_error:true → ok:false", async () => {
|
|
513
|
-
const blob = ndjson(
|
|
514
|
-
{ type: "system", subtype: "init", session_id: "tr-err" },
|
|
515
|
-
{
|
|
516
|
-
type: "assistant",
|
|
517
|
-
message: { content: [{ type: "tool_use", id: "tu_e", name: "Bash", input: { command: "false" } }] },
|
|
518
|
-
session_id: "tr-err",
|
|
519
|
-
},
|
|
520
|
-
{
|
|
521
|
-
type: "user",
|
|
522
|
-
message: {
|
|
523
|
-
content: [{ type: "tool_result", tool_use_id: "tu_e", is_error: true, content: "command failed" }],
|
|
524
|
-
},
|
|
525
|
-
session_id: "tr-err",
|
|
526
|
-
},
|
|
527
|
-
{ type: "result", subtype: "success", is_error: false, result: "done", session_id: "tr-err" },
|
|
528
|
-
);
|
|
529
|
-
const { events } = await collectInterim(blob);
|
|
530
|
-
expect(events).toContainEqual({ kind: "tool_result", tool: "Bash", ok: false, preview: "command failed" });
|
|
531
|
-
});
|
|
532
|
-
|
|
533
|
-
test("a tool_result whose tool_use_id is unknown still emits, just without the tool label", async () => {
|
|
534
|
-
const blob = ndjson(
|
|
535
|
-
{ type: "system", subtype: "init", session_id: "tr-orphan" },
|
|
536
|
-
{
|
|
537
|
-
type: "user",
|
|
538
|
-
message: {
|
|
539
|
-
content: [{ type: "tool_result", tool_use_id: "tu_unknown", is_error: false, content: "orphan result" }],
|
|
540
|
-
},
|
|
541
|
-
session_id: "tr-orphan",
|
|
542
|
-
},
|
|
543
|
-
{ type: "result", subtype: "success", is_error: false, result: "done", session_id: "tr-orphan" },
|
|
544
|
-
);
|
|
545
|
-
const { events } = await collectInterim(blob);
|
|
546
|
-
// no `tool` key (unknown id), but ok + preview present
|
|
547
|
-
expect(events).toContainEqual({ kind: "tool_result", ok: true, preview: "orphan result" });
|
|
548
|
-
});
|
|
549
|
-
|
|
550
|
-
test("the blob parser (no sink) ignores tool inputs + results entirely — final turn unchanged", () => {
|
|
551
|
-
const blob = ndjson(
|
|
552
|
-
{ type: "system", subtype: "init", session_id: "blob-tr" },
|
|
553
|
-
{
|
|
554
|
-
type: "assistant",
|
|
555
|
-
message: { content: [{ type: "tool_use", id: "tu_x", name: "Read", input: { file_path: "/x" } }] },
|
|
556
|
-
session_id: "blob-tr",
|
|
557
|
-
},
|
|
558
|
-
{
|
|
559
|
-
type: "user",
|
|
560
|
-
message: { content: [{ type: "tool_result", tool_use_id: "tu_x", is_error: false, content: "stuff" }] },
|
|
561
|
-
session_id: "blob-tr",
|
|
562
|
-
},
|
|
563
|
-
{ type: "result", subtype: "success", is_error: false, result: "final", session_id: "blob-tr" },
|
|
564
|
-
);
|
|
565
|
-
const t = parseStreamJson(blob);
|
|
566
|
-
expect(t.reply).toBe("final");
|
|
567
|
-
expect(t.success).toBe(true);
|
|
568
|
-
expect(t.sessionId).toBe("blob-tr");
|
|
569
|
-
});
|
|
570
|
-
});
|