feat: report non-terminal PR workflow events (#131)

This commit is contained in:
harshitsinghbhandari 2026-04-17 21:46:57 +05:30
parent 961c064864
commit e7ad928812
10 changed files with 588 additions and 220 deletions

View File

@ -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.

View File

@ -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");
},
);
}

View File

@ -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

View File

@ -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");

View File

@ -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,
};
}

View File

@ -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).

View File

@ -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.

View File

@ -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

View File

@ -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,

View File

@ -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 () => {