feat: report non-terminal PR workflow events (#131)
This commit is contained in:
parent
961c064864
commit
e7ad928812
|
|
@ -0,0 +1,6 @@
|
|||
"@aoagents/ao-core": minor
|
||||
"@aoagents/ao-cli": minor
|
||||
|
||||
---
|
||||
|
||||
Allow workers to report non-terminal PR workflow events like `pr-created`, `draft-pr-created`, and `ready-for-review` with optional PR URL/number metadata, while keeping merged and closed PR state SCM-owned.
|
||||
|
|
@ -43,6 +43,8 @@ async function writeReport(
|
|||
sessionName: string,
|
||||
state: AgentReportedState,
|
||||
note: string | undefined,
|
||||
prUrl: string | undefined,
|
||||
prNumber: number | undefined,
|
||||
source: "acknowledge" | "report",
|
||||
): Promise<void> {
|
||||
const config = loadConfig();
|
||||
|
|
@ -62,6 +64,8 @@ async function writeReport(
|
|||
const result = applyAgentReport(sessionsDir, sessionName, {
|
||||
state,
|
||||
note,
|
||||
prUrl,
|
||||
prNumber,
|
||||
source,
|
||||
actor: process.env["USER"] ?? process.env["LOGNAME"] ?? process.env["USERNAME"],
|
||||
});
|
||||
|
|
@ -72,6 +76,12 @@ async function writeReport(
|
|||
console.log(
|
||||
`${chalk.green("✓")} ${chalk.bold(sessionName)} reported ${chalk.cyan(state)} ${label}`,
|
||||
);
|
||||
if (prUrl || prNumber !== undefined) {
|
||||
const details = [prNumber !== undefined ? `#${prNumber}` : null, prUrl ?? null].filter(
|
||||
(value): value is string => Boolean(value),
|
||||
);
|
||||
console.log(chalk.dim(` PR: ${details.join(" ")}`));
|
||||
}
|
||||
if (note) {
|
||||
console.log(chalk.dim(` note: ${note}`));
|
||||
}
|
||||
|
|
@ -92,7 +102,7 @@ export function registerAcknowledge(program: Command): void {
|
|||
.option("--note <text>", "Optional brief note to include with the acknowledgment")
|
||||
.action(async (session: string | undefined, opts: { note?: string }) => {
|
||||
const sessionId = resolveSessionId(session);
|
||||
await writeReport(sessionId, "started", opts.note, "acknowledge");
|
||||
await writeReport(sessionId, "started", opts.note, undefined, undefined, "acknowledge");
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -103,20 +113,57 @@ export function registerReport(program: Command): void {
|
|||
.description(
|
||||
`Declare a workflow transition (Stage 3). Allowed states: ${allowed} (hyphenated aliases accepted).`,
|
||||
)
|
||||
.argument("<state>", `One of: ${allowed} (aliases: fixing-ci, addressing-reviews, needs-input, ...)`)
|
||||
.argument(
|
||||
"<state>",
|
||||
`One of: ${allowed} (aliases: fixing-ci, addressing-reviews, needs-input, pr-created, ready-for-review, ...)`,
|
||||
)
|
||||
.option("-s, --session <id>", "Session ID (defaults to AO_SESSION_ID)")
|
||||
.option("--note <text>", "Optional brief note to include with the report")
|
||||
.action(async (state: string, opts: { session?: string; note?: string }) => {
|
||||
const canonical = normalizeAgentReportedState(state);
|
||||
if (!canonical) {
|
||||
console.error(
|
||||
chalk.red(
|
||||
`Unknown state: ${state}. Allowed: ${allowed} (or aliases: fixing-ci, addressing-reviews, needs-input).`,
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const sessionId = resolveSessionId(opts.session);
|
||||
await writeReport(sessionId, canonical, opts.note, "report");
|
||||
});
|
||||
.option(
|
||||
"--pr-url <url>",
|
||||
"Attach a PR URL to pr-created / draft-pr-created / ready-for-review reports",
|
||||
)
|
||||
.option(
|
||||
"--pr-number <number>",
|
||||
"Attach a PR number to pr-created / draft-pr-created / ready-for-review reports",
|
||||
)
|
||||
.action(
|
||||
async (
|
||||
state: string,
|
||||
opts: { session?: string; note?: string; prUrl?: string; prNumber?: string },
|
||||
) => {
|
||||
const canonical = normalizeAgentReportedState(state);
|
||||
if (!canonical) {
|
||||
console.error(
|
||||
chalk.red(
|
||||
`Unknown state: ${state}. Allowed: ${allowed} (or aliases: fixing-ci, addressing-reviews, needs-input, pr-created, ready-for-review).`,
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const prWorkflowState =
|
||||
canonical === "pr_created" ||
|
||||
canonical === "draft_pr_created" ||
|
||||
canonical === "ready_for_review";
|
||||
if (!prWorkflowState && (opts.prUrl || opts.prNumber)) {
|
||||
console.error(
|
||||
chalk.red(
|
||||
"PR metadata flags are only valid with pr-created, draft-pr-created, or ready-for-review.",
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const prNumber =
|
||||
opts.prNumber !== undefined ? Number.parseInt(opts.prNumber, 10) : undefined;
|
||||
if (
|
||||
opts.prNumber !== undefined &&
|
||||
(!Number.isInteger(prNumber) || prNumber === undefined || prNumber <= 0)
|
||||
) {
|
||||
console.error(chalk.red(`Invalid PR number: ${opts.prNumber}`));
|
||||
process.exit(1);
|
||||
}
|
||||
const sessionId = resolveSessionId(opts.session);
|
||||
await writeReport(sessionId, canonical, opts.note, opts.prUrl, prNumber, "report");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,9 @@ describe("normalizeAgentReportedState", () => {
|
|||
expect(normalizeAgentReportedState("needs-input")).toBe("needs_input");
|
||||
expect(normalizeAgentReportedState("fixing-ci")).toBe("fixing_ci");
|
||||
expect(normalizeAgentReportedState("addressing-reviews")).toBe("addressing_reviews");
|
||||
expect(normalizeAgentReportedState("pr-created")).toBe("pr_created");
|
||||
expect(normalizeAgentReportedState("draft-pr-created")).toBe("draft_pr_created");
|
||||
expect(normalizeAgentReportedState("ready-for-review")).toBe("ready_for_review");
|
||||
expect(normalizeAgentReportedState("ci")).toBe("fixing_ci");
|
||||
expect(normalizeAgentReportedState("reviews")).toBe("addressing_reviews");
|
||||
expect(normalizeAgentReportedState("complete")).toBe("completed");
|
||||
|
|
@ -121,6 +124,21 @@ describe("mapAgentReportToLifecycle", () => {
|
|||
sessionReason: "resolving_review_comments",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps PR workflow reports to the expected session phase", () => {
|
||||
expect(mapAgentReportToLifecycle("pr_created")).toEqual({
|
||||
sessionState: "idle",
|
||||
sessionReason: "pr_created",
|
||||
});
|
||||
expect(mapAgentReportToLifecycle("draft_pr_created")).toEqual({
|
||||
sessionState: "working",
|
||||
sessionReason: "task_in_progress",
|
||||
});
|
||||
expect(mapAgentReportToLifecycle("ready_for_review")).toEqual({
|
||||
sessionState: "idle",
|
||||
sessionReason: "awaiting_external_review",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateAgentReportTransition", () => {
|
||||
|
|
@ -148,10 +166,13 @@ describe("validateAgentReportTransition", () => {
|
|||
expect(validateAgentReportTransition(lifecycle, "needs_input").ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects reports on merged PRs", () => {
|
||||
it("rejects reports on merged or closed PRs", () => {
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker");
|
||||
lifecycle.pr.state = "merged";
|
||||
expect(validateAgentReportTransition(lifecycle, "working").ok).toBe(false);
|
||||
|
||||
lifecycle.pr.state = "closed";
|
||||
expect(validateAgentReportTransition(lifecycle, "working").ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects reports when runtime is missing or exited", () => {
|
||||
|
|
@ -217,6 +238,78 @@ describe("applyAgentReport", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("records pr_created with PR metadata and pr_open lifecycle", () => {
|
||||
const now = new Date("2025-01-02T09:30:00.000Z");
|
||||
const result = applyAgentReport(dataDir, sessionId, {
|
||||
state: "pr_created",
|
||||
prUrl: "https://github.com/test/repo/pull/42",
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.legacyStatus).toBe("pr_open");
|
||||
expect(result.report.prNumber).toBe(42);
|
||||
expect(result.report.prUrl).toBe("https://github.com/test/repo/pull/42");
|
||||
expect(result.report.prIsDraft).toBe(false);
|
||||
|
||||
const meta = readMetadataRaw(dataDir, sessionId)!;
|
||||
expect(meta[AGENT_REPORT_METADATA_KEYS.PR_URL]).toBe("https://github.com/test/repo/pull/42");
|
||||
expect(meta[AGENT_REPORT_METADATA_KEYS.PR_NUMBER]).toBe("42");
|
||||
expect(meta[AGENT_REPORT_METADATA_KEYS.PR_IS_DRAFT]).toBe("false");
|
||||
|
||||
const payload = JSON.parse(meta["statePayload"]);
|
||||
expect(payload.session.state).toBe("idle");
|
||||
expect(payload.session.reason).toBe("pr_created");
|
||||
expect(payload.pr.state).toBe("open");
|
||||
expect(payload.pr.reason).toBe("in_progress");
|
||||
expect(payload.pr.number).toBe(42);
|
||||
expect(payload.pr.url).toBe("https://github.com/test/repo/pull/42");
|
||||
});
|
||||
|
||||
it("keeps draft PR creation in working and marks the report as draft", () => {
|
||||
const now = new Date("2025-01-02T10:00:00.000Z");
|
||||
const result = applyAgentReport(dataDir, sessionId, {
|
||||
state: "draft_pr_created",
|
||||
prUrl: "https://github.com/test/repo/pull/43",
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.legacyStatus).toBe("pr_open");
|
||||
expect(result.nextState).toBe("working");
|
||||
expect(result.report.prIsDraft).toBe(true);
|
||||
|
||||
const meta = readMetadataRaw(dataDir, sessionId)!;
|
||||
expect(meta[AGENT_REPORT_METADATA_KEYS.PR_IS_DRAFT]).toBe("true");
|
||||
const payload = JSON.parse(meta["statePayload"]);
|
||||
expect(payload.session.state).toBe("working");
|
||||
expect(payload.pr.state).toBe("open");
|
||||
expect(payload.pr.reason).toBe("in_progress");
|
||||
});
|
||||
|
||||
it("promotes ready_for_review to review_pending and clears draft metadata", () => {
|
||||
applyAgentReport(dataDir, sessionId, {
|
||||
state: "draft_pr_created",
|
||||
prUrl: "https://github.com/test/repo/pull/44",
|
||||
now: new Date("2025-01-02T10:00:00.000Z"),
|
||||
});
|
||||
|
||||
const result = applyAgentReport(dataDir, sessionId, {
|
||||
state: "ready_for_review",
|
||||
prNumber: 44,
|
||||
now: new Date("2025-01-02T10:05:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.legacyStatus).toBe("review_pending");
|
||||
expect(result.report.prIsDraft).toBe(false);
|
||||
|
||||
const meta = readMetadataRaw(dataDir, sessionId)!;
|
||||
expect(meta[AGENT_REPORT_METADATA_KEYS.PR_IS_DRAFT]).toBe("false");
|
||||
const payload = JSON.parse(meta["statePayload"]);
|
||||
expect(payload.session.state).toBe("idle");
|
||||
expect(payload.pr.state).toBe("open");
|
||||
expect(payload.pr.reason).toBe("review_pending");
|
||||
expect(payload.pr.number).toBe(44);
|
||||
});
|
||||
|
||||
it("sets startedAt on the first working transition", () => {
|
||||
const now = new Date("2025-01-01T12:00:00.000Z");
|
||||
// Re-seed with startedAt explicitly null so we exercise the first-start
|
||||
|
|
@ -328,6 +421,24 @@ describe("readAgentReport + isAgentReportFresh", () => {
|
|||
expect(report?.note).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses PR workflow payload fields when present", () => {
|
||||
const at = "2025-01-01T00:00:00.000Z";
|
||||
const report = readAgentReport({
|
||||
[AGENT_REPORT_METADATA_KEYS.STATE]: "pr_created",
|
||||
[AGENT_REPORT_METADATA_KEYS.AT]: at,
|
||||
[AGENT_REPORT_METADATA_KEYS.PR_NUMBER]: "55",
|
||||
[AGENT_REPORT_METADATA_KEYS.PR_URL]: "https://github.com/test/repo/pull/55",
|
||||
[AGENT_REPORT_METADATA_KEYS.PR_IS_DRAFT]: "false",
|
||||
});
|
||||
expect(report).toEqual({
|
||||
state: "pr_created",
|
||||
timestamp: at,
|
||||
prNumber: 55,
|
||||
prUrl: "https://github.com/test/repo/pull/55",
|
||||
prIsDraft: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports freshness against the default window", () => {
|
||||
const now = new Date("2025-01-01T12:05:00.000Z");
|
||||
const freshAt = "2025-01-01T12:04:00.000Z"; // 1m old
|
||||
|
|
|
|||
|
|
@ -58,10 +58,7 @@ describe("setupPathWrapperWorkspace", () => {
|
|||
|
||||
it("creates ao bin directory", async () => {
|
||||
await setupPathWrapperWorkspace("/workspace");
|
||||
expect(mockMkdir).toHaveBeenCalledWith(
|
||||
"/home/testuser/.ao/bin",
|
||||
{ recursive: true },
|
||||
);
|
||||
expect(mockMkdir).toHaveBeenCalledWith("/home/testuser/.ao/bin", { recursive: true });
|
||||
});
|
||||
|
||||
it("writes wrapper scripts when version marker is missing", async () => {
|
||||
|
|
@ -69,15 +66,15 @@ describe("setupPathWrapperWorkspace", () => {
|
|||
// atomicWriteFile writes to .tmp then renames
|
||||
expect(mockRename).toHaveBeenCalled();
|
||||
// .ao/AGENTS.md is written directly
|
||||
const agentsMdWrites = mockWriteFile.mock.calls.filter(
|
||||
(c: unknown[]) => String(c[0]).includes(".ao/AGENTS.md"),
|
||||
const agentsMdWrites = mockWriteFile.mock.calls.filter((c: unknown[]) =>
|
||||
String(c[0]).includes(".ao/AGENTS.md"),
|
||||
);
|
||||
expect(agentsMdWrites).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("skips wrapper rewrite when version matches", async () => {
|
||||
mockReadFile
|
||||
.mockResolvedValueOnce("0.2.0") // version marker matches
|
||||
.mockResolvedValueOnce("0.3.0") // version marker matches
|
||||
.mockRejectedValueOnce(new Error("ENOENT")); // AGENTS.md doesn't exist
|
||||
|
||||
await setupPathWrapperWorkspace("/workspace");
|
||||
|
|
@ -91,8 +88,8 @@ describe("setupPathWrapperWorkspace", () => {
|
|||
it("writes .ao/AGENTS.md with session context", async () => {
|
||||
await setupPathWrapperWorkspace("/workspace");
|
||||
|
||||
const agentsMdWrites = mockWriteFile.mock.calls.filter(
|
||||
(c: unknown[]) => String(c[0]).includes(".ao/AGENTS.md"),
|
||||
const agentsMdWrites = mockWriteFile.mock.calls.filter((c: unknown[]) =>
|
||||
String(c[0]).includes(".ao/AGENTS.md"),
|
||||
);
|
||||
expect(agentsMdWrites).toHaveLength(1);
|
||||
expect(String(agentsMdWrites[0][1])).toContain("Agent Orchestrator");
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import type {
|
|||
} from "./types.js";
|
||||
import { updateCanonicalLifecycle, updateMetadata, readMetadataRaw } from "./metadata.js";
|
||||
import { deriveLegacyStatus } from "./lifecycle-state.js";
|
||||
import { parsePrFromUrl } from "./utils/pr.js";
|
||||
import { validateStatus } from "./utils/validation.js";
|
||||
|
||||
/**
|
||||
|
|
@ -39,11 +40,13 @@ import { validateStatus } from "./utils/validation.js";
|
|||
* - `needs_input` — blocked on human input
|
||||
* - `fixing_ci` — responding to a failing CI run
|
||||
* - `addressing_reviews`— responding to requested review changes
|
||||
* - `pr_created` / `draft_pr_created` / `ready_for_review`
|
||||
* — non-terminal PR workflow events with optional PR metadata
|
||||
* - `completed` — finished research/non-coding work (not "merged")
|
||||
*
|
||||
* Note: agents cannot self-report `done`, `terminated`, or PR-state transitions.
|
||||
* Those remain owned by AO so ground-truth sources (SCM, runtime) stay
|
||||
* authoritative.
|
||||
* Note: agents cannot self-report `done`, `terminated`, or terminal PR states
|
||||
* like `merged` / `closed`. Those remain owned by AO so ground-truth sources
|
||||
* (SCM, runtime) stay authoritative.
|
||||
*/
|
||||
export const AGENT_REPORTED_STATES = [
|
||||
"started",
|
||||
|
|
@ -52,6 +55,9 @@ export const AGENT_REPORTED_STATES = [
|
|||
"needs_input",
|
||||
"fixing_ci",
|
||||
"addressing_reviews",
|
||||
"pr_created",
|
||||
"draft_pr_created",
|
||||
"ready_for_review",
|
||||
"completed",
|
||||
] as const;
|
||||
|
||||
|
|
@ -63,6 +69,12 @@ export interface AgentReport {
|
|||
timestamp: string;
|
||||
/** Optional free-text note the agent may include (e.g. brief status line). */
|
||||
note?: string;
|
||||
/** Optional PR number attached to PR workflow reports. */
|
||||
prNumber?: number;
|
||||
/** Optional PR URL attached to PR workflow reports. */
|
||||
prUrl?: string;
|
||||
/** Optional draft hint attached to PR workflow reports. */
|
||||
prIsDraft?: boolean;
|
||||
/** Local actor identity when available (e.g. $USER). */
|
||||
actor?: string;
|
||||
/** Which CLI surface produced this report. */
|
||||
|
|
@ -82,6 +94,9 @@ export interface AgentReportAuditEntry {
|
|||
source: "acknowledge" | "report";
|
||||
reportState: AgentReportedState;
|
||||
note?: string;
|
||||
prNumber?: number;
|
||||
prUrl?: string;
|
||||
prIsDraft?: boolean;
|
||||
accepted: boolean;
|
||||
rejectionReason?: string;
|
||||
before: AgentReportAuditSnapshot;
|
||||
|
|
@ -93,6 +108,9 @@ export const AGENT_REPORT_METADATA_KEYS = {
|
|||
STATE: "agentReportedState",
|
||||
AT: "agentReportedAt",
|
||||
NOTE: "agentReportedNote",
|
||||
PR_NUMBER: "agentReportedPrNumber",
|
||||
PR_URL: "agentReportedPrUrl",
|
||||
PR_IS_DRAFT: "agentReportedPrIsDraft",
|
||||
} as const;
|
||||
|
||||
/** Freshness window — agent reports older than this are ignored. */
|
||||
|
|
@ -106,23 +124,29 @@ export const AGENT_REPORT_FRESHNESS_MS = 300_000; // 5 minutes
|
|||
* `completed` for finished non-coding research/analysis work.
|
||||
*/
|
||||
const INPUT_ALIASES: Record<string, AgentReportedState> = {
|
||||
"start": "started",
|
||||
"started": "started",
|
||||
"working": "working",
|
||||
"work": "working",
|
||||
"wait": "waiting",
|
||||
"waiting": "waiting",
|
||||
start: "started",
|
||||
started: "started",
|
||||
working: "working",
|
||||
work: "working",
|
||||
wait: "waiting",
|
||||
waiting: "waiting",
|
||||
"needs-input": "needs_input",
|
||||
"needs_input": "needs_input",
|
||||
"input": "needs_input",
|
||||
needs_input: "needs_input",
|
||||
input: "needs_input",
|
||||
"fixing-ci": "fixing_ci",
|
||||
"fixing_ci": "fixing_ci",
|
||||
"ci": "fixing_ci",
|
||||
fixing_ci: "fixing_ci",
|
||||
ci: "fixing_ci",
|
||||
"addressing-reviews": "addressing_reviews",
|
||||
"addressing_reviews": "addressing_reviews",
|
||||
"reviews": "addressing_reviews",
|
||||
"completed": "completed",
|
||||
"complete": "completed",
|
||||
addressing_reviews: "addressing_reviews",
|
||||
reviews: "addressing_reviews",
|
||||
"pr-created": "pr_created",
|
||||
pr_created: "pr_created",
|
||||
"draft-pr-created": "draft_pr_created",
|
||||
draft_pr_created: "draft_pr_created",
|
||||
"ready-for-review": "ready_for_review",
|
||||
ready_for_review: "ready_for_review",
|
||||
completed: "completed",
|
||||
complete: "completed",
|
||||
};
|
||||
|
||||
/** Normalize a user-supplied report name into the canonical form. */
|
||||
|
|
@ -149,11 +173,21 @@ export function mapAgentReportToLifecycle(state: AgentReportedState): {
|
|||
return { sessionState: "working", sessionReason: "fixing_ci" };
|
||||
case "addressing_reviews":
|
||||
return { sessionState: "working", sessionReason: "resolving_review_comments" };
|
||||
case "pr_created":
|
||||
return { sessionState: "idle", sessionReason: "pr_created" };
|
||||
case "draft_pr_created":
|
||||
return { sessionState: "working", sessionReason: "task_in_progress" };
|
||||
case "ready_for_review":
|
||||
return { sessionState: "idle", sessionReason: "awaiting_external_review" };
|
||||
case "completed":
|
||||
return { sessionState: "idle", sessionReason: "research_complete" };
|
||||
}
|
||||
}
|
||||
|
||||
function isPRWorkflowReport(state: AgentReportedState): boolean {
|
||||
return state === "pr_created" || state === "draft_pr_created" || state === "ready_for_review";
|
||||
}
|
||||
|
||||
export interface AgentReportTransitionResult {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
|
|
@ -186,8 +220,8 @@ export function validateAgentReportTransition(
|
|||
if (lifecycle.session.state === "done") {
|
||||
return { ok: false, reason: "session is already done" };
|
||||
}
|
||||
if (lifecycle.pr.state === "merged") {
|
||||
return { ok: false, reason: "PR already merged" };
|
||||
if (lifecycle.pr.state === "merged" || lifecycle.pr.state === "closed") {
|
||||
return { ok: false, reason: `PR already ${lifecycle.pr.state}` };
|
||||
}
|
||||
if (lifecycle.runtime.state === "missing" || lifecycle.runtime.state === "exited") {
|
||||
return { ok: false, reason: "runtime is not alive" };
|
||||
|
|
@ -198,6 +232,8 @@ export function validateAgentReportTransition(
|
|||
export interface ApplyAgentReportInput {
|
||||
state: AgentReportedState;
|
||||
note?: string;
|
||||
prNumber?: number;
|
||||
prUrl?: string;
|
||||
actor?: string;
|
||||
source?: "acknowledge" | "report";
|
||||
/** Override the current clock — used by tests. */
|
||||
|
|
@ -330,46 +366,78 @@ export function applyAgentReport(
|
|||
const source = input.source ?? "report";
|
||||
const actor = normalizeActor(input.actor);
|
||||
const trimmedNote = input.note?.trim() || undefined;
|
||||
const trimmedPrUrl = input.prUrl?.trim() || undefined;
|
||||
const parsedPrNumber =
|
||||
typeof input.prNumber === "number" && Number.isInteger(input.prNumber) && input.prNumber > 0
|
||||
? input.prNumber
|
||||
: undefined;
|
||||
const inferredPrNumber =
|
||||
trimmedPrUrl && parsedPrNumber === undefined
|
||||
? (() => {
|
||||
const parsed = parsePrFromUrl(trimmedPrUrl);
|
||||
return parsed?.number;
|
||||
})()
|
||||
: undefined;
|
||||
const prNumber = parsedPrNumber ?? inferredPrNumber;
|
||||
const prIsDraft =
|
||||
input.state === "draft_pr_created"
|
||||
? true
|
||||
: input.state === "pr_created" || input.state === "ready_for_review"
|
||||
? false
|
||||
: undefined;
|
||||
let before: AgentReportAuditSnapshot | null = null;
|
||||
let previousState: CanonicalSessionState | null = null;
|
||||
let nextState: CanonicalSessionState | null = null;
|
||||
let legacyStatus: SessionStatus | null = null;
|
||||
let previousLegacyStatus: SessionStatus | null = null;
|
||||
|
||||
const nextLifecycle = updateCanonicalLifecycle(
|
||||
dataDir,
|
||||
sessionId,
|
||||
(current) => {
|
||||
previousLegacyStatus = deriveLegacyStatus(current, validateStatus(raw["status"]));
|
||||
before = buildAuditSnapshot(current, previousLegacyStatus);
|
||||
const validation = validateAgentReportTransition(current, input.state);
|
||||
if (!validation.ok) {
|
||||
appendAgentReportAuditEntry(dataDir, sessionId, {
|
||||
timestamp: now,
|
||||
actor,
|
||||
source,
|
||||
reportState: input.state,
|
||||
note: trimmedNote,
|
||||
accepted: false,
|
||||
rejectionReason: validation.reason ?? "transition rejected",
|
||||
before,
|
||||
after: before,
|
||||
});
|
||||
throw new Error(validation.reason ?? "transition rejected");
|
||||
const nextLifecycle = updateCanonicalLifecycle(dataDir, sessionId, (current) => {
|
||||
previousLegacyStatus = deriveLegacyStatus(current, validateStatus(raw["status"]));
|
||||
before = buildAuditSnapshot(current, previousLegacyStatus);
|
||||
const validation = validateAgentReportTransition(current, input.state);
|
||||
if (!validation.ok) {
|
||||
appendAgentReportAuditEntry(dataDir, sessionId, {
|
||||
timestamp: now,
|
||||
actor,
|
||||
source,
|
||||
reportState: input.state,
|
||||
note: trimmedNote,
|
||||
prNumber,
|
||||
prUrl: trimmedPrUrl,
|
||||
prIsDraft,
|
||||
accepted: false,
|
||||
rejectionReason: validation.reason ?? "transition rejected",
|
||||
before,
|
||||
after: before,
|
||||
});
|
||||
throw new Error(validation.reason ?? "transition rejected");
|
||||
}
|
||||
const mapped = mapAgentReportToLifecycle(input.state);
|
||||
previousState = current.session.state;
|
||||
nextState = mapped.sessionState;
|
||||
current.session.state = mapped.sessionState;
|
||||
current.session.reason = mapped.sessionReason;
|
||||
current.session.lastTransitionAt = now;
|
||||
if (isPRWorkflowReport(input.state)) {
|
||||
current.pr.state = "open";
|
||||
current.pr.reason = input.state === "ready_for_review" ? "review_pending" : "in_progress";
|
||||
current.pr.lastObservedAt = now;
|
||||
if (trimmedPrUrl) {
|
||||
current.pr.url = trimmedPrUrl;
|
||||
}
|
||||
const mapped = mapAgentReportToLifecycle(input.state);
|
||||
previousState = current.session.state;
|
||||
nextState = mapped.sessionState;
|
||||
current.session.state = mapped.sessionState;
|
||||
current.session.reason = mapped.sessionReason;
|
||||
current.session.lastTransitionAt = now;
|
||||
if (mapped.sessionState === "working" && current.session.startedAt === null) {
|
||||
current.session.startedAt = now;
|
||||
if (prNumber !== undefined) {
|
||||
current.pr.number = prNumber;
|
||||
} else if (trimmedPrUrl) {
|
||||
const parsed = parsePrFromUrl(trimmedPrUrl);
|
||||
current.pr.number = parsed?.number ?? current.pr.number;
|
||||
}
|
||||
legacyStatus = deriveLegacyStatus(current, previousLegacyStatus);
|
||||
return current;
|
||||
},
|
||||
);
|
||||
}
|
||||
if (mapped.sessionState === "working" && current.session.startedAt === null) {
|
||||
current.session.startedAt = now;
|
||||
}
|
||||
legacyStatus = deriveLegacyStatus(current, previousLegacyStatus);
|
||||
return current;
|
||||
});
|
||||
|
||||
if (!nextLifecycle || !before || !previousState || !nextState || !legacyStatus) {
|
||||
throw new Error(`Failed to apply agent report for session ${sessionId}`);
|
||||
|
|
@ -386,6 +454,21 @@ export function applyAgentReport(
|
|||
// Clear stale notes from previous reports so they don't mislead humans.
|
||||
metadataUpdates[AGENT_REPORT_METADATA_KEYS.NOTE] = "";
|
||||
}
|
||||
if (trimmedPrUrl) {
|
||||
metadataUpdates[AGENT_REPORT_METADATA_KEYS.PR_URL] = trimmedPrUrl;
|
||||
} else {
|
||||
metadataUpdates[AGENT_REPORT_METADATA_KEYS.PR_URL] = "";
|
||||
}
|
||||
if (prNumber !== undefined) {
|
||||
metadataUpdates[AGENT_REPORT_METADATA_KEYS.PR_NUMBER] = String(prNumber);
|
||||
} else {
|
||||
metadataUpdates[AGENT_REPORT_METADATA_KEYS.PR_NUMBER] = "";
|
||||
}
|
||||
if (prIsDraft !== undefined) {
|
||||
metadataUpdates[AGENT_REPORT_METADATA_KEYS.PR_IS_DRAFT] = prIsDraft ? "true" : "false";
|
||||
} else {
|
||||
metadataUpdates[AGENT_REPORT_METADATA_KEYS.PR_IS_DRAFT] = "";
|
||||
}
|
||||
updateMetadata(dataDir, sessionId, metadataUpdates);
|
||||
|
||||
const after = buildAuditSnapshot(nextLifecycle, legacyStatus);
|
||||
|
|
@ -395,6 +478,9 @@ export function applyAgentReport(
|
|||
source,
|
||||
reportState: input.state,
|
||||
note: trimmedNote,
|
||||
prNumber,
|
||||
prUrl: trimmedPrUrl,
|
||||
prIsDraft,
|
||||
accepted: true,
|
||||
before,
|
||||
after,
|
||||
|
|
@ -406,6 +492,9 @@ export function applyAgentReport(
|
|||
state: input.state,
|
||||
timestamp: now,
|
||||
note: trimmedNote,
|
||||
prNumber,
|
||||
prUrl: trimmedPrUrl,
|
||||
prIsDraft,
|
||||
actor,
|
||||
source,
|
||||
},
|
||||
|
|
@ -417,7 +506,9 @@ export function applyAgentReport(
|
|||
}
|
||||
|
||||
/** Read an agent report out of a session's raw metadata, or null if absent. */
|
||||
export function readAgentReport(meta: Record<string, string> | null | undefined): AgentReport | null {
|
||||
export function readAgentReport(
|
||||
meta: Record<string, string> | null | undefined,
|
||||
): AgentReport | null {
|
||||
if (!meta) return null;
|
||||
const state = meta[AGENT_REPORT_METADATA_KEYS.STATE];
|
||||
const at = meta[AGENT_REPORT_METADATA_KEYS.AT];
|
||||
|
|
@ -426,10 +517,19 @@ export function readAgentReport(meta: Record<string, string> | null | undefined)
|
|||
const parsed = Date.parse(at);
|
||||
if (Number.isNaN(parsed)) return null;
|
||||
const note = meta[AGENT_REPORT_METADATA_KEYS.NOTE];
|
||||
const rawPrNumber = meta[AGENT_REPORT_METADATA_KEYS.PR_NUMBER];
|
||||
const prNumber =
|
||||
rawPrNumber && /^\d+$/.test(rawPrNumber) ? Number.parseInt(rawPrNumber, 10) : undefined;
|
||||
const prUrl = meta[AGENT_REPORT_METADATA_KEYS.PR_URL] || undefined;
|
||||
const rawPrIsDraft = meta[AGENT_REPORT_METADATA_KEYS.PR_IS_DRAFT];
|
||||
const prIsDraft = rawPrIsDraft === "true" ? true : rawPrIsDraft === "false" ? false : undefined;
|
||||
return {
|
||||
state: state as AgentReportedState,
|
||||
timestamp: new Date(parsed).toISOString(),
|
||||
note: note && note.length > 0 ? note : undefined,
|
||||
prNumber,
|
||||
prUrl,
|
||||
prIsDraft,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
* native hook systems (Codex, Aider, OpenCode).
|
||||
*
|
||||
* Installs ~/.ao/bin/gh and ~/.ao/bin/git wrappers that intercept
|
||||
* PR creation, PR merge, and branch operations to auto-update session metadata.
|
||||
* PR creation and branch operations to auto-update session metadata.
|
||||
*
|
||||
* Claude Code uses its own PostToolUse hook system instead.
|
||||
*/
|
||||
|
|
@ -32,7 +32,7 @@ function getAoBinDir(): string {
|
|||
}
|
||||
|
||||
/** Current version of wrapper scripts — bump when scripts change */
|
||||
const WRAPPER_VERSION = "0.2.0";
|
||||
const WRAPPER_VERSION = "0.3.0";
|
||||
|
||||
// =============================================================================
|
||||
// PATH Builder
|
||||
|
|
@ -134,8 +134,9 @@ update_ao_metadata() {
|
|||
`;
|
||||
|
||||
/**
|
||||
* gh wrapper — intercepts `gh pr create` and `gh pr merge` to auto-update
|
||||
* session metadata. All other commands pass through transparently.
|
||||
* gh wrapper — intercepts `gh pr create` to auto-update session metadata.
|
||||
* Merge/close state remains SCM-owned, so `gh pr merge` is not used to set
|
||||
* terminal session state directly.
|
||||
*/
|
||||
export const GH_WRAPPER = `#!/usr/bin/env bash
|
||||
# ao gh wrapper — auto-updates session metadata on PR operations
|
||||
|
|
@ -167,10 +168,10 @@ fi
|
|||
# Source the metadata helper
|
||||
source "\$ao_bin_dir/ao-metadata-helper.sh" 2>/dev/null || true
|
||||
|
||||
# Only capture output for commands we need to parse (pr/create, pr/merge).
|
||||
# Only capture output for commands we need to parse (pr/create).
|
||||
# All other commands pass through transparently without stream merging.
|
||||
case "\$1/\$2" in
|
||||
pr/create|pr/merge)
|
||||
pr/create)
|
||||
tmpout="\$(mktemp)"
|
||||
trap 'rm -f "\$tmpout"' EXIT
|
||||
|
||||
|
|
@ -179,18 +180,27 @@ case "\$1/\$2" in
|
|||
|
||||
if [[ \$exit_code -eq 0 ]]; then
|
||||
output="\$(cat "\$tmpout")"
|
||||
case "\$1/\$2" in
|
||||
pr/create)
|
||||
pr_url="\$(echo "\$output" | grep -Eo 'https://github\\.com/[^/]+/[^/]+/pull/[0-9]+' | head -1)"
|
||||
if [[ -n "\$pr_url" ]]; then
|
||||
update_ao_metadata pr "\$pr_url"
|
||||
update_ao_metadata status pr_open
|
||||
fi
|
||||
;;
|
||||
pr/merge)
|
||||
update_ao_metadata status merged
|
||||
;;
|
||||
esac
|
||||
pr_url="\$(echo "\$output" | grep -Eo 'https://github\\.com/[^/]+/[^/]+/pull/[0-9]+' | head -1)"
|
||||
report_state="pr_created"
|
||||
report_draft="false"
|
||||
for arg in "\$@"; do
|
||||
if [[ "\$arg" == "--draft" || "\$arg" == "-d" ]]; then
|
||||
report_state="draft_pr_created"
|
||||
report_draft="true"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ -n "\$pr_url" ]]; then
|
||||
update_ao_metadata pr "\$pr_url"
|
||||
update_ao_metadata agentReportedPrUrl "\$pr_url"
|
||||
fi
|
||||
pr_number="\$(printf '%s' "\$pr_url" | grep -Eo '[0-9]+$' | head -1)"
|
||||
if [[ -n "\$pr_number" ]]; then
|
||||
update_ao_metadata agentReportedPrNumber "\$pr_number"
|
||||
fi
|
||||
update_ao_metadata agentReportedState "\$report_state"
|
||||
update_ao_metadata agentReportedAt "\$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
update_ao_metadata agentReportedPrIsDraft "\$report_draft"
|
||||
fi
|
||||
|
||||
exit \$exit_code
|
||||
|
|
@ -317,11 +327,7 @@ export async function setupPathWrapperWorkspace(workspacePath: string): Promise<
|
|||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
await atomicWriteFile(
|
||||
join(getAoBinDir(), "ao-metadata-helper.sh"),
|
||||
AO_METADATA_HELPER,
|
||||
0o755,
|
||||
);
|
||||
await atomicWriteFile(join(getAoBinDir(), "ao-metadata-helper.sh"), AO_METADATA_HELPER, 0o755);
|
||||
// Write wrappers atomically, then write the version marker last.
|
||||
// If we crash between wrapper writes and marker write, the next
|
||||
// invocation will redo the writes (safe: wrappers are idempotent).
|
||||
|
|
|
|||
|
|
@ -36,10 +36,11 @@ The orchestrator infers your status from runtime signals, but explicit reports a
|
|||
- \`ao report needs-input\` — you need a decision or info from the human before proceeding.
|
||||
- \`ao report fixing-ci\` — you are working specifically on making CI green again.
|
||||
- \`ao report addressing-reviews\` — you are working on reviewer-requested changes.
|
||||
- \`ao report pr-created --pr-url <url>\` / \`draft-pr-created\` / \`ready-for-review\` — declare PR workflow milestones as soon as you create or update the PR.
|
||||
- \`ao report completed\` — you finished non-coding research or analysis work that doesn't produce a PR.
|
||||
|
||||
Rules:
|
||||
- Do NOT self-report \`done\`, \`terminated\`, or any PR-merge state — AO owns those transitions via SCM ground truth.
|
||||
- Do NOT self-report \`done\`, \`terminated\`, or terminal PR states like \`merged\`/\`closed\` — AO owns those transitions via SCM ground truth.
|
||||
- A fresh report is trusted over weak inference but runtime death, activity-based waiting_input, and SCM events (merged/closed PR, CI failure, reviews) still take precedence.
|
||||
- Use \`--note "<text>"\` to attach a short rationale when the state change is non-obvious.
|
||||
|
||||
|
|
@ -66,6 +67,7 @@ export const BASE_AGENT_PROMPT_NO_REPO = `You are an AI coding agent managed by
|
|||
Explicit reports help the orchestrator track your state accurately. Run these from the session shell (AO_SESSION_ID is pre-set):
|
||||
- \`ao acknowledge\` — run once after reading the initial task.
|
||||
- \`ao report working\` / \`waiting\` / \`needs-input\` — declare your current phase.
|
||||
- \`ao report pr-created --pr-url <url>\` or \`draft-pr-created\` / \`ready-for-review\` — declare non-terminal PR workflow events when relevant.
|
||||
- \`ao report completed\` — finish non-coding research or analysis work.
|
||||
Do NOT self-report \`done\` or \`terminated\` — AO owns those transitions.
|
||||
|
||||
|
|
|
|||
|
|
@ -53,18 +53,19 @@ ao open {{projectId}}{{REPO_CONFIGURED_SECTION_END}}
|
|||
```
|
||||
|
||||
{{REPO_NOT_CONFIGURED_SECTION_START}}
|
||||
|
||||
> **Note:** No repository remote is configured. Issue tracking, PR, and CI features are unavailable.
|
||||
> Add a `repo` field (owner/repo) to `agent-orchestrator.yaml` to enable them.
|
||||
{{REPO_NOT_CONFIGURED_SECTION_END}}
|
||||
> {{REPO_NOT_CONFIGURED_SECTION_END}}
|
||||
|
||||
## Available Commands
|
||||
|
||||
- `ao status`: Show all sessions{{REPO_CONFIGURED_SECTION_START}} with PR/CI/review status{{REPO_CONFIGURED_SECTION_END}}
|
||||
- `ao spawn [issue] [--prompt <text>]{{REPO_CONFIGURED_SECTION_START}} [--claim-pr <pr>]{{REPO_CONFIGURED_SECTION_END}}`: Spawn a worker session{{REPO_CONFIGURED_SECTION_START}}; use issue ID or --prompt for freeform tasks{{REPO_CONFIGURED_SECTION_END}}{{REPO_NOT_CONFIGURED_SECTION_START}} with --prompt for freeform tasks{{REPO_NOT_CONFIGURED_SECTION_END}}
|
||||
{{REPO_CONFIGURED_SECTION_START}}- `ao batch-spawn <issues...>`: Spawn multiple sessions in parallel (project auto-detected)
|
||||
{{REPO_CONFIGURED_SECTION_END}}- `ao session ls [-p project]`: List all sessions (optionally filter by project)
|
||||
{{REPO_CONFIGURED_SECTION_START}}- `ao session claim-pr <pr> [session]`: Attach an existing PR to a worker session
|
||||
{{REPO_CONFIGURED_SECTION_END}}- `ao session attach <session>`: Attach to a session's tmux window
|
||||
{{REPO_CONFIGURED_SECTION_START}}- `ao batch-spawn <issues...>`: Spawn multiple sessions in parallel (project auto-detected)
|
||||
{{REPO_CONFIGURED_SECTION_END}}- `ao session ls [-p project]`: List all sessions (optionally filter by project)
|
||||
{{REPO_CONFIGURED_SECTION_START}}- `ao session claim-pr <pr> [session]`: Attach an existing PR to a worker session
|
||||
{{REPO_CONFIGURED_SECTION_END}}- `ao session attach <session>`: Attach to a session's tmux window
|
||||
- `ao session kill <session>`: Kill a specific session
|
||||
- `ao session cleanup [-p project]`: Kill completed/merged sessions
|
||||
- `ao send <session> <message>`: Send a message to a running session
|
||||
|
|
@ -95,18 +96,19 @@ ao spawn --prompt "Add rate limiting to the /api/upload endpoint"
|
|||
Use `ao status` to see:
|
||||
|
||||
- Current session status (working, pr_open, review_pending, etc.)
|
||||
{{REPO_CONFIGURED_SECTION_START}}- PR state (open/merged/closed)
|
||||
{{REPO_CONFIGURED_SECTION_START}}- PR state (open/merged/closed)
|
||||
- CI status (passing/failing/pending)
|
||||
- Review decision (approved/changes_requested/pending)
|
||||
- Unresolved comments count
|
||||
{{REPO_CONFIGURED_SECTION_END}}
|
||||
{{REPO_CONFIGURED_SECTION_END}}
|
||||
|
||||
### Explicit Agent Reports
|
||||
|
||||
Worker agents self-declare their workflow phase using `ao acknowledge` and `ao report <state>` (started, working, waiting, needs-input, fixing-ci, addressing-reviews, completed). These reports are persisted alongside the canonical lifecycle and may inform lifecycle inference, but do not replace runtime/activity/SCM-derived truth.
|
||||
Worker agents self-declare their workflow phase using `ao acknowledge` and `ao report <state>` (started, working, waiting, needs-input, fixing-ci, addressing-reviews, pr-created, draft-pr-created, ready-for-review, completed). These reports are persisted alongside the canonical lifecycle and may inform lifecycle inference, but do not replace runtime/activity/SCM-derived truth.
|
||||
|
||||
- Never run `ao acknowledge` or `ao report` from the orchestrator session - they are worker-only commands.
|
||||
- Fresh reports (<5 min) are useful hints when inference is weak, but runtime death, activity-based waiting_input, and SCM truth (merged/closed PR, CI failure, review decisions) still take precedence.
|
||||
- Use `--pr-url` / `--pr-number` on PR workflow reports when the agent knows them; merged/closed remain SCM-owned.
|
||||
- If an agent reports `waiting` but a PR actually merged, trust the PR state and follow up.
|
||||
|
||||
### Sending Messages
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { ActivitySignal, RuntimeHandle, Session, SessionId, SessionStatus } from "../types.js";
|
||||
import { deriveLegacyStatus, parseCanonicalLifecycle } from "../lifecycle-state.js";
|
||||
import { createActivitySignal } from "../activity-signal.js";
|
||||
import { AGENT_REPORT_METADATA_KEYS } from "../agent-report.js";
|
||||
import { parsePrFromUrl } from "./pr.js";
|
||||
import { safeJsonParse, validateStatus } from "./validation.js";
|
||||
|
||||
|
|
@ -50,6 +51,7 @@ export function sessionFromMetadata(
|
|||
});
|
||||
const status = options.status ?? deriveLegacyStatus(lifecycle, validateStatus(meta["status"]));
|
||||
const prUrl = lifecycle.pr.url ?? meta["pr"];
|
||||
const prIsDraft = meta[AGENT_REPORT_METADATA_KEYS.PR_IS_DRAFT] === "true";
|
||||
|
||||
return {
|
||||
id: sessionId,
|
||||
|
|
@ -71,7 +73,7 @@ export function sessionFromMetadata(
|
|||
repo: parsed?.repo ?? "",
|
||||
branch: meta["branch"] ?? "",
|
||||
baseBranch: "",
|
||||
isDraft: false,
|
||||
isDraft: prIsDraft,
|
||||
};
|
||||
})()
|
||||
: null,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { createActivitySignal, type Session, type RuntimeHandle, type AgentLaunchConfig, type AgentSpecificConfig } from "@aoagents/ao-core";
|
||||
import {
|
||||
createActivitySignal,
|
||||
type Session,
|
||||
type RuntimeHandle,
|
||||
type AgentLaunchConfig,
|
||||
type AgentSpecificConfig,
|
||||
} from "@aoagents/ao-core";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoisted mocks — available inside vi.mock factories
|
||||
|
|
@ -72,7 +78,13 @@ vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
|||
});
|
||||
|
||||
import { Readable } from "node:stream";
|
||||
import { create, manifest, default as defaultExport, resolveCodexBinary, _resetSessionFileCache } from "./index.js";
|
||||
import {
|
||||
create,
|
||||
manifest,
|
||||
default as defaultExport,
|
||||
resolveCodexBinary,
|
||||
_resetSessionFileCache,
|
||||
} from "./index.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
|
|
@ -150,15 +162,17 @@ function makeFakeFileHandle(content: string) {
|
|||
const buf = Buffer.from(content, "utf-8");
|
||||
let cursor = 0;
|
||||
return {
|
||||
read: vi.fn().mockImplementation((buffer: Buffer, offset: number, length: number, _position: number) => {
|
||||
if (cursor >= buf.length) {
|
||||
return Promise.resolve({ bytesRead: 0, buffer });
|
||||
}
|
||||
const bytesToCopy = Math.min(length, buf.length - cursor);
|
||||
buf.copy(buffer, offset, cursor, cursor + bytesToCopy);
|
||||
cursor += bytesToCopy;
|
||||
return Promise.resolve({ bytesRead: bytesToCopy, buffer });
|
||||
}),
|
||||
read: vi
|
||||
.fn()
|
||||
.mockImplementation((buffer: Buffer, offset: number, length: number, _position: number) => {
|
||||
if (cursor >= buf.length) {
|
||||
return Promise.resolve({ bytesRead: 0, buffer });
|
||||
}
|
||||
const bytesToCopy = Math.min(length, buf.length - cursor);
|
||||
buf.copy(buffer, offset, cursor, cursor + bytesToCopy);
|
||||
cursor += bytesToCopy;
|
||||
return Promise.resolve({ bytesRead: bytesToCopy, buffer });
|
||||
}),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
|
@ -234,7 +248,9 @@ describe("getLaunchCommand", () => {
|
|||
const agent = create();
|
||||
|
||||
it("generates base command", () => {
|
||||
expect(agent.getLaunchCommand(makeLaunchConfig())).toBe("'codex' -c check_for_update_on_startup=false");
|
||||
expect(agent.getLaunchCommand(makeLaunchConfig())).toBe(
|
||||
"'codex' -c check_for_update_on_startup=false",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes bypass flag when permissions=permissionless", () => {
|
||||
|
|
@ -282,7 +298,9 @@ describe("getLaunchCommand", () => {
|
|||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({ permissions: "permissionless", model: "o3", prompt: "Go" }),
|
||||
);
|
||||
expect(cmd).toBe("'codex' -c check_for_update_on_startup=false --dangerously-bypass-approvals-and-sandbox --model 'o3' -c model_reasoning_effort=high -- 'Go'");
|
||||
expect(cmd).toBe(
|
||||
"'codex' -c check_for_update_on_startup=false --dangerously-bypass-approvals-and-sandbox --model 'o3' -c model_reasoning_effort=high -- 'Go'",
|
||||
);
|
||||
});
|
||||
|
||||
it("escapes single quotes in prompt (POSIX shell escaping)", () => {
|
||||
|
|
@ -291,9 +309,7 @@ describe("getLaunchCommand", () => {
|
|||
});
|
||||
|
||||
it("escapes dangerous characters in prompt", () => {
|
||||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({ prompt: "$(rm -rf /); `evil`; $HOME" }),
|
||||
);
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "$(rm -rf /); `evil`; $HOME" }));
|
||||
// Single-quoted strings prevent shell expansion
|
||||
expect(cmd).toContain("-- '$(rm -rf /); `evil`; $HOME'");
|
||||
});
|
||||
|
|
@ -418,7 +434,7 @@ describe("getEnvironment", () => {
|
|||
process.env["PATH"] = originalPath;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
it("sets CODEX_DISABLE_UPDATE_CHECK=1 to suppress interactive update prompts", () => {
|
||||
const env = agent.getEnvironment(makeLaunchConfig());
|
||||
expect(env["CODEX_DISABLE_UPDATE_CHECK"]).toBe("1");
|
||||
|
|
@ -599,12 +615,12 @@ describe("detectActivity", () => {
|
|||
it("returns waiting_input when permission prompt follows historical activity", () => {
|
||||
// Permission prompt at the bottom should NOT be overridden by historical
|
||||
// spinner/esc output higher in the buffer.
|
||||
expect(
|
||||
agent.detectActivity("✶ Writing files\nDone.\napproval required\n"),
|
||||
).toBe("waiting_input");
|
||||
expect(
|
||||
agent.detectActivity("Working (esc to interrupt)\nFinished\n(y)es / (n)o\n"),
|
||||
).toBe("waiting_input");
|
||||
expect(agent.detectActivity("✶ Writing files\nDone.\napproval required\n")).toBe(
|
||||
"waiting_input",
|
||||
);
|
||||
expect(agent.detectActivity("Working (esc to interrupt)\nFinished\n(y)es / (n)o\n")).toBe(
|
||||
"waiting_input",
|
||||
);
|
||||
});
|
||||
|
||||
// -- Active states --
|
||||
|
|
@ -663,7 +679,10 @@ describe("getActivityState", () => {
|
|||
it("returns null when process is running but no session file found", async () => {
|
||||
mockTmuxWithProcess("codex");
|
||||
mockReaddir.mockRejectedValue(new Error("ENOENT"));
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const session = makeSession({
|
||||
runtimeHandle: makeTmuxHandle(),
|
||||
workspacePath: "/workspace/test",
|
||||
});
|
||||
expect(await agent.getActivityState(session)).toBeNull();
|
||||
});
|
||||
|
||||
|
|
@ -679,7 +698,10 @@ describe("getActivityState", () => {
|
|||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const session = makeSession({
|
||||
runtimeHandle: makeTmuxHandle(),
|
||||
workspacePath: "/workspace/test",
|
||||
});
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result?.state).toBe("active");
|
||||
expect(result?.timestamp).toBeInstanceOf(Date);
|
||||
|
|
@ -698,7 +720,10 @@ describe("getActivityState", () => {
|
|||
modifiedAt: new Date(staleTime),
|
||||
});
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const session = makeSession({
|
||||
runtimeHandle: makeTmuxHandle(),
|
||||
workspacePath: "/workspace/test",
|
||||
});
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result?.state).toBe("idle");
|
||||
expect(result?.timestamp).toBeInstanceOf(Date);
|
||||
|
|
@ -715,7 +740,10 @@ describe("getActivityState", () => {
|
|||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const session = makeSession({
|
||||
runtimeHandle: makeTmuxHandle(),
|
||||
workspacePath: "/workspace/test",
|
||||
});
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result?.state).toBe("waiting_input");
|
||||
});
|
||||
|
|
@ -731,7 +759,10 @@ describe("getActivityState", () => {
|
|||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const session = makeSession({
|
||||
runtimeHandle: makeTmuxHandle(),
|
||||
workspacePath: "/workspace/test",
|
||||
});
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result?.state).toBe("blocked");
|
||||
});
|
||||
|
|
@ -747,7 +778,10 @@ describe("getActivityState", () => {
|
|||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const session = makeSession({
|
||||
runtimeHandle: makeTmuxHandle(),
|
||||
workspacePath: "/workspace/test",
|
||||
});
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result?.state).toBe("ready");
|
||||
});
|
||||
|
|
@ -766,7 +800,10 @@ describe("getActivityState", () => {
|
|||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const session = makeSession({
|
||||
runtimeHandle: makeTmuxHandle(),
|
||||
workspacePath: "/workspace/test",
|
||||
});
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result?.state).toBe("waiting_input");
|
||||
});
|
||||
|
|
@ -783,7 +820,10 @@ describe("getActivityState", () => {
|
|||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const session = makeSession({
|
||||
runtimeHandle: makeTmuxHandle(),
|
||||
workspacePath: "/workspace/test",
|
||||
});
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result?.state).toBe("waiting_input");
|
||||
});
|
||||
|
|
@ -801,7 +841,10 @@ describe("getActivityState", () => {
|
|||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const session = makeSession({
|
||||
runtimeHandle: makeTmuxHandle(),
|
||||
workspacePath: "/workspace/test",
|
||||
});
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result?.state).toBe("blocked");
|
||||
});
|
||||
|
|
@ -818,7 +861,10 @@ describe("getActivityState", () => {
|
|||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const session = makeSession({
|
||||
runtimeHandle: makeTmuxHandle(),
|
||||
workspacePath: "/workspace/test",
|
||||
});
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result?.state).toBe("active");
|
||||
});
|
||||
|
|
@ -835,22 +881,24 @@ describe("getActivityState", () => {
|
|||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const session = makeSession({
|
||||
runtimeHandle: makeTmuxHandle(),
|
||||
workspacePath: "/workspace/test",
|
||||
});
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result?.state).toBe("ready");
|
||||
});
|
||||
|
||||
it("detects activity from payload-wrapped Codex session_meta files", async () => {
|
||||
mockTmuxWithProcess("codex");
|
||||
const content =
|
||||
`${JSON.stringify({
|
||||
type: "session_meta",
|
||||
payload: {
|
||||
cwd: "/workspace/test",
|
||||
id: "thread-123",
|
||||
base_instructions: "x".repeat(8_000),
|
||||
},
|
||||
})}\n`;
|
||||
const content = `${JSON.stringify({
|
||||
type: "session_meta",
|
||||
payload: {
|
||||
cwd: "/workspace/test",
|
||||
id: "thread-123",
|
||||
base_instructions: "x".repeat(8_000),
|
||||
},
|
||||
})}\n`;
|
||||
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
||||
setupMockOpen(content);
|
||||
mockStat.mockResolvedValue({ mtimeMs: Date.now(), mtime: new Date() });
|
||||
|
|
@ -859,7 +907,10 @@ describe("getActivityState", () => {
|
|||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const session = makeSession({
|
||||
runtimeHandle: makeTmuxHandle(),
|
||||
workspacePath: "/workspace/test",
|
||||
});
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result?.state).toBe("ready");
|
||||
});
|
||||
|
|
@ -871,15 +922,14 @@ describe("getActivityState", () => {
|
|||
// character will straddle a read boundary. Without StringDecoder,
|
||||
// the split character decodes to U+FFFD and JSON.parse fails.
|
||||
const padding = "日".repeat(3_000); // 9000 bytes of padding
|
||||
const content =
|
||||
`${JSON.stringify({
|
||||
type: "session_meta",
|
||||
payload: {
|
||||
cwd: "/workspace/test",
|
||||
id: "thread-utf8",
|
||||
base_instructions: padding,
|
||||
},
|
||||
})}\n`;
|
||||
const content = `${JSON.stringify({
|
||||
type: "session_meta",
|
||||
payload: {
|
||||
cwd: "/workspace/test",
|
||||
id: "thread-utf8",
|
||||
base_instructions: padding,
|
||||
},
|
||||
})}\n`;
|
||||
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
||||
setupMockOpen(content);
|
||||
mockStat.mockResolvedValue({ mtimeMs: Date.now(), mtime: new Date() });
|
||||
|
|
@ -888,7 +938,10 @@ describe("getActivityState", () => {
|
|||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const session = makeSession({
|
||||
runtimeHandle: makeTmuxHandle(),
|
||||
workspacePath: "/workspace/test",
|
||||
});
|
||||
const result = await agent.getActivityState(session);
|
||||
// If UTF-8 boundary handling is broken, JSON.parse fails, cwd never
|
||||
// matches, no session file is selected, and state falls through to null.
|
||||
|
|
@ -941,14 +994,34 @@ describe("getSessionInfo", () => {
|
|||
const content = jsonl({ type: "session_meta", cwd: "/other/workspace", model: "gpt-4o" });
|
||||
setupMockOpen(content);
|
||||
mockReadFile.mockResolvedValue(content);
|
||||
expect(await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" }))).toBeNull();
|
||||
expect(
|
||||
await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns session info with cost and model when matching session found", async () => {
|
||||
const sessionContent = jsonl(
|
||||
{ type: "session_meta", cwd: "/workspace/test", model: "o3-mini" },
|
||||
{ type: "event_msg", msg: { type: "token_count", input_tokens: 1000, output_tokens: 500, cached_tokens: 200, reasoning_tokens: 100 } },
|
||||
{ type: "event_msg", msg: { type: "token_count", input_tokens: 2000, output_tokens: 300, cached_tokens: 0, reasoning_tokens: 0 } },
|
||||
{
|
||||
type: "event_msg",
|
||||
msg: {
|
||||
type: "token_count",
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
cached_tokens: 200,
|
||||
reasoning_tokens: 100,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "event_msg",
|
||||
msg: {
|
||||
type: "token_count",
|
||||
input_tokens: 2000,
|
||||
output_tokens: 300,
|
||||
cached_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
mockReaddir.mockResolvedValue(["session-123.jsonl"]);
|
||||
|
|
@ -1042,12 +1115,8 @@ describe("getSessionInfo", () => {
|
|||
});
|
||||
|
||||
it("picks the most recently modified matching session file", async () => {
|
||||
const oldContent = jsonl(
|
||||
{ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" },
|
||||
);
|
||||
const newContent = jsonl(
|
||||
{ type: "session_meta", cwd: "/workspace/test", model: "o3" },
|
||||
);
|
||||
const oldContent = jsonl({ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" });
|
||||
const newContent = jsonl({ type: "session_meta", cwd: "/workspace/test", model: "o3" });
|
||||
|
||||
mockReaddir.mockResolvedValue(["old-session.jsonl", "new-session.jsonl"]);
|
||||
mockOpen.mockImplementation(async (path: string) => {
|
||||
|
|
@ -1079,7 +1148,8 @@ describe("getSessionInfo", () => {
|
|||
});
|
||||
|
||||
it("handles corrupt/malformed JSONL lines gracefully", async () => {
|
||||
const content = '{"type":"session_meta","cwd":"/workspace/test","model":"gpt-4o"}\n' +
|
||||
const content =
|
||||
'{"type":"session_meta","cwd":"/workspace/test","model":"gpt-4o"}\n' +
|
||||
"not valid json\n" +
|
||||
'{"type":"event_msg","msg":{"type":"token_count","input_tokens":500,"output_tokens":200}}\n';
|
||||
|
||||
|
|
@ -1142,15 +1212,17 @@ describe("getSessionInfo", () => {
|
|||
setupMockOpen(jsonl({ type: "session_meta", cwd: "/workspace/test" }));
|
||||
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
||||
mockReadFile.mockRejectedValue(new Error("EACCES"));
|
||||
mockCreateReadStream.mockImplementation(() => { throw new Error("EACCES"); });
|
||||
mockCreateReadStream.mockImplementation(() => {
|
||||
throw new Error("EACCES");
|
||||
});
|
||||
|
||||
expect(await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" }))).toBeNull();
|
||||
expect(
|
||||
await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("skips session files when stat throws", async () => {
|
||||
const content = jsonl(
|
||||
{ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" },
|
||||
);
|
||||
const content = jsonl({ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" });
|
||||
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
||||
setupMockOpen(content);
|
||||
mockReadFile.mockResolvedValue(content);
|
||||
|
|
@ -1191,9 +1263,7 @@ describe("getSessionInfo", () => {
|
|||
mockLstat.mockResolvedValue({ isDirectory: () => true });
|
||||
// stat is used by findCodexSessionFile to get mtimeMs of matching JSONL files
|
||||
mockStat.mockResolvedValue({ mtimeMs: 2000 });
|
||||
const content = jsonl(
|
||||
{ type: "session_meta", cwd: "/workspace/test", model: "o3-mini" },
|
||||
);
|
||||
const content = jsonl({ type: "session_meta", cwd: "/workspace/test", model: "o3-mini" });
|
||||
setupMockOpen(content);
|
||||
setupMockStream(content);
|
||||
mockReadFile.mockResolvedValue(content);
|
||||
|
|
@ -1206,9 +1276,7 @@ describe("getSessionInfo", () => {
|
|||
|
||||
it("ignores non-JSONL files in sessions directory", async () => {
|
||||
mockReaddir.mockResolvedValue(["notes.txt", "config.json", "sess.jsonl"]);
|
||||
const content = jsonl(
|
||||
{ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" },
|
||||
);
|
||||
const content = jsonl({ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" });
|
||||
setupMockOpen(content);
|
||||
setupMockStream(content);
|
||||
mockReadFile.mockResolvedValue(content);
|
||||
|
|
@ -1363,9 +1431,12 @@ describe("getRestoreCommand", () => {
|
|||
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
||||
|
||||
const session = makeSession({ workspacePath: "/workspace/test" });
|
||||
const cmd = await agent.getRestoreCommand!(session, makeProjectConfig({
|
||||
agentConfig: { permissions: "permissionless" },
|
||||
}));
|
||||
const cmd = await agent.getRestoreCommand!(
|
||||
session,
|
||||
makeProjectConfig({
|
||||
agentConfig: { permissions: "permissionless" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(cmd).toContain("--dangerously-bypass-approvals-and-sandbox");
|
||||
expect(cmd).not.toContain("--ask-for-approval");
|
||||
|
|
@ -1383,9 +1454,12 @@ describe("getRestoreCommand", () => {
|
|||
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
||||
|
||||
const session = makeSession({ workspacePath: "/workspace/test" });
|
||||
const cmd = await agent.getRestoreCommand!(session, makeProjectConfig({
|
||||
agentConfig: { permissions: "skip" as unknown as AgentSpecificConfig["permissions"] },
|
||||
}));
|
||||
const cmd = await agent.getRestoreCommand!(
|
||||
session,
|
||||
makeProjectConfig({
|
||||
agentConfig: { permissions: "skip" as unknown as AgentSpecificConfig["permissions"] },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(cmd).toContain("--dangerously-bypass-approvals-and-sandbox");
|
||||
});
|
||||
|
|
@ -1402,9 +1476,12 @@ describe("getRestoreCommand", () => {
|
|||
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
||||
|
||||
const session = makeSession({ workspacePath: "/workspace/test" });
|
||||
const cmd = await agent.getRestoreCommand!(session, makeProjectConfig({
|
||||
agentConfig: { permissions: "auto-edit" },
|
||||
}));
|
||||
const cmd = await agent.getRestoreCommand!(
|
||||
session,
|
||||
makeProjectConfig({
|
||||
agentConfig: { permissions: "auto-edit" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(cmd).toContain("--ask-for-approval never");
|
||||
expect(cmd).not.toContain("--dangerously-bypass-approvals-and-sandbox");
|
||||
|
|
@ -1422,9 +1499,12 @@ describe("getRestoreCommand", () => {
|
|||
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
||||
|
||||
const session = makeSession({ workspacePath: "/workspace/test" });
|
||||
const cmd = await agent.getRestoreCommand!(session, makeProjectConfig({
|
||||
agentConfig: { permissions: "suggest" },
|
||||
}));
|
||||
const cmd = await agent.getRestoreCommand!(
|
||||
session,
|
||||
makeProjectConfig({
|
||||
agentConfig: { permissions: "suggest" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(cmd).toContain("--ask-for-approval untrusted");
|
||||
});
|
||||
|
|
@ -1441,9 +1521,12 @@ describe("getRestoreCommand", () => {
|
|||
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
||||
|
||||
const session = makeSession({ workspacePath: "/workspace/test" });
|
||||
const cmd = await agent.getRestoreCommand!(session, makeProjectConfig({
|
||||
agentConfig: { permissions: "auto-edit", model: "o3-mini" },
|
||||
}));
|
||||
const cmd = await agent.getRestoreCommand!(
|
||||
session,
|
||||
makeProjectConfig({
|
||||
agentConfig: { permissions: "auto-edit", model: "o3-mini" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(cmd).not.toBeNull();
|
||||
// threadId should come after all flags
|
||||
|
|
@ -1466,9 +1549,12 @@ describe("getRestoreCommand", () => {
|
|||
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
||||
|
||||
const session = makeSession({ workspacePath: "/workspace/test" });
|
||||
const cmd = await agent.getRestoreCommand!(session, makeProjectConfig({
|
||||
agentConfig: { model: "o3-mini" },
|
||||
}));
|
||||
const cmd = await agent.getRestoreCommand!(
|
||||
session,
|
||||
makeProjectConfig({
|
||||
agentConfig: { model: "o3-mini" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(cmd).toContain("--model 'o3-mini'");
|
||||
expect(cmd).toContain("-c model_reasoning_effort=high");
|
||||
|
|
@ -1498,7 +1584,9 @@ describe("getRestoreCommand", () => {
|
|||
setupMockOpen(jsonl({ type: "session_meta", cwd: "/workspace/test" }));
|
||||
// readFile (full parse) fails
|
||||
mockReadFile.mockRejectedValue(new Error("EACCES"));
|
||||
mockCreateReadStream.mockImplementation(() => { throw new Error("EACCES"); });
|
||||
mockCreateReadStream.mockImplementation(() => {
|
||||
throw new Error("EACCES");
|
||||
});
|
||||
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
||||
|
||||
const session = makeSession({ workspacePath: "/workspace/test" });
|
||||
|
|
@ -1619,13 +1707,17 @@ describe("postLaunchSetup", () => {
|
|||
mockReadFile.mockRejectedValue(new Error("ENOENT"));
|
||||
|
||||
// Before postLaunchSetup, binary is "codex"
|
||||
expect(agent.getLaunchCommand(makeLaunchConfig())).toBe("'codex' -c check_for_update_on_startup=false");
|
||||
expect(agent.getLaunchCommand(makeLaunchConfig())).toBe(
|
||||
"'codex' -c check_for_update_on_startup=false",
|
||||
);
|
||||
|
||||
// After postLaunchSetup resolves the binary
|
||||
await agent.postLaunchSetup!(makeSession({ workspacePath: "/workspace/test" }));
|
||||
|
||||
// Now getLaunchCommand should use the resolved binary
|
||||
expect(agent.getLaunchCommand(makeLaunchConfig())).toBe("'/opt/bin/codex' -c check_for_update_on_startup=false");
|
||||
expect(agent.getLaunchCommand(makeLaunchConfig())).toBe(
|
||||
"'/opt/bin/codex' -c check_for_update_on_startup=false",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1736,7 +1828,7 @@ describe("setupWorkspaceHooks", () => {
|
|||
// Second call for AGENTS.md — file doesn't exist
|
||||
mockReadFile.mockImplementation((path: string) => {
|
||||
if (typeof path === "string" && path.endsWith(".ao-version")) {
|
||||
return Promise.resolve("0.2.0");
|
||||
return Promise.resolve("0.3.0");
|
||||
}
|
||||
// AGENTS.md read attempt
|
||||
return Promise.reject(new Error("ENOENT"));
|
||||
|
|
@ -1751,7 +1843,9 @@ describe("setupWorkspaceHooks", () => {
|
|||
const wrapperWrites = mockWriteFile.mock.calls.filter(
|
||||
(call: [string, string, object]) =>
|
||||
typeof call[0] === "string" &&
|
||||
(call[0].includes("ao-metadata-helper.sh.tmp.") || call[0].includes("/gh.tmp.") || call[0].includes("/git.tmp.")),
|
||||
(call[0].includes("ao-metadata-helper.sh.tmp.") ||
|
||||
call[0].includes("/gh.tmp.") ||
|
||||
call[0].includes("/git.tmp.")),
|
||||
);
|
||||
expect(wrapperWrites).toHaveLength(0);
|
||||
});
|
||||
|
|
@ -1770,7 +1864,7 @@ describe("setupWorkspaceHooks", () => {
|
|||
typeof call[0] === "string" && call[0].includes(".ao-version.tmp."),
|
||||
);
|
||||
expect(versionWriteCall).toBeDefined();
|
||||
expect(versionWriteCall![1]).toBe("0.2.0");
|
||||
expect(versionWriteCall![1]).toBe("0.3.0");
|
||||
|
||||
const versionRenameCall = mockRename.mock.calls.find(
|
||||
(call: string[]) => typeof call[1] === "string" && call[1].endsWith(".ao-version"),
|
||||
|
|
@ -1782,7 +1876,7 @@ describe("setupWorkspaceHooks", () => {
|
|||
// Version marker matches (skip wrapper install)
|
||||
mockReadFile.mockImplementation((path: string) => {
|
||||
if (typeof path === "string" && path.endsWith(".ao-version")) {
|
||||
return Promise.resolve("0.2.0");
|
||||
return Promise.resolve("0.3.0");
|
||||
}
|
||||
return Promise.reject(new Error("ENOENT"));
|
||||
});
|
||||
|
|
@ -1810,8 +1904,7 @@ describe("setupWorkspaceHooks", () => {
|
|||
// Every wrapper file should be written to a .tmp file first, then renamed
|
||||
// This ensures concurrent readers never see a partially written file
|
||||
const tmpWrites = mockWriteFile.mock.calls.filter(
|
||||
(call: [string, string, object]) =>
|
||||
typeof call[0] === "string" && call[0].includes(".tmp."),
|
||||
(call: [string, string, object]) => typeof call[0] === "string" && call[0].includes(".tmp."),
|
||||
);
|
||||
const renames = mockRename.mock.calls;
|
||||
|
||||
|
|
@ -1829,7 +1922,7 @@ describe("setupWorkspaceHooks", () => {
|
|||
it("writes .ao/AGENTS.md without modifying repo-tracked AGENTS.md", async () => {
|
||||
mockReadFile.mockImplementation((path: string) => {
|
||||
if (typeof path === "string" && path.endsWith(".ao-version")) {
|
||||
return Promise.resolve("0.2.0");
|
||||
return Promise.resolve("0.3.0");
|
||||
}
|
||||
return Promise.reject(new Error("ENOENT"));
|
||||
});
|
||||
|
|
@ -1867,10 +1960,9 @@ describe("shell wrapper content", () => {
|
|||
|
||||
// With atomic writes, content is written to a .tmp. file
|
||||
const call = mockWriteFile.mock.calls.find(
|
||||
(c: [string, string, object]) =>
|
||||
typeof c[0] === "string" && c[0].includes(`/${name}.tmp.`),
|
||||
(c: [string, string, object]) => typeof c[0] === "string" && c[0].includes(`/${name}.tmp.`),
|
||||
);
|
||||
return call ? call[1] as string : "";
|
||||
return call ? (call[1] as string) : "";
|
||||
}
|
||||
|
||||
describe("metadata helper", () => {
|
||||
|
|
@ -1928,9 +2020,10 @@ describe("shell wrapper content", () => {
|
|||
expect(content).not.toMatch(/grep -v "\^\$ao_bin_dir\$"/);
|
||||
});
|
||||
|
||||
it("only captures output for pr/create and pr/merge", async () => {
|
||||
it("only captures output for pr/create", async () => {
|
||||
const content = await getWrapperContent("gh");
|
||||
expect(content).toContain("pr/create|pr/merge");
|
||||
expect(content).toContain('case "$1/$2" in');
|
||||
expect(content).toContain("pr/create)");
|
||||
});
|
||||
|
||||
it("uses exec for non-PR commands (transparent passthrough)", async () => {
|
||||
|
|
@ -1956,9 +2049,11 @@ describe("shell wrapper content", () => {
|
|||
expect(content).toContain("update_ao_metadata pr");
|
||||
});
|
||||
|
||||
it("updates status to merged on gh pr merge", async () => {
|
||||
it("records agent-reported PR metadata on gh pr create", async () => {
|
||||
const content = await getWrapperContent("gh");
|
||||
expect(content).toContain("update_ao_metadata status merged");
|
||||
expect(content).toContain("update_ao_metadata agentReportedState");
|
||||
expect(content).toContain("update_ao_metadata agentReportedPrUrl");
|
||||
expect(content).toContain("update_ao_metadata agentReportedPrIsDraft");
|
||||
});
|
||||
|
||||
it("cleans up temp file on exit", async () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue