@united-workforce/cli 0.7.0 → 0.8.1
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 +32 -5
- package/dist/.build-fingerprint +1 -0
- package/dist/__tests__/broker-step-active-turns.test.d.ts +20 -0
- package/dist/__tests__/broker-step-active-turns.test.d.ts.map +1 -0
- package/dist/__tests__/broker-step-active-turns.test.js +428 -0
- package/dist/__tests__/broker-step-active-turns.test.js.map +1 -0
- package/dist/__tests__/broker-step-turn-chain-phase2.test.d.ts +13 -0
- package/dist/__tests__/broker-step-turn-chain-phase2.test.d.ts.map +1 -0
- package/dist/__tests__/broker-step-turn-chain-phase2.test.js +429 -0
- package/dist/__tests__/broker-step-turn-chain-phase2.test.js.map +1 -0
- package/dist/__tests__/e2e-broker-step-suspend.test.d.ts +18 -0
- package/dist/__tests__/e2e-broker-step-suspend.test.d.ts.map +1 -0
- package/dist/__tests__/e2e-broker-step-suspend.test.js +313 -0
- package/dist/__tests__/e2e-broker-step-suspend.test.js.map +1 -0
- package/dist/__tests__/e2e-thread-resume-timeout-suspend.test.d.ts +28 -0
- package/dist/__tests__/e2e-thread-resume-timeout-suspend.test.d.ts.map +1 -0
- package/dist/__tests__/e2e-thread-resume-timeout-suspend.test.js +322 -0
- package/dist/__tests__/e2e-thread-resume-timeout-suspend.test.js.map +1 -0
- package/dist/__tests__/log-tag-validity.test.d.ts +2 -0
- package/dist/__tests__/log-tag-validity.test.d.ts.map +1 -0
- package/dist/__tests__/log-tag-validity.test.js +110 -0
- package/dist/__tests__/log-tag-validity.test.js.map +1 -0
- package/dist/__tests__/setup-agent-discovery.test.js +23 -23
- package/dist/__tests__/setup-agent-discovery.test.js.map +1 -1
- package/dist/__tests__/step-show-json.test.js +5 -5
- package/dist/__tests__/step-show-json.test.js.map +1 -1
- package/dist/__tests__/step-show-text.test.d.ts +2 -0
- package/dist/__tests__/step-show-text.test.d.ts.map +1 -0
- package/dist/__tests__/step-show-text.test.js +192 -0
- package/dist/__tests__/step-show-text.test.js.map +1 -0
- package/dist/__tests__/step-turns-cli-subprocess.test.d.ts +21 -0
- package/dist/__tests__/step-turns-cli-subprocess.test.d.ts.map +1 -0
- package/dist/__tests__/step-turns-cli-subprocess.test.js +356 -0
- package/dist/__tests__/step-turns-cli-subprocess.test.js.map +1 -0
- package/dist/__tests__/step-turns-panorama-phase3.test.d.ts +21 -0
- package/dist/__tests__/step-turns-panorama-phase3.test.d.ts.map +1 -0
- package/dist/__tests__/step-turns-panorama-phase3.test.js +476 -0
- package/dist/__tests__/step-turns-panorama-phase3.test.js.map +1 -0
- package/dist/__tests__/step-turns.test.d.ts +24 -0
- package/dist/__tests__/step-turns.test.d.ts.map +1 -0
- package/dist/__tests__/step-turns.test.js +646 -0
- package/dist/__tests__/step-turns.test.js.map +1 -0
- package/dist/__tests__/store-turn-chain.test.d.ts +2 -0
- package/dist/__tests__/store-turn-chain.test.d.ts.map +1 -0
- package/dist/__tests__/store-turn-chain.test.js +341 -0
- package/dist/__tests__/store-turn-chain.test.js.map +1 -0
- package/dist/__tests__/thread-list-limit-offset.test.d.ts +24 -0
- package/dist/__tests__/thread-list-limit-offset.test.d.ts.map +1 -0
- package/dist/__tests__/thread-list-limit-offset.test.js +254 -0
- package/dist/__tests__/thread-list-limit-offset.test.js.map +1 -0
- package/dist/__tests__/thread-list-template-ms-date.test.js +7 -2
- package/dist/__tests__/thread-list-template-ms-date.test.js.map +1 -1
- package/dist/__tests__/thread.test.js +28 -14
- package/dist/__tests__/thread.test.js.map +1 -1
- package/dist/cli.js +910 -344
- package/dist/cli.js.map +1 -1
- package/dist/commands/broker-step.d.ts +10 -3
- package/dist/commands/broker-step.d.ts.map +1 -1
- package/dist/commands/broker-step.js +231 -27
- package/dist/commands/broker-step.js.map +1 -1
- package/dist/commands/prompt.d.ts.map +1 -1
- package/dist/commands/prompt.js +42 -50
- package/dist/commands/prompt.js.map +1 -1
- package/dist/commands/setup.d.ts +6 -4
- package/dist/commands/setup.d.ts.map +1 -1
- package/dist/commands/setup.js +16 -26
- package/dist/commands/setup.js.map +1 -1
- package/dist/commands/step.d.ts +48 -1
- package/dist/commands/step.d.ts.map +1 -1
- package/dist/commands/step.js +496 -3
- package/dist/commands/step.js.map +1 -1
- package/dist/output-mappers.d.ts +8 -0
- package/dist/output-mappers.d.ts.map +1 -1
- package/dist/output-mappers.js +72 -18
- package/dist/output-mappers.js.map +1 -1
- package/dist/schemas.d.ts +3 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +17 -3
- package/dist/schemas.js.map +1 -1
- package/dist/store.d.ts +147 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +254 -1
- package/dist/store.js.map +1 -1
- package/dist/text-renderers.d.ts.map +1 -1
- package/dist/text-renderers.js +27 -2
- package/dist/text-renderers.js.map +1 -1
- package/package.json +7 -6
- package/src/__tests__/broker-step-active-turns.test.ts +509 -0
- package/src/__tests__/broker-step-turn-chain-phase2.test.ts +525 -0
- package/src/__tests__/e2e-broker-step-suspend.test.ts +351 -0
- package/src/__tests__/e2e-thread-resume-timeout-suspend.test.ts +360 -0
- package/src/__tests__/log-tag-validity.test.ts +124 -0
- package/src/__tests__/setup-agent-discovery.test.ts +23 -23
- package/src/__tests__/step-show-json.test.ts +5 -5
- package/src/__tests__/step-show-text.test.ts +236 -0
- package/src/__tests__/step-turns-cli-subprocess.test.ts +411 -0
- package/src/__tests__/step-turns-panorama-phase3.test.ts +579 -0
- package/src/__tests__/step-turns.test.ts +734 -0
- package/src/__tests__/store-turn-chain.test.ts +386 -0
- package/src/__tests__/thread-list-limit-offset.test.ts +305 -0
- package/src/__tests__/thread-list-template-ms-date.test.ts +7 -2
- package/src/__tests__/thread.test.ts +29 -15
- package/src/cli.ts +1056 -483
- package/src/commands/broker-step.ts +315 -38
- package/src/commands/prompt.ts +42 -50
- package/src/commands/setup.ts +16 -28
- package/src/commands/step.ts +655 -3
- package/src/output-mappers.ts +99 -21
- package/src/schemas.ts +32 -2
- package/src/store.ts +297 -2
- package/src/text-renderers.ts +35 -2
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #409 — `uwf step turns <thread-id>` is the whole-thread turn panorama.
|
|
3
|
+
*
|
|
4
|
+
* It walks the entire thread chain (reusing `walkChain` + `collectOrderedSteps`,
|
|
5
|
+
* the same infra `cmdStepList` uses) and renders every step's turns in order —
|
|
6
|
+
* each completed step from its own immutable `detail.turns` (marked `✓`), the
|
|
7
|
+
* in-flight step from its `@uwf/active-turns/<tid>/<role>` var (marked
|
|
8
|
+
* `🔄 进行中`). `--role` filters the panorama to one role's steps across the
|
|
9
|
+
* whole chain; `--limit`/`--offset` paginate the flattened cross-step turn
|
|
10
|
+
* sequence (filter first, then paginate). Default is full + untruncated.
|
|
11
|
+
*
|
|
12
|
+
* Covers the issue's testing checklist via the six specs:
|
|
13
|
+
* - step-turns-chain-panorama.md
|
|
14
|
+
* - step-turns-role-selection.md (the #409 regression)
|
|
15
|
+
* - step-turns-read-order-active-then-detail.md
|
|
16
|
+
* - step-turns-pagination.md
|
|
17
|
+
* - step-turns-live-poll-active-var.md
|
|
18
|
+
*
|
|
19
|
+
* Per-turn blocks reuse the SAME pipeline as `step read`
|
|
20
|
+
* (loadTurnData → formatTurnBody), so a turn block here is byte-identical to the
|
|
21
|
+
* same turn under `uwf step read`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
25
|
+
import { tmpdir } from "node:os";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import { putSchema, type Store } from "@ocas/core";
|
|
28
|
+
import type { CasRef, ThreadId } from "@united-workforce/protocol";
|
|
29
|
+
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
30
|
+
import { cmdStepTurns } from "../commands/step.js";
|
|
31
|
+
import {
|
|
32
|
+
appendActiveTurn,
|
|
33
|
+
clearActiveTurns,
|
|
34
|
+
createUwfStore,
|
|
35
|
+
setThread,
|
|
36
|
+
type UwfStore,
|
|
37
|
+
} from "../store.js";
|
|
38
|
+
|
|
39
|
+
// ── schemas (mirror the broker producer's TURN_SCHEMA + DETAIL_SCHEMA) ───────
|
|
40
|
+
|
|
41
|
+
const TURN_SCHEMA = {
|
|
42
|
+
title: "broker-turn",
|
|
43
|
+
type: "object" as const,
|
|
44
|
+
required: ["role", "content"],
|
|
45
|
+
properties: {
|
|
46
|
+
role: { type: "string" as const, enum: ["assistant", "tool"] },
|
|
47
|
+
content: { type: "string" as const },
|
|
48
|
+
},
|
|
49
|
+
additionalProperties: false,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const DETAIL_SCHEMA = {
|
|
53
|
+
title: "broker-detail",
|
|
54
|
+
type: "object" as const,
|
|
55
|
+
required: ["sessionId", "duration", "turnCount", "turns"],
|
|
56
|
+
properties: {
|
|
57
|
+
sessionId: { type: "string" as const },
|
|
58
|
+
duration: { type: "integer" as const },
|
|
59
|
+
turnCount: { type: "integer" as const },
|
|
60
|
+
turns: {
|
|
61
|
+
type: "array" as const,
|
|
62
|
+
items: { type: "string" as const, format: "ocas_ref" },
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
additionalProperties: false,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
function putTurn(store: Store, content: string): CasRef {
|
|
71
|
+
const schemaHash = putSchema(store, TURN_SCHEMA);
|
|
72
|
+
return store.cas.put(schemaHash, { role: "assistant", content }) as CasRef;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function putWorkflowAndStart(uwf: UwfStore): Promise<CasRef> {
|
|
76
|
+
const workflowHash = (await uwf.store.cas.put(uwf.schemas.workflow, {
|
|
77
|
+
version: 1,
|
|
78
|
+
name: "turns-wf",
|
|
79
|
+
description: "phase4",
|
|
80
|
+
roles: {},
|
|
81
|
+
graph: {},
|
|
82
|
+
})) as CasRef;
|
|
83
|
+
return (await uwf.store.cas.put(uwf.schemas.startNode, {
|
|
84
|
+
workflow: workflowHash,
|
|
85
|
+
prompt: "task",
|
|
86
|
+
cwd: "/tmp/work",
|
|
87
|
+
})) as CasRef;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Seed a completed step chain whose head detail.turns === the given hashes. */
|
|
91
|
+
async function seedCompletedStep(
|
|
92
|
+
uwf: UwfStore,
|
|
93
|
+
threadId: ThreadId,
|
|
94
|
+
role: string,
|
|
95
|
+
turnHashes: CasRef[],
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
const startHash = await putWorkflowAndStart(uwf);
|
|
98
|
+
const detailSchemaHash = await putSchema(uwf.store, DETAIL_SCHEMA);
|
|
99
|
+
const detailHash = (await uwf.store.cas.put(detailSchemaHash, {
|
|
100
|
+
sessionId: "ses_x",
|
|
101
|
+
duration: 5,
|
|
102
|
+
turnCount: turnHashes.length,
|
|
103
|
+
turns: turnHashes,
|
|
104
|
+
})) as CasRef;
|
|
105
|
+
const outputHash = (await uwf.store.cas.put(uwf.schemas.text, "output")) as CasRef;
|
|
106
|
+
const stepHash = (await uwf.store.cas.put(uwf.schemas.stepNode, {
|
|
107
|
+
start: startHash,
|
|
108
|
+
prev: null,
|
|
109
|
+
role,
|
|
110
|
+
output: outputHash,
|
|
111
|
+
detail: detailHash,
|
|
112
|
+
agent: "uwf-test",
|
|
113
|
+
edgePrompt: "",
|
|
114
|
+
startedAtMs: 1000,
|
|
115
|
+
completedAtMs: 6000,
|
|
116
|
+
})) as CasRef;
|
|
117
|
+
setThread(uwf.varStore, threadId, {
|
|
118
|
+
head: stepHash,
|
|
119
|
+
status: "idle",
|
|
120
|
+
suspendedRole: null,
|
|
121
|
+
suspendMessage: null,
|
|
122
|
+
completedAt: null,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Seed a linear completed chain of `steps` (oldest → newest), each carrying its
|
|
128
|
+
* own immutable `detail.turns`. The thread head points at the last step; earlier
|
|
129
|
+
* steps are reachable only via `prev`. Returns the per-step CAS hashes in order.
|
|
130
|
+
* A step with `detail: null` is requested by passing `turns: null`.
|
|
131
|
+
*/
|
|
132
|
+
async function seedLinearChain(
|
|
133
|
+
uwf: UwfStore,
|
|
134
|
+
threadId: ThreadId,
|
|
135
|
+
steps: { role: string; turns: CasRef[] | null }[],
|
|
136
|
+
): Promise<CasRef[]> {
|
|
137
|
+
const startHash = await putWorkflowAndStart(uwf);
|
|
138
|
+
const detailSchemaHash = await putSchema(uwf.store, DETAIL_SCHEMA);
|
|
139
|
+
const outputHash = (await uwf.store.cas.put(uwf.schemas.text, "output")) as CasRef;
|
|
140
|
+
const hashes: CasRef[] = [];
|
|
141
|
+
let prev: CasRef | null = null;
|
|
142
|
+
let i = 0;
|
|
143
|
+
for (const step of steps) {
|
|
144
|
+
let detail: CasRef | null = null;
|
|
145
|
+
if (step.turns !== null) {
|
|
146
|
+
detail = (await uwf.store.cas.put(detailSchemaHash, {
|
|
147
|
+
sessionId: `ses_${step.role}_${i}`,
|
|
148
|
+
duration: 5,
|
|
149
|
+
turnCount: step.turns.length,
|
|
150
|
+
turns: step.turns,
|
|
151
|
+
})) as CasRef;
|
|
152
|
+
}
|
|
153
|
+
const stepHash = (await uwf.store.cas.put(uwf.schemas.stepNode, {
|
|
154
|
+
start: startHash,
|
|
155
|
+
prev,
|
|
156
|
+
role: step.role,
|
|
157
|
+
output: outputHash,
|
|
158
|
+
detail,
|
|
159
|
+
agent: "uwf-test",
|
|
160
|
+
edgePrompt: "",
|
|
161
|
+
startedAtMs: 1000 + i,
|
|
162
|
+
completedAtMs: 6000 + i,
|
|
163
|
+
})) as CasRef;
|
|
164
|
+
hashes.push(stepHash);
|
|
165
|
+
prev = stepHash;
|
|
166
|
+
i += 1;
|
|
167
|
+
}
|
|
168
|
+
setThread(uwf.varStore, threadId, {
|
|
169
|
+
head: prev ?? startHash,
|
|
170
|
+
status: "idle",
|
|
171
|
+
suspendedRole: null,
|
|
172
|
+
suspendMessage: null,
|
|
173
|
+
completedAt: null,
|
|
174
|
+
});
|
|
175
|
+
return hashes;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Seed a thread whose head is only a StartNode (no steps yet). */
|
|
179
|
+
async function seedStartOnly(uwf: UwfStore, threadId: ThreadId): Promise<void> {
|
|
180
|
+
const startHash = await putWorkflowAndStart(uwf);
|
|
181
|
+
setThread(uwf.varStore, threadId, {
|
|
182
|
+
head: startHash,
|
|
183
|
+
status: "idle",
|
|
184
|
+
suspendedRole: null,
|
|
185
|
+
suspendMessage: null,
|
|
186
|
+
completedAt: null,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── fixture ──────────────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
let tmpDir: string;
|
|
193
|
+
let savedOcasHome: string | undefined;
|
|
194
|
+
|
|
195
|
+
beforeEach(async () => {
|
|
196
|
+
savedOcasHome = process.env.OCAS_HOME;
|
|
197
|
+
tmpDir = await mkdtemp(join(tmpdir(), "step-turns-"));
|
|
198
|
+
const casDir = join(tmpDir, "cas");
|
|
199
|
+
await mkdir(casDir, { recursive: true });
|
|
200
|
+
process.env.OCAS_HOME = casDir;
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
afterEach(async () => {
|
|
204
|
+
if (savedOcasHome === undefined) delete process.env.OCAS_HOME;
|
|
205
|
+
else process.env.OCAS_HOME = savedOcasHome;
|
|
206
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const THREAD_ID = "06FCYTURNSPHASE4CONSUMER1" as ThreadId;
|
|
210
|
+
|
|
211
|
+
/** The per-turn block region (## Turn 1 onward) — header/markers stripped. */
|
|
212
|
+
function turnBlocks(md: string): string {
|
|
213
|
+
const i = md.indexOf("## Turn ");
|
|
214
|
+
return i === -1 ? "" : md.slice(i);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── chain panorama: walk the whole chain, every step's turns ─────────────────
|
|
218
|
+
|
|
219
|
+
describe("cmdStepTurns chain panorama (#409)", () => {
|
|
220
|
+
test("walks the whole chain: every step group appears, attributed to its role", async () => {
|
|
221
|
+
const uwf = await createUwfStore(tmpDir);
|
|
222
|
+
const p = [putTurn(uwf.store, "p-turn")];
|
|
223
|
+
const d = [putTurn(uwf.store, "d-turn")];
|
|
224
|
+
const r = [putTurn(uwf.store, "r-turn")];
|
|
225
|
+
await seedLinearChain(uwf, THREAD_ID, [
|
|
226
|
+
{ role: "planner", turns: p },
|
|
227
|
+
{ role: "developer", turns: d },
|
|
228
|
+
{ role: "reviewer", turns: r },
|
|
229
|
+
]);
|
|
230
|
+
|
|
231
|
+
const out = await cmdStepTurns(tmpDir, THREAD_ID, { live: false });
|
|
232
|
+
|
|
233
|
+
// All three step groups present, in chronological order.
|
|
234
|
+
expect(out).toContain("## planner");
|
|
235
|
+
expect(out).toContain("## developer");
|
|
236
|
+
expect(out).toContain("## reviewer");
|
|
237
|
+
expect(out.indexOf("## planner")).toBeLessThan(out.indexOf("## developer"));
|
|
238
|
+
expect(out.indexOf("## developer")).toBeLessThan(out.indexOf("## reviewer"));
|
|
239
|
+
// Each step shows its OWN turns (per-step sourcing).
|
|
240
|
+
expect(out.indexOf("p-turn")).toBeLessThan(out.indexOf("d-turn"));
|
|
241
|
+
expect(out.indexOf("d-turn")).toBeLessThan(out.indexOf("r-turn"));
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("completed steps are marked ✓ with their turn count", async () => {
|
|
245
|
+
const uwf = await createUwfStore(tmpDir);
|
|
246
|
+
await seedLinearChain(uwf, THREAD_ID, [
|
|
247
|
+
{ role: "planner", turns: [putTurn(uwf.store, "p1"), putTurn(uwf.store, "p2")] },
|
|
248
|
+
{ role: "developer", turns: [putTurn(uwf.store, "d1")] },
|
|
249
|
+
]);
|
|
250
|
+
|
|
251
|
+
const out = await cmdStepTurns(tmpDir, THREAD_ID, { live: false });
|
|
252
|
+
|
|
253
|
+
expect(out).toContain("## planner ✓ (2 turns)");
|
|
254
|
+
expect(out).toContain("## developer ✓ (1 turns)");
|
|
255
|
+
expect(out).not.toContain("进行中");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("the in-flight step (active var, no settled StepNode) is marked 🔄 进行中", async () => {
|
|
259
|
+
const uwf = await createUwfStore(tmpDir);
|
|
260
|
+
// planner + developer settled; reviewer in flight via active var only.
|
|
261
|
+
await seedLinearChain(uwf, THREAD_ID, [
|
|
262
|
+
{ role: "planner", turns: [putTurn(uwf.store, "p1")] },
|
|
263
|
+
{ role: "developer", turns: [putTurn(uwf.store, "d1")] },
|
|
264
|
+
]);
|
|
265
|
+
appendActiveTurn(uwf.store, THREAD_ID, "reviewer", putTurn(uwf.store, "r-live-1"));
|
|
266
|
+
appendActiveTurn(uwf.store, THREAD_ID, "reviewer", putTurn(uwf.store, "r-live-2"));
|
|
267
|
+
|
|
268
|
+
const out = await cmdStepTurns(tmpDir, THREAD_ID, { live: false });
|
|
269
|
+
|
|
270
|
+
expect(out).toContain("## planner ✓ (1 turns)");
|
|
271
|
+
expect(out).toContain("## developer ✓ (1 turns)");
|
|
272
|
+
expect(out).toContain("## reviewer 🔄 进行中 (2 turns so far)");
|
|
273
|
+
// The in-flight step appears after the completed steps.
|
|
274
|
+
expect(out.indexOf("## developer")).toBeLessThan(out.indexOf("## reviewer"));
|
|
275
|
+
expect(out).toContain("r-live-1");
|
|
276
|
+
expect(out).toContain("r-live-2");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("default shows all turns: no quota cutoff, no omitted-turns notice", async () => {
|
|
280
|
+
const uwf = await createUwfStore(tmpDir);
|
|
281
|
+
const many: CasRef[] = [];
|
|
282
|
+
for (let i = 0; i < 30; i++) many.push(putTurn(uwf.store, `dev-turn-${i}`));
|
|
283
|
+
await seedLinearChain(uwf, THREAD_ID, [{ role: "developer", turns: many }]);
|
|
284
|
+
|
|
285
|
+
const out = await cmdStepTurns(tmpDir, THREAD_ID, { live: false });
|
|
286
|
+
|
|
287
|
+
expect(out).toContain("dev-turn-0");
|
|
288
|
+
expect(out).toContain("dev-turn-29");
|
|
289
|
+
expect(out).not.toContain("omitted");
|
|
290
|
+
expect(out).toContain("## Turn 30");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("empty thread (StartNode head): header only, no step groups, no turn blocks", async () => {
|
|
294
|
+
const uwf = await createUwfStore(tmpDir);
|
|
295
|
+
await seedStartOnly(uwf, THREAD_ID);
|
|
296
|
+
|
|
297
|
+
const out = await cmdStepTurns(tmpDir, THREAD_ID, { live: false });
|
|
298
|
+
|
|
299
|
+
expect(out).toContain(`# Thread ${THREAD_ID}`);
|
|
300
|
+
expect(out).not.toContain("## Turn");
|
|
301
|
+
expect(out).not.toContain("✓");
|
|
302
|
+
expect(out).not.toContain("进行中");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("a step with turnCount === 0 keeps its (0 turns) header, is not dropped", async () => {
|
|
306
|
+
const uwf = await createUwfStore(tmpDir);
|
|
307
|
+
await seedLinearChain(uwf, THREAD_ID, [
|
|
308
|
+
{ role: "planner", turns: [putTurn(uwf.store, "p1")] },
|
|
309
|
+
{ role: "developer", turns: [] },
|
|
310
|
+
{ role: "reviewer", turns: null }, // detail === null
|
|
311
|
+
]);
|
|
312
|
+
|
|
313
|
+
const out = await cmdStepTurns(tmpDir, THREAD_ID, { live: false });
|
|
314
|
+
|
|
315
|
+
expect(out).toContain("## planner ✓ (1 turns)");
|
|
316
|
+
expect(out).toContain("## developer ✓ (0 turns)");
|
|
317
|
+
expect(out).toContain("## reviewer ✓ (0 turns)");
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// ── --role selection: the #409 regression ────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
describe("cmdStepTurns --role filters the chain panorama (#409 regression)", () => {
|
|
324
|
+
test("--role developer on a head=committer thread returns the developer step's turns (NOT empty)", async () => {
|
|
325
|
+
const uwf = await createUwfStore(tmpDir);
|
|
326
|
+
const dev = [putTurn(uwf.store, "dev-1"), putTurn(uwf.store, "dev-2")];
|
|
327
|
+
await seedLinearChain(uwf, THREAD_ID, [
|
|
328
|
+
{ role: "planner", turns: [putTurn(uwf.store, "plan-1")] },
|
|
329
|
+
{ role: "developer", turns: dev },
|
|
330
|
+
{ role: "reviewer", turns: [putTurn(uwf.store, "rev-1")] },
|
|
331
|
+
{ role: "tester", turns: [putTurn(uwf.store, "test-1")] },
|
|
332
|
+
{ role: "committer", turns: [putTurn(uwf.store, "commit-1")] },
|
|
333
|
+
]);
|
|
334
|
+
|
|
335
|
+
const out = await cmdStepTurns(tmpDir, THREAD_ID, { role: "developer", live: false });
|
|
336
|
+
|
|
337
|
+
// The chain is walked to the developer step; its turns render — not empty.
|
|
338
|
+
expect(out).toContain("## developer ✓ (2 turns)");
|
|
339
|
+
expect(out).toContain("dev-1");
|
|
340
|
+
expect(out).toContain("dev-2");
|
|
341
|
+
// Only the developer step survives the filter.
|
|
342
|
+
expect(out).not.toContain("## planner");
|
|
343
|
+
expect(out).not.toContain("## committer");
|
|
344
|
+
expect(out).not.toContain("plan-1");
|
|
345
|
+
expect(out).not.toContain("commit-1");
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("--role planner (head is coder) renders the planner step's turns, never the coder head's", async () => {
|
|
349
|
+
const uwf = await createUwfStore(tmpDir);
|
|
350
|
+
const p1 = putTurn(uwf.store, "p1");
|
|
351
|
+
const c1 = putTurn(uwf.store, "c1");
|
|
352
|
+
const c2 = putTurn(uwf.store, "c2");
|
|
353
|
+
await seedLinearChain(uwf, THREAD_ID, [
|
|
354
|
+
{ role: "planner", turns: [p1] },
|
|
355
|
+
{ role: "coder", turns: [c1, c2] },
|
|
356
|
+
]);
|
|
357
|
+
|
|
358
|
+
const planner = await cmdStepTurns(tmpDir, THREAD_ID, { role: "planner", live: false });
|
|
359
|
+
// #409: walking the chain reaches the earlier planner step — NOT empty.
|
|
360
|
+
expect(planner).toContain("## planner ✓ (1 turns)");
|
|
361
|
+
expect(planner).toContain("p1");
|
|
362
|
+
// The coder head step's turns must NOT surface under --role planner.
|
|
363
|
+
expect(planner).not.toContain("c1");
|
|
364
|
+
expect(planner).not.toContain("c2");
|
|
365
|
+
expect(planner).not.toContain("## coder");
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("--role coder (head IS coder) renders the coder step's turns in order", async () => {
|
|
369
|
+
const uwf = await createUwfStore(tmpDir);
|
|
370
|
+
const p1 = putTurn(uwf.store, "p1");
|
|
371
|
+
const c1 = putTurn(uwf.store, "c1");
|
|
372
|
+
const c2 = putTurn(uwf.store, "c2");
|
|
373
|
+
await seedLinearChain(uwf, THREAD_ID, [
|
|
374
|
+
{ role: "planner", turns: [p1] },
|
|
375
|
+
{ role: "coder", turns: [c1, c2] },
|
|
376
|
+
]);
|
|
377
|
+
|
|
378
|
+
const coder = await cmdStepTurns(tmpDir, THREAD_ID, { role: "coder", live: false });
|
|
379
|
+
expect(coder).toContain("## coder ✓ (2 turns)");
|
|
380
|
+
expect(coder).toContain("c1");
|
|
381
|
+
expect(coder).toContain("c2");
|
|
382
|
+
expect(coder.indexOf("c1")).toBeLessThan(coder.indexOf("c2"));
|
|
383
|
+
expect(coder).not.toContain("p1");
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("--role reviewer (never ran) renders empty (no step matches), exit 0", async () => {
|
|
387
|
+
const uwf = await createUwfStore(tmpDir);
|
|
388
|
+
await seedLinearChain(uwf, THREAD_ID, [
|
|
389
|
+
{ role: "planner", turns: [putTurn(uwf.store, "p1")] },
|
|
390
|
+
{ role: "coder", turns: [putTurn(uwf.store, "c1")] },
|
|
391
|
+
]);
|
|
392
|
+
|
|
393
|
+
const reviewer = await cmdStepTurns(tmpDir, THREAD_ID, { role: "reviewer", live: false });
|
|
394
|
+
expect(reviewer).toContain(`# Thread ${THREAD_ID}`);
|
|
395
|
+
expect(reviewer).not.toContain("## Turn");
|
|
396
|
+
expect(reviewer).not.toContain("p1");
|
|
397
|
+
expect(reviewer).not.toContain("c1");
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("multiple steps of the same role aggregate (two rounds of developer)", async () => {
|
|
401
|
+
const uwf = await createUwfStore(tmpDir);
|
|
402
|
+
const d1 = putTurn(uwf.store, "dev-round1");
|
|
403
|
+
const d2 = putTurn(uwf.store, "dev-round2");
|
|
404
|
+
await seedLinearChain(uwf, THREAD_ID, [
|
|
405
|
+
{ role: "planner", turns: [putTurn(uwf.store, "p1")] },
|
|
406
|
+
{ role: "developer", turns: [d1] },
|
|
407
|
+
{ role: "reviewer", turns: [putTurn(uwf.store, "r1")] },
|
|
408
|
+
{ role: "developer", turns: [d2] },
|
|
409
|
+
]);
|
|
410
|
+
|
|
411
|
+
const out = await cmdStepTurns(tmpDir, THREAD_ID, { role: "developer", live: false });
|
|
412
|
+
// Both developer occurrences are kept, in chronological order.
|
|
413
|
+
expect(out).toContain("dev-round1");
|
|
414
|
+
expect(out).toContain("dev-round2");
|
|
415
|
+
expect(out.indexOf("dev-round1")).toBeLessThan(out.indexOf("dev-round2"));
|
|
416
|
+
expect((out.match(/## developer/g) ?? []).length).toBe(2);
|
|
417
|
+
expect(out).not.toContain("## reviewer");
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("--role is exact-match: 'coder' does not match a 'coder-2' step", async () => {
|
|
421
|
+
const uwf = await createUwfStore(tmpDir);
|
|
422
|
+
await seedLinearChain(uwf, THREAD_ID, [
|
|
423
|
+
{ role: "coder-2", turns: [putTurn(uwf.store, "other-role")] },
|
|
424
|
+
]);
|
|
425
|
+
|
|
426
|
+
const out = await cmdStepTurns(tmpDir, THREAD_ID, { role: "coder", live: false });
|
|
427
|
+
expect(out).not.toContain("other-role");
|
|
428
|
+
expect(out).not.toContain("## Turn");
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// ── per-step read order: active var → detail, source-transparent ─────────────
|
|
433
|
+
|
|
434
|
+
describe("cmdStepTurns per-step read order (active → detail)", () => {
|
|
435
|
+
test("running vs completed: same step's per-turn blocks are byte-identical, marker flips", async () => {
|
|
436
|
+
const uwf = await createUwfStore(tmpDir);
|
|
437
|
+
const r1 = putTurn(uwf.store, "r1");
|
|
438
|
+
const r2 = putTurn(uwf.store, "r2");
|
|
439
|
+
const r3 = putTurn(uwf.store, "r3");
|
|
440
|
+
|
|
441
|
+
// (a) running snapshot: reviewer in flight via active var (StartNode head).
|
|
442
|
+
await seedStartOnly(uwf, THREAD_ID);
|
|
443
|
+
for (const h of [r1, r2, r3]) appendActiveTurn(uwf.store, THREAD_ID, "reviewer", h);
|
|
444
|
+
const running = await cmdStepTurns(tmpDir, THREAD_ID, { live: false });
|
|
445
|
+
|
|
446
|
+
// (b) completed: var gone, same hashes solidified into that step's detail.
|
|
447
|
+
clearActiveTurns(uwf.store, THREAD_ID, "reviewer");
|
|
448
|
+
await seedCompletedStep(uwf, THREAD_ID, "reviewer", [r1, r2, r3]);
|
|
449
|
+
const completed = await cmdStepTurns(tmpDir, THREAD_ID, { live: false });
|
|
450
|
+
|
|
451
|
+
// The per-turn blocks are byte-identical; only the step marker differs.
|
|
452
|
+
expect(turnBlocks(completed)).toBe(turnBlocks(running));
|
|
453
|
+
expect(running).toContain("🔄 进行中");
|
|
454
|
+
expect(completed).toContain("✓");
|
|
455
|
+
expect(completed).toContain("r1");
|
|
456
|
+
expect(completed).toContain("r3");
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test("active var takes precedence over a present detail for the same step/role", async () => {
|
|
460
|
+
const uwf = await createUwfStore(tmpDir);
|
|
461
|
+
// Completed step holds OLD turns…
|
|
462
|
+
const old1 = putTurn(uwf.store, "OLD-DETAIL");
|
|
463
|
+
await seedCompletedStep(uwf, THREAD_ID, "coder", [old1]);
|
|
464
|
+
// …but a fresh active var for the same role holds NEW turns: the var wins.
|
|
465
|
+
const n1 = putTurn(uwf.store, "NEW-ACTIVE");
|
|
466
|
+
appendActiveTurn(uwf.store, THREAD_ID, "coder", n1);
|
|
467
|
+
|
|
468
|
+
const out = await cmdStepTurns(tmpDir, THREAD_ID, { role: "coder", live: false });
|
|
469
|
+
|
|
470
|
+
expect(out).toContain("NEW-ACTIVE");
|
|
471
|
+
expect(out).not.toContain("OLD-DETAIL");
|
|
472
|
+
expect(out).toContain("🔄 进行中");
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test("two concurrent in-flight role vars are both shown, each under its own group", async () => {
|
|
476
|
+
const uwf = await createUwfStore(tmpDir);
|
|
477
|
+
await seedStartOnly(uwf, THREAD_ID);
|
|
478
|
+
appendActiveTurn(uwf.store, THREAD_ID, "coder", putTurn(uwf.store, "c1"));
|
|
479
|
+
appendActiveTurn(uwf.store, THREAD_ID, "planner", putTurn(uwf.store, "p1"));
|
|
480
|
+
|
|
481
|
+
const all = await cmdStepTurns(tmpDir, THREAD_ID, { live: false });
|
|
482
|
+
expect(all).toContain("## coder 🔄 进行中");
|
|
483
|
+
expect(all).toContain("## planner 🔄 进行中");
|
|
484
|
+
|
|
485
|
+
// …and --role isolates one of them.
|
|
486
|
+
const coder = await cmdStepTurns(tmpDir, THREAD_ID, { role: "coder", live: false });
|
|
487
|
+
expect(coder).toContain("c1");
|
|
488
|
+
expect(coder).not.toContain("p1");
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
// ── pagination: default-all, --limit/--offset over the flattened sequence ─────
|
|
493
|
+
|
|
494
|
+
describe("cmdStepTurns pagination (--limit / --offset)", () => {
|
|
495
|
+
// Flattened sequence: [pa, pb, da, db, dc, ra, rb] — global indices 0..6.
|
|
496
|
+
async function seedPaginationFixture(uwf: UwfStore): Promise<void> {
|
|
497
|
+
await seedLinearChain(uwf, THREAD_ID, [
|
|
498
|
+
{ role: "planner", turns: [putTurn(uwf.store, "pa"), putTurn(uwf.store, "pb")] },
|
|
499
|
+
{
|
|
500
|
+
role: "developer",
|
|
501
|
+
turns: [putTurn(uwf.store, "da"), putTurn(uwf.store, "db"), putTurn(uwf.store, "dc")],
|
|
502
|
+
},
|
|
503
|
+
{ role: "reviewer", turns: [putTurn(uwf.store, "ra"), putTurn(uwf.store, "rb")] },
|
|
504
|
+
]);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
test("default (no flags): all 7 turns render", async () => {
|
|
508
|
+
const uwf = await createUwfStore(tmpDir);
|
|
509
|
+
await seedPaginationFixture(uwf);
|
|
510
|
+
const out = await cmdStepTurns(tmpDir, THREAD_ID, { live: false });
|
|
511
|
+
for (const c of ["pa", "pb", "da", "db", "dc", "ra", "rb"]) expect(out).toContain(c);
|
|
512
|
+
expect(out).toContain("## Turn 7");
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test("--limit 3: first 3 turns of the flattened sequence", async () => {
|
|
516
|
+
const uwf = await createUwfStore(tmpDir);
|
|
517
|
+
await seedPaginationFixture(uwf);
|
|
518
|
+
const out = await cmdStepTurns(tmpDir, THREAD_ID, { live: false, limit: 3 });
|
|
519
|
+
expect(out).toContain("pa");
|
|
520
|
+
expect(out).toContain("pb");
|
|
521
|
+
expect(out).toContain("da");
|
|
522
|
+
expect(out).not.toContain("db");
|
|
523
|
+
expect(out).not.toContain("ra");
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test("--offset 2: skips the first 2, spans into later steps", async () => {
|
|
527
|
+
const uwf = await createUwfStore(tmpDir);
|
|
528
|
+
await seedPaginationFixture(uwf);
|
|
529
|
+
const out = await cmdStepTurns(tmpDir, THREAD_ID, { live: false, offset: 2 });
|
|
530
|
+
expect(out).not.toContain("pa");
|
|
531
|
+
expect(out).not.toContain("pb");
|
|
532
|
+
expect(out).toContain("da");
|
|
533
|
+
expect(out).toContain("rb");
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test("--offset 2 --limit 2: the slice [2,4) over the flat sequence, with global numbering", async () => {
|
|
537
|
+
const uwf = await createUwfStore(tmpDir);
|
|
538
|
+
await seedPaginationFixture(uwf);
|
|
539
|
+
const out = await cmdStepTurns(tmpDir, THREAD_ID, { live: false, offset: 2, limit: 2 });
|
|
540
|
+
expect(out).toContain("da");
|
|
541
|
+
expect(out).toContain("db");
|
|
542
|
+
expect(out).not.toContain("pa");
|
|
543
|
+
expect(out).not.toContain("dc");
|
|
544
|
+
// Global numbering: da is global index 2 → "## Turn 3".
|
|
545
|
+
expect(out).toContain("## Turn 3");
|
|
546
|
+
expect(out).toContain("## Turn 4");
|
|
547
|
+
expect(out).not.toContain("## Turn 1");
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test("a slice spanning a step boundary keeps each turn under its owning group", async () => {
|
|
551
|
+
const uwf = await createUwfStore(tmpDir);
|
|
552
|
+
await seedPaginationFixture(uwf);
|
|
553
|
+
// indices 4..5 → dc (developer), ra (reviewer).
|
|
554
|
+
const out = await cmdStepTurns(tmpDir, THREAD_ID, { live: false, offset: 4, limit: 2 });
|
|
555
|
+
expect(out).toContain("## developer");
|
|
556
|
+
expect(out).toContain("## reviewer");
|
|
557
|
+
expect(out).toContain("dc");
|
|
558
|
+
expect(out).toContain("ra");
|
|
559
|
+
expect(out).not.toContain("da");
|
|
560
|
+
expect(out).not.toContain("rb");
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test("filter-then-paginate: --role developer --limit 2 → first 2 developer turns only", async () => {
|
|
564
|
+
const uwf = await createUwfStore(tmpDir);
|
|
565
|
+
await seedPaginationFixture(uwf);
|
|
566
|
+
const out = await cmdStepTurns(tmpDir, THREAD_ID, { live: false, role: "developer", limit: 2 });
|
|
567
|
+
expect(out).toContain("da");
|
|
568
|
+
expect(out).toContain("db");
|
|
569
|
+
expect(out).not.toContain("dc");
|
|
570
|
+
// Not the first 2 turns of the whole thread (pa/pb) filtered down.
|
|
571
|
+
expect(out).not.toContain("pa");
|
|
572
|
+
expect(out).not.toContain("pb");
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
test("--offset >= total turns → header/groups only, no turn blocks", async () => {
|
|
576
|
+
const uwf = await createUwfStore(tmpDir);
|
|
577
|
+
await seedPaginationFixture(uwf);
|
|
578
|
+
const out = await cmdStepTurns(tmpDir, THREAD_ID, { live: false, offset: 7 });
|
|
579
|
+
expect(out).not.toContain("## Turn");
|
|
580
|
+
// Group headers still render.
|
|
581
|
+
expect(out).toContain("## planner");
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
test("--limit 0 → no turns (the ListOptions convention; absent limit means all)", async () => {
|
|
585
|
+
const uwf = await createUwfStore(tmpDir);
|
|
586
|
+
await seedPaginationFixture(uwf);
|
|
587
|
+
const out = await cmdStepTurns(tmpDir, THREAD_ID, { live: false, limit: 0 });
|
|
588
|
+
expect(out).not.toContain("## Turn");
|
|
589
|
+
expect(out).toContain("## planner");
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
test("--limit larger than remaining clamps (no error)", async () => {
|
|
593
|
+
const uwf = await createUwfStore(tmpDir);
|
|
594
|
+
await seedPaginationFixture(uwf);
|
|
595
|
+
const out = await cmdStepTurns(tmpDir, THREAD_ID, { live: false, offset: 5, limit: 100 });
|
|
596
|
+
expect(out).toContain("ra");
|
|
597
|
+
expect(out).toContain("rb");
|
|
598
|
+
expect(out).not.toContain("dc");
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
// ── --live incremental polling ───────────────────────────────────────────────
|
|
603
|
+
|
|
604
|
+
describe("cmdStepTurns --live poll", () => {
|
|
605
|
+
test("prints each new turn exactly once and exits when the active var clears", async () => {
|
|
606
|
+
const uwf = await createUwfStore(tmpDir);
|
|
607
|
+
await seedStartOnly(uwf, THREAD_ID);
|
|
608
|
+
|
|
609
|
+
const printed: string[] = [];
|
|
610
|
+
let tick = 0;
|
|
611
|
+
const t1 = putTurn(uwf.store, "L1");
|
|
612
|
+
const t2 = putTurn(uwf.store, "L2");
|
|
613
|
+
const t3 = putTurn(uwf.store, "L3");
|
|
614
|
+
|
|
615
|
+
await cmdStepTurns(tmpDir, THREAD_ID, {
|
|
616
|
+
role: "coder",
|
|
617
|
+
live: true,
|
|
618
|
+
pollIntervalMs: 0,
|
|
619
|
+
onChunk: (chunk: string) => printed.push(chunk),
|
|
620
|
+
isRunning: async () => tick < 4,
|
|
621
|
+
sleep: async () => {
|
|
622
|
+
tick += 1;
|
|
623
|
+
if (tick === 1) appendActiveTurn(uwf.store, THREAD_ID, "coder", t1);
|
|
624
|
+
else if (tick === 2) appendActiveTurn(uwf.store, THREAD_ID, "coder", t2);
|
|
625
|
+
else if (tick === 3) appendActiveTurn(uwf.store, THREAD_ID, "coder", t3);
|
|
626
|
+
else if (tick >= 4) clearActiveTurns(uwf.store, THREAD_ID, "coder");
|
|
627
|
+
},
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
const joined = printed.join("\n");
|
|
631
|
+
expect(joined.match(/L1/g) ?? []).toHaveLength(1);
|
|
632
|
+
expect(joined.match(/L2/g) ?? []).toHaveLength(1);
|
|
633
|
+
expect(joined.match(/L3/g) ?? []).toHaveLength(1);
|
|
634
|
+
expect(joined.indexOf("L1")).toBeLessThan(joined.indexOf("L2"));
|
|
635
|
+
expect(joined.indexOf("L2")).toBeLessThan(joined.indexOf("L3"));
|
|
636
|
+
expect(joined).toContain("**Turn role:** assistant");
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
test("starting --live after completion degrades to printing detail.turns once", async () => {
|
|
640
|
+
const uwf = await createUwfStore(tmpDir);
|
|
641
|
+
const t1 = putTurn(uwf.store, "D1");
|
|
642
|
+
const t2 = putTurn(uwf.store, "D2");
|
|
643
|
+
await seedCompletedStep(uwf, THREAD_ID, "coder", [t1, t2]);
|
|
644
|
+
|
|
645
|
+
const printed: string[] = [];
|
|
646
|
+
await cmdStepTurns(tmpDir, THREAD_ID, {
|
|
647
|
+
role: "coder",
|
|
648
|
+
live: true,
|
|
649
|
+
pollIntervalMs: 0,
|
|
650
|
+
onChunk: (chunk: string) => printed.push(chunk),
|
|
651
|
+
isRunning: async () => false,
|
|
652
|
+
sleep: async () => {},
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
const joined = printed.join("\n");
|
|
656
|
+
expect(joined).toContain("D1");
|
|
657
|
+
expect(joined).toContain("D2");
|
|
658
|
+
expect(joined.match(/D1/g) ?? []).toHaveLength(1);
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
test("without --role, --live follows the thread's in-flight role", async () => {
|
|
662
|
+
const uwf = await createUwfStore(tmpDir);
|
|
663
|
+
await seedStartOnly(uwf, THREAD_ID);
|
|
664
|
+
// The step is already emitting when --live starts: its active var is present
|
|
665
|
+
// at invocation, so the followed role is discovered from it (no --role given).
|
|
666
|
+
const t1 = putTurn(uwf.store, "AUTO1");
|
|
667
|
+
appendActiveTurn(uwf.store, THREAD_ID, "coder", t1);
|
|
668
|
+
|
|
669
|
+
const printed: string[] = [];
|
|
670
|
+
let tick = 0;
|
|
671
|
+
const t2 = putTurn(uwf.store, "AUTO2");
|
|
672
|
+
await cmdStepTurns(tmpDir, THREAD_ID, {
|
|
673
|
+
live: true,
|
|
674
|
+
pollIntervalMs: 0,
|
|
675
|
+
onChunk: (chunk: string) => printed.push(chunk),
|
|
676
|
+
isRunning: async () => tick < 2,
|
|
677
|
+
sleep: async () => {
|
|
678
|
+
tick += 1;
|
|
679
|
+
if (tick === 1) appendActiveTurn(uwf.store, THREAD_ID, "coder", t2);
|
|
680
|
+
else if (tick >= 2) clearActiveTurns(uwf.store, THREAD_ID, "coder");
|
|
681
|
+
},
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
// The coder active var was discovered and followed without an explicit --role.
|
|
685
|
+
const joined = printed.join("\n");
|
|
686
|
+
expect(joined).toContain("AUTO1");
|
|
687
|
+
expect(joined).toContain("AUTO2");
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
test("multi-step run: exit reconcile is role-scoped — never emits a different role's turns", async () => {
|
|
691
|
+
// Regression for #409 (live counterpart). In a multi-step run the running
|
|
692
|
+
// marker is held for the whole loop, so the thread stays "running" while the
|
|
693
|
+
// head advances coder → reviewer. A `--live --role coder` follower streams the
|
|
694
|
+
// coder turns; by exit the head StepNode is the reviewer step. The reconcile
|
|
695
|
+
// MUST walk the chain to the *coder* step (not blindly the head), else the
|
|
696
|
+
// reviewer tail leaks as continued coder turns.
|
|
697
|
+
const uwf = await createUwfStore(tmpDir);
|
|
698
|
+
await seedStartOnly(uwf, THREAD_ID);
|
|
699
|
+
|
|
700
|
+
const c1 = putTurn(uwf.store, "LC1");
|
|
701
|
+
const c2 = putTurn(uwf.store, "LC2");
|
|
702
|
+
const r1 = putTurn(uwf.store, "LR1");
|
|
703
|
+
const r2 = putTurn(uwf.store, "LR2");
|
|
704
|
+
const r3 = putTurn(uwf.store, "LR3");
|
|
705
|
+
|
|
706
|
+
const printed: string[] = [];
|
|
707
|
+
let tick = 0;
|
|
708
|
+
await cmdStepTurns(tmpDir, THREAD_ID, {
|
|
709
|
+
role: "coder",
|
|
710
|
+
live: true,
|
|
711
|
+
pollIntervalMs: 0,
|
|
712
|
+
onChunk: (chunk: string) => printed.push(chunk),
|
|
713
|
+
isRunning: async () => tick < 3,
|
|
714
|
+
sleep: async () => {
|
|
715
|
+
tick += 1;
|
|
716
|
+
if (tick === 1) appendActiveTurn(uwf.store, THREAD_ID, "coder", c1);
|
|
717
|
+
else if (tick === 2) appendActiveTurn(uwf.store, THREAD_ID, "coder", c2);
|
|
718
|
+
else if (tick === 3) {
|
|
719
|
+
// coder step ends: its var is solidified+deleted and the head advances
|
|
720
|
+
// to the (completed) reviewer step while the thread stays "running".
|
|
721
|
+
clearActiveTurns(uwf.store, THREAD_ID, "coder");
|
|
722
|
+
await seedCompletedStep(uwf, THREAD_ID, "reviewer", [r1, r2, r3]);
|
|
723
|
+
}
|
|
724
|
+
},
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
const joined = printed.join("\n");
|
|
728
|
+
expect(joined.match(/LC1/g) ?? []).toHaveLength(1);
|
|
729
|
+
expect(joined.match(/LC2/g) ?? []).toHaveLength(1);
|
|
730
|
+
expect(joined).not.toContain("LR1");
|
|
731
|
+
expect(joined).not.toContain("LR2");
|
|
732
|
+
expect(joined).not.toContain("LR3");
|
|
733
|
+
});
|
|
734
|
+
});
|