diff --git a/doc/run-log-events.md b/doc/run-log-events.md index d7443e73a92..6a6fe1c9a00 100644 --- a/doc/run-log-events.md +++ b/doc/run-log-events.md @@ -591,6 +591,19 @@ changes, and unrelated thread information do not create a provider notice in chat. Chat also omits the matching notice stored by older runners. Real provider warnings and errors remain visible. +Harness status notices use `provider.notice.recorded` with a status category. +Codex `error` notifications read `error.message`. A notification with +`willRetry: true` becomes a recoverable `provider_retry` notice. A final error +keeps category `error`. The `CodexErrorInfo` kind, the HTTP status, and the +reconnect attempt count become notice details. Claude compaction, model +fallback, and SDK informational banners arrive as ACP message text without a +message identity. The ACPX layer records them as `context_compaction`, +`model_fallback`, or `provider_informational` notices instead of answer text. +Compaction start and end share one notice identity and carry a `Phase` detail. +The task chat shows these notices as inline status chips. A retry chip fades +after later provider work. A final provider error shows the real provider +message. Generic warnings stay out of chat as before. + Recovery lifecycle events retain the original structured failure code, retry attempt, next retry time, and predecessor/successor identifiers. Durable status delivery uses an idempotency marker; delivery grants no provider authority. Failed publication is retried without repeating provider work. These records are not first-party Telemetry. An unstarted legacy conversation retry waiting for execution cleanup retains diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_event_payload.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_event_payload.rs index 5683ae8350f..36dcfd896ba 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_event_payload.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_event_payload.rs @@ -562,6 +562,19 @@ fn decode_runtime_event(payload: &Value) -> Result { bounded_nonempty_text(payload, "category", 160, "provider notice category")?; bounded_nonempty_text(payload, "summary", 4_000, "provider notice summary")?; + bounded_optional_text(payload, "severity", 16, "provider notice severity")?; + bounded_optional_text(payload, "noticeKey", 160, "provider notice key")?; + if let Some(details) = optional_array(payload, "details", "provider notice details")? { + if details.len() > 64 { + return Err(LocalRunnerError::invalid( + "ACPX provider notice details exceed their bound", + )); + } + for detail in details { + bounded_nonempty_text(detail, "name", 160, "provider notice detail name")?; + bounded_optional_text(detail, "value", 4_000, "provider notice detail value")?; + } + } AcpxRuntimeEventKind::ProviderNotice } "error" => { diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs index 00c52068196..735af5e73be 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs @@ -608,6 +608,84 @@ fn codex_terminal_error(params: &Value, event_type: &str) -> Value { } } +/// Codex app-server `error` carries a `TurnError` under `error` plus +/// `willRetry`. A retried error is a transient status notice; only a final +/// error is fatal. Older shapes kept the text at the top level. +fn codex_error_notice(params: &Value) -> Value { + let error = params.get("error").unwrap_or(&Value::Null); + let will_retry = params.get("willRetry").and_then(Value::as_bool) == Some(true); + let info = error.get("codexErrorInfo"); + let info_kind = match info { + Some(Value::String(kind)) => kind.clone(), + Some(Value::Object(map)) => map.keys().next().cloned().unwrap_or_default(), + _ => String::new(), + }; + let http_status = info + .and_then(|value| value.get(&info_kind)) + .and_then(|value| value.get("httpStatusCode")) + .and_then(Value::as_u64); + let summary = [ + string(error.get("message")), + string(params.get("summary")), + string(params.get("message")), + string(params.get("details")), + ] + .into_iter() + .find(|value| !value.trim().is_empty()) + .unwrap_or("Provider notice"); + // Codex reports stream retries as "Reconnecting... {attempt}/{max}" with + // the actual cause in additionalDetails. + let attempt = summary + .trim() + .strip_prefix("Reconnecting...") + .and_then(|rest| rest.trim().split_once('/')) + .and_then(|(attempt, max)| { + Some(( + attempt.trim().parse::().ok()?, + max.trim().parse::().ok()?, + )) + }); + let additional = string(error.get("additionalDetails")).trim(); + let summary = if attempt.is_some() && !additional.is_empty() { + additional + } else { + summary + }; + let mut details = Vec::new(); + if let Some((attempt, max)) = attempt { + details.push(json!({"name": "Attempt", "value": attempt.to_string()})); + details.push(json!({"name": "Max attempts", "value": max.to_string()})); + } + if !info_kind.is_empty() { + details.push(json!({"name": "Reason", "value": bounded_text(&info_kind, 160)})); + } + if let Some(status) = http_status { + details.push(json!({"name": "HTTP status", "value": status.to_string()})); + } + if !additional.is_empty() && summary != additional { + details.push(json!({"name": "Additional details", "value": bounded_text(additional, MAX_TEXT_CHARS)})); + } + let turn_id = string(params.get("turnId")); + let mut notice = json!({ + "schema": "paperclip.provider.notice.v1", + // Retries within one turn update one chip; the final error is its own row. + "noticeId": stable_id( + &format!("codex-error-{}-{}", if will_retry { "retry" } else { "final" }, turn_id), + "codex-notice", + ), + "severity": if will_retry { "warning" } else { "error" }, + "category": if will_retry { "provider_retry" } else { "error" }, + "scope": "turn", + "recoverable": will_retry, + "userActionable": false, + "summary": bounded_text(summary.trim(), MAX_TEXT_CHARS), + }); + if !details.is_empty() { + notice["details"] = Value::Array(details); + } + notice +} + /// Codex wraps MCP startup errors in sentences that repeat what the notice /// already says, e.g. "MCP client for `github` failed to start: MCP startup /// failed: "; keep only the provider's reason. @@ -988,6 +1066,12 @@ pub fn normalize_codex_notification_in( }), ) } + "error" => push( + &mut events, + "provider.notice.recorded", + EventPriority::P0, + codex_error_notice(params), + ), // Only a failed start is user-facing: the agent then runs without that // server's tools. starting/ready/cancelled stay in the raw run log. "mcpServer/startupStatus/updated" @@ -1043,7 +1127,7 @@ pub fn normalize_codex_notification_in( }), ) } - "error" | "warning" | "deprecationNotice" | "configWarning" => push( + "warning" | "deprecationNotice" | "configWarning" => push( &mut events, "provider.notice.recorded", EventPriority::P0, @@ -1331,13 +1415,41 @@ pub fn normalize_acpx_runtime_event( AcpxRuntimeEventKind::ToolCall => { normalize_acpx_tool_call(payload, &item_id, tool_operation.unwrap_or("unknown")) } - AcpxRuntimeEventKind::ProviderNotice => vec![acpx_notice( - &item_id, - string(payload.get("severity")), - string(payload.get("category")), - string(payload.get("summary")), - false, - )], + AcpxRuntimeEventKind::ProviderNotice => { + // A provider-supplied key pairs lifecycle notices (compaction start + // and end) into one notice within the turn. + let notice_id = match string(payload.get("noticeKey")) { + "" => item_id.clone(), + key => stable_id(&format!("{turn_id}:{key}"), fallback_item_id), + }; + let mut event = acpx_notice( + ¬ice_id, + string(payload.get("severity")), + string(payload.get("category")), + string(payload.get("summary")), + false, + ); + if let Some(details) = payload.get("details").and_then(Value::as_array) { + let details: Vec = details + .iter() + .take(64) + .filter_map(|detail| { + let name = detail.get("name").and_then(Value::as_str)?; + let value = detail.get("value").and_then(Value::as_str)?; + (!name.trim().is_empty()).then(|| { + json!({ + "name": bounded_text(name, 160), + "value": bounded_text(value, MAX_TEXT_CHARS), + }) + }) + }) + .collect(); + if !details.is_empty() { + event.payload["details"] = Value::Array(details); + } + } + vec![event] + } AcpxRuntimeEventKind::Error => vec![acpx_notice( &item_id, "error", @@ -1876,6 +1988,44 @@ mod tests { ); } + #[test] + fn maps_codex_turn_error_text_retries_and_error_info() { + let retry = normalize_codex_notification( + "error", + &json!({ + "turnId": "turn-1", "willRetry": true, + "error": { + "message": "Reconnecting... 2/5", + "codexErrorInfo": {"responseStreamDisconnected": {"httpStatusCode": 502}}, + "additionalDetails": "stream closed before response.completed" + } + }), + ); + let notice = &retry[0].payload; + assert_eq!(notice["summary"], "stream closed before response.completed"); + assert_eq!(notice["category"], "provider_retry"); + assert_eq!(notice["severity"], "warning"); + assert_eq!(notice["recoverable"], true); + assert_eq!( + notice["details"], + json!([ + {"name": "Attempt", "value": "2"}, + {"name": "Max attempts", "value": "5"}, + {"name": "Reason", "value": "responseStreamDisconnected"}, + {"name": "HTTP status", "value": "502"}, + ]) + ); + let fatal = normalize_codex_notification( + "error", + &json!({"turnId": "turn-1", "willRetry": false, + "error": {"message": "You've hit your usage limit.", "codexErrorInfo": "usageLimitExceeded"}}), + ); + assert_eq!(fatal[0].payload["category"], "error"); + assert_eq!(fatal[0].payload["recoverable"], false); + assert_eq!(fatal[0].payload["summary"], "You've hit your usage limit."); + assert_ne!(fatal[0].payload["noticeId"], notice["noticeId"]); + } + #[test] fn maps_failed_codex_mcp_startup_to_user_facing_notice() { let events = normalize_codex_notification( diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_events.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_events.rs index 12ff5078ca8..d997bdc1399 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_events.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_events.rs @@ -736,3 +736,33 @@ fn leaves_operational_semantic_and_terminal_events_to_the_adapter() { .is_empty()); assert!(normalize(AcpxRuntimeEventKind::Done, json!({"type":"done"})).is_empty()); } + +#[test] +fn claude_status_notices_pair_by_key_and_keep_details() { + let notice = |phase: &str| { + normalize( + AcpxRuntimeEventKind::ProviderNotice, + json!({ + "type": "provider_notice", "severity": "info", "category": "context_compaction", + "summary": "Compacting context", "noticeKey": "compaction-1", + "details": [{"name": "Phase", "value": phase}] + }), + ) + }; + let started = notice("running"); + let completed = notice("completed"); + assert_eq!( + started[0].payload["noticeId"], + completed[0].payload["noticeId"] + ); + assert_eq!(started[0].payload["category"], "context_compaction"); + assert_eq!( + completed[0].payload["details"], + json!([{"name": "Phase", "value": "completed"}]) + ); + let plain = normalize( + AcpxRuntimeEventKind::ProviderNotice, + json!({"type": "provider_notice", "category": "acpx_x", "summary": "x"}), + ); + assert!(plain[0].payload.get("details").is_none()); +} diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts index f6b75761287..57685b851c7 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts @@ -18,6 +18,7 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { createAcpxSidecarHostEnvironment, createSanitizedAcpxSpawnInput } from "../drivers/acpx/environment.js"; import { createKimiMessageProjection } from "../drivers/acpx/kimi-message-boundary.js"; +import { classifyClaudeStatusBanner } from "../drivers/acpx/claude-status-banners.js"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -609,6 +610,35 @@ describe("qualified ACPX runtime sidecar", () => { projection.settle(); }); + it("turns Claude status banners into paired provider notices before text projection", () => { + const source = readFileSync(fileURLToPath(new URL("./acpx-runtime-sidecar.ts", import.meta.url)), "utf8"); + const sanitizer = source.slice(source.indexOf("function sanitizeRuntimeEvent"), source.indexOf("function claudeStatusBannerNotice")); + // The banner check runs before the text_delta projection. + expect(sanitizer.indexOf("claudeStatusBannerNotice(event)")).toBeGreaterThan(0); + expect(sanitizer.indexOf("claudeStatusBannerNotice(event)")).toBeLessThan(sanitizer.indexOf('if (event.type === "text_delta")')); + const start = source.indexOf("function claudeStatusBannerNotice"); + const end = source.indexOf("\nfunction sanitizeRuntimeStatus", start); + expect(start).toBeGreaterThan(0); + const fn = stripTypeScriptTypes(source.slice(start, end)); + const make = (agent: string) => new Function("classifyClaudeStatusBanner", + `let initializedAgent = ${JSON.stringify(agent)}; let claudeCompactionCount = 0; ${fn} return claudeStatusBannerNotice;`, + )(classifyClaudeStatusBanner) as (event: Record) => Record | null; + const notice = make("claude"); + const chunk = (text: string, messageId?: string) => ({ type: "text_delta", stream: "output", text, ...(messageId ? { messageId } : {}) }); + const started = notice(chunk("Compacting...")); + const completed = notice(chunk("\n\nCompacting completed.")); + const next = notice(chunk("Compacting...")); + expect(started).toMatchObject({ type: "provider_notice", category: "context_compaction", noticeKey: "compaction-1", + details: [{ name: "Phase", value: "running" }] }); + expect(completed).toMatchObject({ noticeKey: "compaction-1", details: [{ name: "Phase", value: "completed" }] }); + expect(next).toMatchObject({ noticeKey: "compaction-2" }); + expect(notice(chunk("**Warning:** Stop hook blocked continuation"))).toMatchObject({ + type: "provider_notice", category: "provider_informational", severity: "warning", summary: "Stop hook blocked continuation", + }); + expect(notice(chunk("Compacting...", "msg_01"))).toBeNull(); + expect(notice({ ...chunk("Compacting..."), stream: "thought" })).toBeNull(); + expect(make("codex")(chunk("Compacting..."))).toBeNull(); + }); it("passes only validated Pi native boundaries and history through the real text sanitizer", () => { const source = readFileSync(fileURLToPath(new URL("./acpx-runtime-sidecar.ts", import.meta.url)), "utf8"); const start = source.indexOf(' if (event.type === "text_delta") {', source.indexOf("function sanitizeRuntimeEvent")); diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index 11738f5c163..8a2c8078cf4 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { classifyClaudeStatusBanner } from "../drivers/acpx/claude-status-banners.js"; import { resolveGrokDisableWebSearch } from "../drivers/acpx/grok-web-search.js"; import { toolFileChangesFromAcpContent, workspaceRootsFor, type ToolFileChange } from "../tool-file-changes.js"; import { redactObservabilityDiagnostic } from "../drivers/agent-observability.js"; @@ -165,6 +166,8 @@ let host: AcpxRuntimeHost | null = null; let activeHostCleanup: Promise | null = null; let failedAdmissionCleanup: Promise | null = null; let openParams: AcpxSidecarOpenParams | null = null; +/** Claude compaction banners seen by this sidecar; pairs start and end notices. */ +let claudeCompactionCount = 0; let runId: string | null = null; let turnId: string | null = null; let activeCopilotEvidence: CopilotToolEvidence | undefined; @@ -1075,6 +1078,8 @@ function sanitizeRuntimeEvent(event: PiProjectedMessageEvent & entries: sanitizeAcpxPlanEntries(record(event).entries), }; } + const statusNotice = claudeStatusBannerNotice(event); + if (statusNotice) return statusNotice; if (event.type === "text_delta") { return { type: event.stream === "thought" ? "thinking" : "text_delta", @@ -1194,6 +1199,24 @@ function sanitizeRuntimeEvent(event: PiProjectedMessageEvent & }; } +/** Claude harness lifecycle text becomes a status notice, never part of the answer. */ +function claudeStatusBannerNotice(event: AcpRuntimeEvent): Record | null { + if (initializedAgent !== "claude" || event.type !== "text_delta" || event.stream === "thought") return null; + const banner = classifyClaudeStatusBanner(event.text, typeof event.messageId === "string" && event.messageId ? event.messageId : null); + if (!banner) return null; + if (banner.phase === "running") claudeCompactionCount += 1; + return { + type: "provider_notice", + severity: banner.severity, + category: banner.category, + summary: banner.summary, + ...(banner.category === "context_compaction" ? { noticeKey: `compaction-${claudeCompactionCount}` } : {}), + ...(banner.phase || banner.details.length > 0 + ? { details: [...(banner.phase ? [{ name: "Phase", value: banner.phase }] : []), ...banner.details] } + : {}), + }; +} + function sanitizeRuntimeStatus(value: unknown): Record { const status = record(value); const models = record(status.models); diff --git a/packages/paperclip-runner/src/drivers/acpx/claude-status-banners.test.ts b/packages/paperclip-runner/src/drivers/acpx/claude-status-banners.test.ts new file mode 100644 index 00000000000..2f913afa9be --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/claude-status-banners.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { classifyClaudeStatusBanner } from "./claude-status-banners.js"; + +describe("classifyClaudeStatusBanner", () => { + it("classifies the claude-agent-acp compaction banners", () => { + expect(classifyClaudeStatusBanner("Compacting...", null)).toMatchObject({ category: "context_compaction", phase: "running" }); + expect(classifyClaudeStatusBanner("\n\nCompacting completed.", null)).toMatchObject({ category: "context_compaction", phase: "completed" }); + expect(classifyClaudeStatusBanner("\n\nCompacting failed: Conversation too long", null)).toMatchObject({ + category: "context_compaction", phase: "failed", severity: "warning", summary: "Conversation too long", + }); + expect(classifyClaudeStatusBanner("\n\nCompacting failed.", null)?.summary).toBe("Context compaction failed."); + }); + + it("parses model fallback into the original and fallback models", () => { + const banner = classifyClaudeStatusBanner( + "**Model fallback:** claude-opus-5-5 declined this request (cyber); retried with claude-sonnet-5-5. The session will continue on claude-sonnet-5-5.\n\nExplanation.", + null, + ); + expect(banner).toMatchObject({ + category: "model_fallback", + details: [ + { name: "Original model", value: "claude-opus-5-5" }, + { name: "Fallback model", value: "claude-sonnet-5-5" }, + { name: "Reason", value: "declined the request (cyber)" }, + ], + }); + }); + + it("classifies prominent SDK informational banners", () => { + expect(classifyClaudeStatusBanner("**Warning:** Stop hook blocked continuation", null)).toMatchObject({ + category: "provider_informational", severity: "warning", summary: "Stop hook blocked continuation", + }); + expect(classifyClaudeStatusBanner("**Notice:** Using cached credentials", null)?.severity).toBe("info"); + }); + + it("leaves model output alone, including identical text from a streamed message", () => { + expect(classifyClaudeStatusBanner("Compacting...", "msg_01")).toBeNull(); + expect(classifyClaudeStatusBanner("**Warning:** this API is deprecated", "msg_01")).toBeNull(); + expect(classifyClaudeStatusBanner("Compacting the bundle now.", null)).toBeNull(); + expect(classifyClaudeStatusBanner("Done.", null)).toBeNull(); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/claude-status-banners.ts b/packages/paperclip-runner/src/drivers/acpx/claude-status-banners.ts new file mode 100644 index 00000000000..35d58e57f80 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/claude-status-banners.ts @@ -0,0 +1,75 @@ +/** + * claude-agent-acp 0.73 reports harness lifecycle (compaction, model fallback, + * SDK informational notices) as plain `agent_message_chunk` text because ACP + * has no status update. Each banner is sent as one complete chunk with no + * `messageId`, while model output always carries the streamed message's id. + * Both conditions must hold, so a model that writes "Compacting..." itself + * stays in the answer. + * + * Source: `dist/acp-agent.js` `status`, `informational` and + * `model_refusal_fallback` handlers. + */ + +export interface ClaudeStatusBanner { + category: "context_compaction" | "model_fallback" | "provider_informational"; + severity: "info" | "warning"; + summary: string; + details: Array<{ name: string; value: string }>; + /** Compaction start and end update the same notice. */ + phase?: "running" | "completed" | "failed"; +} + +const INFORMATIONAL = /^\*\*(Notice|Suggestion|Warning):\*\* ([\s\S]+)$/; +const FALLBACK = /^\*\*Model fallback:\*\* ([\s\S]+)$/; +const FALLBACK_SUMMARY = /^(\S+) declined this request(?: \(([^)]+)\))?; retried with (\S+?)\./; + +function bounded(value: string): string { + return value.trim().slice(0, 4000); +} + +export function classifyClaudeStatusBanner( + chunk: string, + messageId: string | null, +): ClaudeStatusBanner | null { + if (messageId) return null; + const text = chunk.trim(); + if (text === "Compacting...") { + return { category: "context_compaction", severity: "info", summary: "Compacting context", details: [], phase: "running" }; + } + if (text === "Compacting completed.") { + return { category: "context_compaction", severity: "info", summary: "Context compacted", details: [], phase: "completed" }; + } + if (text.startsWith("Compacting failed")) { + const reason = text.slice("Compacting failed".length).replace(/^[:.]\s*/, ""); + return { + category: "context_compaction", + severity: "warning", + summary: bounded(reason || "Context compaction failed."), + details: [], + phase: "failed", + }; + } + const fallback = FALLBACK.exec(text); + if (fallback) { + const summary = bounded(fallback[1]); + const parsed = FALLBACK_SUMMARY.exec(summary); + const details = parsed + ? [ + { name: "Original model", value: parsed[1].slice(0, 160) }, + { name: "Fallback model", value: parsed[3].slice(0, 160) }, + { name: "Reason", value: (parsed[2] ? `declined the request (${parsed[2]})` : "declined the request").slice(0, 4000) }, + ] + : []; + return { category: "model_fallback", severity: "warning", summary, details }; + } + const informational = INFORMATIONAL.exec(text); + if (informational) { + return { + category: "provider_informational", + severity: informational[1] === "Warning" ? "warning" : "info", + summary: bounded(informational[2]), + details: [], + }; + } + return null; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts index 58b956b6568..f49e42294b7 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts @@ -442,6 +442,34 @@ describe("Codex ACPX harness driver", () => { }); }); + it("keeps Claude status banners out of the answer as status notices", async () => { + const fixture = driverFixture({ agent: "claude", model: "claude-sonnet-5" }, { + runtimeEvents: [ + { type: "text_delta", stream: "output", messageId: "m1", text: "Reading the worker." }, + { type: "text_delta", stream: "output", tag: "agent_message_chunk", text: "Compacting..." }, + { type: "text_delta", stream: "output", tag: "agent_message_chunk", text: "\n\nCompacting completed." }, + { type: "text_delta", stream: "output", tag: "agent_message_chunk", text: "**Warning:** Stop hook blocked continuation" }, + { type: "text_delta", stream: "output", messageId: "m1", text: " Fixed it." }, + ], + }); + const session = await fixture.driver.openSession({ runId: "run-claude-banners", normalizedSessionId: "session-1", workingDirectory: "/workspace" }); + await session.startTurn({ message: { text: "Fix it" } }); + fixture.finishTurn({ status: "completed" }); + const events = await collectUntil(session.events(), "turn.completed"); + for (const event of events) validatePrpEvent(event); + const notices = events.filter(e => e.eventType === "provider.notice.recorded").map(e => e.payload); + expect(notices).toEqual([ + expect.objectContaining({ category: "context_compaction", details: [{ name: "Phase", value: "running" }] }), + expect.objectContaining({ category: "context_compaction", details: [{ name: "Phase", value: "completed" }] }), + expect.objectContaining({ category: "provider_informational", severity: "warning", summary: "Stop hook blocked continuation" }), + ]); + expect(notices[0].noticeId).toBe(notices[1].noticeId); + const final = events.find(e => e.eventType === "item.completed" && e.payload.channel === "final"); + expect(final?.payload.text).toBe("Reading the worker. Fixed it."); + expect(events.filter(e => e.eventType === "item.delta").map(e => e.payload.text).join("")).toBe("Reading the worker. Fixed it."); + await session.close({ reason: "banners classified" }); + }); + it("opens the qualified Claude profile through the shared driver", async () => { const fixture = driverFixture({ agent: "claude", diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts index 2a15a4405ed..14c4eacce88 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts @@ -1,4 +1,5 @@ import { type CopilotToolEvidence } from "./copilot-tool-evidence.js"; +import { classifyClaudeStatusBanner } from "./claude-status-banners.js"; import { resolveCompletionContinuation } from "../completion-continuation.js"; import { isProviderMode } from "../../contracts/provider-mode.js"; import { acpxProfileActivity, type AcpxActivityAdapter, type AcpxToolEvidence } from "./profile-activity.js"; @@ -782,6 +783,8 @@ class CodexAcpxSession implements HarnessSession { } | null = null; #usage: Record | null = null; #assistantText = ""; + /** Claude compaction banners in this session; pairs start and end notices. */ + #compactionCount = 0; #assistantMessageId: string | null = null; #workspaceRoots: string[] | null = null; #closed = false; @@ -1695,6 +1698,34 @@ class CodexAcpxSession implements HarnessSession { const isReasoning = event.stream === "thought" || event.tag === "agent_thought_chunk"; const messageId = typeof event.messageId === "string" && event.messageId ? event.messageId : null; + const banner = this.#agent === "claude" && !isReasoning + ? classifyClaudeStatusBanner(output, messageId) + : null; + if (banner) { + // Harness lifecycle text is a status notice, never part of the answer. + if (banner.phase === "running") this.#compactionCount += 1; + const noticeId = banner.category === "context_compaction" + ? `${turnId}:compaction:${this.#compactionCount}` + : `${turnId}:status:${index}`; + this.#emit( + "provider.notice.recorded", + { + schema: "paperclip.provider.notice.v1", + noticeId, + severity: banner.severity, + category: banner.category, + scope: banner.category === "model_fallback" ? "session" : "turn", + recoverable: true, + userActionable: false, + summary: banner.summary, + ...(banner.phase || banner.details.length > 0 + ? { details: [...(banner.phase ? [{ name: "Phase", value: banner.phase }] : []), ...banner.details] } + : {}), + }, + { turnId, itemId: noticeId }, + ); + return; + } let kimiCommentary: { text: string; itemId: string } | undefined; // The pinned Copilot mapper supplies actual native identity, including // empty starts. Unidentified session info/warnings remain activity only. diff --git a/packages/paperclip-runner/src/drivers/codex/codex-session-notifications.ts b/packages/paperclip-runner/src/drivers/codex/codex-session-notifications.ts index 2d4c0dac755..0138c28f605 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-session-notifications.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-session-notifications.ts @@ -281,7 +281,10 @@ async function mapNotificationBody(state: CodexSessionState, notification: Codex state.emit("harness.diagnostic", { code: notification.method.replaceAll("/", "_"), message: redactCodexDiagnostic( - text(params.message, JSON.stringify(boundedCodexValue(params))), + text( + record(params.error).message, + text(params.message, JSON.stringify(boundedCodexValue(params))), + ), ), }); return; diff --git a/packages/paperclip-runner/src/provider-events.test.ts b/packages/paperclip-runner/src/provider-events.test.ts index 2596e9c9521..0c4394efa2c 100644 --- a/packages/paperclip-runner/src/provider-events.test.ts +++ b/packages/paperclip-runner/src/provider-events.test.ts @@ -77,6 +77,47 @@ describe("provider-neutral events", () => { })[0].eventType).toBe("provider.notice.recorded"); }); + it("maps Codex TurnError text, retries and error info into the notice", () => { + const [retry] = canonicalProviderEventsFromCodex("error", { + threadId: "thread-1", + turnId: "turn-1", + willRetry: true, + error: { + message: "Reconnecting... 2/5", + codexErrorInfo: { responseStreamDisconnected: { httpStatusCode: 502 } }, + additionalDetails: "stream closed before response.completed", + }, + }); + expect(retry.payload).toMatchObject({ + severity: "warning", + category: "provider_retry", + recoverable: true, + summary: "stream closed before response.completed", + details: [ + { name: "Attempt", value: "2" }, + { name: "Max attempts", value: "5" }, + { name: "Reason", value: "responseStreamDisconnected" }, + { name: "HTTP status", value: "502" }, + ], + }); + expect(validatePrpEvent(envelope(retry)).ok).toBe(true); + + const [fatal] = canonicalProviderEventsFromCodex("error", { + turnId: "turn-1", + willRetry: false, + error: { message: "You've hit your usage limit.", codexErrorInfo: "usageLimitExceeded", additionalDetails: null }, + }); + expect(fatal.payload).toMatchObject({ + severity: "error", + category: "error", + recoverable: false, + summary: "You've hit your usage limit.", + details: [{ name: "Reason", value: "usageLimitExceeded" }], + }); + expect(fatal.payload.noticeId).not.toBe(retry.payload.noticeId); + expect(validatePrpEvent(envelope(fatal)).ok).toBe(true); + }); + it("preserves Codex notice summaries with legacy and empty-message fallbacks", () => { for (const method of ["configWarning", "deprecationNotice", "warning"]) { for (const [params, expected] of [ diff --git a/packages/paperclip-runner/src/provider-events.ts b/packages/paperclip-runner/src/provider-events.ts index 5ecc80c91a5..e3d64f14a1e 100644 --- a/packages/paperclip-runner/src/provider-events.ts +++ b/packages/paperclip-runner/src/provider-events.ts @@ -1127,9 +1127,9 @@ export function canonicalProviderEventsFromCodex( }, ]; } + if (method === "error") return [codexErrorNotice(params, itemId)]; if ( [ - "error", "warning", "guardianWarning", "deprecationNotice", @@ -1168,6 +1168,60 @@ export function canonicalProviderEventsFromCodex( return []; } +/** + * Codex app-server `error` notifications carry a `TurnError` under `error` + * plus `willRetry`. A retried error is a transient status notice; only a + * final error is fatal. Older shapes kept the text at the top level. + */ +function codexErrorNotice( + params: Record, + itemId: string, +): CanonicalProviderEvent { + const error = record(params.error); + const willRetry = params.willRetry === true; + const info = error.codexErrorInfo; + const infoKind = + typeof info === "string" + ? info + : Object.keys(record(info))[0] ?? ""; + const httpStatus = integer(record(record(info)[infoKind]).httpStatusCode); + const summary = [error.message, params.summary, params.message, params.details] + .map((value) => redactCodexDiagnostic(text(value).trim())) + .find(Boolean) + ?.slice(0, 4000); + // Codex reports stream retries as "Reconnecting... {attempt}/{max}" with + // the actual cause in additionalDetails. + const attempt = /^Reconnecting\.\.\.\s*(\d+)\s*\/\s*(\d+)$/.exec(summary ?? ""); + const additional = redactCodexDiagnostic(text(error.additionalDetails).trim()).slice(0, 4000); + const chipSummary = attempt && additional ? additional : summary; + const details = [ + ...(attempt ? [{ name: "Attempt", value: attempt[1] }, { name: "Max attempts", value: attempt[2] }] : []), + ...(infoKind ? [{ name: "Reason", value: infoKind.slice(0, 160) }] : []), + ...(httpStatus !== null ? [{ name: "HTTP status", value: String(httpStatus) }] : []), + ...(additional && additional !== chipSummary ? [{ name: "Additional details", value: additional }] : []), + ]; + const turnId = text(params.turnId); + return { + eventType: "provider.notice.recorded", + payload: { + schema: "paperclip.provider.notice.v1", + // Retries within one turn update one chip; the final error is its own row. + noticeId: safeId( + `error:${willRetry ? "retry" : "final"}:${turnId || text(params.code, "notice")}`, + "provider-notice", + ), + severity: willRetry ? "warning" : "error", + category: willRetry ? "provider_retry" : "error", + scope: "turn", + recoverable: willRetry, + userActionable: false, + summary: chipSummary || "Provider notice", + ...(details.length > 0 ? { details } : {}), + }, + itemId, + }; +} + function openCodeToolExitCode( part: Record, state: Record, diff --git a/ui/src/components/task-chat/TaskChatProtocolActivityRow.tsx b/ui/src/components/task-chat/TaskChatProtocolActivityRow.tsx index 2c325c06b10..d3f524e60cf 100644 --- a/ui/src/components/task-chat/TaskChatProtocolActivityRow.tsx +++ b/ui/src/components/task-chat/TaskChatProtocolActivityRow.tsx @@ -47,7 +47,7 @@ function stepStatusIcon(status: TaskChatProtocolStep["status"], neutral = false) return ; } -function DetailList({ details }: { details: readonly TaskChatProtocolDetail[] }) { +export function DetailList({ details }: { details: readonly TaskChatProtocolDetail[] }) { if (details.length === 0) return null; return (
diff --git a/ui/src/components/task-chat/TaskChatProtocolCard.tsx b/ui/src/components/task-chat/TaskChatProtocolCard.tsx index 5fa831cf169..08c8698db11 100644 --- a/ui/src/components/task-chat/TaskChatProtocolCard.tsx +++ b/ui/src/components/task-chat/TaskChatProtocolCard.tsx @@ -1,4 +1,6 @@ import { useContext, useEffect, useMemo, useState } from "react"; +import { TaskChatStatusNotice } from "./TaskChatStatusNotice"; +import { isStatusNotice } from "./status-notice-presentation"; import { AlertTriangle, Bot, @@ -1119,7 +1121,9 @@ export function TaskChatProtocolCard({ }: TaskChatProtocolCardProps) { switch (item.surface) { case "provider_activity": - return ; + return isStatusNotice(item) + ? + : ; case "workspace_change": return ; case "workspace_file": diff --git a/ui/src/components/task-chat/TaskChatStatusNotice.test.tsx b/ui/src/components/task-chat/TaskChatStatusNotice.test.tsx new file mode 100644 index 00000000000..3900dde8296 --- /dev/null +++ b/ui/src/components/task-chat/TaskChatStatusNotice.test.tsx @@ -0,0 +1,76 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { TaskChatProviderActivityItem } from "./task-chat-model"; +import { TaskChatStatusNotice } from "./TaskChatStatusNotice"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function notice(details: Record, summary?: string): TaskChatProviderActivityItem { + return { + id: "notice", kind: "protocol", surface: "provider_activity", family: "provider_notice", + eventType: "provider.notice.recorded", status: "informational", title: "Provider notice", summary, + details: Object.entries(details).map(([label, value]) => ({ label, value })), + steps: [], links: [], children: [], + }; +} + +describe("TaskChatStatusNotice", () => { + let container: HTMLDivElement; + let root: Root; + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it("labels a Codex retry by its error info and fades it once resolved", () => { + const item = notice({ Severity: "warning", Category: "provider_retry", Recoverable: "true", Reason: "serverOverloaded" }, "High demand"); + act(() => root.render()); + expect(container.textContent).toContain("Retrying after overload"); + expect(container.textContent).toContain("High demand"); + act(() => root.render()); + expect(container.textContent).toContain("Recovered after retry"); + expect(container.querySelector("[data-status-notice-resolved=true]")).not.toBeNull(); + act(() => root.render()); + expect(container.textContent).toContain("Retry stopped"); + }); + + it("shows compaction phases and a model fallback target", () => { + act(() => root.render()); + expect(container.textContent).toContain("Compacting context…"); + act(() => root.render()); + expect(container.textContent).toContain("Compaction stopped"); + expect(container.querySelector(".animate-spin")).toBeNull(); + act(() => root.render()); + expect(container.textContent).toContain("Fell back to claude-sonnet-5-5"); + }); + + it("shows the full fallback explanation when the chip is expanded", () => { + const full = "claude-opus-5-5 declined this request (cyber); retried with claude-sonnet-5-5. The session will continue on claude-sonnet-5-5."; + act(() => root.render()); + const button = container.querySelector("button"); + expect(button).not.toBeNull(); + act(() => button!.click()); + expect(container.textContent).toContain(full); + }); + + it("does not treat a recoverable error notice from the live adapter as fatal", () => { + act(() => root.render()); + expect(container.querySelector("[role=alert]")).toBeNull(); + }); + + it("renders a fatal provider error as an alert with the real message", () => { + act(() => root.render()); + const alert = container.querySelector("[role=alert]"); + expect(alert?.textContent).toContain("Provider error"); + expect(alert?.textContent).toContain("You've hit your usage limit."); + expect(alert?.textContent).toContain("usageLimitExceeded"); + }); +}); diff --git a/ui/src/components/task-chat/TaskChatStatusNotice.tsx b/ui/src/components/task-chat/TaskChatStatusNotice.tsx new file mode 100644 index 00000000000..3489c555410 --- /dev/null +++ b/ui/src/components/task-chat/TaskChatStatusNotice.tsx @@ -0,0 +1,106 @@ +import { useId, useState } from "react"; +import { ChevronRight } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { TaskChatProviderActivityItem } from "./task-chat-model"; +import { statusNoticePresentation, type StatusNoticeTone } from "./status-notice-presentation"; +import { DetailList } from "./TaskChatProtocolActivityRow"; + +/** Payload keys already reflected by the chip itself. */ +const HIDDEN_DETAILS = new Set(["Severity", "Category", "Scope", "Recoverable", "User Actionable", "Summary", "Phase"]); + +const TONE_CLASS: Record = { + neutral: "border-border text-muted-foreground", + progress: "border-border text-muted-foreground", + warning: "border-(--tc-status-notice-warning-border) bg-(--tc-status-notice-warning-bg) text-(--tc-status-notice-warning-fg)", + danger: "border-destructive/35 bg-destructive/5 text-destructive", +}; + +/** + * An inline harness status chip between the turn's activity rows: context + * compaction, retries, model fallback and provider banners. A fatal provider + * error renders as a red row with the provider's real message instead. + */ +export function TaskChatStatusNotice({ + item, + resolved = false, +}: { + item: TaskChatProviderActivityItem; + /** A retry the turn moved past: the chip fades to a quiet receipt. */ + resolved?: boolean; +}) { + const [open, setOpen] = useState(false); + const detailId = `task-chat-status-notice-${useId().replaceAll(":", "")}`; + const presentation = statusNoticePresentation(item, { resolved }); + if (!presentation) return null; + const Icon = presentation.icon; + const extra = item.details.filter((entry) => !HIDDEN_DETAILS.has(entry.label) && entry.value !== presentation.detail); + // The chip may show a short reason; keep the provider's full message on expand. + const fullSummary = item.summary?.trim() || item.details.find((entry) => entry.label === "Summary")?.value; + const expandedText = fullSummary && fullSummary !== presentation.detail ? fullSummary : presentation.detail; + + if (presentation.fatal) { + return ( +
+
+ + {presentation.label} +
+

{presentation.detail}

+ +
+ ); + } + + // Any message can be truncated on a narrow screen, so it stays reachable. + const expandable = extra.length > 0 || Boolean(presentation.detail) || expandedText !== presentation.detail; + const chip = ( + <> + + + {presentation.label}{presentation.active && presentation.tone === "progress" ? "…" : ""} + + {presentation.detail ? ( + {presentation.detail} + ) : null} + {expandable ? ( + + ) : null} + + ); + const chipClass = cn( + "inline-flex min-h-6 max-w-full min-w-0 items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs transition-opacity duration-(--motion-status-notice-fade)", + TONE_CLASS[presentation.tone], + resolved && "opacity-60", + ); + return ( +
+ {expandable ? ( + + ) : ( + {chip} + )} + {expandable && open ? ( +
+ {expandedText ?

{expandedText}

: null} + +
+ ) : null} +
+ ); +} diff --git a/ui/src/components/task-chat/motion-tokens.ts b/ui/src/components/task-chat/motion-tokens.ts index 158e2309acb..c59720210cc 100644 --- a/ui/src/components/task-chat/motion-tokens.ts +++ b/ui/src/components/task-chat/motion-tokens.ts @@ -55,6 +55,7 @@ export const MOTION_TOKENS: MotionTokenDef[] = [ { name: "--motion-diff-reveal", group: "States", kind: "time", min: 0, max: 1500, step: 10 }, { name: "--motion-status-enter", group: "States", kind: "time", min: 0, max: 1500, step: 10 }, { name: "--motion-status-exit", group: "States", kind: "time", min: 0, max: 1500, step: 10 }, + { name: "--motion-status-notice-fade", group: "States", kind: "time", min: 0, max: 1500, step: 10 }, { name: "--motion-approval-pulse", group: "States", kind: "time", min: 0, max: 3000, step: 20 }, { name: "--motion-plan-entry-stagger", group: "States", kind: "time", min: 0, max: 300, step: 5 }, { name: "--motion-plan-check", group: "States", kind: "time", min: 0, max: 1500, step: 10 }, diff --git a/ui/src/components/task-chat/status-notice-presentation.ts b/ui/src/components/task-chat/status-notice-presentation.ts new file mode 100644 index 00000000000..d9b71500695 --- /dev/null +++ b/ui/src/components/task-chat/status-notice-presentation.ts @@ -0,0 +1,158 @@ +import { AlertCircle, AlertTriangle, ArrowRightLeft, Check, Info, Layers, Loader2, RotateCw, TerminalSquare } from "lucide-react"; +import type { TaskChatProviderActivityItem } from "./task-chat-model"; +import type { ToolIcon } from "./tool-taxonomy"; + +/** + * Provider status notices are harness lifecycle reports (context compaction, + * retries, model fallback, informational banners). They render as inline + * chips between the turn's activity and never as part of the answer. + * + * The runner sets the category; the UI never classifies by message text. + */ +export const STATUS_NOTICE_CATEGORIES = [ + "context_compaction", + "provider_retry", + "model_fallback", + "provider_informational", + "local_command_output", +] as const; +export type StatusNoticeCategory = (typeof STATUS_NOTICE_CATEGORIES)[number]; + +export type StatusNoticeTone = "neutral" | "progress" | "warning" | "danger"; + +export interface StatusNoticePresentation { + category: StatusNoticeCategory | "provider_error"; + icon: ToolIcon; + tone: StatusNoticeTone; + /** The short chip label, such as "Compacting context". */ + label: string; + /** Provider text shown after the label, such as the retry reason. */ + detail?: string; + /** True while the notice describes something still happening. */ + active: boolean; + /** A fatal provider error renders as a full row, not a chip. */ + fatal: boolean; +} + +function detail(item: TaskChatProviderActivityItem, label: string): string | undefined { + return item.details.find((entry) => entry.label === label)?.value; +} + +export function statusNoticeCategory(item: TaskChatProviderActivityItem): StatusNoticeCategory | null { + if (item.family !== "provider_notice") return null; + const category = detail(item, "Category"); + return (STATUS_NOTICE_CATEGORIES as readonly string[]).includes(category ?? "") + ? category as StatusNoticeCategory + : null; +} + +/** A turn-scoped provider error that will not be retried. */ +export function isFatalProviderError(item: TaskChatProviderActivityItem): boolean { + return item.family === "provider_notice" + && detail(item, "Category") === "error" + && detail(item, "Severity") === "error" + && !["true", "Yes"].includes(detail(item, "Recoverable") ?? ""); +} + +/** + * The canonical notice payload a status chip renders: a runner-classified + * status category, or a turn-scoped provider error that will not be retried. + * Generic Codex warnings keep their run-log-only treatment. + */ +export function isStatusNoticePayload(payload: Record): boolean { + if ((STATUS_NOTICE_CATEGORIES as readonly string[]).includes(String(payload.category))) return true; + return payload.category === "error" && payload.severity === "error" && payload.recoverable !== true && payload.scope === "turn"; +} + +export function isStatusNotice(item: TaskChatProviderActivityItem): boolean { + return statusNoticeCategory(item) !== null || isFatalProviderError(item); +} + +function summary(item: TaskChatProviderActivityItem): string | undefined { + return item.summary?.trim() || detail(item, "Summary")?.trim() || undefined; +} + +function retryLabel(item: TaskChatProviderActivityItem): string { + switch (detail(item, "Reason")) { + case "serverOverloaded": return "Retrying after overload"; + case "responseStreamDisconnected": + case "responseStreamConnectionFailed": + case "transport_lost": return "Reconnecting"; + case "httpConnectionFailed": return "Retrying connection"; + case "rate_limit": return "Retrying after rate limit"; + default: return "Retrying"; + } +} + +function attemptSuffix(item: TaskChatProviderActivityItem): string { + const attempt = detail(item, "Attempt"); + const max = detail(item, "Max attempts"); + if (!attempt) return ""; + return max ? ` (attempt ${attempt} of ${max})` : ` (attempt ${attempt})`; +} + +export function statusNoticePresentation( + item: TaskChatProviderActivityItem, + options: { resolved?: boolean } = {}, +): StatusNoticePresentation | null { + if (isFatalProviderError(item)) { + return { + category: "provider_error", + icon: AlertCircle, + tone: "danger", + label: "Provider error", + detail: summary(item) ?? "The provider reported an error without a message.", + active: false, + fatal: true, + }; + } + const category = statusNoticeCategory(item); + if (!category) return null; + const phase = detail(item, "Phase"); + switch (category) { + case "context_compaction": + if (phase === "failed") { + return { category, icon: AlertTriangle, tone: "warning", label: "Context compaction failed", detail: summary(item), active: false, fatal: false }; + } + if (phase === "running" && item.status === "interrupted") { + return { category, icon: Layers, tone: "neutral", label: "Compaction stopped", active: false, fatal: false }; + } + return phase === "running" + ? { category, icon: Loader2, tone: "progress", label: "Compacting context", active: true, fatal: false } + : { category, icon: Layers, tone: "neutral", label: "Context compacted", active: false, fatal: false }; + case "provider_retry": + if (!options.resolved && item.status === "interrupted") { + return { category, icon: AlertTriangle, tone: "neutral", label: "Retry stopped", detail: summary(item), active: false, fatal: false }; + } + return options.resolved + ? { category, icon: Check, tone: "neutral", label: "Recovered after retry", detail: summary(item), active: false, fatal: false } + : { category, icon: RotateCw, tone: "warning", label: `${retryLabel(item)}${attemptSuffix(item)}`, detail: summary(item), active: true, fatal: false }; + case "model_fallback": { + const model = detail(item, "Fallback model"); + const reason = detail(item, "Reason"); + return { + category, + icon: ArrowRightLeft, + tone: "warning", + label: model ? `Fell back to ${model}` : "Fell back to another model", + detail: reason ?? summary(item), + active: false, + fatal: false, + }; + } + case "provider_informational": { + const severity = detail(item, "Severity"); + return { + category, + icon: severity === "info" ? Info : AlertTriangle, + tone: severity === "error" ? "danger" : severity === "warning" ? "warning" : "neutral", + label: severity === "error" ? "Provider error" : severity === "warning" ? "Provider warning" : "Provider update", + detail: summary(item), + active: false, + fatal: false, + }; + } + case "local_command_output": + return { category, icon: TerminalSquare, tone: "neutral", label: "Command output", detail: summary(item), active: false, fatal: false }; + } +} diff --git a/ui/src/components/task-chat/status-relevance.test.ts b/ui/src/components/task-chat/status-relevance.test.ts index a80f31ccf84..95cbec4c5b9 100644 --- a/ui/src/components/task-chat/status-relevance.test.ts +++ b/ui/src/components/task-chat/status-relevance.test.ts @@ -130,3 +130,14 @@ it("removes run lifecycle diagnostics while retaining tool failures and pending expect(withoutHistoricalRunStatus(items)).toEqual(retained); expect(items).toHaveLength(7); }); + +it("keeps harness status chips in historical turns while dropping generic notices", () => { + const notice = (id: string, category: string): TaskChatItem => ({ + id, kind: "protocol", surface: "provider_activity", family: "provider_notice", title: "Provider notice", + status: "informational", eventType: "provider.notice.recorded", + details: [{ label: "Severity", value: "info" }, { label: "Category", value: category }], + steps: [], links: [], children: [], + }); + const kept = notice("compaction", "context_compaction"); + expect(withoutHistoricalRunStatus([kept, notice("generic", "warning")])).toEqual([kept]); +}); diff --git a/ui/src/components/task-chat/status-relevance.ts b/ui/src/components/task-chat/status-relevance.ts index 81eafde7580..f3ae1e69eb6 100644 --- a/ui/src/components/task-chat/status-relevance.ts +++ b/ui/src/components/task-chat/status-relevance.ts @@ -2,6 +2,7 @@ import type { ExecutionProjection, IssueRecoveryAction } from "@paperclipai/shar import type { IssueChatComment } from "@/lib/issue-chat-messages"; import { isTerminalIssueStatus } from "@/lib/liveIssueIds"; import type { TaskChatItem } from "./task-chat-model"; +import { isStatusNotice } from "./status-notice-presentation"; interface StatusRun { id: string; @@ -96,6 +97,9 @@ export function withoutHistoricalRunStatus(items: readonly TaskChatItem[]): Task if (item.kind === "message") return item.author !== "system"; if (item.kind !== "protocol") return true; if (item.surface === "run_terminal" || item.surface === "run_result") return false; + // Harness status chips (compaction, fallback, retries, a fatal provider + // error) explain what happened in that turn, so they stay in history. + if (item.surface === "provider_activity" && isStatusNotice(item)) return true; return item.surface !== "provider_activity" || !["terminal", "wait", "provider_notice"].includes(item.family); }); diff --git a/ui/src/components/task-chat/task-chat-model.ts b/ui/src/components/task-chat/task-chat-model.ts index a05b2a76d12..bdeb0b0f744 100644 --- a/ui/src/components/task-chat/task-chat-model.ts +++ b/ui/src/components/task-chat/task-chat-model.ts @@ -398,6 +398,8 @@ export interface TaskChatProviderActivityItem { outputTruncated?: boolean; /** Index of the latest provider event coalesced into this activity. */ transcriptIndex?: number; + /** A retry status notice the turn has moved past. */ + resolved?: boolean; /** When the provider first reported this activity (ISO timestamp). */ startedAt?: string; } diff --git a/ui/src/components/task-chat/transcript-adapter.test.ts b/ui/src/components/task-chat/transcript-adapter.test.ts index 4d5c50dab36..c36896bbdea 100644 --- a/ui/src/components/task-chat/transcript-adapter.test.ts +++ b/ui/src/components/task-chat/transcript-adapter.test.ts @@ -2867,6 +2867,131 @@ describe("prependIssueBrief (PAP-375)", () => { }); }); +describe("harness status notices", () => { + const notice = (noticeId: string, payload: Record): TranscriptEntry => ({ + kind: "provider_activity", ts: TS, family: "provider_notice", eventType: "provider.notice.recorded", + status: payload.severity === "error" ? "failed" : "informational", title: "Provider notice", + summary: String(payload.summary ?? ""), + payload: { schema: "paperclip.provider.notice.v1", noticeId, scope: "turn", userActionable: false, ...payload }, + }); + const read: TranscriptEntry = { kind: "tool_call", ts: TS, toolUseId: "read-1", name: "read_file", input: { path: "a.ts" } }; + const retry = notice("error:retry:turn-1", { + severity: "warning", category: "provider_retry", recoverable: true, summary: "Reconnecting... 2/5", + details: [{ name: "Reason", value: "responseStreamDisconnected" }], + }); + + it("shows status notices as top-level timeline rows while generic warnings stay hidden", () => { + const parsed = transcriptToTaskChatItems([ + read, + notice("compaction-1", { severity: "info", category: "context_compaction", recoverable: true, summary: "Compacting context", details: [{ name: "Phase", value: "running" }] }), + notice("warning-1", { severity: "warning", category: "warning", recoverable: true, summary: "noise" }), + ], { runId: "status", running: true }); + const rows = buildTurnTimelineRows(paperclipRunnerTimelineItems(parsed), true); + expect(rows.map((row) => row.kind === "protocol" && row.surface === "provider_activity" ? row.id : row.kind)).toHaveLength(2); + const chip = rows[1]; + expect(chip.kind === "protocol" && chip.surface === "provider_activity" && chip.details.find((d) => d.label === "Phase")?.value).toBe("running"); + expect(JSON.stringify(rows)).not.toContain("noise"); + }); + + it("marks a retry resolved once real work follows, and drops it behind a fatal error", () => { + const live = buildTurnTimelineRows(paperclipRunnerTimelineItems(transcriptToTaskChatItems([read, retry], { runId: "r", running: true })), true); + expect(live.at(-1)).toMatchObject({ kind: "protocol" }); + expect(live.at(-1)).not.toHaveProperty("resolved"); + + const recovered = buildTurnTimelineRows(paperclipRunnerTimelineItems(transcriptToTaskChatItems([read, retry, { ...read, toolUseId: "read-2" }], { runId: "r", running: true })), true); + expect(recovered.find((row) => row.kind === "protocol")).toMatchObject({ resolved: true }); + + const failed = buildTurnTimelineRows(paperclipRunnerTimelineItems(transcriptToTaskChatItems([ + read, retry, + notice("error:final:turn-1", { severity: "error", category: "error", recoverable: false, summary: "You've hit your usage limit." }), + ], { runId: "r", running: false })), false); + const protocols = failed.filter((row) => row.kind === "protocol"); + expect(protocols).toHaveLength(1); + expect(protocols[0]).toMatchObject({ summary: "You've hit your usage limit." }); + }); + + it("keeps a later retry active after work between retries, and collapses back-to-back retries", () => { + const retry2 = notice("error:retry:turn-1", { + severity: "warning", category: "provider_retry", recoverable: true, summary: "Reconnecting... 1/5", + }); + const work: TranscriptEntry = { ...read, toolUseId: "read-2" }; + const rows = buildTurnTimelineRows(paperclipRunnerTimelineItems(transcriptToTaskChatItems( + [read, retry, work, retry2], { runId: "r", running: true })), true) + .filter((row) => row.kind === "protocol" && row.surface === "provider_activity"); + expect(rows).toHaveLength(2); + expect(rows[0]).toMatchObject({ resolved: true }); + expect(rows[1]).not.toHaveProperty("resolved"); + const backToBack = buildTurnTimelineRows(paperclipRunnerTimelineItems(transcriptToTaskChatItems( + [read, retry, retry2], { runId: "r", running: true })), true) + .filter((row) => row.kind === "protocol" && row.surface === "provider_activity"); + expect(backToBack).toHaveLength(1); + expect(backToBack[0]).toMatchObject({ summary: "Reconnecting... 1/5" }); + }); + + it("marks a compaction that never completed as interrupted once the turn ends", () => { + const running = notice("compaction-1", { severity: "info", category: "context_compaction", recoverable: true, summary: "Compacting context", details: [{ name: "Phase", value: "running" }] }); + const rows = (live: boolean) => buildTurnTimelineRows(paperclipRunnerTimelineItems(transcriptToTaskChatItems([read, running], { runId: "r", running: live })), live) + .filter((row) => row.kind === "protocol" && row.surface === "provider_activity"); + expect(rows(true)[0]).toMatchObject({ status: "informational" }); + expect(rows(false)[0]).toMatchObject({ status: "interrupted" }); + }); + + it("marks a retry left unresolved by a stopped turn as interrupted", () => { + const rows = (live: boolean) => buildTurnTimelineRows(paperclipRunnerTimelineItems(transcriptToTaskChatItems([read, retry], { runId: "r", running: live })), live) + .filter((row) => row.kind === "protocol" && row.surface === "provider_activity"); + expect(rows(true)[0]).toMatchObject({ status: "informational" }); + expect(rows(false)[0]).toMatchObject({ status: "interrupted" }); + expect(rows(false)[0]).not.toHaveProperty("resolved"); + }); + + it("treats a retry followed only by the final answer as recovered", () => { + const answer: TranscriptEntry = { kind: "assistant", ts: TS, text: "Done: 8 lines.", channel: "final" } as TranscriptEntry; + const rows = buildTurnTimelineRows(paperclipRunnerTimelineItems(transcriptToTaskChatItems([read, retry, answer], { runId: "r", running: false })), false) + .filter((row) => row.kind === "protocol" && row.surface === "provider_activity"); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ resolved: true }); + expect(rows[0]).not.toMatchObject({ status: "interrupted" }); + }); + + it("keeps a retry stopped when only run-end records follow it", () => { + const parsed = [ + ...transcriptToTaskChatItems([read, retry], { runId: "r", running: false }), + { id: "terminal", kind: "protocol", surface: "run_terminal", turnState: "cancelled", runState: "cancelled", disposition: "blocked" } as TaskChatItem, + { id: "stopped", kind: "marker", variant: "interrupted", label: "Run stopped" } as TaskChatItem, + ]; + const rows = buildTurnTimelineRows(paperclipRunnerTimelineItems(parsed), false) + .filter((row) => row.kind === "protocol" && row.surface === "provider_activity"); + expect(rows[0]).toMatchObject({ status: "interrupted" }); + expect(rows[0]).not.toHaveProperty("resolved"); + }); + + it("does not treat a turn-end changed-files card as recovery from a stopped retry", () => { + const parsed = [ + ...transcriptToTaskChatItems([read, retry], { runId: "r", running: false }), + { id: "changes", kind: "protocol", surface: "workspace_change", changeSetId: "c", revision: 1, source: "runner_verified", + complete: true, files: [], totals: { files: 1, additions: 2, deletions: 0 }, patchArtifactRef: null } as TaskChatItem, + ]; + const rows = buildTurnTimelineRows(paperclipRunnerTimelineItems(parsed), false) + .filter((row) => row.kind === "protocol" && row.surface === "provider_activity"); + expect(rows[0]).toMatchObject({ status: "interrupted" }); + expect(rows[0]).not.toHaveProperty("resolved"); + }); + + it("lets a context.compacted divider replace the completed compaction chip", () => { + const compacted = notice("compaction-1", { severity: "info", category: "context_compaction", recoverable: true, summary: "Context compacted", details: [{ name: "Phase", value: "completed" }] }); + const divider: TranscriptEntry = { kind: "provider_activity", ts: TS, family: "context", eventType: "context.compacted", status: "completed", title: "Context", summary: "", payload: { compactionId: "c1", reason: "provider" } }; + const rows = (entries: TranscriptEntry[]) => buildTurnTimelineRows(paperclipRunnerTimelineItems(transcriptToTaskChatItems(entries, { runId: "r", running: true })), true) + .filter((row) => row.kind === "protocol" && row.surface === "provider_activity" && row.family === "provider_notice"); + expect(rows([read, compacted])).toHaveLength(1); + expect(rows([read, compacted, divider])).toHaveLength(0); + const marker = { id: "m", kind: "marker", variant: "context_compacted", label: "Context compacted" } as unknown as TaskChatItem; + const parsed = paperclipRunnerTimelineItems(transcriptToTaskChatItems([read, compacted], { runId: "r", running: true })); + const withMarker = buildTurnTimelineRows([...parsed, marker], true) + .filter((row) => row.kind === "protocol" && row.surface === "provider_activity"); + expect(withMarker).toHaveLength(0); + }); +}); + describe("turn changes assembled from edit rows", () => { it("lists each edited file when the harness reports only per-edit patches", () => { const items = transcriptToTaskChatItems( diff --git a/ui/src/components/task-chat/transcript-adapter.ts b/ui/src/components/task-chat/transcript-adapter.ts index e33362e19f3..c35cdc9d976 100644 --- a/ui/src/components/task-chat/transcript-adapter.ts +++ b/ui/src/components/task-chat/transcript-adapter.ts @@ -5,6 +5,7 @@ * thinking → tool → diff → responding while a run is in flight, rather than * only showing a response after the turn settles. */ +import { isFatalProviderError, isStatusNotice, isStatusNoticePayload, statusNoticeCategory } from "./status-notice-presentation"; import type { TranscriptEntry } from "@/adapters"; import { turnChangesFromEditRows } from "./turn-file-changes"; import type { @@ -910,10 +911,15 @@ export function transcriptToTaskChatItems( // Use producer classification, including the legacy generic Codex // categories, never message-text matching. The run transcript still // retains these diagnostics. - if (entry.family === "provider_notice" && (entry.payload.userActionable !== true || + if (entry.family === "provider_notice" && !isStatusNoticePayload(entry.payload) && + (entry.payload.userActionable !== true || ["error", "warning", "guardianWarning", "configWarning", "deprecationNotice", "windows_worldWritableWarning"] .includes(String(entry.payload.category)))) break; - const key = `provider:${providerActivityKey(entry)}`; + // Each retry keeps its own position, so work between two retries in + // one turn cannot make the later retry look recovered. + const key = entry.family === "provider_notice" && entry.payload.category === "provider_retry" + ? `provider:${providerActivityKey(entry)}:${i}` + : `provider:${providerActivityKey(entry)}`; const item = providerActivityItem(entry, runId, i); const existingIndex = protocolIndexByKey.get(key); if (existingIndex == null) { @@ -1332,8 +1338,17 @@ export function paperclipRunnerActivityItems( * terminal receipt at the request's first-seen position. */ export function paperclipRunnerTimelineItems( - parsed: readonly TaskChatItem[], + allParsed: readonly TaskChatItem[], ): TaskChatItem[] { + // Decide retry recovery on the full transcript: the final answer that + // proves recovery is filtered out of the display rows below. + const parsed = allParsed.map((item, index) => + item.kind === "protocol" && item.surface === "provider_activity" && + statusNoticeCategory(item) === "provider_retry" && !item.resolved && + allParsed.slice(index + 1).some(statusNoticeResolvesRetry) + ? { ...item, resolved: true } + : item, + ); const activityIds = new Set( paperclipRunnerActivityItems(parsed).map((item) => item.id), ); @@ -1827,6 +1842,37 @@ export function buildTurnTimelineRows( } else if (item.kind === "plan_document") { current = null; rows.push(item); + } else if ( + item.kind === "protocol" && + item.surface === "provider_activity" && + isStatusNotice(item) + ) { + // Harness status notices sit between activity as their own chips, so + // a retry or compaction is visible without opening a phase. + const later = parsed.slice(parsed.indexOf(item) + 1); + const retry = statusNoticeCategory(item) === "provider_retry"; + // A fatal error that ends the retries already tells the whole story. + if (retry && later.some((next) => next.kind === "protocol" && next.surface === "provider_activity" && isFatalProviderError(next))) continue; + // Consecutive retries with no work between them show only the latest. + const nextSignal = later.find((next) => statusNoticeResolvesRetry(next) || + (next.kind === "protocol" && next.surface === "provider_activity" && statusNoticeCategory(next) === "provider_retry")); + if (retry && nextSignal?.kind === "protocol" && nextSignal.surface === "provider_activity" && + statusNoticeCategory(nextSignal) === "provider_retry") continue; + // A completed compaction with a canonical context.compacted divider + // after it is shown by that divider alone. + if (statusNoticeCategory(item) === "context_compaction" && + item.details.find((detail) => detail.label === "Phase")?.value === "completed" && + later.some(isCompactionDivider)) continue; + current = null; + // A compaction still "running" when the turn has ended was cut short. + const stoppedCompaction = !running && statusNoticeCategory(item) === "context_compaction" && + item.details.find((detail) => detail.label === "Phase")?.value === "running"; + const resolved = retry && (item.resolved === true || later.some(statusNoticeResolvesRetry)); + // A retry with no later work in a turn that has ended never recovered. + const stoppedRetry = retry && !resolved && !running; + rows.push(resolved + ? { ...item, resolved: true } + : stoppedCompaction || stoppedRetry ? { ...item, status: "interrupted" } : item); } else if ( item.kind === "tool" || item.kind === "usage" || @@ -1848,6 +1894,36 @@ export function buildTurnTimelineRows( return meaningful; } +/** + * A canonical context.compacted item: a context provider activity, or the + * context-meter divider marker (variant compared as text so this works + * before and after that variant joins the marker union). + */ +function isCompactionDivider(item: TaskChatItem): boolean { + if (item.kind === "protocol") return item.surface === "provider_activity" && item.family === "context"; + return item.kind === "marker" && (item.variant as string) === "context_compacted"; +} + +/** Real provider progress after a retry notice shows the retry succeeded. */ +function statusNoticeResolvesRetry(item: TaskChatItem): boolean { + // Only new provider work proves recovery. Run results, terminal records, + // markers and usage also appear when a user stops a reconnecting run. + switch (item.kind) { + case "message": + return item.author === "agent" && item.text.trim().length > 0; + case "thinking": + return item.lines.some((line) => line.trim().length > 0); + case "tool": + return true; + case "protocol": + // Workspace cards are re-positioned to the end of the turn, so their + // place says nothing about when the work happened. + return item.surface === "provider_activity" && !isStatusNotice(item); + default: + return false; + } +} + /** Segment parsed transcript rows at assistant/request boundaries. */ export function buildActivityPhases( parsed: readonly TaskChatItem[], diff --git a/ui/src/index.css b/ui/src/index.css index 3b096912496..7042b163e3f 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -447,6 +447,8 @@ --motion-diff-reveal: var(--motion-duration-base); --motion-status-enter: var(--motion-duration-fast); --motion-status-exit: var(--motion-duration-fast); + /* Harness status chip fading to a receipt once a retry recovers. */ + --motion-status-notice-fade: var(--motion-duration-deliberate); --motion-approval-pulse: 1.6s; --motion-plan-entry-stagger: 40ms; --motion-plan-check: var(--motion-duration-fast); @@ -569,6 +571,12 @@ --tc-mode-plan: var(--status-task-todo); --tc-mode-ask: #3b82f6; + /* Harness status chips (retry, model fallback, provider warnings) in the + task-chat timeline: a quiet amber outline on the page surface. */ + --tc-status-notice-warning-fg: var(--status-task-icon-todo); + --tc-status-notice-warning-border: color-mix(in oklch, var(--status-task-todo) 40%, transparent); + --tc-status-notice-warning-bg: color-mix(in oklch, var(--status-task-todo) 8%, transparent); + /* Agent-comment comparison fill used by TaskChatLab. The chosen production treatment sits directly on the page surface; this token keeps the darker card available as an explicit lab alternative. */ diff --git a/ui/storybook/stories/tasks/task-chat-status-notices.stories.tsx b/ui/storybook/stories/tasks/task-chat-status-notices.stories.tsx new file mode 100644 index 00000000000..d9b27d64506 --- /dev/null +++ b/ui/storybook/stories/tasks/task-chat-status-notices.stories.tsx @@ -0,0 +1,254 @@ +import type { ReactNode } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { TaskChatBubble } from "@/components/task-chat/TaskChatBubble"; +import { TaskChatRunnerTurn } from "@/components/task-chat/TaskChatRunnerTurn"; +import type { + TaskChatItem, + TaskChatMessageItem, + TaskChatProviderActivityItem, + TaskChatToolItem, +} from "@/components/task-chat/task-chat-model"; + +type RunStatus = "running" | "succeeded" | "failed"; + +function tool(id: string, name: string, target: string, status: TaskChatToolItem["status"] = "completed"): TaskChatToolItem { + return { id, kind: "tool", name, rawName: name.toLowerCase(), target, status }; +} + +/** + * The same shape the live transcript adapter builds from a canonical + * `provider.notice.recorded` event: payload keys become title-cased details. + */ +function notice( + id: string, + fields: { category: string; severity: "info" | "warning" | "error"; recoverable: boolean; summary?: string }, + extra: Record = {}, +): TaskChatProviderActivityItem { + return { + id, + kind: "protocol", + surface: "provider_activity", + family: "provider_notice", + eventType: "provider.notice.recorded", + status: fields.severity === "error" && !fields.recoverable ? "failed" : "informational", + title: "Provider notice", + summary: fields.summary, + details: [ + { label: "Severity", value: fields.severity }, + { label: "Category", value: fields.category }, + { label: "Scope", value: "turn" }, + { label: "Recoverable", value: String(fields.recoverable) }, + ...(fields.summary ? [{ label: "Summary", value: fields.summary }] : []), + ...Object.entries(extra).map(([label, value]) => ({ label, value })), + ], + steps: [], + links: [], + children: [], + }; +} + +function progress(id: string, text: string): TaskChatMessageItem { + return { id, kind: "message", author: "agent", channel: "progress", interstitial: true, text }; +} + +function final(id: string, text: string): TaskChatMessageItem { + return { id, kind: "message", author: "agent", channel: "final", interstitial: false, text, timestamp: "2:41 PM" }; +} + +const compactingNow = notice("compaction-1", { category: "context_compaction", severity: "info", recoverable: true }, { Phase: "running" }); +const compacted = notice("compaction-1", { category: "context_compaction", severity: "info", recoverable: true }, { Phase: "completed" }); +const compactionFailed = notice("compaction-1", { + category: "context_compaction", severity: "warning", recoverable: true, + summary: "Conversation too long to summarize; continuing with the full context.", +}, { Phase: "failed" }); + +const overloadRetry = notice("retry-1", { + category: "provider_retry", severity: "warning", recoverable: true, + summary: "We're currently experiencing high demand, which may cause temporary errors.", +}, { Reason: "serverOverloaded", Attempt: "2", "Max attempts": "5" }); + +const streamRetry = notice("retry-2", { + category: "provider_retry", severity: "warning", recoverable: true, + summary: "stream disconnected before completion: error sending request for url (https://chatgpt.com/backend-api/codex/responses)", +}, { Reason: "responseStreamDisconnected", Attempt: "1", "Max attempts": "5" }); + +const fallback = notice("fallback-1", { + category: "model_fallback", severity: "warning", recoverable: true, + summary: "claude-opus-5-5 declined this request; retried with claude-sonnet-5-5. The session will continue on claude-sonnet-5-5.", +}, { "Original model": "claude-opus-5-5", "Fallback model": "claude-sonnet-5-5", Reason: "declined the request (cyber)" }); + +const hookWarning = notice("info-1", { + category: "provider_informational", severity: "warning", recoverable: true, + summary: "Stop hook blocked continuation: lint must pass before finishing (pnpm lint exited 1).", +}); + +const fatal = notice("error-1", { + category: "error", severity: "error", recoverable: false, + summary: "You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), or try again at 4:12 PM.", +}, { "Codex error": "usageLimitExceeded" }); + +const reading = [ + tool("t1", "Read", "server/src/services/webhooks/delivery-worker.ts"), + tool("t2", "Search", "\"deliverWebhook\" in server/src"), + tool("t3", "Read", "server/src/services/webhooks/retry-policy.ts"), +]; +const editing = [ + tool("t4", "Edit", "server/src/services/webhooks/delivery-worker.ts"), + tool("t5", "Bash", "pnpm vitest run server/src/services/webhooks"), +]; + +const ANSWER = "Deliveries are now idempotent on the event id: the worker records the id before sending and skips ids it has already delivered. I added a regression test that retries the same event twice and asserts a single POST. The webhook suite passes (14 tests)."; + +interface Scenario { + title: string; + rule: string; + agent: string; + status: RunStatus; + items: TaskChatItem[]; +} + +const SCENARIOS = { + compacting: { + title: "Compacting context (Claude)", + rule: "A spinner chip while the harness summarizes earlier context. It never joins the answer text.", + agent: "ClaudeCoder", status: "running", + items: [progress("p1", "Tracing the retry path through the delivery worker."), ...reading, compactingNow], + }, + compacted: { + title: "Context compacted, work continues", + rule: "The chip settles to a quiet receipt and later activity continues below it.", + agent: "ClaudeCoder", status: "succeeded", + items: [progress("p1", "Tracing the retry path through the delivery worker."), ...reading, compacted, ...editing, final("f1", ANSWER)], + }, + compactionFailed: { + title: "Compaction failed", + rule: "Amber, with the harness's reason. The run keeps going.", + agent: "ClaudeCoder", status: "running", + items: [...reading, compactionFailed, tool("t4", "Edit", "server/src/services/webhooks/delivery-worker.ts", "in_progress")], + }, + retrying: { + title: "Retrying after overload (Codex)", + rule: "Amber while the provider is retrying, with the attempt count and the provider's real message.", + agent: "CodexCoder", status: "running", + items: [...reading, overloadRetry], + }, + recovered: { + title: "Retry recovered", + rule: "Once real work follows, the retry chip fades to “Recovered after retry”.", + agent: "CodexCoder", status: "succeeded", + items: [...reading, streamRetry, ...editing, final("f1", ANSWER)], + }, + fallback: { + title: "Model fallback (Claude)", + rule: "Names the model the session moved to and why. Click for the full explanation.", + agent: "ClaudeCoder", status: "succeeded", + items: [...reading, fallback, ...editing, final("f1", ANSWER)], + }, + hookWarning: { + title: "Provider warning banner", + rule: "Claude's bold “Warning:” banners become an amber chip instead of answer text.", + agent: "ClaudeCoder", status: "running", + items: [...reading, ...editing, hookWarning], + }, + fatal: { + title: "Fatal provider error", + rule: "A red row with the provider's real message, not “Provider notice”.", + agent: "CodexCoder", status: "failed", + items: [...reading, overloadRetry, fatal], + }, +} satisfies Record; + +function Thread({ scenario }: { scenario: Scenario }) { + return ( +
+ + +
+ ); +} + +function Card({ scenario, children }: { scenario: Scenario; children: ReactNode }) { + return ( +
+
+

{scenario.title}

+

{scenario.rule}

+
+
{children}
+
+ ); +} + +function Page({ children }: { children: ReactNode }) { + return ( +
+
{children}
+
+ ); +} + +function Gallery() { + return ( + +
+

Harness status notices

+

+ Compaction, retries, model fallback and provider banners render as chips between the turn's activity. + They come from canonical provider.notice.recorded events with a + status category, so they stay out of the answer and the issue comment. +

+
+ {Object.values(SCENARIOS).map((scenario) => ( + + ))} +
+ ); +} + +function Single({ scenario }: { scenario: Scenario }) { + return ; +} + +const meta = { + id: "product-task-chat-status-notices", + title: "Tasks and conversations/Task creation and lifecycle/Product/Task chat/Status notices", + component: Single, + args: { scenario: SCENARIOS.retrying }, + parameters: { + layout: "fullscreen", + paperclip: { + "capabilities": [ + "tasks/discussion", + "runs-adapters/runs" + ], + "implementation": "production", + "boundary": "fixtures", + "notes": "Renders the shipped runner turn with fixture provider notices shaped like the live transcript adapter output. Live provider event delivery is not exercised." + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const AllStatesGallery: Story = { render: () => }; +export const CompactingContext: Story = { args: { scenario: SCENARIOS.compacting } }; +export const ContextCompacted: Story = { args: { scenario: SCENARIOS.compacted } }; +export const CompactionFailed: Story = { args: { scenario: SCENARIOS.compactionFailed } }; +export const RetryingAfterOverload: Story = { args: { scenario: SCENARIOS.retrying } }; +export const RetryRecovered: Story = { args: { scenario: SCENARIOS.recovered } }; +export const ModelFallback: Story = { args: { scenario: SCENARIOS.fallback } }; +export const ProviderWarningBanner: Story = { args: { scenario: SCENARIOS.hookWarning } }; +export const FatalProviderError: Story = { args: { scenario: SCENARIOS.fatal } }; +export const Light: Story = { render: () => , globals: { theme: "light" } };