import assert from "node:assert/strict"; import { after, before, describe, it } from "node:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { buildChainExpressionSteps, hasGroupSyntax, PARALLEL_GROUP_USAGE, parseChainExpression, parseGroupSegment, parseSingleTaskToken, SlashParseError, } from "../../src/slash/slash-commands.ts"; function makeCtx(notifications: string[]): { ui: { notify: (message: string, level?: string) => void }; } { return { ui: { notify: (message: string) => { notifications.push(message); }, }, }; } function makeState(cwd: string) { return { baseCwd: cwd, }; } let tempRoot: string; before(() => { tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-slash-chain-test-")); fs.mkdirSync(path.join(tempRoot, ".selesai", "agents"), { recursive: true }); const agentsDir = path.join(tempRoot, ".selesai", "agents"); const writeAgent = (name: string) => { fs.writeFileSync( path.join(agentsDir, `${name}.md`), `---\nname: ${name}\ndescription: ${name}\n---\nBody\n`, "utf-8", ); }; for (const name of ["scout", "reviewer", "writer", "unknown"]) { writeAgent(name); } }); after(() => { fs.rmSync(tempRoot, { recursive: true, force: true }); }); describe("parseSingleTaskToken", () => { it("parses a quoted task", () => { const parsed = parseSingleTaskToken('reviewer "review auth module"'); assert.equal(parsed.kind, "step"); assert.equal(parsed.name, "reviewer"); assert.equal(parsed.task, "review auth module"); }); it("parses an agent with inline config and no task", () => { const parsed = parseSingleTaskToken( "scout[output=ctx.md,outputMode=file-only]", ); assert.equal(parsed.kind, "step"); assert.equal(parsed.name, "scout"); assert.equal(parsed.config.output, "ctx.md"); assert.equal(parsed.config.outputMode, "file-only"); assert.equal(parsed.task, undefined); }); it("parses a task via -- delimiter", () => { const parsed = parseSingleTaskToken("reviewer -- Review {previous}"); assert.equal(parsed.kind, "step"); assert.equal(parsed.name, "reviewer"); assert.equal(parsed.task, "Review {previous}"); }); it("parses extended metadata in inline config", () => { const parsed = parseSingleTaskToken( 'reviewer[as=rev,label=Review,phase=p1,cwd=sub,count=3,acceptance=checked] "task"', ); assert.equal(parsed.config.as, "rev"); assert.equal(parsed.config.label, "Review"); assert.equal(parsed.config.phase, "p1"); assert.equal(parsed.config.cwd, "sub"); assert.equal(parsed.config.count, 3); assert.equal(parsed.config.acceptance, "checked"); assert.equal(parsed.task, "task"); }); it("ignores a non-positive count", () => { assert.equal(parseSingleTaskToken("scout[count=0]").config.count, undefined); assert.equal(parseSingleTaskToken("scout[count=x]").config.count, undefined); }); }); describe("parseGroupSegment", () => { it("parses a static parallel group with two quoted tasks", () => { const parsed = parseGroupSegment('(reviewer "A" | reviewer "B")'); assert.equal(parsed.kind, "group"); assert.equal(parsed.tasks.length, 2); assert.equal(parsed.tasks[0]?.name, "reviewer"); assert.equal(parsed.tasks[0]?.task, "A"); assert.equal(parsed.tasks[1]?.task, "B"); }); it("rejects groups with a single task", () => { assert.throws( () => parseGroupSegment('(reviewer "A")'), (error: unknown) => error instanceof SlashParseError, ); }); it("rejects groups with unbalanced parentheses", () => { assert.throws( () => parseGroupSegment('(reviewer "A"'), (error: unknown) => error instanceof SlashParseError, ); }); it("parses a trailing group-options suffix", () => { const parsed = parseGroupSegment('(reviewer "A" | reviewer "B")[concurrency=2,failFast,worktree]'); assert.equal(parsed.tasks.length, 2); assert.equal(parsed.config.concurrency, 2); assert.equal(parsed.config.failFast, true); assert.equal(parsed.config.worktree, true); }); it("defaults to an empty group config without a suffix", () => { assert.deepEqual(parseGroupSegment('(a "x" | b "y")').config, {}); }); it("rejects a non-bracketed group suffix", () => { assert.throws( () => parseGroupSegment('(a "x" | b "y") concurrency=2'), (error: unknown) => error instanceof SlashParseError, ); }); }); describe("hasGroupSyntax", () => { it("detects parentheses", () => { assert.equal(hasGroupSyntax("a -> (b | c)"), true); }); it("does not treat a bare pipe as group syntax", () => { assert.equal(hasGroupSyntax("a -> b | c"), false); }); it("ignores parens inside quotes", () => { assert.equal(hasGroupSyntax('a -> b "with (paren) inside"'), false); }); it("returns false for plain chain input", () => { assert.equal(hasGroupSyntax("scout -> reviewer"), false); }); it("does not treat unquoted parens inside a -- task as group syntax", () => { // Regression: a shared-task command must stay on the legacy path even when the // task text contains bare parentheses. A group is a *step* that opens with `(`. assert.equal(hasGroupSyntax("scout -- inspect auth (backend)"), false); assert.equal(hasGroupSyntax("scout -- inspect (auth) -> writer"), false); }); it("still detects a group that opens a step", () => { assert.equal(hasGroupSyntax('scout "x" -> (a "y" | b "z")'), true); assert.equal(hasGroupSyntax('(a "y" | b "z") -> writer'), true); }); }); describe("parseChainExpression", () => { it("parses sequential + group + sequential", () => { const expression = parseChainExpression( 'scout "scan" -> (reviewer "A" | reviewer "B") -> writer "fix"', ); assert.equal(expression.steps.length, 3); const group = expression.steps[1]; assert.ok(group); assert.equal(group.kind, "group"); if (group.kind === "group") { assert.equal(group.tasks.length, 2); } assert.equal(expression.steps[0]?.kind, "step"); assert.equal((expression.steps[0] as { name: string }).name, "scout"); assert.equal((expression.steps[2] as { name: string }).name, "writer"); }); it("rejects expression without arrows", () => { assert.throws( () => parseChainExpression('(reviewer "A" | reviewer "B")'), (error: unknown) => error instanceof SlashParseError && error.message.includes("->"), ); }); it("rejects groups with one task", () => { assert.throws( () => parseChainExpression('scout "scan" -> (reviewer "A")'), (error: unknown) => error instanceof SlashParseError, ); }); it("respects quotes when splitting on arrows", () => { const expression = parseChainExpression( 'scout "scan -> quick" -> reviewer "Review"', ); assert.equal(expression.steps.length, 2); assert.equal( (expression.steps[0] as { task: string }).task, "scan -> quick", ); }); it("allows balanced parens in a -- task after a group", () => { const expression = parseChainExpression( 'scout "scan" -> (reviewer "A" | reviewer "B") -> writer -- fix (backend)', ); assert.equal(expression.steps.length, 3); }); it("still rejects truly unmatched parens in a non-group segment", () => { assert.throws( () => parseChainExpression('scout "scan" -> (reviewer "A" | reviewer "B") -> writer -- fix (backend'), (error: unknown) => error instanceof SlashParseError && error.message.includes("Unmatched parentheses"), ); }); }); describe("buildChainExpressionSteps", () => { it("emits a chain with a { parallel: [...] } step for groups", () => { const notifications: string[] = []; const built = buildChainExpressionSteps( makeState(tempRoot) as never, 'scout "scan" -> (reviewer "A" | reviewer "B") -> writer "fix"', makeCtx(notifications) as never, ); assert.ok(built, "should build a chain"); if (!built) return; assert.deepEqual(notifications, []); assert.equal(built.chain.length, 3); assert.equal( built.chain[0] && "agent" in built.chain[0] ? built.chain[0].agent : undefined, "scout", ); assert.equal( built.chain[1] && "parallel" in built.chain[1] ? built.chain[1].parallel.length : undefined, 2, ); assert.equal( built.chain[2] && "agent" in built.chain[2] ? built.chain[2].agent : undefined, "writer", ); assert.equal(built.task, "scan"); }); it("preserves backward-compatible linear chain behavior", () => { const notifications: string[] = []; const built = buildChainExpressionSteps( makeState(tempRoot) as never, 'scout "scan" -> reviewer "review"', makeCtx(notifications) as never, ); assert.ok(built); if (!built) return; assert.equal(built.chain.length, 2); assert.equal(built.task, "scan"); }); it("falls back to shared task when first step has no task", () => { const notifications: string[] = []; const built = buildChainExpressionSteps( makeState(tempRoot) as never, "scout -> reviewer", makeCtx(notifications) as never, ); assert.equal(built, null); assert.ok( notifications.some((message) => /task/i.test(message)), `notifications: ${notifications.join(" | ")}`, ); }); it("reports parallel group errors as notifications", () => { const notifications: string[] = []; const built = buildChainExpressionSteps( makeState(tempRoot) as never, 'scout "scan" -> (reviewer "A")', makeCtx(notifications) as never, ); assert.equal(built, null); assert.equal(notifications.length, 1); assert.match(notifications[0] ?? "", /at least two/i); }); it("propagates inline metadata onto chain steps", () => { const notifications: string[] = []; const built = buildChainExpressionSteps( makeState(tempRoot) as never, 'scout[as=ctx,label=Scan,phase=recon] "scan" -> reviewer "review"', makeCtx(notifications) as never, ); assert.ok(built); if (!built) return; assert.deepEqual(notifications, []); const first = built.chain[0] as Record; assert.equal(first.as, "ctx"); assert.equal(first.label, "Scan"); assert.equal(first.phase, "recon"); }); it("applies count only inside a parallel group", () => { const notifications: string[] = []; const built = buildChainExpressionSteps( makeState(tempRoot) as never, 'scout[count=2] "scan" -> (reviewer[count=3] "A" | writer "B")', makeCtx(notifications) as never, ); assert.ok(built); if (!built) return; assert.deepEqual(notifications, []); // sequential first step: count ignored assert.equal((built.chain[0] as Record).count, undefined); const parallel = (built.chain[1] as { parallel: Array> }).parallel; assert.equal(parallel[0]?.count, 3); assert.equal(parallel[1]?.count, undefined); }); it("propagates group-level options onto the parallel step", () => { const notifications: string[] = []; const built = buildChainExpressionSteps( makeState(tempRoot) as never, 'scout "scan" -> (reviewer "A" | writer "B")[concurrency=2,failFast]', makeCtx(notifications) as never, ); assert.ok(built); if (!built) return; assert.deepEqual(notifications, []); const group = built.chain[1] as Record; assert.equal(group.concurrency, 2); assert.equal(group.failFast, true); assert.equal(group.worktree, undefined); }); it("rejects an invalid acceptance level", () => { const notifications: string[] = []; const built = buildChainExpressionSteps( makeState(tempRoot) as never, 'scout[acceptance=bogus] "scan" -> reviewer "review"', makeCtx(notifications) as never, ); assert.equal(built, null); assert.equal(notifications.length, 1); assert.match(notifications[0] ?? "", /acceptance/i); }); it("rejects inline acceptance levels that require object contracts", () => { const notifications: string[] = []; const built = buildChainExpressionSteps( makeState(tempRoot) as never, 'scout[acceptance=verified] "scan" -> reviewer "review"', makeCtx(notifications) as never, ); assert.equal(built, null); assert.equal(notifications.length, 1); assert.match(notifications[0] ?? "", /supports auto, attested, or checked/); }); it("loads an inline outputSchema path and rejects a missing one", () => { fs.writeFileSync( path.join(tempRoot, "schema.json"), JSON.stringify({ type: "object" }), "utf-8", ); const ok: string[] = []; const built = buildChainExpressionSteps( makeState(tempRoot) as never, 'scout[outputSchema=schema.json] "scan" -> reviewer "review"', makeCtx(ok) as never, ); assert.ok(built); if (built) { assert.deepEqual((built.chain[0] as Record).outputSchema, { type: "object" }); } const missing: string[] = []; const bad = buildChainExpressionSteps( makeState(tempRoot) as never, 'scout[outputSchema=nope.json] "scan" -> reviewer "review"', makeCtx(missing) as never, ); assert.equal(bad, null); assert.equal(missing.length, 1); assert.match(missing[0] ?? "", /outputSchema/i); }); it("keeps a bare pipe in a -- task on the legacy single-agent path", () => { const notifications: string[] = []; const built = buildChainExpressionSteps( makeState(tempRoot) as never, "scout -- do x | y", makeCtx(notifications) as never, ); assert.ok(built); if (!built) return; assert.deepEqual(notifications, []); assert.equal(built.chain.length, 1); assert.equal(built.task, "do x | y"); assert.equal( built.chain[0] && "task" in built.chain[0] ? built.chain[0].task : undefined, "do x | y", ); }); it("keeps unquoted parens in a -- task on the legacy single-agent path", () => { // Regression: `/chain scout -- inspect auth (backend)` was wrongly rejected as a // malformed group. Bare parens in the shared task must parse as plain task text. const notifications: string[] = []; const built = buildChainExpressionSteps( makeState(tempRoot) as never, "scout -- inspect auth (backend)", makeCtx(notifications) as never, ); assert.ok(built); if (!built) return; assert.deepEqual(notifications, []); assert.equal(built.chain.length, 1); assert.equal(built.task, "inspect auth (backend)"); assert.equal( built.chain[0] && "task" in built.chain[0] ? built.chain[0].task : undefined, "inspect auth (backend)", ); }); it("allows balanced parens in a -- task after a group step", () => { const notifications: string[] = []; const built = buildChainExpressionSteps( makeState(tempRoot) as never, 'scout "scan" -> (reviewer "A" | reviewer "B") -> writer -- fix (backend)', makeCtx(notifications) as never, ); assert.ok(built); if (!built) return; assert.deepEqual(notifications, []); assert.equal(built.chain.length, 3); assert.equal(built.task, "scan"); }); it("exports a stable parallel group usage hint", () => { assert.match(PARALLEL_GROUP_USAGE, /Usage: \/chain/); assert.match(PARALLEL_GROUP_USAGE, /\(/); assert.match(PARALLEL_GROUP_USAGE, /\|/); }); });