@ilya-lesikov/pi-pi 0.9.0 → 0.10.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/3p/pi-ask-user/index.ts +71 -124
- package/3p/pi-ask-user/single-select-layout.ts +3 -14
- package/3p/pi-subagents/package.json +13 -7
- package/3p/pi-subagents/src/agent-manager.ts +243 -79
- package/3p/pi-subagents/src/agent-runner.ts +501 -365
- package/3p/pi-subagents/src/agent-types.ts +46 -57
- package/3p/pi-subagents/src/cross-extension-rpc.ts +52 -46
- package/3p/pi-subagents/src/custom-agents.ts +30 -6
- package/3p/pi-subagents/src/default-agents.ts +25 -43
- package/3p/pi-subagents/src/enabled-models.ts +180 -0
- package/3p/pi-subagents/src/index.ts +599 -839
- package/3p/pi-subagents/src/model-resolver.ts +26 -7
- package/3p/pi-subagents/src/output-file.ts +21 -2
- package/3p/pi-subagents/src/prompts.ts +17 -3
- package/3p/pi-subagents/src/schedule-store.ts +153 -0
- package/3p/pi-subagents/src/schedule.ts +365 -0
- package/3p/pi-subagents/src/settings.ts +261 -0
- package/3p/pi-subagents/src/skill-loader.ts +77 -54
- package/3p/pi-subagents/src/status-note.ts +25 -0
- package/3p/pi-subagents/src/types.ts +97 -6
- package/3p/pi-subagents/src/ui/agent-widget.ts +94 -21
- package/3p/pi-subagents/src/ui/conversation-viewer.ts +147 -35
- package/3p/pi-subagents/src/ui/schedule-menu.ts +104 -0
- package/3p/pi-subagents/src/ui/viewer-keys.ts +39 -0
- package/3p/pi-subagents/src/usage.ts +60 -0
- package/3p/pi-subagents/src/worktree.ts +49 -20
- package/extensions/orchestrator/agents/advisor.ts +13 -8
- package/extensions/orchestrator/agents/brainstorm-reviewer.ts +8 -3
- package/extensions/orchestrator/agents/code-reviewer.ts +8 -3
- package/extensions/orchestrator/agents/constraints.test.ts +26 -0
- package/extensions/orchestrator/agents/constraints.ts +12 -2
- package/extensions/orchestrator/agents/deep-debugger.ts +13 -8
- package/extensions/orchestrator/agents/explore.ts +12 -6
- package/extensions/orchestrator/agents/librarian.ts +13 -5
- package/extensions/orchestrator/agents/plan-reviewer.ts +8 -3
- package/extensions/orchestrator/agents/planner.ts +8 -3
- package/extensions/orchestrator/agents/pools.test.ts +56 -0
- package/extensions/orchestrator/agents/prompts.test.ts +52 -10
- package/extensions/orchestrator/agents/registry.test.ts +245 -0
- package/extensions/orchestrator/agents/registry.ts +145 -10
- package/extensions/orchestrator/agents/reviewer.ts +14 -8
- package/extensions/orchestrator/agents/task.ts +12 -6
- package/extensions/orchestrator/agents/tool-routing.ts +213 -85
- package/extensions/orchestrator/agents/wait-for-completion.test.ts +171 -0
- package/extensions/orchestrator/ast-search.test.ts +124 -0
- package/extensions/orchestrator/billing-spoof.test.ts +67 -0
- package/extensions/orchestrator/billing-spoof.ts +95 -0
- package/extensions/orchestrator/cbm.more.test.ts +358 -0
- package/extensions/orchestrator/command-handlers.ts +6 -0
- package/extensions/orchestrator/commands.test.ts +15 -2
- package/extensions/orchestrator/commands.ts +1 -1
- package/extensions/orchestrator/config.test.ts +89 -1
- package/extensions/orchestrator/config.ts +102 -19
- package/extensions/orchestrator/context.test.ts +46 -0
- package/extensions/orchestrator/context.ts +18 -5
- package/extensions/orchestrator/custom-footer.test.ts +24 -10
- package/extensions/orchestrator/custom-footer.ts +4 -2
- package/extensions/orchestrator/doctor.more.test.ts +315 -0
- package/extensions/orchestrator/doctor.ts +6 -2
- package/extensions/orchestrator/event-handlers.more.test.ts +561 -0
- package/extensions/orchestrator/event-handlers.test.ts +96 -9
- package/extensions/orchestrator/event-handlers.ts +344 -151
- package/extensions/orchestrator/exa.more.test.ts +118 -0
- package/extensions/orchestrator/flant-infra.more.test.ts +326 -0
- package/extensions/orchestrator/flant-infra.test.ts +127 -0
- package/extensions/orchestrator/flant-infra.ts +113 -39
- package/extensions/orchestrator/index.test.ts +76 -0
- package/extensions/orchestrator/index.ts +2 -0
- package/extensions/orchestrator/integration.test.ts +183 -65
- package/extensions/orchestrator/model-registry.test.ts +2 -1
- package/extensions/orchestrator/model-registry.ts +12 -2
- package/extensions/orchestrator/orchestrator.test.ts +67 -0
- package/extensions/orchestrator/orchestrator.ts +119 -27
- package/extensions/orchestrator/phases/brainstorm.test.ts +30 -1
- package/extensions/orchestrator/phases/brainstorm.ts +43 -6
- package/extensions/orchestrator/phases/implementation.ts +2 -0
- package/extensions/orchestrator/phases/machine.test.ts +17 -1
- package/extensions/orchestrator/phases/machine.ts +4 -1
- package/extensions/orchestrator/phases/planning.test.ts +47 -1
- package/extensions/orchestrator/phases/planning.ts +18 -3
- package/extensions/orchestrator/phases/review-task.ts +5 -0
- package/extensions/orchestrator/phases/review.test.ts +10 -0
- package/extensions/orchestrator/phases/review.ts +22 -7
- package/extensions/orchestrator/phases/spawn-blocking.test.ts +1 -0
- package/extensions/orchestrator/phases/verdict.ts +6 -5
- package/extensions/orchestrator/plannotator-open-failure.test.ts +67 -0
- package/extensions/orchestrator/plannotator.test.ts +38 -1
- package/extensions/orchestrator/plannotator.ts +50 -3
- package/extensions/orchestrator/pp-menu.leaves.test.ts +590 -0
- package/extensions/orchestrator/pp-menu.more.test.ts +524 -0
- package/extensions/orchestrator/pp-menu.test.ts +114 -7
- package/extensions/orchestrator/pp-menu.ts +579 -90
- package/extensions/orchestrator/pp-state-tools.test.ts +80 -0
- package/extensions/orchestrator/pp-state-tools.ts +65 -0
- package/extensions/orchestrator/rate-limit-fallback.more.test.ts +241 -0
- package/extensions/orchestrator/rate-limit-fallback.test.ts +20 -1
- package/extensions/orchestrator/rate-limit-fallback.ts +12 -0
- package/extensions/orchestrator/review-files.test.ts +26 -0
- package/extensions/orchestrator/review-files.ts +3 -0
- package/extensions/orchestrator/state.test.ts +73 -1
- package/extensions/orchestrator/state.ts +95 -23
- package/extensions/orchestrator/suppress-pierre-theme-spam.test.ts +56 -0
- package/extensions/orchestrator/suppress-pierre-theme-spam.ts +45 -0
- package/extensions/orchestrator/validate-artifacts.ts +1 -1
- package/package.json +6 -2
- package/scripts/test-3p.sh +52 -0
|
@@ -24,8 +24,13 @@ afterEach(() => {
|
|
|
24
24
|
interface RegisteredTool {
|
|
25
25
|
name: string;
|
|
26
26
|
execute: (id: string, params: any) => Promise<any>;
|
|
27
|
+
renderShell?: string;
|
|
28
|
+
renderCall?: (args: any, theme: any, context: any) => any;
|
|
29
|
+
renderResult?: (result: any, options: any, theme: any, context: any) => any;
|
|
27
30
|
}
|
|
28
31
|
|
|
32
|
+
const theme = { fg: (_c: string, t: string) => t } as any;
|
|
33
|
+
|
|
29
34
|
function setup() {
|
|
30
35
|
const cwd = makeTempCwd();
|
|
31
36
|
const taskDir = join(cwd, ".pp", "state", "brainstorm", "abc_brainstorm");
|
|
@@ -190,3 +195,78 @@ describe("pp_edit_state_file", () => {
|
|
|
190
195
|
expect(readFileSync(join(taskDir, "RESEARCH.md"), "utf-8")).toContain("## Affected Code");
|
|
191
196
|
});
|
|
192
197
|
});
|
|
198
|
+
|
|
199
|
+
describe("state-file tool rendering (hidden on success, visible on failure/hang)", () => {
|
|
200
|
+
for (const name of ["pp_write_state_file", "pp_edit_state_file"]) {
|
|
201
|
+
it(`${name} owns its shell and renders nothing on a settled (non-partial) call`, () => {
|
|
202
|
+
const { tools } = setup();
|
|
203
|
+
const tool = tools.get(name)!;
|
|
204
|
+
expect(tool.renderShell).toBe("self");
|
|
205
|
+
const callComp = tool.renderCall!({}, theme, { isPartial: false });
|
|
206
|
+
expect(callComp.render(80)).toEqual([]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it(`${name} renders a visible line for an in-flight (partial) call — hang visibility`, () => {
|
|
210
|
+
const { tools } = setup();
|
|
211
|
+
const tool = tools.get(name)!;
|
|
212
|
+
const callComp = tool.renderCall!({}, theme, { isPartial: true });
|
|
213
|
+
expect(callComp.render(80).length).toBe(1);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it(`${name} renders nothing on a successful result`, () => {
|
|
217
|
+
const { tools } = setup();
|
|
218
|
+
const tool = tools.get(name)!;
|
|
219
|
+
const comp = tool.renderResult!({ content: [{ type: "text", text: "Created X" }] }, { isPartial: false }, theme, { isError: false });
|
|
220
|
+
expect(comp.render(80)).toEqual([]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it(`${name} renders a visible line while partial (hang)`, () => {
|
|
224
|
+
const { tools } = setup();
|
|
225
|
+
const tool = tools.get(name)!;
|
|
226
|
+
const comp = tool.renderResult!({ content: [] }, { isPartial: true }, theme, { isError: false });
|
|
227
|
+
expect(comp.render(80).length).toBe(1);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it(`${name} renders a visible line on error`, () => {
|
|
231
|
+
const { tools } = setup();
|
|
232
|
+
const tool = tools.get(name)!;
|
|
233
|
+
const comp = tool.renderResult!({ content: [{ type: "text", text: "boom" }], isError: true }, { isPartial: false }, theme, { isError: true });
|
|
234
|
+
const rows = comp.render(80);
|
|
235
|
+
expect(rows.length).toBe(1);
|
|
236
|
+
expect(rows[0]).toContain("boom");
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("state-file writes invalidate a clean review (review pass 3)", () => {
|
|
242
|
+
function setupWithState() {
|
|
243
|
+
const cwd = makeTempCwd();
|
|
244
|
+
const taskDir = join(cwd, ".pp", "state", "brainstorm", "abc_brainstorm");
|
|
245
|
+
mkdirSync(join(taskDir, "artifacts"), { recursive: true });
|
|
246
|
+
const tools = new Map<string, RegisteredTool>();
|
|
247
|
+
const state: any = { reviewApprovedClean: true };
|
|
248
|
+
const orchestrator: any = {
|
|
249
|
+
cwd,
|
|
250
|
+
active: { dir: taskDir, state },
|
|
251
|
+
pi: { registerTool: (t: RegisteredTool) => tools.set(t.name, t) },
|
|
252
|
+
};
|
|
253
|
+
registerStateFileTools(orchestrator);
|
|
254
|
+
return { taskDir, tools, state };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
it("pp_write_state_file clears reviewApprovedClean", async () => {
|
|
258
|
+
const { tools, state } = setupWithState();
|
|
259
|
+
const res = await tools.get("pp_write_state_file")!.execute("1", { path: "RESEARCH.md", content: VALID_RESEARCH });
|
|
260
|
+
expect(res.isError).toBeFalsy();
|
|
261
|
+
expect(state.reviewApprovedClean).toBe(false);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("pp_edit_state_file clears reviewApprovedClean", async () => {
|
|
265
|
+
const { taskDir, tools, state } = setupWithState();
|
|
266
|
+
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
267
|
+
state.reviewApprovedClean = true;
|
|
268
|
+
const res = await tools.get("pp_edit_state_file")!.execute("1", { path: "RESEARCH.md", oldText: "does a thing", newText: "does another thing" });
|
|
269
|
+
expect(res.isError).toBeFalsy();
|
|
270
|
+
expect(state.reviewApprovedClean).toBe(false);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
2
2
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "path";
|
|
3
3
|
import { Type } from "@sinclair/typebox";
|
|
4
|
+
import { Container, Text, type Component } from "@earendil-works/pi-tui";
|
|
4
5
|
import type { Orchestrator } from "./orchestrator.js";
|
|
6
|
+
import { saveTask } from "./state.js";
|
|
5
7
|
import { getLogger } from "./log.js";
|
|
6
8
|
import {
|
|
7
9
|
validateArtifact,
|
|
@@ -147,12 +149,67 @@ function validationError(label: string, errors: string[], hint: string): ToolRes
|
|
|
147
149
|
);
|
|
148
150
|
}
|
|
149
151
|
|
|
152
|
+
// Minimal-on-success rendering for the state-file tools. With renderShell:"self"
|
|
153
|
+
// the tool owns its inner framing: renderCall shows a line only while the call is
|
|
154
|
+
// in flight (isPartial) so a HANG is visible, and renders nothing once settled;
|
|
155
|
+
// renderResult renders nothing on success and a visible line on failure/hang.
|
|
156
|
+
// Net on success: no tool title, no args echo, no diff, no result text.
|
|
157
|
+
// NOTE: the host ToolExecutionComponent still prepends one unconditional
|
|
158
|
+
// Spacer(1) and treats any returned renderer as "content" (so its full
|
|
159
|
+
// hideComponent collapse path is not taken), meaning a successful write leaves at
|
|
160
|
+
// most a single blank line rather than truly zero rows. That is the intended
|
|
161
|
+
// fallback: it eliminates the full-file/diff "state-file spam" while keeping a
|
|
162
|
+
// hang (isPartial) or failure (isError) visible with an explicit line. Fully
|
|
163
|
+
// removing the residual blank line would require an upstream host change (the
|
|
164
|
+
// Spacer is host-owned).
|
|
165
|
+
function emptyComponent(): Component {
|
|
166
|
+
return new Container();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function lineComponent(text: string): Component {
|
|
170
|
+
const c = new Container();
|
|
171
|
+
c.addChild(new Text(text, 0, 0));
|
|
172
|
+
return c;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function renderStateCall(_args: unknown, theme: any, context: any): Component {
|
|
176
|
+
// The host re-runs BOTH renderCall and renderResult every cycle and stacks
|
|
177
|
+
// their output, so renderCall must stay empty once a result exists — otherwise
|
|
178
|
+
// a "Saving state…" line would persist onto a successful (silent) write. While
|
|
179
|
+
// the call is still in flight (isPartial, no final result yet) we DO show the
|
|
180
|
+
// line, so a genuine HANG before any result is visible. context.isPartial is
|
|
181
|
+
// true during execution and set false when the final result arrives.
|
|
182
|
+
if (context?.isPartial) return lineComponent(theme.fg("warning", "◆ [PI-PI] Saving state…"));
|
|
183
|
+
return emptyComponent();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function renderStateResult(result: ToolResult, options: { isPartial?: boolean }, theme: any, context: any): Component {
|
|
187
|
+
if (options?.isPartial) return lineComponent(theme.fg("warning", "◆ [PI-PI] Saving state…"));
|
|
188
|
+
if (result?.isError || context?.isError) {
|
|
189
|
+
const text = result?.content?.[0]?.type === "text" ? result.content[0].text.split("\n")[0] : "State write failed";
|
|
190
|
+
return lineComponent(theme.fg("error", text));
|
|
191
|
+
}
|
|
192
|
+
return emptyComponent();
|
|
193
|
+
}
|
|
194
|
+
|
|
150
195
|
// Dedicated compact-output tools for editing .pp state files. Unlike the generic
|
|
151
196
|
// write/edit tools (which render the full file content / a unified diff into the
|
|
152
197
|
// UI on every update — the "state-file spam"), these return only a one-line
|
|
153
198
|
// summary and no details.diff, so the TUI shows nothing large. Structure is
|
|
154
199
|
// validated inline (rejecting bad writes up front) instead of via an appended
|
|
155
200
|
// <validation-error> round-trip on the generic tools.
|
|
201
|
+
// These tools only ever write the reviewed artifact set (USER_REQUEST.md,
|
|
202
|
+
// RESEARCH.md, artifacts/*.md, plans/*_synthesized.md), so a successful write
|
|
203
|
+
// invalidates any prior clean review for the phase — otherwise "Auto review,
|
|
204
|
+
// then continue" would skip re-review over content changed through the preferred
|
|
205
|
+
// state-file tools (the generic edit/write hook does not see these writes).
|
|
206
|
+
function invalidateCleanReview(orchestrator: Orchestrator): void {
|
|
207
|
+
const active = orchestrator.active;
|
|
208
|
+
if (!active?.state?.reviewApprovedClean) return;
|
|
209
|
+
active.state.reviewApprovedClean = false;
|
|
210
|
+
try { saveTask(active.dir, active.state); } catch { /* best-effort */ }
|
|
211
|
+
}
|
|
212
|
+
|
|
156
213
|
export function registerStateFileTools(orchestrator: Orchestrator): void {
|
|
157
214
|
const pi = orchestrator.pi;
|
|
158
215
|
const log = getLogger();
|
|
@@ -160,6 +217,9 @@ export function registerStateFileTools(orchestrator: Orchestrator): void {
|
|
|
160
217
|
pi.registerTool({
|
|
161
218
|
name: "pp_write_state_file",
|
|
162
219
|
label: "pi-pi",
|
|
220
|
+
renderShell: "self",
|
|
221
|
+
renderCall: renderStateCall,
|
|
222
|
+
renderResult: renderStateResult as any,
|
|
163
223
|
description:
|
|
164
224
|
"Create or overwrite a pi-pi state file (USER_REQUEST.md, RESEARCH.md, artifacts/*.md, " +
|
|
165
225
|
"or plans/*_synthesized.md) under the active task directory. PREFER this over the generic " +
|
|
@@ -188,6 +248,7 @@ export function registerStateFileTools(orchestrator: Orchestrator): void {
|
|
|
188
248
|
return err(`Failed to write ${resolved.label}: ${e?.message ?? String(e)}`);
|
|
189
249
|
}
|
|
190
250
|
const { added, removed } = lineDelta(before, content);
|
|
251
|
+
invalidateCleanReview(orchestrator);
|
|
191
252
|
log.debug({ s: "tool", tool: "pp_write_state_file", path: resolved.label, added, removed }, "state file written");
|
|
192
253
|
const verb = existed ? "Updated" : "Created";
|
|
193
254
|
return ok(`${verb} ${resolved.label} (+${added}/-${removed} lines)`);
|
|
@@ -197,6 +258,9 @@ export function registerStateFileTools(orchestrator: Orchestrator): void {
|
|
|
197
258
|
pi.registerTool({
|
|
198
259
|
name: "pp_edit_state_file",
|
|
199
260
|
label: "pi-pi",
|
|
261
|
+
renderShell: "self",
|
|
262
|
+
renderCall: renderStateCall,
|
|
263
|
+
renderResult: renderStateResult as any,
|
|
200
264
|
description:
|
|
201
265
|
"Edit a pi-pi state file in place by replacing an exact text span. PREFER this over the " +
|
|
202
266
|
"generic edit tool for .pp state files (USER_REQUEST.md, RESEARCH.md, artifacts/*.md, " +
|
|
@@ -238,6 +302,7 @@ export function registerStateFileTools(orchestrator: Orchestrator): void {
|
|
|
238
302
|
return err(`Failed to write ${resolved.label}: ${e?.message ?? String(e)}`);
|
|
239
303
|
}
|
|
240
304
|
const { added, removed } = lineDelta(before, after);
|
|
305
|
+
invalidateCleanReview(orchestrator);
|
|
241
306
|
log.debug(
|
|
242
307
|
{ s: "tool", tool: "pp_edit_state_file", path: resolved.label, occurrences, replaceAll, added, removed },
|
|
243
308
|
"state file edited",
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mocks = vi.hoisted(() => ({
|
|
4
|
+
isSubscriptionRouted: vi.fn(),
|
|
5
|
+
setSubscriptionFallbackActive: vi.fn(),
|
|
6
|
+
toNonSubSpec: vi.fn((id: string) => `nonsub:${id}`),
|
|
7
|
+
loadFlantSettings: vi.fn(() => ({ switchBackIntervalMinutes: 30 })),
|
|
8
|
+
probeSubscriptionCleared: vi.fn(),
|
|
9
|
+
askUser: vi.fn(),
|
|
10
|
+
isCancel: vi.fn(() => false),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock("./usage-tracker.js", () => ({
|
|
14
|
+
isSubscriptionRouted: mocks.isSubscriptionRouted,
|
|
15
|
+
}));
|
|
16
|
+
vi.mock("./model-registry.js", () => ({
|
|
17
|
+
setSubscriptionFallbackActive: mocks.setSubscriptionFallbackActive,
|
|
18
|
+
toNonSubSpec: mocks.toNonSubSpec,
|
|
19
|
+
}));
|
|
20
|
+
vi.mock("./flant-infra.js", () => ({
|
|
21
|
+
loadFlantSettings: mocks.loadFlantSettings,
|
|
22
|
+
probeSubscriptionCleared: mocks.probeSubscriptionCleared,
|
|
23
|
+
}));
|
|
24
|
+
vi.mock("../../3p/pi-ask-user/index.js", () => ({
|
|
25
|
+
askUser: mocks.askUser,
|
|
26
|
+
isCancel: mocks.isCancel,
|
|
27
|
+
}));
|
|
28
|
+
vi.mock("./log.js", () => ({
|
|
29
|
+
getLogger: () => ({ debug: vi.fn(), warn: vi.fn(), info: vi.fn() }),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
isRateLimitError,
|
|
34
|
+
isSdkRetryableError,
|
|
35
|
+
handleMainRateLimit,
|
|
36
|
+
handleSubagentRateLimit,
|
|
37
|
+
armSwitchBackProbe,
|
|
38
|
+
} from "./rate-limit-fallback.js";
|
|
39
|
+
|
|
40
|
+
function makeOrchestrator(overrides: Record<string, unknown> = {}): any {
|
|
41
|
+
return {
|
|
42
|
+
subFallbackActive: false,
|
|
43
|
+
subFallbackModelId: null,
|
|
44
|
+
subFallbackDialogPending: false,
|
|
45
|
+
subFallbackPendingDecision: false,
|
|
46
|
+
activeTaskToken: 1,
|
|
47
|
+
active: { state: { phase: "implement" } },
|
|
48
|
+
subSwitchBackTimer: null,
|
|
49
|
+
lastCtx: null,
|
|
50
|
+
config: { agents: { orchestrators: { implement: { thinking: "high" } } } },
|
|
51
|
+
cancelPendingRetry: vi.fn(),
|
|
52
|
+
switchModel: vi.fn(async () => true),
|
|
53
|
+
sendUserMessageWhenIdle: vi.fn(),
|
|
54
|
+
...overrides,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function makeCtx(overrides: Record<string, unknown> = {}): any {
|
|
59
|
+
return {
|
|
60
|
+
hasUI: true,
|
|
61
|
+
abort: vi.fn(),
|
|
62
|
+
ui: { notify: vi.fn() },
|
|
63
|
+
...overrides,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
mocks.isSubscriptionRouted.mockReturnValue(true);
|
|
69
|
+
mocks.isCancel.mockReturnValue(false);
|
|
70
|
+
mocks.loadFlantSettings.mockReturnValue({ switchBackIntervalMinutes: 30 } as any);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
afterEach(() => {
|
|
74
|
+
vi.clearAllMocks();
|
|
75
|
+
vi.useRealTimers();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("isRateLimitError extra shapes", () => {
|
|
79
|
+
it("matches dotted/spaced rate-limit variants", () => {
|
|
80
|
+
expect(isRateLimitError("rate-limit hit")).toBe(true);
|
|
81
|
+
expect(isRateLimitError("RATELIMIT")).toBe(true);
|
|
82
|
+
expect(isRateLimitError("HTTP 429 returned")).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
it("does not match numbers that merely contain 429", () => {
|
|
85
|
+
expect(isRateLimitError("error code 4290")).toBe(false);
|
|
86
|
+
expect(isRateLimitError("connection reset")).toBe(false);
|
|
87
|
+
expect(isRateLimitError(42 as any)).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("isSdkRetryableError extra shapes", () => {
|
|
92
|
+
it("matches a broad set of transient network/server errors", () => {
|
|
93
|
+
for (const m of [
|
|
94
|
+
"provider returned error",
|
|
95
|
+
"502 bad gateway",
|
|
96
|
+
"504",
|
|
97
|
+
"service unavailable",
|
|
98
|
+
"internal error",
|
|
99
|
+
"connection refused",
|
|
100
|
+
"websocket closed",
|
|
101
|
+
"other side closed",
|
|
102
|
+
"upstream connect error",
|
|
103
|
+
"reset before headers",
|
|
104
|
+
"socket hang up",
|
|
105
|
+
"http2 request did not get a response",
|
|
106
|
+
"terminated",
|
|
107
|
+
"retry delay exceeded",
|
|
108
|
+
]) {
|
|
109
|
+
expect(isSdkRetryableError(m)).toBe(true);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
it("rejects plain application errors and non-strings", () => {
|
|
113
|
+
expect(isSdkRetryableError("permission denied")).toBe(false);
|
|
114
|
+
expect(isSdkRetryableError(null as any)).toBe(false);
|
|
115
|
+
expect(isSdkRetryableError(123 as any)).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("handleMainRateLimit", () => {
|
|
120
|
+
it("returns early without aborting when not subscription-routed", async () => {
|
|
121
|
+
mocks.isSubscriptionRouted.mockReturnValue(false);
|
|
122
|
+
const orch = makeOrchestrator();
|
|
123
|
+
const ctx = makeCtx();
|
|
124
|
+
await handleMainRateLimit(orch, ctx, "claude", "provider");
|
|
125
|
+
expect(ctx.abort).not.toHaveBeenCalled();
|
|
126
|
+
expect(orch.cancelPendingRetry).not.toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("aborts and cancels retries, then stops when already on non-sub", async () => {
|
|
130
|
+
const orch = makeOrchestrator({ subFallbackActive: true });
|
|
131
|
+
const ctx = makeCtx();
|
|
132
|
+
await handleMainRateLimit(orch, ctx, "sub/claude", "sub");
|
|
133
|
+
expect(ctx.abort).toHaveBeenCalled();
|
|
134
|
+
expect(orch.cancelPendingRetry).toHaveBeenCalled();
|
|
135
|
+
expect(mocks.askUser).not.toHaveBeenCalled();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("skips the dialog when no UI is available", async () => {
|
|
139
|
+
const orch = makeOrchestrator();
|
|
140
|
+
const ctx = makeCtx({ hasUI: false });
|
|
141
|
+
await handleMainRateLimit(orch, ctx, "sub/claude", "sub");
|
|
142
|
+
expect(mocks.askUser).not.toHaveBeenCalled();
|
|
143
|
+
expect(orch.switchModel).not.toHaveBeenCalled();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("activates fallback and switches the main model on confirm", async () => {
|
|
147
|
+
mocks.askUser.mockResolvedValue({ kind: "selection", selections: ["Switch to non-sub Claude"] });
|
|
148
|
+
vi.useFakeTimers();
|
|
149
|
+
const orch = makeOrchestrator();
|
|
150
|
+
const ctx = makeCtx();
|
|
151
|
+
await handleMainRateLimit(orch, ctx, "sub/claude-opus", "sub");
|
|
152
|
+
expect(orch.subFallbackActive).toBe(true);
|
|
153
|
+
expect(mocks.setSubscriptionFallbackActive).toHaveBeenCalledWith(true);
|
|
154
|
+
expect(mocks.toNonSubSpec).toHaveBeenCalledWith("sub/claude-opus");
|
|
155
|
+
expect(orch.switchModel).toHaveBeenCalled();
|
|
156
|
+
expect(orch.sendUserMessageWhenIdle).toHaveBeenCalled();
|
|
157
|
+
expect(orch.subSwitchBackTimer).not.toBeNull();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("stays on subscription and notifies when the user declines", async () => {
|
|
161
|
+
mocks.askUser.mockResolvedValue({ kind: "selection", selections: ["Stay on subscription"] });
|
|
162
|
+
const orch = makeOrchestrator();
|
|
163
|
+
const ctx = makeCtx();
|
|
164
|
+
await handleMainRateLimit(orch, ctx, "sub/claude", "sub");
|
|
165
|
+
expect(orch.subFallbackActive).toBe(false);
|
|
166
|
+
expect(ctx.ui.notify).toHaveBeenCalled();
|
|
167
|
+
expect(orch.switchModel).not.toHaveBeenCalled();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("does not open a second dialog while one is pending", async () => {
|
|
171
|
+
const orch = makeOrchestrator({ subFallbackDialogPending: true });
|
|
172
|
+
const ctx = makeCtx();
|
|
173
|
+
await handleMainRateLimit(orch, ctx, "sub/claude", "sub");
|
|
174
|
+
expect(mocks.askUser).not.toHaveBeenCalled();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("aborts activation when the task token changes mid-dialog", async () => {
|
|
178
|
+
const orch = makeOrchestrator();
|
|
179
|
+
mocks.askUser.mockImplementation(async () => {
|
|
180
|
+
orch.activeTaskToken = 999;
|
|
181
|
+
return { kind: "selection", selections: ["Switch to non-sub Claude"] };
|
|
182
|
+
});
|
|
183
|
+
const ctx = makeCtx();
|
|
184
|
+
await handleMainRateLimit(orch, ctx, "sub/claude", "sub");
|
|
185
|
+
expect(orch.subFallbackActive).toBe(false);
|
|
186
|
+
expect(orch.switchModel).not.toHaveBeenCalled();
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("handleSubagentRateLimit", () => {
|
|
191
|
+
it("returns early when not subscription-routed", async () => {
|
|
192
|
+
mocks.isSubscriptionRouted.mockReturnValue(false);
|
|
193
|
+
const orch = makeOrchestrator();
|
|
194
|
+
await handleSubagentRateLimit(orch, makeCtx(), "claude");
|
|
195
|
+
expect(mocks.askUser).not.toHaveBeenCalled();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("returns early when already on non-sub", async () => {
|
|
199
|
+
const orch = makeOrchestrator({ subFallbackActive: true });
|
|
200
|
+
await handleSubagentRateLimit(orch, makeCtx(), "sub/claude");
|
|
201
|
+
expect(mocks.askUser).not.toHaveBeenCalled();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("activates fallback WITHOUT switching the main model for a subagent 429", async () => {
|
|
205
|
+
mocks.askUser.mockResolvedValue({ kind: "selection", selections: ["Switch to non-sub Claude"] });
|
|
206
|
+
vi.useFakeTimers();
|
|
207
|
+
const orch = makeOrchestrator();
|
|
208
|
+
await handleSubagentRateLimit(orch, makeCtx(), "sub/claude-haiku");
|
|
209
|
+
expect(mocks.setSubscriptionFallbackActive).toHaveBeenCalledWith(true);
|
|
210
|
+
expect(orch.switchModel).not.toHaveBeenCalled();
|
|
211
|
+
expect(orch.subFallbackActive).toBe(true);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("armSwitchBackProbe", () => {
|
|
216
|
+
it("schedules a timer using the configured interval and clears any prior timer", () => {
|
|
217
|
+
vi.useFakeTimers();
|
|
218
|
+
const prior = setTimeout(() => {}, 100000) as any;
|
|
219
|
+
const orch = makeOrchestrator({ subSwitchBackTimer: prior });
|
|
220
|
+
armSwitchBackProbe(orch);
|
|
221
|
+
expect(orch.subSwitchBackTimer).not.toBeNull();
|
|
222
|
+
expect(orch.subSwitchBackTimer).not.toBe(prior);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("floors the interval to at least one minute", () => {
|
|
226
|
+
vi.useFakeTimers();
|
|
227
|
+
mocks.loadFlantSettings.mockReturnValue({ switchBackIntervalMinutes: 0 } as any);
|
|
228
|
+
const orch = makeOrchestrator();
|
|
229
|
+
armSwitchBackProbe(orch);
|
|
230
|
+
expect(orch.subSwitchBackTimer).not.toBeNull();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("runs the probe when the timer fires and stays on non-sub while still limited", async () => {
|
|
234
|
+
vi.useFakeTimers();
|
|
235
|
+
mocks.probeSubscriptionCleared.mockResolvedValue("rate_limited");
|
|
236
|
+
const orch = makeOrchestrator({ subFallbackActive: true, subFallbackModelId: "sub/claude" });
|
|
237
|
+
armSwitchBackProbe(orch);
|
|
238
|
+
await vi.runOnlyPendingTimersAsync();
|
|
239
|
+
expect(mocks.probeSubscriptionCleared).toHaveBeenCalledWith("sub/claude");
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { isRateLimitError, isSdkRetryableError } from "./rate-limit-fallback.js";
|
|
2
|
+
import { isRateLimitError, isExtraUsageError, isSdkRetryableError } from "./rate-limit-fallback.js";
|
|
3
3
|
import { isSubscriptionRouted } from "./usage-tracker.js";
|
|
4
4
|
import { SUB_MODEL_PREFIX, SUB_PROVIDER, subProbeModelId } from "./flant-infra.js";
|
|
5
5
|
|
|
@@ -18,6 +18,25 @@ describe("isRateLimitError", () => {
|
|
|
18
18
|
expect(isRateLimitError("")).toBe(false);
|
|
19
19
|
expect(isRateLimitError(undefined)).toBe(false);
|
|
20
20
|
});
|
|
21
|
+
|
|
22
|
+
it("does not match the 400 extra-usage message (kept separate from 429)", () => {
|
|
23
|
+
expect(isRateLimitError("Third-party apps now draw from extra usage, not plan limits")).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("isExtraUsageError", () => {
|
|
28
|
+
it("matches the subscription 400 extra-usage phrasing", () => {
|
|
29
|
+
expect(isExtraUsageError("400 Third-party apps now draw from extra usage, not plan limits")).toBe(true);
|
|
30
|
+
expect(isExtraUsageError("you are out of extra usage")).toBe(true);
|
|
31
|
+
expect(isExtraUsageError("requests now draw from your extra usage balance")).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("does not match 429 or unrelated errors (no over-match)", () => {
|
|
35
|
+
expect(isExtraUsageError("Error 429: too many requests")).toBe(false);
|
|
36
|
+
expect(isExtraUsageError("500 internal server error")).toBe(false);
|
|
37
|
+
expect(isExtraUsageError("")).toBe(false);
|
|
38
|
+
expect(isExtraUsageError(undefined)).toBe(false);
|
|
39
|
+
});
|
|
21
40
|
});
|
|
22
41
|
|
|
23
42
|
describe("isSdkRetryableError", () => {
|
|
@@ -11,6 +11,18 @@ export function isRateLimitError(message?: string): boolean {
|
|
|
11
11
|
return /\b429\b|rate.?limit|too many requests|exceed your account/i.test(message);
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
// Recognise the subscription-routing-specific 400 "extra usage" error, e.g.
|
|
15
|
+
// "Third-party apps now draw from extra usage, not plan limits". This is a
|
|
16
|
+
// DISTINCT failure class from 429 (not retryable, not a plan rate-limit) but is
|
|
17
|
+
// funneled into the SAME sub→non-sub fallback. Kept separate from
|
|
18
|
+
// isRateLimitError so the 429 regex stays clean. Anchored on the specific
|
|
19
|
+
// phrasing to avoid over-matching other 400s; the CALLER additionally requires
|
|
20
|
+
// isSubscriptionRouted.
|
|
21
|
+
export function isExtraUsageError(message?: string): boolean {
|
|
22
|
+
if (typeof message !== "string" || !message) return false;
|
|
23
|
+
return /extra usage|draw from[\s\S]{0,40}plan limits/i.test(message);
|
|
24
|
+
}
|
|
25
|
+
|
|
14
26
|
// Mirror of the SDK's AgentSession._isRetryableError classifier
|
|
15
27
|
// (agent-session.js: overloaded/rate-limit/5xx/network/stream-ended/timeout/...).
|
|
16
28
|
// When true, the SDK auto-retries the SAME turn itself (abortable backoff bound
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { isReviewFileForRound } from "./review-files.js";
|
|
3
|
+
|
|
4
|
+
describe("isReviewFileForRound", () => {
|
|
5
|
+
it("matches the exact round suffix", () => {
|
|
6
|
+
expect(isReviewFileForRound("001_alpha_round-1.md", 1)).toBe(true);
|
|
7
|
+
expect(isReviewFileForRound("42_gemini_round-3.md", 3)).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("does not let round-1 match round-10 through round-19", () => {
|
|
11
|
+
expect(isReviewFileForRound("001_alpha_round-10.md", 1)).toBe(false);
|
|
12
|
+
expect(isReviewFileForRound("001_alpha_round-11.md", 1)).toBe(false);
|
|
13
|
+
expect(isReviewFileForRound("001_alpha_round-19.md", 1)).toBe(false);
|
|
14
|
+
expect(isReviewFileForRound("001_alpha_round-10.md", 10)).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("excludes synthesized final-pass files", () => {
|
|
18
|
+
expect(isReviewFileForRound("001_final_pass-1.md", 1)).toBe(false);
|
|
19
|
+
expect(isReviewFileForRound("001_final_pass-10.md", 1)).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("requires the .md extension", () => {
|
|
23
|
+
expect(isReviewFileForRound("001_alpha_round-1.txt", 1)).toBe(false);
|
|
24
|
+
expect(isReviewFileForRound("001_alpha_round-1", 1)).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
2
2
|
import { tmpdir } from "os";
|
|
3
|
-
import { join, resolve } from "path";
|
|
3
|
+
import { join, resolve, relative } from "path";
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import lockfile from "proper-lockfile";
|
|
6
6
|
import {
|
|
7
7
|
createTask,
|
|
8
8
|
formatModeIndicator,
|
|
9
9
|
getActiveTask,
|
|
10
|
+
getActiveTaskStatus,
|
|
10
11
|
getEffectiveMode,
|
|
11
12
|
getFirstPhase,
|
|
12
13
|
listTasks,
|
|
@@ -152,6 +153,23 @@ describe("listTasks", () => {
|
|
|
152
153
|
expect(brainstormOnly[0].type).toBe("brainstorm");
|
|
153
154
|
});
|
|
154
155
|
|
|
156
|
+
it("includes done tasks when includeDone is set", () => {
|
|
157
|
+
const cwd = makeCwd();
|
|
158
|
+
const active = createTask(cwd, "implement", "Active feature");
|
|
159
|
+
const doneTask = createTask(cwd, "implement", "Finished feature");
|
|
160
|
+
const doneState = loadTask(doneTask);
|
|
161
|
+
doneState.phase = "done";
|
|
162
|
+
saveTask(doneTask, doneState);
|
|
163
|
+
|
|
164
|
+
const activeOnly = listTasks(cwd, { type: "implement" }).map((t) => t.dir);
|
|
165
|
+
expect(activeOnly).toContain(active);
|
|
166
|
+
expect(activeOnly).not.toContain(doneTask);
|
|
167
|
+
|
|
168
|
+
const withDone = listTasks(cwd, { type: "implement", includeDone: true }).map((t) => t.dir);
|
|
169
|
+
expect(withDone).toContain(active);
|
|
170
|
+
expect(withDone).toContain(doneTask);
|
|
171
|
+
});
|
|
172
|
+
|
|
155
173
|
it("skips corrupt task entries", () => {
|
|
156
174
|
const cwd = makeCwd();
|
|
157
175
|
createTask(cwd, "implement", "Healthy task");
|
|
@@ -219,6 +237,17 @@ describe("validateFromPath", () => {
|
|
|
219
237
|
reason: "No state.json found at . — not a valid task directory",
|
|
220
238
|
});
|
|
221
239
|
});
|
|
240
|
+
|
|
241
|
+
it("accepts the stateDir-relative form the From fork derives from an absolute dir", () => {
|
|
242
|
+
// startTask receives an absolute fromTaskDir and feeds validateFromPath the
|
|
243
|
+
// stateDir-relative form (the same shape stored in state.from). This mirrors
|
|
244
|
+
// that conversion and confirms the guard accepts it and returns the absolute dir.
|
|
245
|
+
const cwd = makeCwd();
|
|
246
|
+
const absoluteTaskDir = createTask(cwd, "brainstorm", "Source");
|
|
247
|
+
const rel = relative(join(cwd, ".pp", "state"), absoluteTaskDir);
|
|
248
|
+
|
|
249
|
+
expect(validateFromPath(cwd, rel)).toEqual({ ok: true, dir: absoluteTaskDir });
|
|
250
|
+
});
|
|
222
251
|
});
|
|
223
252
|
|
|
224
253
|
describe("taskName", () => {
|
|
@@ -235,6 +264,21 @@ describe("taskName", () => {
|
|
|
235
264
|
|
|
236
265
|
expect(taskName(taskDir)).toBe("my fallback name");
|
|
237
266
|
});
|
|
267
|
+
|
|
268
|
+
it("falls back to RESEARCH.md when description is generic and no USER_REQUEST.md", () => {
|
|
269
|
+
const cwd = makeCwd();
|
|
270
|
+
const taskDir = createTask(cwd, "review", "review");
|
|
271
|
+
writeFileSync(join(taskDir, "RESEARCH.md"), "## Affected Code\nReview the payment refactor\n", "utf-8");
|
|
272
|
+
expect(taskName(taskDir)).toBe("Review the payment refactor");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("prefers USER_REQUEST.md over RESEARCH.md for a generic description", () => {
|
|
276
|
+
const cwd = makeCwd();
|
|
277
|
+
const taskDir = createTask(cwd, "brainstorm", "brainstorm");
|
|
278
|
+
writeFileSync(join(taskDir, "USER_REQUEST.md"), "# User Request\nAdd dark mode\n", "utf-8");
|
|
279
|
+
writeFileSync(join(taskDir, "RESEARCH.md"), "## Affected Code\nsomething else\n", "utf-8");
|
|
280
|
+
expect(taskName(taskDir)).toBe("Add dark mode");
|
|
281
|
+
});
|
|
238
282
|
});
|
|
239
283
|
|
|
240
284
|
describe("taskAge", () => {
|
|
@@ -371,6 +415,34 @@ describe("getActiveTask", () => {
|
|
|
371
415
|
});
|
|
372
416
|
});
|
|
373
417
|
|
|
418
|
+
describe("getActiveTaskStatus", () => {
|
|
419
|
+
it("reports none when no tasks exist", () => {
|
|
420
|
+
const cwd = makeCwd();
|
|
421
|
+
expect(getActiveTaskStatus(cwd).kind).toBe("none");
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("reports single for exactly one unlocked task", () => {
|
|
425
|
+
const cwd = makeCwd();
|
|
426
|
+
const taskDir = createTask(cwd, "implement", "Only one");
|
|
427
|
+
vi.spyOn(lockfile, "checkSync").mockReturnValue(false);
|
|
428
|
+
|
|
429
|
+
const status = getActiveTaskStatus(cwd);
|
|
430
|
+
expect(status.kind).toBe("single");
|
|
431
|
+
if (status.kind === "single") expect(status.task.dir).toBe(taskDir);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("reports ambiguous with all tasks when multiple are unlocked", () => {
|
|
435
|
+
const cwd = makeCwd();
|
|
436
|
+
createTask(cwd, "implement", "First");
|
|
437
|
+
createTask(cwd, "debug", "Second");
|
|
438
|
+
vi.spyOn(lockfile, "checkSync").mockReturnValue(false);
|
|
439
|
+
|
|
440
|
+
const status = getActiveTaskStatus(cwd);
|
|
441
|
+
expect(status.kind).toBe("ambiguous");
|
|
442
|
+
if (status.kind === "ambiguous") expect(status.tasks).toHaveLength(2);
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
374
446
|
describe("getFirstPhase", () => {
|
|
375
447
|
it("returns expected first phase for each task type", () => {
|
|
376
448
|
expect(getFirstPhase("implement")).toBe("brainstorm");
|