fix: address PR #1300 requested changes

This commit is contained in:
harshitsinghbhandari 2026-04-18 11:13:53 +05:30
parent 79c7ef85a6
commit bcdda4b9ba
17 changed files with 930 additions and 282 deletions

View File

@ -0,0 +1,7 @@
---
"@aoagents/ao-core": patch
"@aoagents/ao-cli": patch
"@aoagents/ao-web": patch
---
Tighten the session lifecycle review follow-ups by debouncing report-watcher reactions, restoring the shared Geist/JetBrains font setup, wiring recovery validation to real agent activity probes, and adding direct coverage for `ao report`, activity-signal classification, and dashboard lifecycle audit panels.

View File

@ -0,0 +1,185 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Command } from "commander";
import type * as CoreModule from "@aoagents/ao-core";
const { mockConfigRef, mockSessionManager, mockApplyAgentReport, mockGetSessionsDir } = vi.hoisted(
() => ({
mockConfigRef: { current: null as Record<string, unknown> | null },
mockSessionManager: {
get: vi.fn(),
},
mockApplyAgentReport: vi.fn(),
mockGetSessionsDir: vi.fn(),
}),
);
vi.mock("@aoagents/ao-core", async (importOriginal) => {
const actual = (await importOriginal()) as typeof CoreModule;
return {
...actual,
loadConfig: () => mockConfigRef.current,
getSessionsDir: (...args: unknown[]) => mockGetSessionsDir(...args),
applyAgentReport: (...args: unknown[]) => mockApplyAgentReport(...args),
};
});
vi.mock("../../src/lib/create-session-manager.js", () => ({
getSessionManager: async () => mockSessionManager,
}));
import { registerAcknowledge, registerReport } from "../../src/commands/report.js";
describe("report commands", () => {
let program: Command;
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
let exitSpy: ReturnType<typeof vi.spyOn>;
const originalEnv = { ...process.env };
beforeEach(() => {
program = new Command();
program.exitOverride();
registerAcknowledge(program);
registerReport(program);
consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => {
throw new Error(`process.exit(${code})`);
});
process.env = { ...originalEnv };
delete process.env["AO_SESSION_ID"];
process.env["USER"] = "codex";
mockConfigRef.current = {
configPath: "/tmp/agent-orchestrator.yaml",
projects: {
app: {
name: "app",
path: "/tmp/app",
},
},
};
mockSessionManager.get.mockReset();
mockApplyAgentReport.mockReset();
mockGetSessionsDir.mockReset();
mockGetSessionsDir.mockReturnValue("/tmp/sessions");
mockSessionManager.get.mockResolvedValue({
id: "app-1",
projectId: "app",
});
mockApplyAgentReport.mockReturnValue({
previousState: "working",
nextState: "started",
});
});
afterEach(() => {
process.env = originalEnv;
consoleLogSpy.mockRestore();
consoleErrorSpy.mockRestore();
exitSpy.mockRestore();
});
it("uses AO_SESSION_ID for acknowledge when no explicit session is provided", async () => {
process.env["AO_SESSION_ID"] = "app-1";
await program.parseAsync(["node", "test", "acknowledge", "--note", "picked up"]);
expect(mockSessionManager.get).toHaveBeenCalledWith("app-1");
expect(mockApplyAgentReport).toHaveBeenCalledWith(
"/tmp/sessions",
"app-1",
expect.objectContaining({
state: "started",
note: "picked up",
source: "acknowledge",
actor: "codex",
}),
);
});
it("prefers explicit --session over AO_SESSION_ID", async () => {
process.env["AO_SESSION_ID"] = "wrong-session";
await program.parseAsync(["node", "test", "report", "working", "--session", "app-2"]);
expect(mockSessionManager.get).toHaveBeenCalledWith("app-2");
});
it("rejects unknown states before touching the session manager", async () => {
await expect(program.parseAsync(["node", "test", "report", "bogus-state"])).rejects.toThrow(
"process.exit(1)",
);
expect(mockSessionManager.get).not.toHaveBeenCalled();
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown state"));
});
it("rejects invalid PR numbers", async () => {
await expect(
program.parseAsync(["node", "test", "report", "pr-created", "--pr-number", "abc"]),
).rejects.toThrow("process.exit(1)");
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Invalid PR number"));
});
it("rejects PR metadata flags for non-PR workflow states", async () => {
await expect(
program.parseAsync(["node", "test", "report", "working", "--pr-url", "https://example.com"]),
).rejects.toThrow("process.exit(1)");
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("PR metadata flags are only valid"),
);
});
it("surfaces session-not-found errors", async () => {
mockSessionManager.get.mockResolvedValue(null);
await expect(program.parseAsync(["node", "test", "report", "working", "--session", "app-1"]))
.rejects.toThrow("process.exit(1)");
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Session not found"));
});
it("surfaces project-not-found errors", async () => {
mockConfigRef.current = {
configPath: "/tmp/agent-orchestrator.yaml",
projects: {},
};
await expect(program.parseAsync(["node", "test", "report", "working", "--session", "app-1"]))
.rejects.toThrow("process.exit(1)");
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("Project not found for session"),
);
});
it("surfaces rejected reports from applyAgentReport", async () => {
mockApplyAgentReport.mockImplementation(() => {
throw new Error("PR number 7 does not match PR URL");
});
await expect(
program.parseAsync([
"node",
"test",
"report",
"pr-created",
"--session",
"app-1",
"--pr-number",
"7",
"--pr-url",
"https://github.com/acme/app/pull/9",
]),
).rejects.toThrow("process.exit(1)");
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("Report rejected: PR number 7 does not match PR URL"),
);
});
});

View File

