@zhushanwen/pi-subagent-workflow 8.3.0 → 8.5.0
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/package.json +18 -4
- package/relay/relay.mjs +390 -0
- package/skills/subagent-ext-config/SKILL.md +80 -0
- package/src/execution/__tests__/agent-registry.test.ts +110 -0
- package/src/execution/__tests__/chat-engine-routing.test.ts +597 -0
- package/src/execution/__tests__/execution-record.test.ts +127 -1
- package/src/execution/__tests__/pi-invocation.test.ts +62 -1
- package/src/execution/__tests__/relay-agent.test.ts +448 -0
- package/src/execution/__tests__/relay-env.test.ts +42 -0
- package/src/execution/__tests__/startup-config-declaration.test.ts +35 -0
- package/src/execution/__tests__/stream-sink-retirement.test.ts +261 -0
- package/src/execution/__tests__/subprocess-agent-runner-routing.test.ts +310 -0
- package/src/execution/__tests__/subprocess-agent-runner.test.ts +53 -5
- package/src/execution/agent-registry.ts +10 -0
- package/src/execution/config.ts +25 -2
- package/src/execution/engine/__tests__/common/data-dir.test.ts +53 -0
- package/src/execution/engine/__tests__/common/errors.test.ts +132 -0
- package/src/execution/engine/__tests__/common/event-journal.test.ts +177 -0
- package/src/execution/engine/__tests__/common/kill-chain.test.ts +192 -0
- package/src/execution/engine/__tests__/common/nesting-guard.test.ts +81 -0
- package/src/execution/engine/__tests__/common/persona-router.test.ts +123 -0
- package/src/execution/engine/__tests__/common/pool-manager.test.ts +154 -0
- package/src/execution/engine/__tests__/common/schema-emulation.test.ts +128 -0
- package/src/execution/engine/__tests__/conformance/__fixtures__/pi-golden-events.json +28 -0
- package/src/execution/engine/__tests__/conformance/agent-event-invariants.ts +141 -0
- package/src/execution/engine/__tests__/conformance/contract.abort.test.ts +109 -0
- package/src/execution/engine/__tests__/conformance/contract.agent-events.test.ts +101 -0
- package/src/execution/engine/__tests__/conformance/contract.probe.test.ts +77 -0
- package/src/execution/engine/__tests__/conformance/contract.read-degradation.test.ts +104 -0
- package/src/execution/engine/__tests__/conformance/contract.relay.test.ts +342 -0
- package/src/execution/engine/__tests__/conformance/engine-conformance.live.test.ts +201 -0
- package/src/execution/engine/__tests__/conformance/golden-replay.pi.test.ts +76 -0
- package/src/execution/engine/__tests__/conformance/golden-replay.zcode.test.ts +79 -0
- package/src/execution/engine/__tests__/engine-discovery.test.ts +87 -0
- package/src/execution/engine/__tests__/engines-declaration.test.ts +36 -0
- package/src/execution/engine/__tests__/model-prompt.test.ts +85 -0
- package/src/execution/engine/__tests__/paths.test.ts +39 -0
- package/src/execution/engine/__tests__/registry.test.ts +120 -0
- package/src/execution/engine/__tests__/routing.test.ts +231 -0
- package/src/execution/engine/common/data-dir.ts +62 -0
- package/src/execution/engine/common/errors.ts +183 -0
- package/src/execution/engine/common/event-journal.ts +254 -0
- package/src/execution/engine/common/journal-replay.ts +62 -0
- package/src/execution/engine/common/kill-chain.ts +221 -0
- package/src/execution/engine/common/nesting-guard.ts +50 -0
- package/src/execution/engine/common/persona-router.ts +108 -0
- package/src/execution/engine/common/pool-manager.ts +226 -0
- package/src/execution/engine/common/schema-emulation.ts +189 -0
- package/src/execution/engine/common/session-view-projection.ts +51 -0
- package/src/execution/engine/engine-discovery.ts +65 -0
- package/src/execution/engine/engines/pi/__tests__/pi-engine.test.ts +469 -0
- package/src/execution/engine/engines/pi/__tests__/reader.test.ts +155 -0
- package/src/execution/engine/engines/pi/__tests__/task-spec-mapper.test.ts +164 -0
- package/src/execution/engine/engines/pi/pi-engine.ts +415 -0
- package/src/execution/engine/engines/pi/reader.ts +48 -0
- package/src/execution/engine/engines/pi/registration.ts +35 -0
- package/src/execution/engine/engines/pi/task-spec-mapper.ts +100 -0
- package/src/execution/engine/engines/zcode/__tests__/__fixtures__/zcode-golden-spawn.json +39 -0
- package/src/execution/engine/engines/zcode/__tests__/launcher.test.ts +150 -0
- package/src/execution/engine/engines/zcode/__tests__/parser.test.ts +246 -0
- package/src/execution/engine/engines/zcode/__tests__/preparer.test.ts +228 -0
- package/src/execution/engine/engines/zcode/__tests__/reader.test.ts +210 -0
- package/src/execution/engine/engines/zcode/__tests__/registration.test.ts +64 -0
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine.live.test.ts +127 -0
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine.test.ts +567 -0
- package/src/execution/engine/engines/zcode/constants.ts +43 -0
- package/src/execution/engine/engines/zcode/golden-sample.ts +39 -0
- package/src/execution/engine/engines/zcode/launcher.ts +161 -0
- package/src/execution/engine/engines/zcode/parser.ts +436 -0
- package/src/execution/engine/engines/zcode/preparer.ts +363 -0
- package/src/execution/engine/engines/zcode/reader.ts +381 -0
- package/src/execution/engine/engines/zcode/registration.ts +37 -0
- package/src/execution/engine/engines/zcode/zcode-engine.ts +648 -0
- package/src/execution/engine/host-task-spec.ts +47 -0
- package/src/execution/engine/model-prompt.ts +59 -0
- package/src/execution/engine/paths.ts +42 -0
- package/src/execution/engine/port.ts +153 -0
- package/src/execution/engine/registry.ts +123 -0
- package/src/execution/engine/routing.ts +218 -0
- package/src/execution/engine/types.ts +304 -0
- package/src/execution/execute-options-mapper.ts +5 -1
- package/src/execution/execution-record.ts +6 -0
- package/src/execution/model-resolver.ts +6 -0
- package/src/execution/pi-invocation.ts +32 -2
- package/src/execution/record-entry.ts +14 -0
- package/src/execution/record-store.ts +34 -0
- package/src/execution/relay-env.ts +37 -0
- package/src/execution/session-runner.ts +24 -0
- package/src/execution/stream-sink.ts +26 -0
- package/src/execution/subagent-service.ts +249 -11
- package/src/execution/subprocess-agent-runner.ts +196 -14
- package/src/execution/types.ts +56 -0
- package/src/index.ts +46 -1
- package/src/interface/command-actions.ts +72 -8
- package/src/interface/subagent-actions.ts +8 -2
- package/src/interface/subagent-tool.ts +6 -0
- package/src/interface/subagents.ts +198 -30
- package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +5 -2
- package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +3 -3
- package/src/orchestration/models/types.ts +7 -0
- package/src/orchestration/worker-script-builder.ts +5 -2
- package/src/shared/meta-parser.ts +5 -1
- package/src/shared/resource-meta.ts +5 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
// contract.relay.test.ts —— conformance relay 变体(E 方案 §2.3,默认 CI 层,免 LLM)。
|
|
2
|
+
//
|
|
3
|
+
// 断言口径:同一契约,spawn 通道不同——relay 是 pi 引擎的进程拓扑变体,不是引擎身份:
|
|
4
|
+
// 1. 协议常量镜像一致性:relay.mjs(零依赖脚本不能 import workspace 包,只能内嵌
|
|
5
|
+
// 镜像)与 relay-env.ts SSOT 逐字对齐——改名/改值双侧不同步即此处转红(§10-5)。
|
|
6
|
+
// 2. pi-invocation 通道契约:三 env 齐备 → 代理形态;任一缺失 → 直连(全有或全无,
|
|
7
|
+
// E-TUI 零回归);relay:false 强制直连(probe 语义)。
|
|
8
|
+
// 3. buildChildEnv 归属契约:激活时写入 tee 帧路由键(SESSION_ID/RECORD_ID),未激活
|
|
9
|
+
// 不写(继承值保持,无 relay 环境不携带误导性 env)。
|
|
10
|
+
//
|
|
11
|
+
// 真机全链(经代理 spawn 真实 pi)是 live 手动门:engine-conformance.live.test.ts 的
|
|
12
|
+
// relay describe(ENGINE_CONFORMANCE_LIVE=1 + relay env 齐备),不在本文件。
|
|
13
|
+
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
|
|
18
|
+
import type { PassThrough } from "node:stream";
|
|
19
|
+
|
|
20
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
21
|
+
|
|
22
|
+
// ── runSpawn 集成段 mock(与 session-runner-schema-env.test.ts 同模式)──
|
|
23
|
+
|
|
24
|
+
vi.mock("node:child_process", async () => {
|
|
25
|
+
const { EventEmitter } = await import("node:events");
|
|
26
|
+
const { PassThrough } = await import("node:stream");
|
|
27
|
+
|
|
28
|
+
class FakeChild extends EventEmitter {
|
|
29
|
+
pid = 12345;
|
|
30
|
+
stdout = new PassThrough();
|
|
31
|
+
stdin = new PassThrough();
|
|
32
|
+
stderr = new PassThrough();
|
|
33
|
+
killed = false;
|
|
34
|
+
killSignal: string | undefined;
|
|
35
|
+
kill(sig?: string): boolean {
|
|
36
|
+
this.killed = true;
|
|
37
|
+
this.killSignal = sig;
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
spawn: vi.fn(() => new FakeChild()),
|
|
44
|
+
execFile: vi.fn(
|
|
45
|
+
(
|
|
46
|
+
_cmd: string,
|
|
47
|
+
_args: readonly string[],
|
|
48
|
+
_opts: unknown,
|
|
49
|
+
cb: (err: Error | null, stdout?: string, stderr?: string) => void,
|
|
50
|
+
) => cb(new Error("execFile not configured in this test")),
|
|
51
|
+
),
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
vi.mock("node:fs", async () => {
|
|
56
|
+
const actual = await import("node:fs");
|
|
57
|
+
return {
|
|
58
|
+
default: {
|
|
59
|
+
...actual,
|
|
60
|
+
mkdirSync: vi.fn(),
|
|
61
|
+
existsSync: vi.fn(() => false),
|
|
62
|
+
appendFileSync: vi.fn(),
|
|
63
|
+
writeFileSync: vi.fn(),
|
|
64
|
+
readdirSync: vi.fn(() => []),
|
|
65
|
+
},
|
|
66
|
+
mkdirSync: vi.fn(),
|
|
67
|
+
existsSync: vi.fn(() => false),
|
|
68
|
+
appendFileSync: vi.fn(),
|
|
69
|
+
writeFileSync: vi.fn(),
|
|
70
|
+
readdirSync: vi.fn(() => []),
|
|
71
|
+
// 镜像段(C-镜像 describe)读 relay.mjs 源文本走真实实现——session-runner 只消费
|
|
72
|
+
// 上面被替换的几个方法,透传 readFileSync 不影响 runSpawn 集成段的 mock 语义。
|
|
73
|
+
readFileSync: actual.readFileSync,
|
|
74
|
+
promises: actual.promises,
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
vi.mock("../../../alive-store.ts", () => ({
|
|
79
|
+
writeAliveMarker: vi.fn(),
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
vi.mock("../../../temp-prompt.ts", () => ({
|
|
83
|
+
writePromptToTempFile: vi.fn(async (agent: string) => {
|
|
84
|
+
const safeName = agent.replace(/[^\w.-]+/g, "_");
|
|
85
|
+
return { dir: `/tmp/fake-${safeName}`, filePath: `/tmp/fake-${safeName}/prompt-${safeName}.md` };
|
|
86
|
+
}),
|
|
87
|
+
cleanupTempPrompt: vi.fn(async () => {}),
|
|
88
|
+
}));
|
|
89
|
+
|
|
90
|
+
import { spawn } from "node:child_process";
|
|
91
|
+
|
|
92
|
+
import {
|
|
93
|
+
RELAY_ENV_NODE,
|
|
94
|
+
RELAY_ENV_RECORD_ID,
|
|
95
|
+
RELAY_ENV_SCRIPT,
|
|
96
|
+
RELAY_ENV_SESSION_ID,
|
|
97
|
+
RELAY_ENV_SOCKET,
|
|
98
|
+
RELAY_EXIT_CODES,
|
|
99
|
+
RELAY_PROTOCOL_VERSION,
|
|
100
|
+
isRelayActive,
|
|
101
|
+
} from "../../../relay-env.ts";
|
|
102
|
+
import { getPiInvocation } from "../../../pi-invocation.ts";
|
|
103
|
+
import { runSpawn, type RunOptions, type SessionRunnerContext } from "../../../session-runner.ts";
|
|
104
|
+
import { createRecord } from "../../../execution-record.ts";
|
|
105
|
+
import { waitForSpawn } from "../../../__tests__/helpers/spawn-mock.ts";
|
|
106
|
+
|
|
107
|
+
const mockSpawn = vi.mocked(spawn);
|
|
108
|
+
|
|
109
|
+
/** 被锁定的代理脚本:包根 relay/relay.mjs(与 src/ 平行的零依赖脚本)。 */
|
|
110
|
+
const RELAY_SCRIPT_PATH = path.resolve(
|
|
111
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
112
|
+
"../../../../../relay/relay.mjs",
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
/** relay 激活三 env 齐备的基准值。 */
|
|
116
|
+
const RELAY_FULL: Record<string, string> = {
|
|
117
|
+
[RELAY_ENV_SOCKET]: "/tmp/contract-relay.sock",
|
|
118
|
+
[RELAY_ENV_NODE]: "/usr/bin/contract-relay-node",
|
|
119
|
+
[RELAY_ENV_SCRIPT]: "/opt/contract-relay/relay.mjs",
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const RELAY_ENV_KEYS = [RELAY_ENV_SOCKET, RELAY_ENV_NODE, RELAY_ENV_SCRIPT] as const;
|
|
123
|
+
|
|
124
|
+
function setRelayEnv(env: Record<string, string> | undefined): void {
|
|
125
|
+
for (const key of [...RELAY_ENV_KEYS, RELAY_ENV_SESSION_ID, RELAY_ENV_RECORD_ID]) {
|
|
126
|
+
delete process.env[key];
|
|
127
|
+
}
|
|
128
|
+
if (env !== undefined) Object.assign(process.env, env);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── 1. 协议常量镜像一致性(relay-env.ts SSOT ↔ relay.mjs 内嵌镜像)──
|
|
132
|
+
|
|
133
|
+
describe("relay 变体 C-镜像:relay.mjs 内嵌常量与 relay-env.ts SSOT 一致", () => {
|
|
134
|
+
const source = fs.readFileSync(RELAY_SCRIPT_PATH, "utf8");
|
|
135
|
+
|
|
136
|
+
/** 字符串常量:`const NAME = "value"` 形式逐字对齐(值来自 SSOT 导出,漂移即红)。 */
|
|
137
|
+
const stringVars: Array<[string, string]> = [
|
|
138
|
+
["RELAY_ENV_SOCKET", RELAY_ENV_SOCKET],
|
|
139
|
+
["RELAY_ENV_SESSION_ID", RELAY_ENV_SESSION_ID],
|
|
140
|
+
["RELAY_ENV_RECORD_ID", RELAY_ENV_RECORD_ID],
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
it.each(stringVars)("镜像字符串常量 %s 与 SSOT 逐字一致", (name, value) => {
|
|
144
|
+
expect(source).toMatch(new RegExp(`${name}\\s*=\\s*["']${value}["']`));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("镜像协议版本 RELAY_PROTOCOL_VERSION 与 SSOT 一致", () => {
|
|
148
|
+
expect(source).toMatch(new RegExp(`RELAY_PROTOCOL_VERSION\\s*=\\s*${RELAY_PROTOCOL_VERSION}\\b`));
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("镜像退出码 RELAY_EXIT_CODES 四值与 SSOT 一致", () => {
|
|
152
|
+
const pairs: Array<[keyof typeof RELAY_EXIT_CODES, number]> = [
|
|
153
|
+
["VERSION_MISMATCH", RELAY_EXIT_CODES.VERSION_MISMATCH],
|
|
154
|
+
["SOCKET_UNREACHABLE", RELAY_EXIT_CODES.SOCKET_UNREACHABLE],
|
|
155
|
+
["SOCKET_CLOSED", RELAY_EXIT_CODES.SOCKET_CLOSED],
|
|
156
|
+
["MISSING_IDENTITY", RELAY_EXIT_CODES.MISSING_IDENTITY],
|
|
157
|
+
];
|
|
158
|
+
for (const [key, value] of pairs) {
|
|
159
|
+
expect(source).toMatch(new RegExp(`${key}:\\s*${value}\\b`));
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("镜像面不自作主张扩充:NODE/SCRIPT 不在镜像表(代理零消费,见 relay.mjs 头注)", () => {
|
|
164
|
+
// 若未来有人在 relay.mjs 加镜像而忘记消费,契约面应当显式扩表——此处断言现状
|
|
165
|
+
// 「镜像常量恰好是代理实际消费的三个 env 名」防镜像面无界生长。
|
|
166
|
+
expect(source).toMatch(/const RELAY_ENV_SOCKET/);
|
|
167
|
+
expect(source).not.toMatch(/const RELAY_ENV_NODE\b/);
|
|
168
|
+
expect(source).not.toMatch(/const RELAY_ENV_SCRIPT\b/);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ── 2. pi-invocation 通道契约(spawn 目标切换:全有或全无 + probe 排除)──
|
|
173
|
+
|
|
174
|
+
describe("relay 变体 C-通道:getPiInvocation 三分支契约", () => {
|
|
175
|
+
const originalArgv = process.argv;
|
|
176
|
+
const originalExecPath = process.execPath;
|
|
177
|
+
|
|
178
|
+
beforeEach(() => {
|
|
179
|
+
// 落到「node + 不存在脚本 → pi-in-PATH」稳定断言直连形态
|
|
180
|
+
Object.defineProperty(process, "argv", { value: ["node", "/nonexistent"], configurable: true });
|
|
181
|
+
Object.defineProperty(process, "execPath", { value: "/usr/bin/node", configurable: true });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
afterEach(() => {
|
|
185
|
+
Object.defineProperty(process, "argv", { value: originalArgv, configurable: true });
|
|
186
|
+
Object.defineProperty(process, "execPath", { value: originalExecPath, configurable: true });
|
|
187
|
+
setRelayEnv(undefined);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("三 env 齐备 → 代理形态(command=NODE、args[0]=SCRIPT,userArgs 原样后置)", () => {
|
|
191
|
+
setRelayEnv(RELAY_FULL);
|
|
192
|
+
const result = getPiInvocation(["--mode", "rpc", "--session-dir", "/x"]);
|
|
193
|
+
expect(result.command).toBe(RELAY_FULL[RELAY_ENV_NODE]);
|
|
194
|
+
expect(result.args).toEqual([
|
|
195
|
+
RELAY_FULL[RELAY_ENV_SCRIPT],
|
|
196
|
+
"--mode",
|
|
197
|
+
"rpc",
|
|
198
|
+
"--session-dir",
|
|
199
|
+
"/x",
|
|
200
|
+
]);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it.each([...RELAY_ENV_KEYS])("env 缺失 %s → 回落直连(全有或全无,E-TUI 零回归)", (missing) => {
|
|
204
|
+
const partial = { ...RELAY_FULL };
|
|
205
|
+
delete partial[missing];
|
|
206
|
+
setRelayEnv(partial);
|
|
207
|
+
const result = getPiInvocation(["--mode", "rpc"]);
|
|
208
|
+
expect(result.command).toBe("pi");
|
|
209
|
+
expect(result.args).toEqual(["--mode", "rpc"]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("relay:false → 直连(probe 探 pi 本体可解析性,不经 relay)", () => {
|
|
213
|
+
setRelayEnv(RELAY_FULL);
|
|
214
|
+
const result = getPiInvocation(["--version"], { relay: false });
|
|
215
|
+
expect(result.command).toBe("pi");
|
|
216
|
+
expect(result.args).toEqual(["--version"]);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// ── 3. buildChildEnv 归属契约(runSpawn 集成,mock spawn 捕获 childEnv)──
|
|
221
|
+
|
|
222
|
+
describe("relay 变体 C-归属:buildChildEnv 激活写入 / 未激活不写", () => {
|
|
223
|
+
interface FakeChild {
|
|
224
|
+
pid: number;
|
|
225
|
+
stdout: PassThrough;
|
|
226
|
+
stderr: PassThrough;
|
|
227
|
+
killed: boolean;
|
|
228
|
+
emit(event: string, ...args: unknown[]): boolean;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function makeRecord(): ReturnType<typeof createRecord> {
|
|
232
|
+
return createRecord("relay-env-record-1", {
|
|
233
|
+
agent: "general-purpose",
|
|
234
|
+
model: "test/model",
|
|
235
|
+
mode: "sync",
|
|
236
|
+
task: "test task",
|
|
237
|
+
startedAt: Date.now(),
|
|
238
|
+
rootSessionId: "s1",
|
|
239
|
+
parentRecordId: undefined,
|
|
240
|
+
depth: 0,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function makeRunOpts(): RunOptions {
|
|
245
|
+
return {
|
|
246
|
+
resolved: { model: { provider: "test", id: "model" }, thinkingLevel: undefined },
|
|
247
|
+
agentConfig: undefined,
|
|
248
|
+
appendSystemPrompt: undefined,
|
|
249
|
+
skillPath: undefined,
|
|
250
|
+
schema: undefined,
|
|
251
|
+
maxTurns: undefined,
|
|
252
|
+
graceTurns: undefined,
|
|
253
|
+
signal: undefined,
|
|
254
|
+
onEvent: undefined,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function makeCtx(): SessionRunnerContext {
|
|
259
|
+
return {
|
|
260
|
+
cwd: "/fake/cwd",
|
|
261
|
+
agentDir: "/fake/agent",
|
|
262
|
+
skillDirs: [],
|
|
263
|
+
mainCwd: "/fake/cwd",
|
|
264
|
+
sessionRootId: "relay-root-session",
|
|
265
|
+
rootCwd: "/fake/cwd",
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function getLastSpawnEnv(): Record<string, string | undefined> {
|
|
270
|
+
return (mockSpawn.mock.calls.at(-1)?.[2]?.env as Record<string, string | undefined>) ?? {};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function getLastSpawnedChild(): FakeChild {
|
|
274
|
+
const result = mockSpawn.mock.results.at(-1);
|
|
275
|
+
if (!result) throw new Error("spawn was not called yet");
|
|
276
|
+
return result.value as FakeChild;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
beforeEach(() => {
|
|
280
|
+
vi.clearAllMocks();
|
|
281
|
+
setRelayEnv(undefined);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
afterEach(() => {
|
|
285
|
+
setRelayEnv(undefined);
|
|
286
|
+
vi.restoreAllMocks();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("激活:childEnv 写入归属键(SESSION_ID=ctx.sessionRootId、RECORD_ID=record.id)", async () => {
|
|
290
|
+
setRelayEnv(RELAY_FULL);
|
|
291
|
+
const record = makeRecord();
|
|
292
|
+
const ctx = makeCtx();
|
|
293
|
+
const resultPromise = runSpawn(record, "test task", makeRunOpts(), ctx);
|
|
294
|
+
await waitForSpawn(mockSpawn);
|
|
295
|
+
const childEnv = getLastSpawnEnv();
|
|
296
|
+
expect(childEnv[RELAY_ENV_SESSION_ID]).toBe(ctx.sessionRootId);
|
|
297
|
+
expect(childEnv[RELAY_ENV_RECORD_ID]).toBe(record.id);
|
|
298
|
+
// 顺带契约:激活时 spawn 目标即代理(command=NODE、args[0]=SCRIPT)
|
|
299
|
+
expect(mockSpawn.mock.calls.at(-1)?.[0]).toBe(RELAY_FULL[RELAY_ENV_NODE]);
|
|
300
|
+
expect(mockSpawn.mock.calls.at(-1)?.[1]?.[0]).toBe(RELAY_FULL[RELAY_ENV_SCRIPT]);
|
|
301
|
+
|
|
302
|
+
const child = getLastSpawnedChild();
|
|
303
|
+
child.emit("close", 0);
|
|
304
|
+
await resultPromise;
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("未激活:不写入归属键(继承 process.env 原值,无 relay 环境零噪声)", async () => {
|
|
308
|
+
setRelayEnv(undefined);
|
|
309
|
+
const record = makeRecord();
|
|
310
|
+
const ctx = makeCtx();
|
|
311
|
+
const resultPromise = runSpawn(record, "test task", makeRunOpts(), ctx);
|
|
312
|
+
await waitForSpawn(mockSpawn);
|
|
313
|
+
const childEnv = getLastSpawnEnv();
|
|
314
|
+
// 「不写」的精确语义 = 继承值保持(本用例前置已删,故 undefined),而非写 record 值
|
|
315
|
+
expect(childEnv[RELAY_ENV_SESSION_ID]).toBe(process.env[RELAY_ENV_SESSION_ID]);
|
|
316
|
+
expect(childEnv[RELAY_ENV_RECORD_ID]).toBe(process.env[RELAY_ENV_RECORD_ID]);
|
|
317
|
+
expect(childEnv[RELAY_ENV_SESSION_ID]).not.toBe(ctx.sessionRootId);
|
|
318
|
+
expect(childEnv[RELAY_ENV_RECORD_ID]).not.toBe(record.id);
|
|
319
|
+
|
|
320
|
+
const child = getLastSpawnedChild();
|
|
321
|
+
child.emit("close", 0);
|
|
322
|
+
await resultPromise;
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("未激活但环境残留归属值:继承值保持不被覆盖(无害性锁定)", async () => {
|
|
326
|
+
// 场景:孙进程链上 runtime 已剥离三激活 env(relay-registry 剥离逻辑),但主进程 env
|
|
327
|
+
// 残留旧归属值——buildChildEnv 未激活不写 = 不覆盖继承值(设计 §5.2-2 语义)。
|
|
328
|
+
setRelayEnv({ [RELAY_ENV_SESSION_ID]: "stale-inherited-sid", [RELAY_ENV_RECORD_ID]: "stale-inherited-rid" });
|
|
329
|
+
expect(isRelayActive(process.env)).toBe(false);
|
|
330
|
+
const record = makeRecord();
|
|
331
|
+
const ctx = makeCtx();
|
|
332
|
+
const resultPromise = runSpawn(record, "test task", makeRunOpts(), ctx);
|
|
333
|
+
await waitForSpawn(mockSpawn);
|
|
334
|
+
const childEnv = getLastSpawnEnv();
|
|
335
|
+
expect(childEnv[RELAY_ENV_SESSION_ID]).toBe("stale-inherited-sid");
|
|
336
|
+
expect(childEnv[RELAY_ENV_RECORD_ID]).toBe("stale-inherited-rid");
|
|
337
|
+
|
|
338
|
+
const child = getLastSpawnedChild();
|
|
339
|
+
child.emit("close", 0);
|
|
340
|
+
await resultPromise;
|
|
341
|
+
});
|
|
342
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// engine-conformance.live.test.ts —— conformance run 层(真实 spawn 简单任务,C2/C4
|
|
2
|
+
// 真机面)。手动门(设计 §3.3.8:run 层需已装引擎 + 有效凭据,不进默认 CI):
|
|
3
|
+
//
|
|
4
|
+
// cd extensions/universal/subagent-workflow
|
|
5
|
+
// ENGINE_CONFORMANCE_LIVE=1 pnpm vitest run src/execution/engine/__tests__/conformance/engine-conformance.live.test.ts
|
|
6
|
+
//
|
|
7
|
+
// pi 部分复用 PiEngine 的服务面注入(真实 executeAndAwait 需要完整 SubagentService 装配,
|
|
8
|
+
// live 场景从进程单例取——未初始化时该用例 skip 并说明);zcode 部分与 P3 的
|
|
9
|
+
// zcode-engine.live.test.ts 互补(后者已覆盖 schema 全链,此处只跑 conformance 最小面)。
|
|
10
|
+
//
|
|
11
|
+
// relay 变体(E 方案 §2.3)= 同一契约 × 不同 spawn 通道:测试内起伪 runtime(net server,
|
|
12
|
+
// 按 E-2 协议收握手回 accept / down 帧转写真实 pi stdin / pi stdout 回发 up 帧 / exit 帧收尾),
|
|
13
|
+
// 经 PiEngine 真实 run 全链断言 C2+C3。前置 = SubagentService 已装配(真实会话进程内),
|
|
14
|
+
// 且需真实模型凭据(最小任务由 LLM 完成)——纯 CI/无凭据环境必然 skip,属预期。
|
|
15
|
+
|
|
16
|
+
import { spawn as childSpawn, type ChildProcess } from "node:child_process";
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import * as net from "node:net";
|
|
19
|
+
import * as os from "node:os";
|
|
20
|
+
import * as path from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
|
|
23
|
+
import { describe, expect, it } from "vitest";
|
|
24
|
+
|
|
25
|
+
import { getSubagentService } from "../../../subagent-service.ts";
|
|
26
|
+
import { ZcodeEngine } from "../../engines/zcode/zcode-engine.ts";
|
|
27
|
+
import { createPiEngine } from "../../engines/pi/registration.ts";
|
|
28
|
+
import type { RunContext } from "../../port.ts";
|
|
29
|
+
import type { AgentEvent } from "../../../types.ts";
|
|
30
|
+
import type { AgentTaskSpec } from "../../types.ts";
|
|
31
|
+
import { getPiInvocation } from "../../../pi-invocation.ts";
|
|
32
|
+
import {
|
|
33
|
+
RELAY_ENV_NODE,
|
|
34
|
+
RELAY_ENV_RECORD_ID,
|
|
35
|
+
RELAY_ENV_SCRIPT,
|
|
36
|
+
RELAY_ENV_SESSION_ID,
|
|
37
|
+
RELAY_ENV_SOCKET,
|
|
38
|
+
RELAY_PROTOCOL_VERSION,
|
|
39
|
+
isRelayActive,
|
|
40
|
+
} from "../../../relay-env.ts";
|
|
41
|
+
import { assertAgentEventInvariants } from "./agent-event-invariants.ts";
|
|
42
|
+
|
|
43
|
+
const LIVE = process.env["ENGINE_CONFORMANCE_LIVE"] === "1";
|
|
44
|
+
|
|
45
|
+
describe.skipIf(!LIVE)("conformance run 层(真实 spawn,手动门)", () => {
|
|
46
|
+
it("pi:简单任务全链(C2:outcome 无 error、content 非空、engineId=pi)", async (testCtx) => {
|
|
47
|
+
const service = getSubagentService();
|
|
48
|
+
if (service === null) {
|
|
49
|
+
// 服务装配是 pi live 的前置(完整 SubagentService + modelRegistry 注入)——
|
|
50
|
+
// live 门内说明跳过原因而非静默 pass(失败要出声)
|
|
51
|
+
testCtx.skip("SubagentService 未装配(需在真实会话进程内运行)");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const engine = createPiEngine(() => service);
|
|
55
|
+
const task: AgentTaskSpec = { task: "Reply with the single word: ok", slug: "live-c2" };
|
|
56
|
+
const ctx: RunContext = { taskId: "sa-live-pi-c2", poolKey: "shared" };
|
|
57
|
+
const { outcome } = await engine.run(task, ctx);
|
|
58
|
+
expect(outcome.error).toBeUndefined();
|
|
59
|
+
expect(outcome.content.trim().length).toBeGreaterThan(0);
|
|
60
|
+
expect(outcome.engineId).toBe("pi");
|
|
61
|
+
}, 120_000);
|
|
62
|
+
|
|
63
|
+
it("zcode:probe 真机(C1 live 面:三项 check 全过)", async () => {
|
|
64
|
+
const engine = new ZcodeEngine({ engineDataDir: () => "/tmp/zcode-conformance-live" });
|
|
65
|
+
const report = await engine.probe();
|
|
66
|
+
expect(report.ok).toBe(true);
|
|
67
|
+
expect(report.engineVersion).toMatch(/^0\.\d+\.\d+$/);
|
|
68
|
+
}, 60_000);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ── relay 变体(E 方案 §2.3:同一契约 × 不同 spawn 通道,手动门内的手动门)──
|
|
72
|
+
//
|
|
73
|
+
// 前置:①ENGINE_CONFORMANCE_LIVE=1(套件级门);②SubagentService 已装配(真实会话
|
|
74
|
+
// 进程内跑——vitest 裸进程恒 null → skip 说明,与上方 pi live 用例同形态);③真实模型
|
|
75
|
+
// 凭据(最小任务由 LLM 完成)。relay 三 env 由测试自构(socket 指向测试内伪 runtime,
|
|
76
|
+
// 执行器 = 本进程 node,脚本 = 包根 relay/relay.mjs),不依赖外部注入。
|
|
77
|
+
|
|
78
|
+
describe.skipIf(!LIVE)("conformance relay 变体(经代理 spawn 全链,手动门)", () => {
|
|
79
|
+
/** 伪 runtime:按 E-2 协议扮演 runtime 侧——握手 accept + spawn 真实 pi + 双向转发 + exit 传播。 */
|
|
80
|
+
function startFakeRuntime(socketPath: string): Promise<net.Server> {
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
const server = net.createServer((conn) => {
|
|
83
|
+
let lineBuf = "";
|
|
84
|
+
let pi: ChildProcess | null = null;
|
|
85
|
+
const send = (frame: Record<string, unknown>): void => {
|
|
86
|
+
conn.write(`${JSON.stringify(frame)}\n`);
|
|
87
|
+
};
|
|
88
|
+
const handleLine = (line: string): void => {
|
|
89
|
+
let frame: Record<string, unknown>;
|
|
90
|
+
try {
|
|
91
|
+
const parsed: unknown = JSON.parse(line);
|
|
92
|
+
if (typeof parsed !== "object" || parsed === null) return;
|
|
93
|
+
frame = parsed as Record<string, unknown>;
|
|
94
|
+
} catch {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (frame.kind === "handshake" && pi === null) {
|
|
98
|
+
// 按 E-2 协议回 accept,随后按握手帧 spawn 真实 pi(直连——本进程 env 已带
|
|
99
|
+
// relay 三 env,必须 relay:false 防二次代理死循环);env 剥离 relay 五键
|
|
100
|
+
//(对齐 E-2 registry 剥离逻辑,防孙进程嵌套 relay 时旧值误导)。
|
|
101
|
+
send({ v: RELAY_PROTOCOL_VERSION, kind: "accept" });
|
|
102
|
+
const argv = Array.isArray(frame.argv) ? (frame.argv as string[]) : [];
|
|
103
|
+
const invocation = getPiInvocation(argv, { relay: false });
|
|
104
|
+
const env: NodeJS.ProcessEnv = { ...(frame.env as NodeJS.ProcessEnv) };
|
|
105
|
+
for (const key of [RELAY_ENV_SOCKET, RELAY_ENV_NODE, RELAY_ENV_SCRIPT, RELAY_ENV_SESSION_ID, RELAY_ENV_RECORD_ID]) {
|
|
106
|
+
delete env[key];
|
|
107
|
+
}
|
|
108
|
+
pi = childSpawn(invocation.command, invocation.args, {
|
|
109
|
+
env,
|
|
110
|
+
cwd: typeof frame.cwd === "string" ? frame.cwd : undefined,
|
|
111
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
112
|
+
});
|
|
113
|
+
pi.stdout?.on("data", (c: Buffer) => {
|
|
114
|
+
send({ v: RELAY_PROTOCOL_VERSION, kind: "data", dir: "up", b64: c.toString("base64") });
|
|
115
|
+
});
|
|
116
|
+
pi.stderr?.on("data", (c: Buffer) => {
|
|
117
|
+
send({ v: RELAY_PROTOCOL_VERSION, kind: "data", dir: "up-stderr", b64: c.toString("base64") });
|
|
118
|
+
});
|
|
119
|
+
pi.on("close", (code, signal) => {
|
|
120
|
+
send(signal !== null ? { kind: "exit", signal } : { kind: "exit", code: code ?? 1 });
|
|
121
|
+
});
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (frame.kind === "data" && frame.dir === "down" && typeof frame.b64 === "string") {
|
|
125
|
+
pi?.stdin?.write(Buffer.from(frame.b64, "base64"));
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
conn.setEncoding("utf8");
|
|
129
|
+
conn.on("data", (chunk: string) => {
|
|
130
|
+
lineBuf += chunk;
|
|
131
|
+
let nl: number;
|
|
132
|
+
while ((nl = lineBuf.indexOf("\n")) >= 0) {
|
|
133
|
+
const line = lineBuf.slice(0, nl);
|
|
134
|
+
lineBuf = lineBuf.slice(nl + 1);
|
|
135
|
+
if (line.trim()) handleLine(line);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
// 断连即杀(对齐 E-2 registry 语义)——防代理死/测试收尾后真实 pi 变孤儿
|
|
139
|
+
conn.on("close", () => {
|
|
140
|
+
if (pi !== null && !pi.killed) pi.kill("SIGTERM");
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
server.once("error", reject);
|
|
144
|
+
server.listen(socketPath, () => resolve(server));
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
it("pi × relay:伪 runtime 环回全链(C2 outcome 无 error + C3 事件不变量经代理转发全等)", { timeout: 180_000 }, async (testCtx) => {
|
|
149
|
+
const service = getSubagentService();
|
|
150
|
+
if (service === null) {
|
|
151
|
+
testCtx.skip(
|
|
152
|
+
"SubagentService 未装配(需在真实会话进程内运行,如 pi --extension 挂载本包后触发)" +
|
|
153
|
+
"——relay 变体与直连 live 用例共享该前置;纯 vitest 进程恒 skip,全链真机另由 " +
|
|
154
|
+
"packages/runtime relay-integration.test.ts(已存在)+ §9 人工验收承载",
|
|
155
|
+
);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 测试自构 relay env:socket 指向伪 runtime,执行器 = 本进程 node,脚本 = 包根 relay.mjs
|
|
160
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "relay-conformance-live-"));
|
|
161
|
+
const socketPath = path.join(tmpDir, "relay.sock");
|
|
162
|
+
const server = await startFakeRuntime(socketPath);
|
|
163
|
+
const savedEnv: Record<string, string | undefined> = {};
|
|
164
|
+
const restoreKeys = [RELAY_ENV_SOCKET, RELAY_ENV_NODE, RELAY_ENV_SCRIPT];
|
|
165
|
+
for (const key of restoreKeys) savedEnv[key] = process.env[key];
|
|
166
|
+
process.env[RELAY_ENV_SOCKET] = socketPath;
|
|
167
|
+
process.env[RELAY_ENV_NODE] = process.execPath;
|
|
168
|
+
process.env[RELAY_ENV_SCRIPT] = path.resolve(
|
|
169
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
170
|
+
"../../../../../relay/relay.mjs",
|
|
171
|
+
);
|
|
172
|
+
if (!isRelayActive(process.env)) {
|
|
173
|
+
throw new Error("relay env 自构后仍不激活——前置断言失败");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const engine = createPiEngine(() => service);
|
|
178
|
+
const events: AgentEvent[] = [];
|
|
179
|
+
const task: AgentTaskSpec = { task: "Reply with the single word: ok", slug: "live-relay-c2" };
|
|
180
|
+
const ctx: RunContext = {
|
|
181
|
+
taskId: "sa-live-pi-relay",
|
|
182
|
+
poolKey: "shared",
|
|
183
|
+
onEvent: (event) => events.push(event),
|
|
184
|
+
};
|
|
185
|
+
const { outcome } = await engine.run(task, ctx);
|
|
186
|
+
// C2:outcome 无 error + engineId 仍为 pi(relay 是 spawn 通道不是引擎身份)
|
|
187
|
+
expect(outcome.error).toBeUndefined();
|
|
188
|
+
expect(outcome.content.trim().length).toBeGreaterThan(0);
|
|
189
|
+
expect(outcome.engineId).toBe("pi");
|
|
190
|
+
// C3:事件不变量五条对「经代理转发的子进程 stdout」逐一成立(同一 parser 消费同一字节流)
|
|
191
|
+
assertAgentEventInvariants(events, { granularity: "stream", content: outcome.content });
|
|
192
|
+
} finally {
|
|
193
|
+
for (const key of restoreKeys) {
|
|
194
|
+
if (savedEnv[key] === undefined) delete process.env[key];
|
|
195
|
+
else process.env[key] = savedEnv[key];
|
|
196
|
+
}
|
|
197
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
198
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// golden-replay.pi.test.ts —— pi 引擎 golden 回放层(conformance 免 LLM 默认 CI 层,
|
|
2
|
+
// 设计 §3.3.8 两层结构的第一层)。锚定物 = 统一 AgentEvent 序列(onEvent 出口 / journal
|
|
3
|
+
// 落盘形态)——pi 的完整翻译链(parseSpawnLine → handleSdkEvent)闭包在 session-runner
|
|
4
|
+
// 的 spawn 状态里,conformance 以「翻译产物」为契约锚点(journal/record 消费的正是它)。
|
|
5
|
+
//
|
|
6
|
+
// 覆盖:C3 不变量(流式口径)+ journal 往返保真(replayJournal === golden 序列)+
|
|
7
|
+
// parseSpawnLine 对实录行形态的回归(pi parser 的纯函数面)。
|
|
8
|
+
|
|
9
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
import { describe, expect, it } from "vitest";
|
|
15
|
+
|
|
16
|
+
import type { AgentEvent } from "../../../types.ts";
|
|
17
|
+
import { JournalWriter, replayJournal } from "../../common/event-journal.ts";
|
|
18
|
+
import { parseSpawnLine } from "../../../spawn-event-adapter.ts";
|
|
19
|
+
import { assertAgentEventInvariants } from "./agent-event-invariants.ts";
|
|
20
|
+
|
|
21
|
+
interface PiGoldenFile {
|
|
22
|
+
events: AgentEvent[];
|
|
23
|
+
content: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const fixturePath = join(dirname(fileURLToPath(import.meta.url)), "__fixtures__", "pi-golden-events.json");
|
|
27
|
+
const golden = JSON.parse(readFileSync(fixturePath, "utf8")) as PiGoldenFile;
|
|
28
|
+
|
|
29
|
+
describe("pi golden 回放(conformance C3/C5-journal,免 LLM)", () => {
|
|
30
|
+
it("golden 事件序列满足全部产出不变量(流式口径:text_delta 拼接 === content)", () => {
|
|
31
|
+
assertAgentEventInvariants(golden.events, { granularity: "stream", content: golden.content });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("journal 往返保真:golden 序列经 JournalWriter 落盘后 replayJournal 逐事件相等", async () => {
|
|
35
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-golden-journal-"));
|
|
36
|
+
const writer = new JournalWriter({
|
|
37
|
+
path: join(dir, "journal-sa-pi-golden.jsonl"),
|
|
38
|
+
taskId: "sa-pi-golden",
|
|
39
|
+
engineId: "pi",
|
|
40
|
+
});
|
|
41
|
+
try {
|
|
42
|
+
for (const ev of golden.events) writer.append(ev);
|
|
43
|
+
await writer.close();
|
|
44
|
+
const replayed = replayJournal(writer.path);
|
|
45
|
+
// 深比较(含 undefined 键缺省语义——JSON 序列化后 undefined 字段自然消失,两侧同构)
|
|
46
|
+
expect(replayed).toEqual(golden.events);
|
|
47
|
+
} finally {
|
|
48
|
+
rmSync(dir, { recursive: true, force: true });
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("parseSpawnLine 回归:实录行形态(header/事件行)识别不漂移", () => {
|
|
53
|
+
const header = parseSpawnLine(
|
|
54
|
+
JSON.stringify({ type: "session", id: "sess-pi-golden", timestamp: "2026-08-25T01:00:00.000Z", cwd: "/tmp" }),
|
|
55
|
+
);
|
|
56
|
+
expect(header?.kind).toBe("header");
|
|
57
|
+
|
|
58
|
+
const toolLine = parseSpawnLine(
|
|
59
|
+
JSON.stringify({ type: "tool_execution_start", toolName: "bash", toolCallId: "c1", args: { command: "ls" } }),
|
|
60
|
+
);
|
|
61
|
+
expect(toolLine?.kind).toBe("event");
|
|
62
|
+
if (toolLine?.kind === "event") expect(toolLine.event.type).toBe("tool_execution_start");
|
|
63
|
+
|
|
64
|
+
const msgEnd = parseSpawnLine(
|
|
65
|
+
JSON.stringify({
|
|
66
|
+
type: "message_end",
|
|
67
|
+
message: { role: "assistant", usage: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0 } },
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
expect(msgEnd?.kind).toBe("event");
|
|
71
|
+
if (msgEnd?.kind === "event") expect(msgEnd.event.type).toBe("message_end");
|
|
72
|
+
|
|
73
|
+
const turnEnd = parseSpawnLine(JSON.stringify({ type: "turn_end" }));
|
|
74
|
+
expect(turnEnd?.kind).toBe("event");
|
|
75
|
+
});
|
|
76
|
+
});
|