diff --git a/.changeset/late-lions-share.md b/.changeset/late-lions-share.md new file mode 100644 index 000000000..b02745029 --- /dev/null +++ b/.changeset/late-lions-share.md @@ -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. diff --git a/packages/cli/src/commands/report.ts b/packages/cli/src/commands/report.ts index 6b1a8502b..48585e229 100644 --- a/packages/cli/src/commands/report.ts +++ b/packages/cli/src/commands/report.ts @@ -43,6 +43,8 @@ async function writeReport( sessionName: string, state: AgentReportedState, note: string | undefined, + prUrl: string | undefined, + prNumber: number | undefined, source: "acknowledge" | "report", ): Promise { 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 ", "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("", `One of: ${allowed} (aliases: fixing-ci, addressing-reviews, needs-input, ...)`) + .argument( + "", + `One of: ${allowed} (aliases: fixing-ci, addressing-reviews, needs-input, pr-created, ready-for-review, ...)`, + ) .option("-s, --session ", "Session ID (defaults to AO_SESSION_ID)") .option("--note ", "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 ", + "Attach a PR URL to pr-created / draft-pr-created / ready-for-review reports", + ) + .option( + "--pr-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"); + }, + ); } diff --git a/packages/core/src/__tests__/agent-report.test.ts b/packages/core/src/__tests__/agent-report.test.ts index 4ab4e8019..e3604d3d4 100644 --- a/packages/core/src/__tests__/agent-report.test.ts +++ b/packages/core/src/__tests__/agent-report.test.ts @@ -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 diff --git a/packages/core/src/__tests__/agent-workspace-hooks.test.ts b/packages/core/src/__tests__/agent-workspace-hooks.test.ts index a953b0d3a..22aba9e1e 100644 --- a/packages/core/src/__tests__/agent-workspace-hooks.test.ts +++ b/packages/core/src/__tests__/agent-workspace-hooks.test.ts @@ -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"); diff --git a/packages/core/src/agent-report.ts b/packages/core/src/agent-report.ts index 28272bd3c..756e7f33b 100644 --- a/packages/core/src/agent-report.ts +++ b/packages/core/src/agent-report.ts @@ -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 = { - "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 | null | undefined): AgentReport | null { +export function readAgentReport( + meta: Record | 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 | 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, }; } diff --git a/packages/core/src/agent-workspace-hooks.ts b/packages/core/src/agent-workspace-hooks.ts index c1f0bb47c..e759e5f08 100644 --- a/packages/core/src/agent-workspace-hooks.ts +++ b/packages/core/src/agent-workspace-hooks.ts @@ -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). diff --git a/packages/core/src/prompt-builder.ts b/packages/core/src/prompt-builder.ts index c16ae8697..9090ddf31 100644 --- a/packages/core/src/prompt-builder.ts +++ b/packages/core/src/prompt-builder.ts @@ -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 \` / \`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 ""\` 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 \` 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. diff --git a/packages/core/src/prompts/orchestrator.md b/packages/core/src/prompts/orchestrator.md index 64a0f3b42..37fab7e50 100644 --- a/packages/core/src/prompts/orchestrator.md +++ b/packages/core/src/prompts/orchestrator.md @@ -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 ]{{REPO_CONFIGURED_SECTION_START}} [--claim-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 `: 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 [session]`: Attach an existing PR to a worker session -{{REPO_CONFIGURED_SECTION_END}}- `ao session attach `: Attach to a session's tmux window + {{REPO_CONFIGURED_SECTION_START}}- `ao batch-spawn `: 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 [session]`: Attach an existing PR to a worker session + {{REPO_CONFIGURED_SECTION_END}}- `ao session attach `: Attach to a session's tmux window - `ao session kill `: Kill a specific session - `ao session cleanup [-p project]`: Kill completed/merged sessions - `ao send `: 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 ` (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 ` (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 diff --git a/packages/core/src/utils/session-from-metadata.ts b/packages/core/src/utils/session-from-metadata.ts index 820376010..d20f9d056 100644 --- a/packages/core/src/utils/session-from-metadata.ts +++ b/packages/core/src/utils/session-from-metadata.ts @@ -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, diff --git a/packages/plugins/agent-codex/src/index.test.ts b/packages/plugins/agent-codex/src/index.test.ts index 3d4e1a635..16ebd6502 100644 --- a/packages/plugins/agent-codex/src/index.test.ts +++ b/packages/plugins/agent-codex/src/index.test.ts @@ -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 () => {