@ -0,0 +1,173 @@
import { describe, expect, it } from "vitest";
import {
ACTIVITY_STRONG_WINDOW_MS,
ACTIVITY_WEAK_WINDOW_MS,
classifyActivitySignal,
createActivitySignal,
hasPositiveIdleEvidence,
isWeakActivityEvidence,
summarizeActivityFreshness,
supportsRecentLiveness,
} from "../activity-signal.js";
describe("summarizeActivityFreshness", () => {
const now = new Date("2025-01-01T12:00:00.000Z");
it("returns none without a timestamp", () => {
expect(summarizeActivityFreshness(undefined, now)).toBe("none");
});
it("classifies exact strong and weak boundaries correctly", () => {
expect(
summarizeActivityFreshness(new Date(now.getTime() - ACTIVITY_STRONG_WINDOW_MS), now),
).toBe("strong");
expect(
summarizeActivityFreshness(new Date(now.getTime() - ACTIVITY_WEAK_WINDOW_MS), now),
).toBe("weak");
});
it("treats future timestamps as strong instead of going negative", () => {
expect(summarizeActivityFreshness(new Date("2025-01-01T12:01:00.000Z"), now)).toBe("strong");
});
it("returns stale when the timestamp is older than the weak window", () => {
expect(
summarizeActivityFreshness(new Date(now.getTime() - ACTIVITY_WEAK_WINDOW_MS - 1), now),
).toBe("stale");
});
});
describe("classifyActivitySignal", () => {
const now = new Date("2025-01-01T12:00:00.000Z");
it("keeps fresh active activity as valid", () => {
expect(
classifyActivitySignal(
{
state: "active",
timestamp: new Date("2025-01-01T11:59:30.000Z"),
},
"native",
now,
),
).toEqual({
state: "valid",
activity: "active",
timestamp: new Date("2025-01-01T11:59:30.000Z"),
source: "native",
detail: undefined,
});
});
it("marks idle-without-timestamp as stale missing_timestamp evidence", () => {
expect(classifyActivitySignal({ state: "idle" }, "native", now)).toEqual({
state: "stale",
activity: "idle",
timestamp: undefined,
source: "native",
detail: "missing_timestamp",
});
});
it("marks blocked-without-timestamp as stale missing_timestamp evidence", () => {
expect(classifyActivitySignal({ state: "blocked" }, "terminal", now)).toEqual({
state: "stale",
activity: "blocked",
timestamp: undefined,
source: "terminal",
detail: "missing_timestamp",
});
});
it("keeps active-without-timestamp valid because it is positive liveness evidence", () => {
expect(classifyActivitySignal({ state: "active" }, "native", now).state).toBe("valid");
});
it("marks stale active evidence as stale_timestamp", () => {
expect(
classifyActivitySignal(
{
state: "active",
timestamp: new Date(now.getTime() - ACTIVITY_WEAK_WINDOW_MS - 1),
},
"native",
now,
),
).toMatchObject({
state: "stale",
activity: "active",
detail: "stale_timestamp",
});
});
it("keeps exited activity valid even without timestamp", () => {
expect(classifyActivitySignal({ state: "exited" }, "runtime", now)).toEqual({
state: "valid",
activity: "exited",
timestamp: undefined,
source: "runtime",
detail: undefined,
});
});
});
describe("activity signal helpers", () => {
const now = new Date("2025-01-01T12:00:00.000Z");
it("detects positive idle evidence only for valid idle states with timestamps", () => {
expect(
hasPositiveIdleEvidence(
createActivitySignal("valid", {
activity: "idle",
timestamp: new Date("2025-01-01T11:59:00.000Z"),
source: "native",
}),
),
).toBe(true);
expect(
hasPositiveIdleEvidence(createActivitySignal("valid", { activity: "idle", source: "native" })),
).toBe(false);
expect(
hasPositiveIdleEvidence(
createActivitySignal("stale", {
activity: "idle",
timestamp: new Date("2025-01-01T11:59:00.000Z"),
source: "native",
}),
),
).toBe(false);
});
it("supports recent liveness only for valid active/ready timestamps within the weak window", () => {
expect(
supportsRecentLiveness(
createActivitySignal("valid", {
activity: "ready",
timestamp: new Date("2025-01-01T11:56:00.000Z"),
source: "native",
}),
now,
),
).toBe(true);
expect(
supportsRecentLiveness(
createActivitySignal("valid", {
activity: "active",
timestamp: new Date("2025-01-01T11:54:59.999Z"),
source: "native",
}),
now,
),
).toBe(false);
});
it("treats all non-valid signals as weak evidence", () => {
expect(isWeakActivityEvidence(createActivitySignal("valid", { activity: "active" }))).toBe(
false,
);
expect(isWeakActivityEvidence(createActivitySignal("stale", { activity: "active" }))).toBe(
true,
);
expect(isWeakActivityEvidence(createActivitySignal("unavailable"))).toBe(true);
});
});

View File

