Files
openrig/packages/cli/test/up-attention-render.test.ts
Mike Schwarz 67a59f4ac9 feat: a Claude seat's own agent, prompt file and plugin folders (#1737)
A Claude member in a rig spec can bring its own agent, an appended system prompt file and plugin folders, and that seat (only that seat) launches with them, every time it launches:

    members:
      - id: reviewer
        runtime: claude-code
        agent_file: claude/agents/reviewer.md
        append_system_prompt_file: claude/prompts/house.md
        plugin_dirs: [claude/plugins/review]

- The three keys are Claude-only and resolve relative to the spec. OpenRig copies the files into the seat's own storage, beside its skills plugin, and never into the project.
- Every Claude launch passes the copies: fresh, resume, restore, handover, first-start retry and fork. They go through `--agent openrig:<name>`, `--append-system-prompt-file` and `CLAUDE_CODE_PLUGIN_DIRS`. A seat without additions launches exactly as before.
- The fresh-launch plan, the seat view and `rig skill loadout --seat` list a seat's additions. A missing or colliding one fails that seat by name, and `rig up` prints each failed seat's error.
- Identity proof reads `--agent` and the prompt file. A resume reports whether the conversation started with the current prompt (`system_prompt_pending` or `system_prompt_unknown`).
- `rig fork` copies the source seat's additions into the new seat after it's validated, and says when it carried only part of them. If copying fails partway, the files already written stay in seat storage, and the command reports them.

Native checks ran on an earlier head of this work, not on this one; a native fork is untested.

Cost: source +766/-24, tests +1081/-4, docs +3 (48 files).

Refs #875
2026-10-21 19:28:37 -07:00

105 lines
5.6 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { Command } from "commander";
import { upCommand } from "../src/commands/up.js";
import { STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js";
import type { DaemonClient } from "../src/client.js";
import { buildAttentionResponse } from "../../daemon/src/routes/up.js";
const attentionNodes = [
{ logicalId: "dev.owner", sessionName: "dev-owner@first", reason: "update_gate", evidence: "Update available!" },
{ logicalId: "dev.checker", sessionName: "dev-checker@first", reason: "returned_to_shell", evidence: "$" },
];
function attentionBody() {
const result = { rigId: "first", stages: [{ stage: "import_rig", status: "blocked",
detail: { code: "attention_required", message: "2 members need attention.", attentionNodes } }] };
return { ...result, ...buildAttentionResponse(result)! };
}
// Execute the actual command and daemon response builder; transport/lifecycle
// are injected. No listener, daemon, tmux or provider process is started.
async function render(data: Record<string, unknown>, status = 409, json = false) {
const oldExit = process.exitCode;
const out: string[] = [], err: string[] = [];
const log = vi.spyOn(console, "log").mockImplementation((...s) => { out.push(s.join(" ")); });
const error = vi.spyOn(console, "error").mockImplementation((...s) => { err.push(s.join(" ")); });
const forbidden = () => { throw new Error("unexpected lifecycle operation"); };
const lifecycleDeps = {
spawn: forbidden, kill: forbidden, writeFile: forbidden, removeFile: forbidden,
mkdirp: forbidden, openForAppend: forbidden,
fetch: async () => ({ ok: true }), isProcessAlive: () => true,
exists: (p: string) => p === STATE_FILE,
readFile: (p: string) => p === STATE_FILE ? JSON.stringify({ pid: 123, port: 12345, db: "mock", startedAt: "2000-01-01" }) : null,
} as LifecycleDeps;
const client = { get: vi.fn(async () => ({ status: 200, data: [] })),
post: vi.fn(async () => ({ status, data })) };
process.exitCode = undefined;
try {
const cmd = new Command().exitOverride().addCommand(upCommand({
lifecycleDeps, clientFactory: () => client as unknown as DaemonClient,
preflightExec: forbidden,
}));
await cmd.parseAsync(["node", "rig", "up", "first-project", ...(json ? ["--json"] : [])]);
expect(client.post).toHaveBeenCalledTimes(1);
return { out: out.join("\n"), err: err.join("\n"), exit: process.exitCode };
} finally { process.exitCode = oldExit; log.mockRestore(); error.mockRestore(); }
}
describe("startup attention guidance", () => {
it("shows pending startup guidance on a partial fresh up and preserves JSON and exits", async () => {
const reason = "Permission consent needs attention; After resolving it in dev-owner@first, run: rig seat continue 'dev-owner@first'";
const data = { status: "partial", rigId: "first", stages: [{ stage: "import_rig", status: "blocked", detail: {
attentionNodes: [{ logicalId: "dev.owner", sessionName: "dev-owner@first", reason }],
} }] };
const human = await render(data, 200);
expect(human.out).toContain(`Startup attention (dev-owner@first): ${reason}`);
expect(human.out).toContain("Status: partial");
expect(human.exit).toBe(1);
const json = await render(data, 200, true);
expect(json.out).toBe(JSON.stringify(data));
expect(json.err).toBe("");
expect(json.exit).toBeUndefined();
});
it("names a seat's launch error on a partial fresh up (OPR.0.7.0.9)", async () => {
const error = "addition_missing: the agent file claude/missing.md is missing";
const r = await render({ status: "partial", rigId: "proof", stages: [{ stage: "import_rig", status: "failed", detail: {
nodes: [{ logicalId: "dev.ok", status: "launched" }, { logicalId: "dev.proof", status: "failed", error }] } }] }, 200);
expect(r.err).toContain(`dev.proof: failed: ${error}`);
expect(r.err).not.toContain("dev.ok");
});
it("renders actual structured response and all affected members without spec advice", async () => {
const body = attentionBody();
const r = await render(body);
expect(r.err).toContain(`Error: ${body.error.fact}`);
expect(r.err).toContain(body.error.consequence);
expect(r.err).toContain(body.error.action);
for (const n of attentionNodes) {
expect(r.err).toContain(n.logicalId);
expect(r.err).toContain(n.sessionName);
expect(r.err).toContain(n.reason);
}
expect(r.err).not.toMatch(/\[object Object\]|validate your spec|Sessions are running|answering it.*completes/);
expect(r.out).toContain("import_rig: blocked");
expect(r.exit).toBe(1);
});
it("daemon guidance does not assume a trust prompt or a live runtime", () => {
const { error } = attentionBody();
expect(error.consequence).toContain("attention_required");
expect(error.consequence).toMatch(/not.*proven|not.*confirmed/i);
expect(error.action).toMatch(/inspect/i);
expect(error.action).not.toMatch(/answer.*prompt|parked session/i);
});
it.each([409, 500])("preserves exact JSON body and exit for HTTP %i", async (status) => {
const data = attentionBody();
const r = await render(data, status, true);
expect(r.out).toBe(JSON.stringify(data));
expect(r.err).toBe("");
expect(r.exit).toBe(status === 409 ? 1 : 2);
});
it.each([409, 500])("preserves string fallback and exit for HTTP %i", async (status) => {
const r = await render({ error: "agent_ref resolution failed", stages: [] }, status);
expect(r.err).toContain(`Up failed: agent_ref resolution failed (HTTP ${status})`);
expect(r.err).toContain("local: agent_ref paths resolve relative");
expect(r.exit).toBe(status === 409 ? 1 : 2);
});
});