diff --git a/apps/server/src/orchestration/Layers/ProviderActivityProjection.test.ts b/apps/server/src/orchestration/Layers/ProviderActivityProjection.test.ts index 013ed235..0711ca2d 100644 --- a/apps/server/src/orchestration/Layers/ProviderActivityProjection.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderActivityProjection.test.ts @@ -574,4 +574,46 @@ describe("ProviderActivityProjection", () => { }), ]); }); + + it("projects structured approval-review outcomes with their real tone", () => { + const [activity] = projectRuntimeEventToActivities({ + type: "task.completed", + eventId: EventId.make("evt-approval-review-denied"), + provider: ProviderDriverKind.make("codex"), + threadId: ThreadId.make("thread-1"), + turnId: TurnId.make("turn-1"), + createdAt: "2026-08-28T12:00:00.000Z", + payload: { + taskId: RuntimeTaskId.make("review-1"), + status: "failed", + taskType: "approval-review", + summary: "Auto-review denied command", + approvalReview: { + status: "denied", + rationale: "The command exceeded the authorized scope.", + riskLevel: "high", + userAuthorization: "low", + }, + }, + } satisfies ProviderRuntimeEvent); + + expect(activity).toMatchObject({ + kind: "task.completed", + tone: "warning", + summary: "Auto-review denied command", + payload: { + taskId: "review-1", + status: "failed", + taskType: "approval-review", + summary: "Auto-review denied command", + detail: "The command exceeded the authorized scope.", + approvalReview: { + status: "denied", + rationale: "The command exceeded the authorized scope.", + riskLevel: "high", + userAuthorization: "low", + }, + }, + }); + }); }); diff --git a/apps/server/src/orchestration/Layers/ProviderActivityProjection.ts b/apps/server/src/orchestration/Layers/ProviderActivityProjection.ts index 1f2a7e87..5f1cbed1 100644 --- a/apps/server/src/orchestration/Layers/ProviderActivityProjection.ts +++ b/apps/server/src/orchestration/Layers/ProviderActivityProjection.ts @@ -786,14 +786,22 @@ export function projectRuntimeEventToActivities( }), ]; - case "task.completed": + case "task.completed": { + const approvalReview = + event.payload.taskType === "approval-review" ? event.payload.approvalReview : undefined; return [ baseActivity(event, { id: event.eventId, - tone: event.payload.status === "failed" ? "error" : "info", + tone: + approvalReview && approvalReview.status !== "approved" + ? "warning" + : event.payload.status === "failed" + ? "error" + : "info", kind: "task.completed", - summary: - event.payload.status === "failed" + summary: approvalReview + ? (event.payload.summary ?? "Approval review completed") + : event.payload.status === "failed" ? "Task failed" : event.payload.status === "stopped" ? "Task stopped" @@ -801,7 +809,16 @@ export function projectRuntimeEventToActivities( payload: { taskId: event.payload.taskId, status: event.payload.status, - ...(event.payload.summary ? { detail: truncateDetail(event.payload.summary) } : {}), + ...(event.payload.taskType ? { taskType: event.payload.taskType } : {}), + ...(approvalReview ? { approvalReview } : {}), + ...(approvalReview && event.payload.summary + ? { summary: truncateDetail(event.payload.summary) } + : {}), + ...(approvalReview?.rationale + ? { detail: truncateDetail(approvalReview.rationale) } + : event.payload.summary + ? { detail: truncateDetail(event.payload.summary) } + : {}), ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}), ...(event.payload.toolUseId ? { toolUseId: event.payload.toolUseId } : {}), ...(event.payload.ownerAgentToolUseId @@ -810,6 +827,7 @@ export function projectRuntimeEventToActivities( }, }), ]; + } case "hook.started": return [ diff --git a/apps/server/src/provider/Layers/CodexAdapter.mapping.test.ts b/apps/server/src/provider/Layers/CodexAdapter.mapping.test.ts index bb6204b7..2c9c34af 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.mapping.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.mapping.test.ts @@ -18,6 +18,100 @@ import { } from "./CodexAdapter.ts"; describe("CodexAdapter item mapping", () => { + it("maps structured automatic approval review outcomes", () => { + const cases = [ + { reviewStatus: "approved", taskStatus: "completed", summary: "Auto-approved command" }, + { reviewStatus: "denied", taskStatus: "failed", summary: "Auto-review denied command" }, + { + reviewStatus: "timedOut", + taskStatus: "failed", + summary: "Auto-review timed out for command", + }, + { + reviewStatus: "aborted", + taskStatus: "stopped", + summary: "Auto-review stopped for command", + }, + ] as const; + + for (const { reviewStatus, taskStatus, summary } of cases) { + const [runtimeEvent] = mapToRuntimeEvents( + { + id: EventId.make(`evt-review-${reviewStatus}`), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-08-28T12:00:00.000Z", + method: "item/autoApprovalReview/completed", + threadId: ThreadId.make("thread-1"), + payload: { + action: { + command: "Get-ChildItem", + cwd: "C:/repo", + source: "unifiedExec", + type: "command", + }, + completedAtMs: 1_788_000_001_000, + decisionSource: "agent", + review: { + status: reviewStatus, + rationale: "The requested check is read-only.", + riskLevel: "low", + userAuthorization: "high", + }, + reviewId: `review-${reviewStatus}`, + startedAtMs: 1_788_000_000_000, + targetItemId: "command-1", + threadId: "provider-thread-1", + turnId: "turn-1", + }, + }, + ThreadId.make("thread-1"), + ); + + assert.equal(runtimeEvent?.type, "task.completed"); + if (runtimeEvent?.type !== "task.completed") { + continue; + } + assert.equal(runtimeEvent.turnId, "turn-1"); + assert.equal(runtimeEvent.itemId, "command-1"); + assert.deepStrictEqual(runtimeEvent.payload, { + taskId: `review-${reviewStatus}`, + status: taskStatus, + taskType: "approval-review", + summary, + approvalReview: { + status: reviewStatus, + rationale: "The requested check is read-only.", + riskLevel: "low", + userAuthorization: "high", + }, + }); + } + }); + + it("classifies guardian notices without inferring their outcome from text", () => { + const [runtimeEvent] = mapToRuntimeEvents( + { + id: EventId.make("evt-guardian-warning"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-08-28T12:00:00.000Z", + method: "guardianWarning", + threadId: ThreadId.make("thread-1"), + payload: { + message: "Automatic approval review approved: safe read-only check.", + threadId: "provider-thread-1", + }, + }, + ThreadId.make("thread-1"), + ); + + assert.equal(runtimeEvent?.type, "runtime.warning"); + if (runtimeEvent?.type === "runtime.warning") { + assert.equal(runtimeEvent.payload.warningKind, "guardian"); + } + }); + it("maps native subagent activity into the canonical collab-agent shape", () => { const [runtimeEvent, metadataEvent] = mapToRuntimeEvents( { diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index e521e739..92a2f9b2 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -905,6 +905,37 @@ function summarizeReviewAction(action: unknown): string | undefined { return firstStringField(action, ["type", "kind", "name", "command", "tool"]); } +type ApprovalReviewStatus = + EffectCodexSchema.V2ItemGuardianApprovalReviewCompletedNotification["review"]["status"]; + +function approvalReviewTaskStatus( + status: ApprovalReviewStatus, +): "completed" | "failed" | "stopped" { + if (status === "approved") { + return "completed"; + } + if (status === "aborted") { + return "stopped"; + } + return "failed"; +} + +function approvalReviewSummary(status: ApprovalReviewStatus, action: string | undefined): string { + const target = action ? ` ${action}` : " request"; + switch (status) { + case "approved": + return `Auto-approved${target}`; + case "denied": + return `Auto-review denied${target}`; + case "timedOut": + return `Auto-review timed out for${target}`; + case "aborted": + return `Auto-review stopped for${target}`; + case "inProgress": + return `Auto-review ended without a decision for${target}`; + } +} + function summarizePatchChanges( changes: ReadonlyArray<{ readonly kind: unknown; readonly path: string }>, ): string { @@ -1861,10 +1892,7 @@ export function mapToRuntimeEvents( return []; } const action = summarizeReviewAction(payload.action); - const decision = - typeof payload.decisionSource === "string" - ? payload.decisionSource - : firstStringField(payload.decisionSource, ["type", "kind", "decision"]); + const rationale = trimText(payload.review.rationale ?? undefined); return [ { ...runtimeEventBase(event, canonicalThreadId), @@ -1873,10 +1901,17 @@ export function mapToRuntimeEvents( type: "task.completed", payload: { taskId: RuntimeTaskId.make(payload.reviewId), - status: "completed", - summary: action - ? `Approval review completed for ${action}${decision ? `: ${decision}` : ""}` - : "Approval review completed", + status: approvalReviewTaskStatus(payload.review.status), + taskType: "approval-review", + summary: approvalReviewSummary(payload.review.status, action), + approvalReview: { + status: payload.review.status, + ...(rationale ? { rationale } : {}), + ...(payload.review.riskLevel ? { riskLevel: payload.review.riskLevel } : {}), + ...(payload.review.userAuthorization + ? { userAuthorization: payload.review.userAuthorization } + : {}), + }, }, }, ]; @@ -2608,6 +2643,7 @@ export function mapToRuntimeEvents( ...runtimeEventBase(event, canonicalThreadId), payload: { message, + warningKind: "guardian", ...(event.payload !== undefined ? { detail: event.payload } : {}), }, }, diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index bc5b0eac..58d65ec5 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -3299,6 +3299,102 @@ describe("deriveWorkLogEntries", () => { }); }); + it("replaces approved guardian warnings while preserving denied warnings", () => { + const pairedWarning = makeActivity({ + id: "guardian-warning", + createdAt: "2026-08-28T12:00:00.000Z", + sequence: 1, + kind: "runtime.warning", + summary: "Runtime warning", + tone: "warning", + payload: { + message: "Automatic approval review completed.", + warningKind: "guardian", + }, + }); + + const approved = deriveWorkLogEntries([ + pairedWarning, + makeActivity({ + id: "approval-review-approved", + createdAt: "2026-08-28T12:00:00.001Z", + sequence: 2, + kind: "task.completed", + summary: "Auto-approved command", + tone: "info", + payload: { + taskId: "review-approved", + status: "completed", + taskType: "approval-review", + summary: "Auto-approved command", + detail: "The requested check is read-only.", + approvalReview: { status: "approved" }, + }, + }), + ]); + + expect(approved).toHaveLength(1); + expect(approved[0]).toMatchObject({ + id: "approval-review-approved", + label: "Auto-approved command", + detail: "The requested check is read-only.", + tone: "info", + }); + + const denied = deriveWorkLogEntries([ + pairedWarning, + makeActivity({ + id: "approval-review-denied", + createdAt: "2026-08-28T12:00:00.001Z", + sequence: 2, + kind: "task.completed", + summary: "Auto-review denied command", + tone: "warning", + payload: { + taskId: "review-denied", + status: "failed", + taskType: "approval-review", + summary: "Auto-review denied command", + detail: "The command exceeded the authorized scope.", + approvalReview: { status: "denied" }, + }, + }), + ]); + + expect(denied).toHaveLength(2); + expect(denied[0]).toMatchObject({ + id: "guardian-warning", + tone: "warning", + }); + expect(denied[1]).toMatchObject({ + id: "approval-review-denied", + label: "Auto-review denied command", + detail: "The command exceeded the authorized scope.", + tone: "warning", + }); + }); + + it("keeps standalone guardian warnings visible", () => { + const [entry] = deriveWorkLogEntries([ + makeActivity({ + id: "guardian-circuit-breaker", + kind: "runtime.warning", + summary: "Runtime warning", + tone: "warning", + payload: { + message: "Automatic approval review rejected too many requests; interrupting the turn.", + warningKind: "guardian", + }, + }), + ]); + + expect(entry).toMatchObject({ + id: "guardian-circuit-breaker", + tone: "warning", + detail: "Automatic approval review rejected too many requests; interrupting the turn.", + }); + }); + it("marks failed command executions distinctly", () => { const [entry] = deriveWorkLogEntries([ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 015c2112..8a67f45a 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -2089,10 +2089,11 @@ export function deriveWorkLogEntries( activities: ReadonlyArray, activeTurnId?: TurnId | null, ): WorkLogEntry[] { - const ordered = + const ordered = filterPairedGuardianReviewWarnings( filterSupersededManualContextCompactionActivities(activities).toSorted( compareActivitiesByOrder, - ); + ), + ); const agentTaskIndex = collectAgentTaskIndex(ordered); const entries = ordered .filter((activity) => activity.kind !== "task.started") @@ -2127,6 +2128,34 @@ export function deriveWorkLogEntries( ).map(({ collapseKey: _collapseKey, browserReceipt: _browserReceipt, ...entry }) => entry); } +/** Codex emits a human-readable guardian warning immediately before the + * structured approval-review completion that replaces it. Drop only the + * harmless approved duplicate; every non-approved guardian warning remains + * visible. */ +function filterPairedGuardianReviewWarnings( + activities: ReadonlyArray, +): OrchestrationThreadActivity[] { + const filtered: OrchestrationThreadActivity[] = []; + for (const activity of activities) { + const payload = asRecord(activity.payload); + const isApprovedReviewCompletion = + activity.kind === "task.completed" && + asTrimmedString(payload?.taskType) === "approval-review" && + asTrimmedString(asRecord(payload?.approvalReview)?.status) === "approved"; + const previous = filtered.at(-1); + const previousPayload = previous ? asRecord(previous.payload) : null; + if ( + isApprovedReviewCompletion && + previous?.kind === "runtime.warning" && + asTrimmedString(previousPayload?.warningKind) === "guardian" + ) { + filtered.pop(); + } + filtered.push(activity); + } + return filtered; +} + /** The task-notification completion replay re-emits the original Task tool * call with the agent's final message. The work log already narrates the * completion via its task.completed entry, and the message itself renders as diff --git a/packages/contracts/src/providerRuntime.test.ts b/packages/contracts/src/providerRuntime.test.ts index c26d8d73..3621cbbc 100644 --- a/packages/contracts/src/providerRuntime.test.ts +++ b/packages/contracts/src/providerRuntime.test.ts @@ -188,6 +188,35 @@ describe("ProviderRuntimeEvent", () => { expect(parsed.payload.answers.sandbox_mode).toBe("workspace-write"); }); + it("decodes structured approval-review task completions", () => { + const parsed = decodeRuntimeEvent({ + type: "task.completed", + eventId: "event-review-1", + provider: "codex", + createdAt: "2026-08-28T12:00:00.000Z", + threadId: "thread-1", + turnId: "turn-1", + payload: { + taskId: "review-1", + status: "failed", + taskType: "approval-review", + summary: "Auto-review denied command", + approvalReview: { + status: "denied", + rationale: "The requested command exceeds the authorized scope.", + riskLevel: "high", + userAuthorization: "low", + }, + }, + }); + + expect(parsed.type).toBe("task.completed"); + if (parsed.type === "task.completed") { + expect(parsed.payload.approvalReview?.status).toBe("denied"); + expect(parsed.payload.approvalReview?.riskLevel).toBe("high"); + } + }); + it("rejects legacy message.delta type", () => { expect(() => decodeRuntimeEvent({ diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 87d1d4d3..039ad9b7 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -648,10 +648,29 @@ const TaskProgressPayload = Schema.Struct({ }); export type TaskProgressPayload = typeof TaskProgressPayload.Type; +const TaskApprovalReviewStatus = Schema.Literals([ + "inProgress", + "approved", + "denied", + "timedOut", + "aborted", +]); + +const TaskApprovalReviewResult = Schema.Struct({ + status: TaskApprovalReviewStatus, + rationale: Schema.optional(TrimmedNonEmptyStringSchema), + riskLevel: Schema.optional(Schema.Literals(["low", "medium", "high", "critical"])), + userAuthorization: Schema.optional(Schema.Literals(["unknown", "low", "medium", "high"])), +}); + const TaskCompletedPayload = Schema.Struct({ taskId: RuntimeTaskId, status: Schema.Literals(["completed", "failed", "stopped"]), summary: Schema.optional(TrimmedNonEmptyStringSchema), + taskType: Schema.optional(TrimmedNonEmptyStringSchema), + /** Structured outcome for Codex approval-review tasks. Consumers use this + * instead of parsing the provider's human-readable guardian warning. */ + approvalReview: Schema.optional(TaskApprovalReviewResult), usage: Schema.optional(Schema.Unknown), /** Tool call that started the task (e.g. a background subagent's Task tool * use), so consumers can settle the originating tool's UI state. */