@ -4,6 +4,7 @@ import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import {
AGENT_REPORT_CLOCK_SKEW_TOLERANCE_MS,
AGENT_REPORTED_STATES,
AGENT_REPORT_FRESHNESS_MS,
AGENT_REPORT_METADATA_KEYS,
@ -265,6 +266,24 @@ describe("applyAgentReport", () => {
expect(payload.pr.url).toBe("https://github.com/test/repo/pull/42");
});
it("does not invent an open PR without a URL or number", () => {
const now = new Date("2025-01-02T09:35:00.000Z");
const result = applyAgentReport(dataDir, sessionId, {
state: "pr_created",
now,
});
expect(result.legacyStatus).toBe("idle");
const meta = readMetadataRaw(dataDir, sessionId)!;
const payload = JSON.parse(meta["statePayload"]);
expect(payload.pr.state).toBe("none");
expect(payload.pr.reason).toBe("not_created");
expect(meta[AGENT_REPORT_METADATA_KEYS.PR_URL]).toBeUndefined();
expect(meta[AGENT_REPORT_METADATA_KEYS.PR_NUMBER]).toBeUndefined();
});
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, {
@ -502,12 +521,24 @@ describe("readAgentReport + isAgentReportFresh", () => {
expect(isAgentReportFresh(stale, now)).toBe(false);
});
it("rejects future timestamps (clock skew must not appear forever-fresh)", () => {
it("accepts small future skew inside the tolerance window", () => {
const now = new Date("2025-01-01T12:00:00.000Z");
const slightlyFuture = readAgentReport({
[AGENT_REPORT_METADATA_KEYS.STATE]: "working",
[AGENT_REPORT_METADATA_KEYS.AT]: new Date(
now.getTime() + AGENT_REPORT_CLOCK_SKEW_TOLERANCE_MS - 1,
).toISOString(),
})!;
expect(isAgentReportFresh(slightlyFuture, now)).toBe(true);
});
it("rejects future timestamps outside the skew tolerance", () => {
const now = new Date("2025-01-01T12:00:00.000Z");
const futureAt = "2025-01-01T12:10:00.000Z"; // 10m in the future
const future = readAgentReport({
[AGENT_REPORT_METADATA_KEYS.STATE]: "working",
[AGENT_REPORT_METADATA_KEYS.AT]: futureAt,
[AGENT_REPORT_METADATA_KEYS.AT]: new Date(
now.getTime() + AGENT_REPORT_CLOCK_SKEW_TOLERANCE_MS + 1,
).toISOString(),
})!;
expect(isAgentReportFresh(future, now)).toBe(false);
});

View File

@ -311,6 +311,99 @@ describe("recovery validator", () => {
expect(assessment.reason).toContain("Signal disagreement");
});
it("uses agent activity as liveness fallback when process probing says not running", async () => {
rootDir = join(tmpdir(), `ao-recovery-validator-${randomUUID()}`);
mkdirSync(rootDir, { recursive: true });
const projectPath = join(rootDir, "project");
mkdirSync(projectPath, { recursive: true });
writeFileSync(join(rootDir, "agent-orchestrator.yaml"), "projects: {}\n", "utf-8");
const mockRuntime: Runtime = {
name: "tmux",
create: vi.fn(),
destroy: vi.fn(),
sendMessage: vi.fn(),
getOutput: vi.fn(),
isAlive: vi.fn().mockResolvedValue(true),
};
const mockWorkspace: Workspace = {
name: "worktree",
create: vi.fn(),
destroy: vi.fn(),
list: vi.fn(),
exists: vi.fn().mockResolvedValue(true),
};
const mockAgent: Agent = {
name: "mock-agent",
processName: "mock-agent",
getLaunchCommand: vi.fn(),
getEnvironment: vi.fn(),
detectActivity: vi.fn(),
getActivityState: vi.fn().mockResolvedValue({ state: "active" }),
isProcessRunning: vi.fn().mockResolvedValue(false),
getSessionInfo: vi.fn(),
};
const registry: PluginRegistry = {
register: vi.fn(),
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return mockRuntime;
if (slot === "workspace") return mockWorkspace;
if (slot === "agent") return mockAgent;
return null;
}),
list: vi.fn().mockReturnValue([]),
loadBuiltins: vi.fn().mockResolvedValue(undefined),
loadFromConfig: vi.fn().mockResolvedValue(undefined),
};
const config: OrchestratorConfig = {
configPath: join(rootDir, "agent-orchestrator.yaml"),
port: 3000,
readyThresholdMs: 300_000,
power: { preventIdleSleep: false },
defaults: {
runtime: "tmux",
agent: "mock-agent",
workspace: "worktree",
notifiers: ["desktop"],
},
projects: {
app: {
name: "app",
repo: "org/repo",
path: projectPath,
defaultBranch: "main",
sessionPrefix: "app",
},
},
notifiers: {},
notificationRouting: {
urgent: ["desktop"],
action: ["desktop"],
warning: [],
info: [],
},
reactions: {},
};
const scanned: ScannedSession = {
sessionId: "app-1",
projectId: "app",
project: config.projects.app,
sessionsDir: getSessionsDir(config.configPath, projectPath),
rawMetadata: {
worktree: projectPath,
status: "working",
runtimeHandle: JSON.stringify({ id: "rt-1", runtimeName: "tmux", data: {} }),
},
};
const assessment = await validateSession(scanned, config, registry);
expect(mockAgent.getActivityState).toHaveBeenCalled();
expect(assessment.agentActivity).toBe("active");
expect(assessment.agentProcessRunning).toBe(true);
expect(assessment.classification).toBe("live");
});
it("keeps terminal metadata unrecoverable even when probes are uncertain", async () => {
rootDir = join(tmpdir(), `ao-recovery-validator-${randomUUID()}`);
mkdirSync(rootDir, { recursive: true });

View File

@ -199,7 +199,7 @@ describe("checkBlockedAgent", () => {
expect(result?.message).toContain("Need API key");
});
it("ignores stale reports", () => {
it("keeps needs_input visible even after the freshness window", () => {
const now = new Date();
const oldReportTime = new Date(now.getTime() - 10 * 60 * 1000); // 10 minutes ago
const session = createMockSession();
@ -209,7 +209,8 @@ describe("checkBlockedAgent", () => {
};
const result = checkBlockedAgent(session, report, now, config);
expect(result).toBeNull();
expect(result?.trigger).toBe("agent_needs_input");
expect(result?.timeSinceReportMs).toBeGreaterThan(0);
});
});

View File

@ -115,6 +115,7 @@ export const AGENT_REPORT_METADATA_KEYS = {
/** Freshness window — agent reports older than this are ignored. */
export const AGENT_REPORT_FRESHNESS_MS = 300_000; // 5 minutes
export const AGENT_REPORT_CLOCK_SKEW_TOLERANCE_MS = 60_000; // 60 seconds
/**
* CLI surface accepts these hyphen/underscore aliases for convenience.
@ -432,9 +433,16 @@ export function applyAgentReport(
const effectivePrUrl = trimmedPrUrl ?? current.pr.url ?? existingPrUrl;
const effectivePrNumber =
prNumber ?? current.pr.number ?? existingPrNumber ?? parsedPrFromUrl?.number;
current.pr.state = "open";
current.pr.reason = input.state === "ready_for_review" ? "review_pending" : "in_progress";
current.pr.lastObservedAt = now;
const canAdvancePrState =
effectivePrUrl !== undefined ||
effectivePrNumber !== undefined ||
current.pr.state !== "none";
if (canAdvancePrState) {
current.pr.state = "open";
current.pr.reason =
input.state === "ready_for_review" ? "review_pending" : "in_progress";
current.pr.lastObservedAt = now;
}
if (effectivePrUrl) {
current.pr.url = effectivePrUrl;
}
@ -550,10 +558,11 @@ export function isAgentReportFresh(
report: AgentReport,
now: Date = new Date(),
windowMs: number = AGENT_REPORT_FRESHNESS_MS,
clockSkewToleranceMs: number = AGENT_REPORT_CLOCK_SKEW_TOLERANCE_MS,
): boolean {
const reportedAt = Date.parse(report.timestamp);
if (Number.isNaN(reportedAt)) return false;
const currentTime = now.getTime();
if (reportedAt > currentTime) return false;
if (reportedAt > currentTime + clockSkewToleranceMs) return false;
return currentTime - reportedAt <= windowMs;
}

View File

@ -40,6 +40,7 @@ import {
import { buildLifecycleMetadataPatch, cloneLifecycle, deriveLegacyStatus } from "./lifecycle-state.js";
import { updateMetadata } from "./metadata.js";
import { getSessionsDir } from "./paths.js";
import { applyDecisionToLifecycle as commitLifecycleDecisionInPlace } from "./lifecycle-transition.js";
import {
classifyActivitySignal,
createActivitySignal,
@ -235,31 +236,6 @@ interface ProbeResult {
state: "alive" | "dead" | "unknown";
failed: boolean;
}
function applyLifecycleDecision(
lifecycle: CanonicalSessionLifecycle,
decision: LifecycleDecision,
nowIso: string,
): void {
if (decision.prState && decision.prReason) {
lifecycle.pr.state = decision.prState;
lifecycle.pr.reason = decision.prReason;
}
if (decision.sessionState && decision.sessionReason) {
lifecycle.session.state = decision.sessionState;
lifecycle.session.reason = decision.sessionReason;
lifecycle.session.lastTransitionAt = nowIso;
if (decision.sessionState === "working" && lifecycle.session.startedAt === null) {
lifecycle.session.startedAt = nowIso;
}
if (decision.sessionState === "done") {
lifecycle.session.completedAt = nowIso;
}
if (decision.sessionState === "terminated") {
lifecycle.session.terminatedAt = nowIso;
}
}
}
function splitEvidenceSignals(evidence: string): string[] {
return evidence
@ -536,7 +512,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
detectingAttempts: currentDetectingAttempts,
},
): DeterminedStatus => {
applyLifecycleDecision(lifecycle, decision, nowIso);
commitLifecycleDecisionInPlace(lifecycle, decision, nowIso);
session.lifecycle = lifecycle;
session.status = decision.status;
session.activitySignal = activitySignal;
@ -1848,7 +1824,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
});
// Execute reaction if configured
if (reactionConfig && reactionConfig.auto !== false) {
if (isNewTrigger && reactionConfig && reactionConfig.auto !== false) {
await executeReaction(session.id, session.projectId, reactionKey, reactionConfig);
}
}

View File

@ -9,6 +9,7 @@ import {
type RuntimeHandle,
type SessionStatus,
type ActivityState,
type Session,
} from "../types.js";
import { safeJsonParse, validateStatus } from "../utils/validation.js";
import type { ScannedSession } from "./scanner.js";
@ -20,6 +21,18 @@ import {
type RecoveryConfig,
} from "./types.js";
import { resolveAgentSelection, resolveSessionRole } from "../agent-selection.js";
import { createInitialCanonicalLifecycle } from "../lifecycle-state.js";
import { createActivitySignal } from "../activity-signal.js";
function indicatesLiveAgentActivity(activity: ActivityState | null): boolean {
return (
activity === "active" ||
activity === "ready" ||
activity === "idle" ||
activity === "waiting_input" ||
activity === "blocked"
);
}
export async function validateSession(
scanned: ScannedSession,
@ -86,7 +99,7 @@ export async function validateSession(
let agentProcessRunning = false;
let processProbeSucceeded = false;
const agentActivity: ActivityState | null = null;
let agentActivity: ActivityState | null = null;
if (agent && runtimeHandle) {
try {
agentProcessRunning = await agent.isProcessRunning(runtimeHandle);
@ -95,6 +108,37 @@ export async function validateSession(
agentProcessRunning = false;
processProbeSucceeded = false;
}
try {
const lifecycle = createInitialCanonicalLifecycle("worker");
const probeSession: Session = {
id: sessionId,
projectId,
status: metadataStatus,
activity: null,
activitySignal: createActivitySignal("unavailable"),
lifecycle,
branch: rawMetadata["branch"] ?? null,
issueId: rawMetadata["issue"] ?? null,
pr: null,
workspacePath,
runtimeHandle,
agentInfo: null,
createdAt: new Date(rawMetadata["createdAt"] ?? Date.now()),
lastActivityAt: new Date(rawMetadata["lastActivityAt"] ?? Date.now()),
metadata: rawMetadata,
};
const detection = await agent.getActivityState(probeSession, config.readyThresholdMs);
agentActivity = detection?.state ?? null;
if (!agentProcessRunning && indicatesLiveAgentActivity(agentActivity)) {
agentProcessRunning = true;
}
if (agentActivity === "exited") {
agentProcessRunning = false;
}
} catch {
agentActivity = null;
}
}
const metadataValid = Object.keys(rawMetadata).length > 0;

View File

@ -10,7 +10,7 @@
*/
import type { Session, SessionStatus } from "./types.js";
import { readAgentReport, isAgentReportFresh, type AgentReport } from "./agent-report.js";
import { readAgentReport, type AgentReport } from "./agent-report.js";
/**
* Report watcher trigger types.
@ -174,15 +174,15 @@ export function checkBlockedAgent(
// If no report, nothing to check
if (!report) return null;
// Only check fresh reports (don't re-trigger on old blocked states)
if (!isAgentReportFresh(report, now)) return null;
if (report.state === "needs_input") {
const reportTime = Date.parse(report.timestamp);
const timeSinceReportMs = Number.isNaN(reportTime) ? undefined : now.getTime() - reportTime;
return {
trigger: "agent_needs_input",
message: `Agent needs input: ${report.note ?? "waiting for user decision"}`,
checkedAt: now.toISOString(),
report,
timeSinceReportMs,
};
}

View File

@ -1,15 +1,23 @@
import type { CSSProperties, ReactNode } from "react";
import type { Metadata, Viewport } from "next";
import type { ReactNode } from "react";
import { Geist, JetBrains_Mono } from "next/font/google";
import { getProjectName } from "@/lib/project-name";
import { ServiceWorkerRegistrar } from "@/components/ServiceWorkerRegistrar";
import { Providers } from "@/app/providers";
import "./globals.css";
const rootFontVariables = {
"--font-geist-sans": '"SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
"--font-jetbrains-mono":
'"SF Mono", "JetBrains Mono", "Menlo", "Consolas", "Liberation Mono", monospace',
} as CSSProperties;
const geistSans = Geist({
subsets: ["latin"],
variable: "--font-geist-sans",
display: "swap",
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-jetbrains-mono",
display: "swap",
weight: ["400", "500"],
});
export const viewport: Viewport = {
width: "device-width",
@ -41,8 +49,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html
lang="en"
className="dark"
style={rootFontVariables}
className={`dark ${geistSans.variable} ${jetbrainsMono.variable}`}
suppressHydrationWarning
>
<body className="bg-[var(--color-bg-base)] text-[var(--color-text-primary)] antialiased">

View File

@ -4,23 +4,13 @@ import { useState, useEffect, useRef, useMemo, useCallback, type ReactNode } fro
import { useSearchParams } from "next/navigation";
import { useMediaQuery, MOBILE_BREAKPOINT } from "@/hooks/useMediaQuery";
import {
type DashboardAgentReportAuditEntry,
type DashboardSession,
type DashboardPR,
NON_RESTORABLE_STATUSES,
getActivitySignalLabel,
isPRMergeReady,
isPRRateLimited,
isPRUnenriched,
getSessionTruthLabel,
getSessionTruthReasonLabel,
getPRTruthLabel,
getPRTruthReasonLabel,
getRuntimeTruthLabel,
getRuntimeTruthReasonLabel,
getLifecycleGuidance,
getLifecycleEvidence,
getActivitySignalLabel,
getActivitySignalReasonLabel,
isDashboardRuntimeEnded,
isDashboardSessionRestorable,
} from "@/lib/types";
@ -32,6 +22,8 @@ import type { ProjectInfo } from "@/lib/project-name";
import { MobileBottomNav } from "./MobileBottomNav";
import { ProjectSidebar } from "./ProjectSidebar";
import { SessionTruthPanel } from "./SessionTruthPanel";
import { SessionReportAuditPanel } from "./SessionReportAuditPanel";
const DirectTerminal = dynamic(
() => import("./DirectTerminal").then((m) => ({ default: m.DirectTerminal })),
@ -64,18 +56,6 @@ interface SessionDetailProps {
onRetrySidebar?: () => void;
}
function truthBadgeTone(label: string): string {
const normalized = label.toLowerCase();
if (normalized.includes("detect")) return "var(--color-status-attention)";
if (normalized.includes("terminated") || normalized.includes("missing")) {
return "var(--color-status-error)";
}
if (normalized.includes("merged") || normalized.includes("alive")) {
return "var(--color-status-ready)";
}
return "var(--color-accent)";
}
// ── Helpers ──────────────────────────────────────────────────────────
function formatTimeCompact(isoDate: string | null): string {
@ -152,209 +132,6 @@ function activityStateClass(activityLabel: string): string {
return "session-detail-status-pill--neutral";
}
function SessionTruthPanel({ session }: { session: DashboardSession }) {
if (!session.lifecycle) return null;
const facts = [
{
heading: "Activity",
label: getActivitySignalLabel(session),
reason: getActivitySignalReasonLabel(session),
},
{
heading: "Session",
label: getSessionTruthLabel(session),
reason: getSessionTruthReasonLabel(session),
},
{
heading: "PR",
label: getPRTruthLabel(session),
reason: getPRTruthReasonLabel(session),
},
{
heading: "Runtime",
label: getRuntimeTruthLabel(session),
reason: getRuntimeTruthReasonLabel(session),
},
];
const guidance = getLifecycleGuidance(session);
const evidence = getLifecycleEvidence(session);
return (
<section className="mb-4 rounded-[20px] border border-[var(--color-border-muted)] bg-[var(--color-bg-panel)] px-4 py-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--color-text-muted)]">
Lifecycle Truth
</p>
<p className="mt-1 text-[13px] text-[var(--color-text-secondary)]">
{session.lifecycle.summary}
</p>
<div className="mt-3 flex flex-wrap gap-2">
{facts.map((fact) => (
<div
key={fact.heading}
className="rounded-full border border-[var(--color-border-muted)] bg-[var(--color-bg-base)] px-3 py-1.5"
>
<div className="text-[10px] uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
{fact.heading}
</div>
<div
className="mt-0.5 text-[12px] font-medium"
style={{ color: truthBadgeTone(fact.label) }}
>
{fact.label}
</div>
{fact.reason ? (
<div className="mt-0.5 text-[11px] text-[var(--color-text-tertiary)]">
{fact.reason}
</div>
) : null}
</div>
))}
</div>
{guidance ? (
<p className="mt-3 text-[12px] text-[var(--color-status-attention)]">
{guidance}
</p>
) : null}
{evidence ? (
<p className="mt-2 text-[11px] text-[var(--color-text-tertiary)]">
Evidence: {evidence}
</p>
) : null}
</section>
);
}
function formatAuditTimestamp(isoDate: string): string {
const parsed = Date.parse(isoDate);
if (Number.isNaN(parsed)) return isoDate;
return new Date(parsed).toLocaleString();
}
function getAuditCommandLabel(entry: DashboardAgentReportAuditEntry): string {
return entry.source === "acknowledge" ? "ao acknowledge" : `ao report ${entry.reportState}`;
}
function SessionReportAuditPanel({
sessionId,
entries,
}: {
sessionId: string;
entries: DashboardAgentReportAuditEntry[];
}) {
const [isExpanded, setIsExpanded] = useState(true);
if (entries.length === 0) {
return null;
}
return (
<section className="mb-4 rounded-[20px] border border-[var(--color-border-muted)] bg-[var(--color-bg-panel)] px-4 py-3">
<button
type="button"
className="flex w-full items-center justify-between gap-3 text-left"
aria-expanded={isExpanded}
aria-controls="session-report-audit-panel"
onClick={() => setIsExpanded((current) => !current)}
>
<span className="block">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--color-text-muted)]">
Agent Reports
</span>
<span className="mt-1 block text-[12px] text-[var(--color-text-secondary)]">
{entries.length} audit {entries.length === 1 ? "entry" : "entries"}
</span>
</span>
<span
className="flex h-7 w-7 items-center justify-center rounded-full border border-[var(--color-border-muted)] bg-[var(--color-bg-base)] text-[var(--color-text-secondary)]"
aria-hidden="true"
>
<svg
className={cn(
"h-4 w-4 transition-transform duration-200",
isExpanded ? "rotate-180" : "rotate-0",
)}
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="m6 9 6 6 6-6" />
</svg>
</span>
</button>
{isExpanded ? (
<div id="session-report-audit-panel" className="mt-3 space-y-3">
{entries.map((entry, index) => (
<div
key={`${entry.timestamp}-${entry.reportState}-${entry.actor}-${entry.source}-${String(entry.accepted)}-${index}`}
className="rounded-[16px] border border-[var(--color-border-muted)] bg-[var(--color-bg-base)] px-3 py-3"
>
<div className="flex flex-wrap items-center gap-2">
<span
className="rounded-full px-2 py-0.5 text-[11px] font-medium"
style={{
background: entry.accepted
? "color-mix(in srgb, var(--color-status-ready) 14%, transparent)"
: "color-mix(in srgb, var(--color-status-error) 14%, transparent)",
color: entry.accepted
? "var(--color-status-ready)"
: "var(--color-status-error)",
}}
>
{entry.accepted ? "Accepted" : "Rejected"}
</span>
<span className="text-[11px] text-[var(--color-text-tertiary)]">
{formatAuditTimestamp(entry.timestamp)}
</span>
</div>
<div className="mt-3 grid gap-2 text-[12px] text-[var(--color-text-secondary)] sm:grid-cols-3">
<div className="rounded-[12px] border border-[var(--color-border-muted)] px-2.5 py-2">
<div className="text-[10px] uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
Session
</div>
<div className="mt-1 font-[var(--font-mono)] text-[var(--color-text-primary)]">
{sessionId}
</div>
</div>
<div className="rounded-[12px] border border-[var(--color-border-muted)] px-2.5 py-2">
<div className="text-[10px] uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
Actor
</div>
<div className="mt-1 text-[var(--color-text-primary)]">{entry.actor}</div>
</div>
<div className="rounded-[12px] border border-[var(--color-border-muted)] px-2.5 py-2">
<div className="text-[10px] uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
Source
</div>
<div className="mt-1 font-[var(--font-mono)] text-[var(--color-text-primary)]">
{getAuditCommandLabel(entry)}
</div>
</div>
</div>
<div className="mt-3 text-[12px] text-[var(--color-text-secondary)]">
{entry.before.legacyStatus} / {entry.before.sessionState}
{" -> "}
{entry.after.legacyStatus} / {entry.after.sessionState}
</div>
{entry.note ? (
<p className="mt-2 text-[12px] text-[var(--color-text-secondary)]">
Note: {entry.note}
</p>
) : null}
{entry.rejectionReason ? (
<p className="mt-2 text-[12px] text-[var(--color-status-error)]">
Rejection: {entry.rejectionReason}
</p>
) : null}
</div>
))}
</div>
) : null}
</section>
);
}
function SessionTopStrip({
headline,
crumbId,

View File

@ -0,0 +1,146 @@
"use client";
import { useState } from "react";
import { cn } from "@/lib/cn";
import type { DashboardAgentReportAuditEntry } from "@/lib/types";
const RECENT_AUDIT_LIMIT = 10;
function formatAuditTimestamp(isoDate: string): string {
const parsed = Date.parse(isoDate);
if (Number.isNaN(parsed)) return isoDate;
return new Date(parsed).toLocaleString();
}
function getAuditCommandLabel(entry: DashboardAgentReportAuditEntry): string {
return entry.source === "acknowledge" ? "ao acknowledge" : `ao report ${entry.reportState}`;
}
export function SessionReportAuditPanel({
sessionId,
entries,
}: {
sessionId: string;
entries: DashboardAgentReportAuditEntry[];
}) {
const [isExpanded, setIsExpanded] = useState(false);
const [showOlderEntries, setShowOlderEntries] = useState(false);
const panelId = `${sessionId}-report-audit-panel`;
const hasOlderEntries = entries.length > RECENT_AUDIT_LIMIT;
const visibleEntries =
showOlderEntries || !hasOlderEntries ? entries : entries.slice(0, RECENT_AUDIT_LIMIT);
if (entries.length === 0) {
return null;
}
return (
<section className="mb-4 rounded-[20px] border border-[var(--color-border-muted)] bg-[var(--color-bg-panel)] px-4 py-3">
<button
type="button"
className="flex w-full items-center justify-between gap-3 text-left"
aria-expanded={isExpanded}
aria-controls={panelId}
onClick={() => setIsExpanded((current) => !current)}
>
<span className="block">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--color-text-muted)]">
Agent Reports
</span>
<span className="mt-1 block text-[12px] text-[var(--color-text-secondary)]">
{entries.length} audit {entries.length === 1 ? "entry" : "entries"}
</span>
</span>
<span
className="flex h-7 w-7 items-center justify-center rounded-full border border-[var(--color-border-muted)] bg-[var(--color-bg-base)] text-[var(--color-text-secondary)]"
aria-hidden="true"
>
<svg
className={cn(
"h-4 w-4 transition-transform duration-200",
isExpanded ? "rotate-180" : "rotate-0",
)}
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="m6 9 6 6 6-6" />
</svg>
</span>
</button>
<div id={panelId} hidden={!isExpanded} className="mt-3 space-y-3">
{visibleEntries.map((entry, index) => (
<div
key={`${entry.timestamp}-${entry.reportState}-${entry.actor}-${entry.source}-${String(entry.accepted)}-${index}`}
className="rounded-[16px] border border-[var(--color-border-muted)] bg-[var(--color-bg-base)] px-3 py-3"
>
<div className="flex flex-wrap items-center gap-2">
<span
className={cn(
"rounded-full border px-2 py-0.5 text-[11px] font-medium",
entry.accepted
? "border-[var(--color-status-ready)]/25 bg-[var(--color-tint-green)] text-[var(--color-status-ready)]"
: "border-[var(--color-status-error)]/25 bg-[var(--color-tint-red)] text-[var(--color-status-error)]",
)}
>
{entry.accepted ? "Accepted" : "Rejected"}
</span>
<span className="text-[11px] text-[var(--color-text-tertiary)]">
{formatAuditTimestamp(entry.timestamp)}
</span>
</div>
<div className="mt-3 grid gap-2 text-[12px] text-[var(--color-text-secondary)] sm:grid-cols-3">
<div className="rounded-[12px] border border-[var(--color-border-muted)] px-2.5 py-2">
<div className="text-[10px] uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
Session
</div>
<div className="mt-1 font-[var(--font-mono)] text-[var(--color-text-primary)]">
{sessionId}
</div>
</div>
<div className="rounded-[12px] border border-[var(--color-border-muted)] px-2.5 py-2">
<div className="text-[10px] uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
Actor
</div>
<div className="mt-1 text-[var(--color-text-primary)]">{entry.actor}</div>
</div>
<div className="rounded-[12px] border border-[var(--color-border-muted)] px-2.5 py-2">
<div className="text-[10px] uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
Source
</div>
<div className="mt-1 font-[var(--font-mono)] text-[var(--color-text-primary)]">
{getAuditCommandLabel(entry)}
</div>
</div>
</div>
<div className="mt-3 text-[12px] text-[var(--color-text-secondary)]">
{entry.before.legacyStatus} / {entry.before.sessionState}
{" -> "}
{entry.after.legacyStatus} / {entry.after.sessionState}
</div>
{entry.note ? (
<p className="mt-2 text-[12px] text-[var(--color-text-secondary)]">
Note: {entry.note}
</p>
) : null}
{entry.rejectionReason ? (
<p className="mt-2 text-[12px] text-[var(--color-status-error)]">
Rejection: {entry.rejectionReason}
</p>
) : null}
</div>
))}
{hasOlderEntries && !showOlderEntries ? (
<button
type="button"
className="text-[12px] font-medium text-[var(--color-accent)] transition-colors hover:text-[var(--color-accent-hover)]"
onClick={() => setShowOlderEntries(true)}
>
Show older reports ({entries.length - RECENT_AUDIT_LIMIT})
</button>
) : null}
</div>
</section>
);
}

View File

@ -0,0 +1,157 @@
"use client";
import {
getActivitySignalLabel,
getActivitySignalReasonLabel,
getLifecycleEvidence,
getLifecycleGuidance,
getPRTruthLabel,
getPRTruthReasonLabel,
getRuntimeTruthLabel,
getRuntimeTruthReasonLabel,
getSessionTruthLabel,
getSessionTruthReasonLabel,
type DashboardSession,
} from "@/lib/types";
function getActivityFactToneClass(session: DashboardSession): string {
if (!session.activitySignal || session.activitySignal.state !== "valid") {
return "text-[var(--color-text-secondary)]";
}
switch (session.activitySignal.activity) {
case "active":
return "text-[var(--color-status-working)]";
case "ready":
return "text-[var(--color-status-ready)]";
case "idle":
return "text-[var(--color-status-idle)]";
case "waiting_input":
return "text-[var(--color-status-attention)]";
case "blocked":
case "exited":
return "text-[var(--color-status-error)]";
default:
return "text-[var(--color-text-secondary)]";
}
}
function getSessionFactToneClass(session: DashboardSession): string {
switch (session.lifecycle?.session.state) {
case "detecting":
case "needs_input":
case "stuck":
return "text-[var(--color-status-attention)]";
case "terminated":
return "text-[var(--color-status-error)]";
case "done":
return "text-[var(--color-status-ready)]";
case "working":
return "text-[var(--color-status-working)]";
case "idle":
return "text-[var(--color-status-pending)]";
default:
return "text-[var(--color-text-secondary)]";
}
}
function getPrFactToneClass(session: DashboardSession): string {
switch (session.lifecycle?.pr.state) {
case "merged":
return "text-[var(--color-status-ready)]";
case "closed":
return "text-[var(--color-status-error)]";
case "open":
return "text-[var(--color-accent)]";
default:
return "text-[var(--color-text-secondary)]";
}
}
function getRuntimeFactToneClass(session: DashboardSession): string {
switch (session.lifecycle?.runtime.state) {
case "alive":
return "text-[var(--color-status-ready)]";
case "probe_failed":
return "text-[var(--color-status-attention)]";
case "missing":
case "exited":
return "text-[var(--color-status-error)]";
default:
return "text-[var(--color-text-secondary)]";
}
}
export function SessionTruthPanel({ session }: { session: DashboardSession }) {
if (!session.lifecycle) return null;
const facts = [
{
heading: "Activity",
label: getActivitySignalLabel(session),
reason: getActivitySignalReasonLabel(session),
toneClassName: getActivityFactToneClass(session),
},
{
heading: "Session",
label: getSessionTruthLabel(session),
reason: getSessionTruthReasonLabel(session),
toneClassName: getSessionFactToneClass(session),
},
{
heading: "PR",
label: getPRTruthLabel(session),
reason: getPRTruthReasonLabel(session),
toneClassName: getPrFactToneClass(session),
},
{
heading: "Runtime",
label: getRuntimeTruthLabel(session),
reason: getRuntimeTruthReasonLabel(session),
toneClassName: getRuntimeFactToneClass(session),
},
];
const guidance = getLifecycleGuidance(session);
const evidence = getLifecycleEvidence(session);
return (
<section className="mb-4 rounded-[20px] border border-[var(--color-border-muted)] bg-[var(--color-bg-panel)] px-4 py-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--color-text-muted)]">
Lifecycle Truth
</p>
<p className="mt-1 text-[13px] text-[var(--color-text-secondary)]">
{session.lifecycle.summary}
</p>
<div className="mt-3 flex flex-wrap gap-2">
{facts.map((fact) => (
<div
key={fact.heading}
className="rounded-full border border-[var(--color-border-muted)] bg-[var(--color-bg-base)] px-3 py-1.5"
>
<div className="text-[10px] uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
{fact.heading}
</div>
<div className={`mt-0.5 text-[12px] font-medium ${fact.toneClassName}`}>
{fact.label}
</div>
{fact.reason ? (
<div className="mt-0.5 text-[11px] text-[var(--color-text-tertiary)]">
{fact.reason}
</div>
) : null}
</div>
))}
</div>
{guidance ? (
<p className="mt-3 text-[12px] text-[var(--color-status-attention)]">
{guidance}
</p>
) : null}
{evidence ? (
<p className="mt-2 text-[11px] text-[var(--color-text-tertiary)]">
Evidence: {evidence}
</p>
) : null}
</section>
);
}

View File

@ -149,8 +149,9 @@ describe("SessionDetail desktop layout", () => {
expect(screen.getByText("The empty state text needs to be shorter.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Agent Reports/i })).toHaveAttribute(
"aria-expanded",
"true",
"false",
);
fireEvent.click(screen.getByRole("button", { name: /Agent Reports/i }));
expect(screen.getAllByText("worker-desktop").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("ao report working")).toBeInTheDocument();
expect(screen.getByText("codex")).toBeInTheDocument();
@ -189,15 +190,15 @@ describe("SessionDetail desktop layout", () => {
);
const toggle = screen.getByRole("button", { name: /Agent Reports/i });
expect(screen.getByText("ao acknowledge")).toBeInTheDocument();
fireEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByText("ao acknowledge")).not.toBeInTheDocument();
expect(screen.getByText("ao acknowledge")).not.toBeVisible();
fireEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(screen.getByText("ao acknowledge")).toBeInTheDocument();
fireEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(screen.getByText("ao acknowledge")).not.toBeVisible();
});
it("sends unresolved comments back to the agent and shows sent state", async () => {

View File

@ -611,6 +611,47 @@ describe("getAttentionLevel", () => {
});
expect(getAttentionLevel(session)).toBe("working");
});
it("should keep idle sessions with open PRs and in-flight CI in working", () => {
const session = createSession({
status: "working",
lifecycle: {
sessionState: "idle",
sessionReason: "task_in_progress",
prState: "open",
prReason: "in_progress",
runtimeState: "alive",
runtimeReason: "process_running",
session: {
state: "idle",
reason: "task_in_progress",
label: "idle",
reasonLabel: "task in progress",
},
pr: {
state: "open",
reason: "in_progress",
label: "open",
reasonLabel: "in progress",
},
runtime: {
state: "alive",
reason: "process_running",
label: "alive",
reasonLabel: "process running",
},
legacyStatus: "working",
evidence: null,
detectingAttempts: 0,
detectingEscalatedAt: null,
summary: "Session idle while CI is still running",
guidance: null,
},
pr: null,
});
expect(getAttentionLevel(session)).toBe("working");
});
});
describe("working state", () => {

View File

@ -535,8 +535,8 @@ function getDetailedAttentionLevel(session: DashboardSession): AttentionLevel {
// ── Pending: waiting on external (reviewer, CI) ───────────────────
if (
session.lifecycle?.sessionState === "idle" ||
session.lifecycle?.prReason === "review_pending" ||
session.lifecycle?.prReason === "closed_unmerged" ||
session.status === "review_pending"
) {
return "pending";