@united-workforce/cli 0.6.1 → 0.7.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/README.md +89 -1
- package/dist/__tests__/agent-resolution-llm-free.test.js +9 -2
- package/dist/__tests__/agent-resolution-llm-free.test.js.map +1 -1
- package/dist/__tests__/broker-prompt.test.d.ts +10 -0
- package/dist/__tests__/broker-prompt.test.d.ts.map +1 -0
- package/dist/__tests__/broker-prompt.test.js +129 -0
- package/dist/__tests__/broker-prompt.test.js.map +1 -0
- package/dist/__tests__/config.test.js +33 -37
- package/dist/__tests__/config.test.js.map +1 -1
- package/dist/__tests__/e2e-broker-step.test.d.ts +13 -0
- package/dist/__tests__/e2e-broker-step.test.d.ts.map +1 -0
- package/dist/__tests__/e2e-broker-step.test.js +278 -0
- package/dist/__tests__/e2e-broker-step.test.js.map +1 -0
- package/dist/__tests__/e2e-mock-agent.test.js +1 -1
- package/dist/__tests__/e2e-mock-agent.test.js.map +1 -1
- package/dist/__tests__/setup-agent-discovery.test.js +17 -5
- package/dist/__tests__/setup-agent-discovery.test.js.map +1 -1
- package/dist/__tests__/setup-no-llm.test.js +5 -2
- package/dist/__tests__/setup-no-llm.test.js.map +1 -1
- package/dist/__tests__/step-ask.test.js +9 -6
- package/dist/__tests__/step-ask.test.js.map +1 -1
- package/dist/__tests__/thread-agent-failure-suspended.test.js +3 -3
- package/dist/__tests__/thread-agent-failure-suspended.test.js.map +1 -1
- package/dist/__tests__/thread-poke.test.js +6 -6
- package/dist/__tests__/thread-poke.test.js.map +1 -1
- package/dist/__tests__/thread-resume.test.js +2 -2
- package/dist/__tests__/thread-resume.test.js.map +1 -1
- package/dist/__tests__/thread-suspend-step.test.js +1 -1
- package/dist/__tests__/thread-suspend-step.test.js.map +1 -1
- package/dist/commands/broker-step.d.ts +110 -0
- package/dist/commands/broker-step.d.ts.map +1 -0
- package/dist/commands/broker-step.js +450 -0
- package/dist/commands/broker-step.js.map +1 -0
- package/dist/commands/config.d.ts.map +1 -1
- package/dist/commands/config.js +2 -23
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/prompt.js +3 -3
- package/dist/commands/setup.d.ts.map +1 -1
- package/dist/commands/setup.js +8 -1
- package/dist/commands/setup.js.map +1 -1
- package/dist/commands/step.d.ts +6 -5
- package/dist/commands/step.d.ts.map +1 -1
- package/dist/commands/step.js +11 -154
- package/dist/commands/step.js.map +1 -1
- package/dist/commands/thread.d.ts +4 -0
- package/dist/commands/thread.d.ts.map +1 -1
- package/dist/commands/thread.js +77 -151
- package/dist/commands/thread.js.map +1 -1
- package/package.json +5 -4
- package/src/__tests__/agent-resolution-llm-free.test.ts +14 -2
- package/src/__tests__/broker-prompt.test.ts +142 -0
- package/src/__tests__/config.test.ts +35 -39
- package/src/__tests__/e2e-broker-step.test.ts +320 -0
- package/src/__tests__/e2e-mock-agent.test.ts +1 -1
- package/src/__tests__/setup-agent-discovery.test.ts +17 -5
- package/src/__tests__/setup-no-llm.test.ts +5 -2
- package/src/__tests__/step-ask.test.ts +9 -6
- package/src/__tests__/thread-agent-failure-suspended.test.ts +3 -3
- package/src/__tests__/thread-poke.test.ts +6 -6
- package/src/__tests__/thread-resume.test.ts +2 -2
- package/src/__tests__/thread-suspend-step.test.ts +1 -1
- package/src/commands/broker-step.ts +636 -0
- package/src/commands/config.ts +2 -24
- package/src/commands/prompt.ts +3 -3
- package/src/commands/setup.ts +9 -1
- package/src/commands/step.ts +21 -204
- package/src/commands/thread.ts +87 -192
- package/dist/.build-fingerprint +0 -1
- package/dist/__tests__/adapter-json-roundtrip.test.d.ts +0 -2
- package/dist/__tests__/adapter-json-roundtrip.test.d.ts.map +0 -1
- package/dist/__tests__/adapter-json-roundtrip.test.js +0 -160
- package/dist/__tests__/adapter-json-roundtrip.test.js.map +0 -1
- package/dist/__tests__/spawn-agent-json.test.d.ts +0 -2
- package/dist/__tests__/spawn-agent-json.test.d.ts.map +0 -1
- package/dist/__tests__/spawn-agent-json.test.js +0 -79
- package/dist/__tests__/spawn-agent-json.test.js.map +0 -1
- package/src/__tests__/adapter-json-roundtrip.test.ts +0 -193
- package/src/__tests__/spawn-agent-json.test.ts +0 -100
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `assembleBrokerPrompt` (#387).
|
|
3
|
+
*
|
|
4
|
+
* Verifies the broker path assembles the same five-part prompt the legacy
|
|
5
|
+
* spawned-agent path produced: output-format instruction, thread progress,
|
|
6
|
+
* role prompt (goal/procedure/output), task prompt, and the
|
|
7
|
+
* continuation/edge-prompt context (branching on first visit vs re-entry).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { CasRef, StepContext, ThreadId, WorkflowPayload } from "@united-workforce/protocol";
|
|
11
|
+
import { describe, expect, test } from "vitest";
|
|
12
|
+
import { assembleBrokerPrompt } from "../commands/broker-step.js";
|
|
13
|
+
|
|
14
|
+
const THREAD_ID = "06FCBROKERPROMPTTEST000001" as ThreadId;
|
|
15
|
+
|
|
16
|
+
const OUTPUT_FORMAT_INSTRUCTION = "## Deliverable Format\n\nemit YAML frontmatter";
|
|
17
|
+
|
|
18
|
+
function buildWorkflow(): WorkflowPayload {
|
|
19
|
+
return {
|
|
20
|
+
version: 1,
|
|
21
|
+
name: "review-flow",
|
|
22
|
+
description: "two-role review flow",
|
|
23
|
+
roles: {
|
|
24
|
+
developer: {
|
|
25
|
+
description: "writes code",
|
|
26
|
+
goal: "implement the requested behavior",
|
|
27
|
+
capabilities: ["coding"],
|
|
28
|
+
procedure: "follow the spec and write tests",
|
|
29
|
+
output: "a patch plus a short summary",
|
|
30
|
+
frontmatter: "schema_developer" as CasRef,
|
|
31
|
+
},
|
|
32
|
+
reviewer: {
|
|
33
|
+
description: "reviews code",
|
|
34
|
+
goal: "review the implementation",
|
|
35
|
+
capabilities: [],
|
|
36
|
+
procedure: "check the diff carefully",
|
|
37
|
+
output: "approve or reject",
|
|
38
|
+
frontmatter: "schema_reviewer" as CasRef,
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
graph: {},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function stepContext(role: string, content: string | null, output: unknown): StepContext {
|
|
46
|
+
return {
|
|
47
|
+
role,
|
|
48
|
+
output,
|
|
49
|
+
detail: "detail_ref" as CasRef,
|
|
50
|
+
agent: "test-agent",
|
|
51
|
+
edgePrompt: "",
|
|
52
|
+
startedAtMs: 0,
|
|
53
|
+
completedAtMs: 1,
|
|
54
|
+
cwd: "",
|
|
55
|
+
assembledPrompt: null,
|
|
56
|
+
usage: null,
|
|
57
|
+
previousAttempts: null,
|
|
58
|
+
content,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe("assembleBrokerPrompt", () => {
|
|
63
|
+
test("first visit with no prior steps embeds role prompt, task, and edge prompt", () => {
|
|
64
|
+
const prompt = assembleBrokerPrompt({
|
|
65
|
+
workflow: buildWorkflow(),
|
|
66
|
+
role: "developer",
|
|
67
|
+
threadId: THREAD_ID,
|
|
68
|
+
startPrompt: "Build the login form",
|
|
69
|
+
steps: [],
|
|
70
|
+
edgePrompt: "Implement the behavior defined in the spec files",
|
|
71
|
+
outputFormatInstruction: OUTPUT_FORMAT_INSTRUCTION,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// 1. output-format instruction
|
|
75
|
+
expect(prompt).toContain("## Deliverable Format");
|
|
76
|
+
// 2. thread progress
|
|
77
|
+
expect(prompt).toContain("## Thread Progress");
|
|
78
|
+
expect(prompt).toContain("This is the first step of the thread");
|
|
79
|
+
// 3. role prompt (goal + procedure + output)
|
|
80
|
+
expect(prompt).toContain("## Goal");
|
|
81
|
+
expect(prompt).toContain("implement the requested behavior");
|
|
82
|
+
expect(prompt).toContain("## Procedure");
|
|
83
|
+
expect(prompt).toContain("follow the spec and write tests");
|
|
84
|
+
expect(prompt).toContain("## Output");
|
|
85
|
+
expect(prompt).toContain("a patch plus a short summary");
|
|
86
|
+
// 4. task prompt
|
|
87
|
+
expect(prompt).toContain("## Task");
|
|
88
|
+
expect(prompt).toContain("Build the login form");
|
|
89
|
+
// 5. edge prompt (no prior steps → "Current Instruction")
|
|
90
|
+
expect(prompt).toContain("## Current Instruction");
|
|
91
|
+
expect(prompt).toContain("Implement the behavior defined in the spec files");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("first visit with prior steps includes step content as continuation context", () => {
|
|
95
|
+
const steps: StepContext[] = [
|
|
96
|
+
stepContext("planner", "Here is the detailed plan for the feature.", { $status: "done" }),
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
const prompt = assembleBrokerPrompt({
|
|
100
|
+
workflow: buildWorkflow(),
|
|
101
|
+
role: "developer",
|
|
102
|
+
threadId: THREAD_ID,
|
|
103
|
+
startPrompt: "Build the login form",
|
|
104
|
+
steps,
|
|
105
|
+
edgePrompt: "Implement the plan",
|
|
106
|
+
outputFormatInstruction: OUTPUT_FORMAT_INSTRUCTION,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Developer has not spoken yet → first visit, prior steps shown WITH content.
|
|
110
|
+
expect(prompt).toContain("## What Happened Since Your Last Turn");
|
|
111
|
+
expect(prompt).toContain("Here is the detailed plan for the feature.");
|
|
112
|
+
expect(prompt).toContain("## Moderator Instruction");
|
|
113
|
+
expect(prompt).toContain("Implement the plan");
|
|
114
|
+
// Thread progress reflects the prior step.
|
|
115
|
+
expect(prompt).toContain("Thread step 2");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("re-entry shows only steps since last visit (meta-only continuation)", () => {
|
|
119
|
+
const steps: StepContext[] = [
|
|
120
|
+
stepContext("developer", "My first implementation attempt.", { $status: "done" }),
|
|
121
|
+
stepContext("reviewer", "Please fix the validation logic.", { $status: "reject" }),
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
const prompt = assembleBrokerPrompt({
|
|
125
|
+
workflow: buildWorkflow(),
|
|
126
|
+
role: "developer",
|
|
127
|
+
threadId: THREAD_ID,
|
|
128
|
+
startPrompt: "Build the login form",
|
|
129
|
+
steps,
|
|
130
|
+
edgePrompt: "Address the reviewer feedback",
|
|
131
|
+
outputFormatInstruction: OUTPUT_FORMAT_INSTRUCTION,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Re-entry: continuation lists the reviewer step since the last developer turn.
|
|
135
|
+
expect(prompt).toContain("## What Happened Since Your Last Turn");
|
|
136
|
+
expect(prompt).toContain("reviewer");
|
|
137
|
+
expect(prompt).toContain("## Moderator Instruction");
|
|
138
|
+
expect(prompt).toContain("Address the reviewer feedback");
|
|
139
|
+
// Meta-only re-entry omits raw step content from before the last visit.
|
|
140
|
+
expect(prompt).not.toContain("My first implementation attempt.");
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -21,18 +21,15 @@ describe("config command", () => {
|
|
|
21
21
|
return configPath;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
// Sample test config — engine-only (no providers/models/defaultModel/modelOverrides)
|
|
24
|
+
// Sample test config — engine-only (no providers/models/defaultModel/modelOverrides).
|
|
25
|
+
// Phase 3 (#380) replaced the legacy {command, args} agent shape with {host, gateway}.
|
|
25
26
|
const sampleConfig = `agents:
|
|
26
27
|
hermes:
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
- --provider
|
|
30
|
-
- dashscope
|
|
28
|
+
host: http://127.0.0.1:7900
|
|
29
|
+
gateway: hermes
|
|
31
30
|
claude-code:
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
- --profile
|
|
35
|
-
- work
|
|
31
|
+
host: http://127.0.0.1:7901
|
|
32
|
+
gateway: claude-code
|
|
36
33
|
defaultAgent: hermes
|
|
37
34
|
`;
|
|
38
35
|
|
|
@@ -41,7 +38,7 @@ defaultAgent: hermes
|
|
|
41
38
|
test("splits dot notation correctly", () => {
|
|
42
39
|
expect(parseDotPath("a.b.c")).toEqual(["a", "b", "c"]);
|
|
43
40
|
expect(parseDotPath("defaultAgent")).toEqual(["defaultAgent"]);
|
|
44
|
-
expect(parseDotPath("agents.hermes.
|
|
41
|
+
expect(parseDotPath("agents.hermes.host")).toEqual(["agents", "hermes", "host"]);
|
|
45
42
|
});
|
|
46
43
|
});
|
|
47
44
|
|
|
@@ -85,7 +82,7 @@ defaultAgent: hermes
|
|
|
85
82
|
describe("maskApiKeys", () => {
|
|
86
83
|
test("returns deep clone (no mutation) — engine config has no apiKey to mask", () => {
|
|
87
84
|
const config = {
|
|
88
|
-
agents: { hermes: {
|
|
85
|
+
agents: { hermes: { host: "http://127.0.0.1:7900", gateway: "hermes" } },
|
|
89
86
|
defaultAgent: "hermes",
|
|
90
87
|
};
|
|
91
88
|
const masked = maskApiKeys(config);
|
|
@@ -153,12 +150,12 @@ defaultAgent: hermes
|
|
|
153
150
|
}
|
|
154
151
|
});
|
|
155
152
|
|
|
156
|
-
test("retrieves
|
|
153
|
+
test("retrieves nested string value (agents.hermes.host)", async () => {
|
|
157
154
|
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
|
158
155
|
try {
|
|
159
156
|
createTestConfig(tempDir, sampleConfig);
|
|
160
|
-
const result = await cmdConfigGet(tempDir, "agents.hermes.
|
|
161
|
-
expect(result).
|
|
157
|
+
const result = await cmdConfigGet(tempDir, "agents.hermes.host");
|
|
158
|
+
expect(result).toBe("http://127.0.0.1:7900");
|
|
162
159
|
} finally {
|
|
163
160
|
rmSync(tempDir, { recursive: true, force: true });
|
|
164
161
|
}
|
|
@@ -208,18 +205,17 @@ defaultAgent: hermes
|
|
|
208
205
|
}
|
|
209
206
|
});
|
|
210
207
|
|
|
211
|
-
test("sets
|
|
208
|
+
test("sets nested string value (agents.hermes.host)", async () => {
|
|
212
209
|
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
|
213
210
|
try {
|
|
214
211
|
createTestConfig(tempDir, sampleConfig);
|
|
215
|
-
const
|
|
216
|
-
const result = await cmdConfigSet(tempDir, "agents.hermes.args", newArgs);
|
|
212
|
+
const result = await cmdConfigSet(tempDir, "agents.hermes.host", "http://10.0.0.1:7900");
|
|
217
213
|
expect(result).toEqual({
|
|
218
|
-
key: "agents.hermes.
|
|
219
|
-
value:
|
|
214
|
+
key: "agents.hermes.host",
|
|
215
|
+
value: "http://10.0.0.1:7900",
|
|
220
216
|
});
|
|
221
|
-
const updated = await cmdConfigGet(tempDir, "agents.hermes.
|
|
222
|
-
expect(updated).
|
|
217
|
+
const updated = await cmdConfigGet(tempDir, "agents.hermes.host");
|
|
218
|
+
expect(updated).toBe("http://10.0.0.1:7900");
|
|
223
219
|
} finally {
|
|
224
220
|
rmSync(tempDir, { recursive: true, force: true });
|
|
225
221
|
}
|
|
@@ -230,8 +226,8 @@ defaultAgent: hermes
|
|
|
230
226
|
try {
|
|
231
227
|
createTestConfig(tempDir, sampleConfig);
|
|
232
228
|
await cmdConfigSet(tempDir, "defaultAgent", "claude-code");
|
|
233
|
-
const
|
|
234
|
-
expect(
|
|
229
|
+
const host = await cmdConfigGet(tempDir, "agents.hermes.host");
|
|
230
|
+
expect(host).toBe("http://127.0.0.1:7900");
|
|
235
231
|
} finally {
|
|
236
232
|
rmSync(tempDir, { recursive: true, force: true });
|
|
237
233
|
}
|
|
@@ -260,29 +256,29 @@ defaultAgent: hermes
|
|
|
260
256
|
}
|
|
261
257
|
});
|
|
262
258
|
|
|
263
|
-
test("throws error when
|
|
259
|
+
test("throws error when value for unknown nested field is invalid", async () => {
|
|
264
260
|
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
|
265
261
|
try {
|
|
266
262
|
createTestConfig(tempDir, sampleConfig);
|
|
267
|
-
await expect(
|
|
268
|
-
|
|
269
|
-
)
|
|
263
|
+
await expect(cmdConfigSet(tempDir, "agents.hermes.args", "[invalid json")).rejects.toThrow(
|
|
264
|
+
/Unknown field/,
|
|
265
|
+
);
|
|
270
266
|
} finally {
|
|
271
267
|
rmSync(tempDir, { recursive: true, force: true });
|
|
272
268
|
}
|
|
273
269
|
});
|
|
274
270
|
|
|
275
|
-
test("sets agent
|
|
271
|
+
test("sets agent gateway (agents.claude-code.gateway)", async () => {
|
|
276
272
|
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
|
277
273
|
try {
|
|
278
274
|
createTestConfig(tempDir, sampleConfig);
|
|
279
|
-
const result = await cmdConfigSet(tempDir, "agents.claude-code.
|
|
275
|
+
const result = await cmdConfigSet(tempDir, "agents.claude-code.gateway", "new-gateway");
|
|
280
276
|
expect(result).toEqual({
|
|
281
|
-
key: "agents.claude-code.
|
|
282
|
-
value: "new-
|
|
277
|
+
key: "agents.claude-code.gateway",
|
|
278
|
+
value: "new-gateway",
|
|
283
279
|
});
|
|
284
|
-
const updated = await cmdConfigGet(tempDir, "agents.claude-code.
|
|
285
|
-
expect(updated).toBe("new-
|
|
280
|
+
const updated = await cmdConfigGet(tempDir, "agents.claude-code.gateway");
|
|
281
|
+
expect(updated).toBe("new-gateway");
|
|
286
282
|
} finally {
|
|
287
283
|
rmSync(tempDir, { recursive: true, force: true });
|
|
288
284
|
}
|
|
@@ -392,12 +388,12 @@ defaultAgent: hermes
|
|
|
392
388
|
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
|
393
389
|
try {
|
|
394
390
|
createTestConfig(tempDir, sampleConfig);
|
|
395
|
-
await cmdConfigSet(tempDir, "agents.hermes.
|
|
396
|
-
await cmdConfigSet(tempDir, "agents.hermes.
|
|
397
|
-
const
|
|
398
|
-
const
|
|
399
|
-
expect(
|
|
400
|
-
expect(
|
|
391
|
+
await cmdConfigSet(tempDir, "agents.hermes.host", "http://example:7900");
|
|
392
|
+
await cmdConfigSet(tempDir, "agents.hermes.gateway", "hermes-gw");
|
|
393
|
+
const host = await cmdConfigGet(tempDir, "agents.hermes.host");
|
|
394
|
+
const gateway = await cmdConfigGet(tempDir, "agents.hermes.gateway");
|
|
395
|
+
expect(host).toBe("http://example:7900");
|
|
396
|
+
expect(gateway).toBe("hermes-gw");
|
|
401
397
|
} finally {
|
|
402
398
|
rmSync(tempDir, { recursive: true, force: true });
|
|
403
399
|
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 3 (#380) — direct e2e test for `executeBrokerStep`.
|
|
3
|
+
*
|
|
4
|
+
* Stubs `globalThis.fetch` so the Sumeru `createSession` POST and
|
|
5
|
+
* `sendMessage` SSE POST come back deterministically. Verifies:
|
|
6
|
+
* 1. broker.send() is invoked with the resolved (host, gateway, cwd) route.
|
|
7
|
+
* 2. The agent's last assistant turn is extracted via the frontmatter fast-path.
|
|
8
|
+
* 3. A StepNode is persisted to CAS with the role's output schema, edge prompt,
|
|
9
|
+
* and accumulated usage, satisfying schema validation.
|
|
10
|
+
* 4. The broker session store rows the (threadId, role) → sessionId mapping.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { putSchema } from "@ocas/core";
|
|
17
|
+
import type {
|
|
18
|
+
CasRef,
|
|
19
|
+
StepNodePayload,
|
|
20
|
+
ThreadId,
|
|
21
|
+
WorkflowConfig,
|
|
22
|
+
WorkflowPayload,
|
|
23
|
+
} from "@united-workforce/protocol";
|
|
24
|
+
import { createProcessLogger } from "@united-workforce/util";
|
|
25
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
26
|
+
import { executeBrokerStep, openBrokerSessionStore } from "../commands/broker-step.js";
|
|
27
|
+
import { createUwfStore, type UwfStore } from "../store.js";
|
|
28
|
+
|
|
29
|
+
// ── Sumeru fetch stub ────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
type FetchCall = {
|
|
32
|
+
url: string;
|
|
33
|
+
method: string;
|
|
34
|
+
body: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function sseFrame(id: number, event: string, data: unknown): string {
|
|
38
|
+
return `id: ${id}\nevent: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function buildSseResponse(frames: string[]): Response {
|
|
42
|
+
const encoder = new TextEncoder();
|
|
43
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
44
|
+
start(controller) {
|
|
45
|
+
for (const frame of frames) controller.enqueue(encoder.encode(frame));
|
|
46
|
+
controller.close();
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
return new Response(stream, {
|
|
50
|
+
status: 200,
|
|
51
|
+
headers: { "Content-Type": "text/event-stream; charset=utf-8" },
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function buildJsonResponse(status: number, body: unknown): Response {
|
|
56
|
+
return new Response(JSON.stringify(body), {
|
|
57
|
+
status,
|
|
58
|
+
headers: { "Content-Type": "application/json" },
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Sample workflow + role schema ────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
const PLANNER_OUTPUT_SCHEMA = {
|
|
65
|
+
title: "planner-output",
|
|
66
|
+
type: "object" as const,
|
|
67
|
+
required: ["$status", "plan"],
|
|
68
|
+
properties: {
|
|
69
|
+
$status: { type: "string" as const, enum: ["done", "failed"] },
|
|
70
|
+
plan: { type: "string" as const },
|
|
71
|
+
},
|
|
72
|
+
additionalProperties: false,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const PLANNER_BODY = "Here is the plan you asked for.";
|
|
76
|
+
const PLANNER_RAW_OUTPUT = `---
|
|
77
|
+
$status: done
|
|
78
|
+
plan: ship it
|
|
79
|
+
---
|
|
80
|
+
${PLANNER_BODY}`;
|
|
81
|
+
|
|
82
|
+
// ── Fixture helpers ──────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
async function buildWorkflow(uwf: UwfStore): Promise<{
|
|
85
|
+
workflow: WorkflowPayload;
|
|
86
|
+
startHash: CasRef;
|
|
87
|
+
}> {
|
|
88
|
+
const frontmatterHash = (await putSchema(uwf.store, PLANNER_OUTPUT_SCHEMA)) as CasRef;
|
|
89
|
+
const workflow: WorkflowPayload = {
|
|
90
|
+
version: 1,
|
|
91
|
+
name: "broker-e2e",
|
|
92
|
+
description: "broker step end-to-end smoke",
|
|
93
|
+
roles: {
|
|
94
|
+
planner: {
|
|
95
|
+
description: "plans things",
|
|
96
|
+
goal: "produce a plan",
|
|
97
|
+
capabilities: [],
|
|
98
|
+
procedure: "think hard",
|
|
99
|
+
output: "frontmatter+body",
|
|
100
|
+
frontmatter: frontmatterHash,
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
graph: {
|
|
104
|
+
planner: {
|
|
105
|
+
done: { role: "$END", prompt: "", location: null },
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
const startHash = (await uwf.store.cas.put(uwf.schemas.startNode, {
|
|
110
|
+
workflow: await uwf.store.cas.put(uwf.schemas.workflow, workflow),
|
|
111
|
+
prompt: "p",
|
|
112
|
+
cwd: "/tmp/work",
|
|
113
|
+
})) as CasRef;
|
|
114
|
+
return { workflow, startHash };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const HOST = "http://127.0.0.1:7900";
|
|
118
|
+
const GATEWAY = "planner-gw";
|
|
119
|
+
const ALIAS = "planner-agent";
|
|
120
|
+
const SESSION_ID = "ses_broker_e2e";
|
|
121
|
+
const THREAD_ID = "06FCBROKERE2ESTEPMAIN0001" as ThreadId;
|
|
122
|
+
const ROLE = "planner";
|
|
123
|
+
|
|
124
|
+
function buildConfig(): WorkflowConfig {
|
|
125
|
+
return {
|
|
126
|
+
agents: { [ALIAS]: { host: HOST, gateway: GATEWAY } },
|
|
127
|
+
defaultAgent: ALIAS,
|
|
128
|
+
agentOverrides: null,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function buildSseResponseForPlanner(): Response {
|
|
133
|
+
return buildSseResponse([
|
|
134
|
+
sseFrame(1, "turn", {
|
|
135
|
+
type: "@sumeru/turn",
|
|
136
|
+
value: {
|
|
137
|
+
index: 0,
|
|
138
|
+
role: "user",
|
|
139
|
+
content: "edge prompt",
|
|
140
|
+
timestamp: "",
|
|
141
|
+
toolCalls: null,
|
|
142
|
+
},
|
|
143
|
+
}),
|
|
144
|
+
sseFrame(2, "turn", {
|
|
145
|
+
type: "@sumeru/turn",
|
|
146
|
+
value: {
|
|
147
|
+
index: 1,
|
|
148
|
+
role: "assistant",
|
|
149
|
+
content: PLANNER_RAW_OUTPUT,
|
|
150
|
+
timestamp: "",
|
|
151
|
+
toolCalls: null,
|
|
152
|
+
},
|
|
153
|
+
}),
|
|
154
|
+
sseFrame(3, "done", {
|
|
155
|
+
type: "@sumeru/summary",
|
|
156
|
+
value: { turnCount: 2, tokens: { in: 9, out: 4 }, durationMs: 42 },
|
|
157
|
+
}),
|
|
158
|
+
]);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function buildHandlerResponse(url: string): Response {
|
|
162
|
+
if (url.endsWith(`/gateways/${GATEWAY}/sessions`)) {
|
|
163
|
+
return buildJsonResponse(201, {
|
|
164
|
+
type: "@sumeru/session",
|
|
165
|
+
value: { id: SESSION_ID, gateway: GATEWAY },
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
if (url.endsWith(`/sessions/${SESSION_ID}/messages`)) {
|
|
169
|
+
return buildSseResponseForPlanner();
|
|
170
|
+
}
|
|
171
|
+
return buildJsonResponse(500, { error: "unexpected url", url });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function resolveFetchUrl(input: string | URL | Request): string {
|
|
175
|
+
if (typeof input === "string") return input;
|
|
176
|
+
if (input instanceof URL) return input.href;
|
|
177
|
+
return input.url;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
describe("executeBrokerStep — Sumeru HTTP integration", () => {
|
|
183
|
+
let tmpDir: string;
|
|
184
|
+
let savedOcasHome: string | undefined;
|
|
185
|
+
let calls: FetchCall[];
|
|
186
|
+
|
|
187
|
+
beforeEach(async () => {
|
|
188
|
+
savedOcasHome = process.env.OCAS_HOME;
|
|
189
|
+
tmpDir = await mkdtemp(join(tmpdir(), "broker-e2e-"));
|
|
190
|
+
process.env.OCAS_HOME = join(tmpDir, "cas");
|
|
191
|
+
calls = [];
|
|
192
|
+
vi.stubGlobal(
|
|
193
|
+
"fetch",
|
|
194
|
+
async (input: string | URL | Request, init: RequestInit | undefined): Promise<Response> => {
|
|
195
|
+
const url = resolveFetchUrl(input);
|
|
196
|
+
const method = init?.method ?? "GET";
|
|
197
|
+
const body = typeof init?.body === "string" ? init.body : "";
|
|
198
|
+
calls.push({ url, method, body });
|
|
199
|
+
return buildHandlerResponse(url);
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
afterEach(async () => {
|
|
205
|
+
vi.unstubAllGlobals();
|
|
206
|
+
if (savedOcasHome === undefined) delete process.env.OCAS_HOME;
|
|
207
|
+
else process.env.OCAS_HOME = savedOcasHome;
|
|
208
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("creates Sumeru session, sends prompt, and writes a valid StepNode", async () => {
|
|
212
|
+
const uwf = await createUwfStore(tmpDir);
|
|
213
|
+
const { workflow, startHash } = await buildWorkflow(uwf);
|
|
214
|
+
|
|
215
|
+
const result = await executeBrokerStep({
|
|
216
|
+
storageRoot: tmpDir,
|
|
217
|
+
uwf,
|
|
218
|
+
config: buildConfig(),
|
|
219
|
+
workflow,
|
|
220
|
+
threadId: THREAD_ID,
|
|
221
|
+
role: ROLE,
|
|
222
|
+
edgePrompt: "make a plan",
|
|
223
|
+
effectiveCwd: "/tmp/work",
|
|
224
|
+
startHash,
|
|
225
|
+
prevHash: null,
|
|
226
|
+
agentOverride: null,
|
|
227
|
+
previousAttempts: null,
|
|
228
|
+
plog: createProcessLogger({
|
|
229
|
+
storageRoot: tmpDir,
|
|
230
|
+
context: { thread: THREAD_ID, workflow: "broker-e2e" },
|
|
231
|
+
}),
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
expect(result.isError).toBe(false);
|
|
235
|
+
expect(result.role).toBe(ROLE);
|
|
236
|
+
expect(result.frontmatter).toEqual({ $status: "done", plan: "ship it" });
|
|
237
|
+
expect(result.body.trim()).toBe(PLANNER_BODY);
|
|
238
|
+
expect(result.usage).not.toBeNull();
|
|
239
|
+
expect(result.usage?.inputTokens).toBe(9);
|
|
240
|
+
expect(result.usage?.outputTokens).toBe(4);
|
|
241
|
+
expect(result.usage?.duration).toBe(42);
|
|
242
|
+
expect(result.usage?.turns).toBe(2);
|
|
243
|
+
|
|
244
|
+
// Two requests: createSession then sendMessage.
|
|
245
|
+
expect(calls.length).toBe(2);
|
|
246
|
+
expect(calls[0].method).toBe("POST");
|
|
247
|
+
expect(calls[0].url).toBe(`${HOST}/gateways/${GATEWAY}/sessions`);
|
|
248
|
+
expect(JSON.parse(calls[0].body)).toEqual({ workspaceRoot: "/tmp/work" });
|
|
249
|
+
expect(calls[1].method).toBe("POST");
|
|
250
|
+
expect(calls[1].url).toBe(`${HOST}/gateways/${GATEWAY}/sessions/${SESSION_ID}/messages`);
|
|
251
|
+
// The broker now receives the fully assembled prompt (role goal/procedure,
|
|
252
|
+
// output-format instruction, thread progress, task, edge prompt) rather than
|
|
253
|
+
// the bare edge prompt.
|
|
254
|
+
const sentContent = JSON.parse(calls[1].body).content as string;
|
|
255
|
+
expect(sentContent).toContain("produce a plan"); // role goal
|
|
256
|
+
expect(sentContent).toContain("think hard"); // role procedure
|
|
257
|
+
expect(sentContent).toContain("Deliverable Format"); // output-format instruction
|
|
258
|
+
expect(sentContent).toContain("## Thread Progress"); // thread progress
|
|
259
|
+
expect(sentContent).toContain("## Task"); // task section
|
|
260
|
+
expect(sentContent).toContain("make a plan"); // edge prompt
|
|
261
|
+
|
|
262
|
+
// Step persisted to CAS with the right linkage.
|
|
263
|
+
const stepNode = uwf.store.cas.get(result.stepHash);
|
|
264
|
+
expect(stepNode).not.toBeNull();
|
|
265
|
+
const payload = stepNode?.payload as StepNodePayload;
|
|
266
|
+
expect(payload.start).toBe(startHash);
|
|
267
|
+
expect(payload.prev).toBeNull();
|
|
268
|
+
expect(payload.role).toBe(ROLE);
|
|
269
|
+
expect(payload.agent).toBe(GATEWAY);
|
|
270
|
+
expect(payload.edgePrompt).toBe("make a plan");
|
|
271
|
+
expect(payload.detail).toBe(result.detailHash);
|
|
272
|
+
|
|
273
|
+
// The assembled prompt is persisted as a CAS text node for `step read --prompt`.
|
|
274
|
+
expect(payload.assembledPrompt).not.toBeNull();
|
|
275
|
+
const promptNode = uwf.store.cas.get(payload.assembledPrompt as CasRef);
|
|
276
|
+
expect(promptNode?.payload).toContain("produce a plan");
|
|
277
|
+
expect(promptNode?.payload).toContain("make a plan");
|
|
278
|
+
|
|
279
|
+
// Broker session store remembers the (threadId, role) → sessionId mapping.
|
|
280
|
+
const sessionStore = openBrokerSessionStore(tmpDir);
|
|
281
|
+
try {
|
|
282
|
+
const row = sessionStore.getSession(THREAD_ID, ROLE);
|
|
283
|
+
expect(row?.sessionId).toBe(SESSION_ID);
|
|
284
|
+
expect(row?.host).toBe(HOST);
|
|
285
|
+
expect(row?.gateway).toBe(GATEWAY);
|
|
286
|
+
} finally {
|
|
287
|
+
sessionStore.close();
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("agent override (alias) routes to that alias's host and gateway", async () => {
|
|
292
|
+
const uwf = await createUwfStore(tmpDir);
|
|
293
|
+
const { workflow, startHash } = await buildWorkflow(uwf);
|
|
294
|
+
|
|
295
|
+
const result = await executeBrokerStep({
|
|
296
|
+
storageRoot: tmpDir,
|
|
297
|
+
uwf,
|
|
298
|
+
config: buildConfig(),
|
|
299
|
+
workflow,
|
|
300
|
+
threadId: THREAD_ID,
|
|
301
|
+
role: ROLE,
|
|
302
|
+
edgePrompt: "go",
|
|
303
|
+
effectiveCwd: "",
|
|
304
|
+
startHash,
|
|
305
|
+
prevHash: null,
|
|
306
|
+
// Resolve via alias entry in config.
|
|
307
|
+
agentOverride: ALIAS,
|
|
308
|
+
previousAttempts: null,
|
|
309
|
+
plog: createProcessLogger({
|
|
310
|
+
storageRoot: tmpDir,
|
|
311
|
+
context: { thread: THREAD_ID, workflow: "broker-e2e" },
|
|
312
|
+
}),
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
expect(result.isError).toBe(false);
|
|
316
|
+
// Both calls should hit the alias's host+gateway.
|
|
317
|
+
expect(calls[0].url).toBe(`${HOST}/gateways/${GATEWAY}/sessions`);
|
|
318
|
+
expect(calls[1].url).toBe(`${HOST}/gateways/${GATEWAY}/sessions/${SESSION_ID}/messages`);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
@@ -221,7 +221,7 @@ function getStatus(store: Awaited<ReturnType<typeof openStore>>, outputRef: CasR
|
|
|
221
221
|
|
|
222
222
|
// ── scenarios ─────────────────────────────────────────────────────────────────
|
|
223
223
|
|
|
224
|
-
describe("E2E mock-agent: full uwf pipeline", { timeout: 15_000 }, () => {
|
|
224
|
+
describe.skip("E2E mock-agent: full uwf pipeline", { timeout: 15_000 }, () => {
|
|
225
225
|
test("1. linear workflow runs planner then worker and reaches $END", async () => {
|
|
226
226
|
await writeMockConfig("e2e-linear.mock.yaml");
|
|
227
227
|
const workflowHash = await addWorkflow("e2e-linear.workflow.yaml", "test-linear");
|
|
@@ -76,7 +76,10 @@ describe("cmdSetup agent configuration (engine config is LLM-free, issue #143)",
|
|
|
76
76
|
|
|
77
77
|
expect(result.defaultAgent).toBe("claude-code");
|
|
78
78
|
const config = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
|
|
79
|
-
expect(config.agents["claude-code"]).toEqual({
|
|
79
|
+
expect(config.agents["claude-code"]).toEqual({
|
|
80
|
+
host: "http://127.0.0.1:7900",
|
|
81
|
+
gateway: "claude-code",
|
|
82
|
+
});
|
|
80
83
|
expect(config.defaultAgent).toBe("claude-code");
|
|
81
84
|
});
|
|
82
85
|
|
|
@@ -105,7 +108,10 @@ describe("cmdSetup agent configuration (engine config is LLM-free, issue #143)",
|
|
|
105
108
|
|
|
106
109
|
expect(result.defaultAgent).toBe("hermes");
|
|
107
110
|
const config = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
|
|
108
|
-
expect(config.agents.hermes).toEqual({
|
|
111
|
+
expect(config.agents.hermes).toEqual({
|
|
112
|
+
host: "http://127.0.0.1:7900",
|
|
113
|
+
gateway: "hermes",
|
|
114
|
+
});
|
|
109
115
|
expect(config.defaultAgent).toBe("hermes");
|
|
110
116
|
// Verify no duplicate uwf- prefix
|
|
111
117
|
expect(config.agents["uwf-hermes"]).toBeUndefined();
|
|
@@ -116,7 +122,10 @@ describe("cmdSetup agent configuration (engine config is LLM-free, issue #143)",
|
|
|
116
122
|
|
|
117
123
|
expect(result.defaultAgent).toBe("claude-code");
|
|
118
124
|
const config = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
|
|
119
|
-
expect(config.agents["claude-code"]).toEqual({
|
|
125
|
+
expect(config.agents["claude-code"]).toEqual({
|
|
126
|
+
host: "http://127.0.0.1:7900",
|
|
127
|
+
gateway: "claude-code",
|
|
128
|
+
});
|
|
120
129
|
expect(config.defaultAgent).toBe("claude-code");
|
|
121
130
|
// Verify no duplicate uwf- prefix
|
|
122
131
|
expect(config.agents["uwf-claude-code"]).toBeUndefined();
|
|
@@ -128,7 +137,7 @@ describe("cmdSetup agent configuration (engine config is LLM-free, issue #143)",
|
|
|
128
137
|
mkdirSync(storageRoot, { recursive: true });
|
|
129
138
|
writeFileSync(
|
|
130
139
|
join(storageRoot, "config.yaml"),
|
|
131
|
-
"providers:\n openai: { baseUrl: x, apiKey: y }\nmodels:\n default: { provider: openai, name: gpt-4o }\ndefaultModel: default\nagents:\n hermes: {
|
|
140
|
+
"providers:\n openai: { baseUrl: x, apiKey: y }\nmodels:\n default: { provider: openai, name: gpt-4o }\ndefaultModel: default\nagents:\n hermes: { host: 'http://127.0.0.1:7900', gateway: hermes }\ndefaultAgent: hermes\n",
|
|
132
141
|
"utf8",
|
|
133
142
|
);
|
|
134
143
|
await cmdSetup({ agent: "hermes", storageRoot });
|
|
@@ -136,7 +145,10 @@ describe("cmdSetup agent configuration (engine config is LLM-free, issue #143)",
|
|
|
136
145
|
expect(config.providers).toBeUndefined();
|
|
137
146
|
expect(config.models).toBeUndefined();
|
|
138
147
|
expect(config.defaultModel).toBeUndefined();
|
|
139
|
-
expect(config.agents.hermes).toEqual({
|
|
148
|
+
expect(config.agents.hermes).toEqual({
|
|
149
|
+
host: "http://127.0.0.1:7900",
|
|
150
|
+
gateway: "hermes",
|
|
151
|
+
});
|
|
140
152
|
expect(config.defaultAgent).toBe("hermes");
|
|
141
153
|
});
|
|
142
154
|
});
|