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 e519409d5f5..00c52068196 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,23 @@ fn codex_terminal_error(params: &Value, event_type: &str) -> Value { } } +/// 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. +fn mcp_startup_error_reason(error: &str) -> String { + let redacted = redact_text(error); + let mut reason = redacted.trim(); + if let Some(rest) = reason.strip_prefix("MCP client for `") { + if let Some(index) = rest.find("` failed to start:") { + reason = rest[index + "` failed to start:".len()..].trim(); + } + } + if let Some(rest) = reason.strip_prefix("MCP startup failed:") { + reason = rest.trim(); + } + reason.to_owned() +} + fn string(value: Option<&Value>) -> &str { value.and_then(Value::as_str).unwrap_or("") } @@ -971,6 +988,61 @@ pub fn normalize_codex_notification_in( }), ) } + // 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" + if params.get("status").and_then(Value::as_str) == Some("failed") => + { + let name: String = string(params.get("name")) + .trim() + .chars() + .take(160) + .collect(); + let server_name = if name.is_empty() { + "An".to_owned() + } else { + name + }; + let failure_reason = string(params.get("failureReason")); + let reason = if failure_reason == "reauthenticationRequired" { + "needs re-authentication".to_owned() + } else { + mcp_startup_error_reason(string(params.get("error"))) + }; + let notice_id = stable_id(&format!("mcp-startup:{server_name}"), "mcp-startup"); + let mut details = vec![json!({"name": "server", "value": server_name})]; + if !reason.is_empty() { + details.push( + json!({"name": "reason", "value": bounded_text(&reason, MAX_TEXT_CHARS)}), + ); + } + if !failure_reason.is_empty() { + details.push( + json!({"name": "failureReason", "value": bounded_text(failure_reason, 160)}), + ); + } + let summary = if reason.is_empty() { + format!("{server_name} MCP server failed to start") + } else { + format!("{server_name} MCP server failed to start: {reason}") + }; + push( + &mut events, + "provider.notice.recorded", + EventPriority::P0, + json!({ + "schema": "paperclip.provider.notice.v1", + "noticeId": notice_id, + "severity": "warning", + "category": "mcp_server_startup_failed", + "scope": "session", + "recoverable": true, + "userActionable": true, + "summary": bounded_text(&summary, MAX_TEXT_CHARS), + "details": details, + }), + ) + } "error" | "warning" | "deprecationNotice" | "configWarning" => push( &mut events, "provider.notice.recorded", @@ -1804,6 +1876,53 @@ mod tests { ); } + #[test] + fn maps_failed_codex_mcp_startup_to_user_facing_notice() { + let events = normalize_codex_notification( + "mcpServer/startupStatus/updated", + &json!({ + "threadId": null, "name": "github", "status": "failed", + // Codex 0.160's own wording, observed on a live run. + "error": "MCP client for `github` failed to start: MCP startup failed: No such file or directory (os error 2)", + "failureReason": null, + }), + ); + assert_eq!(events.len(), 1); + assert_eq!(events[0].event_type, "provider.notice.recorded"); + assert_eq!( + events[0].payload, + json!({ + "schema": "paperclip.provider.notice.v1", + "noticeId": "mcp-startup:github", + "severity": "warning", + "category": "mcp_server_startup_failed", + "scope": "session", + "recoverable": true, + "userActionable": true, + "summary": "github MCP server failed to start: No such file or directory (os error 2)", + "details": [ + {"name": "server", "value": "github"}, + {"name": "reason", "value": "No such file or directory (os error 2)"}, + ], + }) + ); + let reauth = normalize_codex_notification( + "mcpServer/startupStatus/updated", + &json!({"name": "linear", "status": "failed", "failureReason": "reauthenticationRequired"}), + ); + assert_eq!( + reauth[0].payload["summary"], + "linear MCP server failed to start: needs re-authentication" + ); + for status in ["starting", "ready", "cancelled"] { + assert!(normalize_codex_notification( + "mcpServer/startupStatus/updated", + &json!({"name": "github", "status": status}), + ) + .is_empty()); + } + } + #[test] fn preserves_codex_notice_text_from_current_and_legacy_payloads() { for method in ["configWarning", "deprecationNotice", "warning"] { diff --git a/packages/paperclip-runner/src/drivers/codex/codex-thread-normalization.ts b/packages/paperclip-runner/src/drivers/codex/codex-thread-normalization.ts index d3b218bac4e..9908937eec2 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-thread-normalization.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-thread-normalization.ts @@ -140,6 +140,7 @@ export function isSupportedCodexNotificationMethod(method: string): boolean { method === "model/rerouted" || method === "model/verification" || method === "model/safetyBuffering/updated" || + method === "mcpServer/startupStatus/updated" || method.startsWith("item/") || method === "paperclip/canonicalProviderEvent" || method === "paperclip/runtimeRequestExpired" || diff --git a/packages/paperclip-runner/src/provider-events.test.ts b/packages/paperclip-runner/src/provider-events.test.ts index 8a8071d5214..2596e9c9521 100644 --- a/packages/paperclip-runner/src/provider-events.test.ts +++ b/packages/paperclip-runner/src/provider-events.test.ts @@ -94,6 +94,48 @@ describe("provider-neutral events", () => { } }); + it("maps a failed Codex MCP server start to a user-facing notice", () => { + const [event] = canonicalProviderEventsFromCodex("mcpServer/startupStatus/updated", { + threadId: null, + name: "paperclip", + status: "failed", + error: "MCP client for `paperclip` failed to start: program not found: paperclip-mcp", + failureReason: null, + }); + expect(event).toMatchObject({ + eventType: "provider.notice.recorded", + itemId: "mcp-startup:paperclip", + payload: { + severity: "warning", + category: "mcp_server_startup_failed", + scope: "session", + userActionable: true, + summary: "paperclip MCP server failed to start: program not found: paperclip-mcp", + details: [ + { name: "server", value: "paperclip" }, + { name: "reason", value: "program not found: paperclip-mcp" }, + ], + }, + }); + expect(validatePrpEvent(envelope(event)).ok).toBe(true); + + const [reauth] = canonicalProviderEventsFromCodex("mcpServer/startupStatus/updated", { + name: "linear", status: "failed", error: null, failureReason: "reauthenticationRequired", + }); + expect(reauth.payload.summary).toBe("linear MCP server failed to start: needs re-authentication"); + // Codex 0.160's own wording, observed on a live run. + const [live] = canonicalProviderEventsFromCodex("mcpServer/startupStatus/updated", { + name: "github", status: "failed", + error: "MCP client for `github` failed to start: MCP startup failed: No such file or directory (os error 2)", + }); + expect(live.payload.summary).toBe("github MCP server failed to start: No such file or directory (os error 2)"); + expect(validatePrpEvent(envelope(reauth)).ok).toBe(true); + + for (const status of ["starting", "ready", "cancelled"]) { + expect(canonicalProviderEventsFromCodex("mcpServer/startupStatus/updated", { name: "paperclip", status })).toEqual([]); + } + }); + it("classifies the complete qualified 18-variant Codex ThreadItem inventory", () => { expect(Object.keys(CODEX_THREAD_ITEM_CLASSIFICATION)).toEqual([ "userMessage", diff --git a/packages/paperclip-runner/src/provider-events.ts b/packages/paperclip-runner/src/provider-events.ts index 4fe1cb70d95..5ecc80c91a5 100644 --- a/packages/paperclip-runner/src/provider-events.ts +++ b/packages/paperclip-runner/src/provider-events.ts @@ -751,6 +751,43 @@ export function canonicalProviderEventsFromCodex( }, }]; } + if (method === "mcpServer/startupStatus/updated") { + // 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. + if (params.status !== "failed") return []; + const serverName = [...text(params.name).trim()].slice(0, 160).join("") || "An"; + const failureReason = text(params.failureReason); + // Codex wraps the reason in sentences that repeat what the notice already + // says: "MCP client for `github` failed to start: MCP startup failed: ". + const error = redactCodexDiagnostic(text(params.error)) + .trim() + .replace(/^MCP client for `[^`]*` failed to start:\s*/i, "") + .replace(/^MCP startup failed:\s*/i, "") + .trim(); + const reason = failureReason === "reauthenticationRequired" + ? "needs re-authentication" + : error; + const noticeId = safeId(`mcp-startup:${serverName}`, "mcp-startup"); + return [{ + eventType: "provider.notice.recorded", + itemId: noticeId, + payload: { + schema: "paperclip.provider.notice.v1", + noticeId, + severity: "warning", + category: "mcp_server_startup_failed", + scope: "session", + recoverable: true, + userActionable: true, + summary: `${serverName} MCP server failed to start${reason ? `: ${reason}` : ""}`.slice(0, 4000), + details: [ + { name: "server", value: serverName }, + ...(reason ? [{ name: "reason", value: reason.slice(0, 4000) }] : []), + ...(failureReason ? [{ name: "failureReason", value: failureReason.slice(0, 160) }] : []), + ], + }, + }]; + } if (method === "turn/plan/updated") { const turnPlanId = safeId(text(params.turnId), "turn-plan"); return [ diff --git a/server/src/__tests__/task-cold-open.test.ts b/server/src/__tests__/task-cold-open.test.ts index 225821c1927..f256fc2119b 100644 --- a/server/src/__tests__/task-cold-open.test.ts +++ b/server/src/__tests__/task-cold-open.test.ts @@ -167,14 +167,18 @@ const support = externalDatabase ? { supported: true } : await getEmbeddedPostgr event(6, "runtime_request.resolved", { requestId: "request" }), event(7, "item.completed", { kind: "assistant_message", text: "Final answer" }), event(8, "turn.completed", {}), + event(9, "provider.notice.recorded", { schema: "paperclip.provider.notice.v1", category: "mcp_server_startup_failed", + summary: "github MCP server failed to start: No such file or directory (os error 2)" }), + event(10, "provider.notice.recorded", { schema: "paperclip.provider.notice.v1", category: "configWarning", summary: "diagnostic" }), ]); const service = createHeartbeatRunState(db); const first = await service.listEvents(runId, 0, 2, true); expect(first.map(e => e.seq)).toEqual([3, 4]); const rest = await service.listEvents(runId, 4, 1000, true); - expect(rest.map(e => e.seq)).toEqual([5, 6, 7, 8]); + // A settled run keeps the MCP startup failure that explains missing tools. + expect(rest.map(e => e.seq)).toEqual([5, 6, 7, 8, 9]); expect(JSON.stringify([...first, ...rest])).not.toContain("large tool arguments"); - expect(await service.listEvents(runId)).toHaveLength(8); + expect(await service.listEvents(runId)).toHaveLength(10); await db.insert(heartbeatRuns).values({ companyId, agentId, issueId, status: "succeeded", createdAt: new Date(Date.now() + 1000) }); const activity = activityService(db); await db.insert(activityLog).values(Array.from({ length: 55 }, () => ({ diff --git a/server/src/services/heartbeat/conversation-events.ts b/server/src/services/heartbeat/conversation-events.ts index aa22a5372a2..aed0c6d17dc 100644 --- a/server/src/services/heartbeat/conversation-events.ts +++ b/server/src/services/heartbeat/conversation-events.ts @@ -35,6 +35,7 @@ function planWriteCondition(runId: string) { * Canonical PRP conversation data, excluding ordinary historical tool activity. * Successful document writes retain a body-free boundary for saved Plan cards. * Keep request lifecycles so a cold open cannot hide a question or approval. + * Keep MCP startup failures so a settled run still explains missing tools. * Item identity may occur on started/completed rather than each text delta. */ export function conversationEventCondition(runId: string) { @@ -44,6 +45,10 @@ export function conversationEventCondition(runId: string) { ${planWriteCondition(runId)} ) or ${heartbeatRunEvents.eventType} in ('run.result.accepted', 'run.terminal', 'turn.completed', 'turn.failed', 'turn.cancelled', 'turn.interrupted') + or ( + ${heartbeatRunEvents.eventType} = 'provider.notice.recorded' + and ${heartbeatRunEvents.payload} #>> '{prpEvent,payload,category}' = 'mcp_server_startup_failed' + ) or ( ${heartbeatRunEvents.eventType} in ('item.started', 'item.delta', 'item.completed') and (lower(replace(coalesce(${heartbeatRunEvents.payload} #>> '{prpEvent,payload,kind}', ${heartbeatRunEvents.payload} #>> '{prpEvent,payload,item,kind}', ${heartbeatRunEvents.payload} #>> '{prpEvent,payload,item,type}', ''), '_', '')) in ('agentmessage', 'assistantmessage') diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index 2add6c0bdbc..aeaad6723b3 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -248,6 +248,37 @@ describe.each(["legacy", "native"] as const)("historical %s system status", (run expect(props.onResumeAssignee).not.toHaveBeenCalled(); }); + it.runIf(runtimeMode === "native")("keeps a failed MCP start on a historical settled run while hiding other notices", () => { + const prp = (seq: number, eventType: string, payload: Record) => ({ + id: seq, companyId: "company", runId: "mcp", agentId: "agent", seq, eventType, stream: "system", level: "info", + color: null, message: null, createdAt: new Date(`2025-01-01T10:00:0${seq}Z`), + payload: { prpEvent: { schema: "paperclip.prp.event.v1", sourceEventId: `event-${seq}`, sourceSeq: seq, + sourceInstanceId: "runner", sourceKind: "runner", runId: "mcp", normalizedSessionId: "session", eventType, + schemaVersion: 1, priority: 1, emittedAt: `2025-01-01T10:00:0${seq}.000Z`, payload } }, + }) as Parameters[0][number]; + const notice = (seq: number, category: string, summary: string, details: unknown[] = []) => prp(seq, "provider.notice.recorded", { + schema: "paperclip.provider.notice.v1", noticeId: `notice-${seq}`, severity: "warning", category, scope: "session", + recoverable: true, userActionable: true, summary, details, + }); + nativeTranscriptState.transcriptByRun.set("mcp", [ + ...nativeRunEventsToTranscript([ + notice(1, "mcp_server_startup_failed", "github MCP server failed to start: No such file or directory (os error 2)", + [{ name: "server", value: "github" }, { name: "reason", value: "No such file or directory (os error 2)" }]), + notice(2, "auth", "Sign in again"), + ]), + { kind: "assistant", channel: "progress", ts: "2025-01-01T10:00:05Z", text: "Inspected the source." }, + { kind: "assistant", channel: "final", ts: "2025-01-01T10:00:20Z", text: "I couldn't reach the GitHub MCP server." }, + ]); + render( {}} issueStatus="done" />); + const banners = container.querySelectorAll('[data-testid="task-chat-run-health-banner"]'); + expect([...banners].map((banner) => banner.textContent)).toEqual([ + "github MCP server failed to start: No such file or directory (os error 2)", + ]); + expect(container.textContent).not.toContain("Sign in again"); + expect(container.textContent).toContain("I couldn't reach the GitHub MCP server."); + }); + it("replaces old failure and wait notices when a new attempt succeeds, regardless of fetch order", () => { setTranscript("old"); const notice = { ...comment("notice", "Old recovery details."), authorType: "system" as const, diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 9cc995370b4..09b1edb3386 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -44,6 +44,7 @@ import { import { useNativeRunTranscripts } from "@/components/transcript/useNativeRunTranscripts"; import { TaskChatLiveTail } from "@/components/task-chat/TaskChatLiveTail"; import { TaskChatRunnerHeader, TaskChatRunnerTurn } from "@/components/task-chat/TaskChatRunnerTurn"; +import { partitionRunHealthItems } from "@/components/task-chat/TaskChatRunHealthBanner"; import { TaskChatTurnStatusIsland, taskChatTurnStatusModel, @@ -2058,6 +2059,9 @@ export function TaskChatThread(props: TaskChatThreadProps) { return { segment, parsed, + // Historical turns drop provider notices, but a failed MCP start + // still explains that run's answer, so take it before the filter. + mcpStartupFailures: partitionRunHealthItems(parsed).failures, timelineItems: sourceIsPaperclipRunner ? paperclipRunnerTimelineItems(visibleActivity) : visibleActivity, @@ -2112,9 +2116,11 @@ export function TaskChatThread(props: TaskChatThreadProps) { allowFallback: sourceAllowSummaryFallback, }) : undefined; - const children = settledRunChildren( - timelineItemsBySegment[segmentIndex] ?? [], - ); + const runHealth = { + failures: projectedSegments[segmentIndex]?.mcpStartupFailures ?? [], + rest: partitionRunHealthItems(timelineItemsBySegment[segmentIndex] ?? []).rest, + }; + const children = settledRunChildren(runHealth.rest); if (children.length === 0 && !finalResponse && !sourceIsPaperclipRunner) continue; settledRunIds.add(source.id); @@ -2155,6 +2161,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { continuedAfterSteering: sourceIsPaperclipRunner && segmentIndex > 0, animateFold: liveSeenRef.current.has(source.id), items: children, + ...(runHealth.failures.length > 0 ? { mcpStartupFailures: runHealth.failures } : {}), finalResponse, summary: buildTurnSummary(segment.entries, { durationMs: segmentDurationMs, @@ -2209,12 +2216,13 @@ export function TaskChatThread(props: TaskChatThreadProps) { liveRun.id === planDocumentSourceRunId && planTurnItem ? embedPlanDocumentAtWriteBoundary(parsedTranscript, planTurnItem) : parsedTranscript; - const children = settledRunChildren( + const runHealth = partitionRunHealthItems( isNativePaperclipRunnerRun(liveRun) ? paperclipRunnerTimelineItems(parsed) : parsed, ); - if (children.length === 0) continue; + const children = settledRunChildren(runHealth.rest); + if (children.length === 0 && runHealth.failures.length === 0) continue; const turnId = `${liveRun.id}:turn:${segmentIndex}`; const segmentDurationMs = Number.isFinite(segment.startMs) && Number.isFinite(segment.endMs) @@ -2250,6 +2258,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { continuedAfterSteering: isNativePaperclipRunnerRun(liveRun) && segmentIndex > 0, items: children, + ...(runHealth.failures.length > 0 ? { mcpStartupFailures: runHealth.failures } : {}), summary: buildTurnSummary(segment.entries, { durationMs: segmentDurationMs, }), diff --git a/ui/src/components/task-chat/TaskChatRunHealthBanner.test.tsx b/ui/src/components/task-chat/TaskChatRunHealthBanner.test.tsx new file mode 100644 index 00000000000..70ba3096faa --- /dev/null +++ b/ui/src/components/task-chat/TaskChatRunHealthBanner.test.tsx @@ -0,0 +1,156 @@ +// @vitest-environment jsdom + +import type { HeartbeatRunEvent } from "@paperclipai/shared"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { ThemeProvider } from "@/context/ThemeContext"; +import { MemoryRouter } from "@/lib/router"; +import { nativeRunEventsToTranscript } from "../transcript/native-run-events"; +import { TaskChatRunnerTurn } from "./TaskChatRunnerTurn"; +import { TaskChatTurn } from "./TaskChatTurn"; +import { partitionRunHealthItems } from "./TaskChatRunHealthBanner"; +import type { TaskChatTurnItem } from "./task-chat-model"; +import { coalesceSettledTurns, transcriptToTaskChatItems } from "./transcript-adapter"; + +const RUN_ID = "10000000-0000-4000-8000-000000000001"; + +function event(seq: number, eventType: string, payload: Record): HeartbeatRunEvent { + return { + id: seq, + companyId: "10000000-0000-4000-8000-000000000002", + runId: RUN_ID, + agentId: "10000000-0000-4000-8000-000000000003", + seq, + eventType, + stream: "system", + level: "info", + color: null, + message: null, + payload: { + prpEvent: { + schema: "paperclip.prp.event.v1", + sourceEventId: `event-${seq}`, + sourceSeq: seq, + sourceInstanceId: "runner-1", + sourceKind: "runner", + runId: RUN_ID, + normalizedSessionId: "session-1", + eventType, + schemaVersion: 1, + priority: 1, + emittedAt: `2026-11-05T18:00:${String(seq).padStart(2, "0")}.000Z`, + payload, + }, + }, + createdAt: new Date(`2026-11-05T18:00:${String(seq).padStart(2, "0")}.000Z`), + } as HeartbeatRunEvent; +} + +/** The runner's mapping of a failed Codex mcpServer/startupStatus/updated. */ +function mcpFailure(seq: number, server: string, reason: string) { + return event(seq, "provider.notice.recorded", { + schema: "paperclip.provider.notice.v1", + noticeId: `mcp-startup:${server}`, + severity: "warning", + category: "mcp_server_startup_failed", + scope: "session", + recoverable: true, + userActionable: true, + summary: `${server} MCP server failed to start: ${reason}`, + details: [{ name: "server", value: server }, { name: "reason", value: reason }], + }); +} + +function itemsFrom(events: HeartbeatRunEvent[]) { + return transcriptToTaskChatItems(nativeRunEventsToTranscript(events), { runId: RUN_ID, running: true }); +} + +describe("run health banner", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + const render = (events: HeartbeatRunEvent[]) => + act(() => + root.render( + + + + + , + ), + ); + + it("lifts a failed MCP start out of the timeline into one banner under the header", () => { + render([ + mcpFailure(1, "paperclip", "program not found: paperclip-mcp"), + event(2, "provider.notice.recorded", { + schema: "paperclip.provider.notice.v1", noticeId: "other", severity: "warning", + category: "auth", scope: "session", recoverable: true, userActionable: true, summary: "Sign in again", + }), + ]); + const banners = container.querySelectorAll('[data-testid="task-chat-run-health-banner"]'); + expect(banners).toHaveLength(1); + expect(banners[0].textContent).toBe("paperclip MCP server failed to start: program not found: paperclip-mcp"); + const inline = container.querySelectorAll('[data-activity-family="provider_notice"]'); + expect([...inline].map((row) => row.textContent)).toEqual([expect.stringContaining("Sign in again")]); + const header = container.querySelector('[data-testid="task-chat-runner-identity-row"]'); + expect(header?.nextElementSibling).toBe(banners[0]); + }); + + it("names every failed server once and keeps the latest reason", () => { + const { failures, rest } = partitionRunHealthItems(itemsFrom([ + mcpFailure(1, "github", "request timed out"), + mcpFailure(2, "linear", "needs re-authentication"), + mcpFailure(3, "github", "program not found: github-mcp-server"), + ])); + expect(rest).toEqual([]); + expect(failures.map(({ serverName, reason }) => ({ serverName, reason }))).toEqual([ + { serverName: "github", reason: "program not found: github-mcp-server" }, + { serverName: "linear", reason: "needs re-authentication" }, + ]); + }); + + it("shows no banner and keeps the item array when every server started", () => { + const items = itemsFrom([event(1, "item.completed", { kind: "agentMessage", channel: "final", text: "Done." })]); + expect(partitionRunHealthItems(items).rest).toBe(items); + render([event(1, "item.completed", { kind: "agentMessage", channel: "final", text: "Done." })]); + expect(container.querySelector('[data-testid="task-chat-run-health-banner"]')).toBeNull(); + }); + + const settledTurn = (id: string, failures: TaskChatTurnItem["mcpStartupFailures"]): TaskChatTurnItem => ({ + id, kind: "turn", settled: true, historical: true, standaloneHeader: true, agentName: "Runner", + items: [], mcpStartupFailures: failures, summary: { toolCount: 0, added: 0, removed: 0 }, + }); + + it("keeps the banner on a settled historical turn, above its activity", () => { + act(() => root.render( + + null} /> + , + )); + expect(container.querySelector('[data-testid="task-chat-run-health-banner"]')?.textContent) + .toBe("github MCP server failed to start: No such file or directory (os error 2)"); + }); + + it("merges failures when one agent's settled turns coalesce", () => { + const meta = { agentKey: "agent-1", agentName: "Runner", parts: [] }; + const [merged] = coalesceSettledTurns([ + settledTurn("a", [{ id: "1", serverName: "github", reason: "timed out" }]), + settledTurn("b", [{ id: "2", serverName: "github", reason: "program not found" }, { id: "3", serverName: "linear", reason: null }]), + ], new Map([["a", meta], ["b", meta]])); + expect(merged.kind === "turn" && merged.mcpStartupFailures?.map(({ serverName, reason }) => [serverName, reason])) + .toEqual([["github", "program not found"], ["linear", null]]); + }); +}); diff --git a/ui/src/components/task-chat/TaskChatRunHealthBanner.tsx b/ui/src/components/task-chat/TaskChatRunHealthBanner.tsx new file mode 100644 index 00000000000..52ffe6b5658 --- /dev/null +++ b/ui/src/components/task-chat/TaskChatRunHealthBanner.tsx @@ -0,0 +1,110 @@ +import { useId, useState } from "react"; +import { ChevronRight, TriangleAlert } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { TaskChatItem } from "./task-chat-model"; + +/** One MCP server that the harness could not start for this run. */ +export interface TaskChatMcpStartupFailure { + id: string; + serverName: string; + /** Provider-reported reason, already bounded by the runner. */ + reason: string | null; +} + +export const MCP_STARTUP_FAILED_CATEGORY = "mcp_server_startup_failed"; + +function detailValue(item: Extract, label: string) { + if (item.surface !== "provider_activity") return undefined; + return item.details.find((detail) => detail.label === label)?.value; +} + +function isMcpStartupFailure(item: TaskChatItem): item is Extract { + return item.kind === "protocol" + && item.surface === "provider_activity" + && item.family === "provider_notice" + && detailValue(item, "Category") === MCP_STARTUP_FAILED_CATEGORY; +} + +/** + * Lifts MCP startup failures out of a run's items so they render once as the + * run-health banner instead of as inline notice rows. Later reports for the + * same server replace earlier ones. + */ +export function partitionRunHealthItems(items: readonly TaskChatItem[]): { + failures: TaskChatMcpStartupFailure[]; + rest: TaskChatItem[]; +} { + const byServer = new Map(); + const rest: TaskChatItem[] = []; + for (const item of items) { + if (!isMcpStartupFailure(item)) { + rest.push(item); + continue; + } + const serverName = detailValue(item, "server") ?? "An"; + byServer.set(serverName, { id: item.id, serverName, reason: detailValue(item, "reason") ?? null }); + } + // Keep the caller's array identity when nothing was lifted out. + return { failures: [...byServer.values()], rest: byServer.size === 0 ? (items as TaskChatItem[]) : rest }; +} + +function failureLine(failure: TaskChatMcpStartupFailure) { + return ( + <> + {failure.serverName} + MCP server failed to start + {failure.reason ? : {failure.reason} : null} + + ); +} + +/** + * Run-level health warning shown directly under the runner header. A failed + * MCP server explains a whole class of runs where the agent says it cannot + * reach its tools, so it stays visible above the transcript instead of + * scrolling away as one more activity row. + */ +export function TaskChatRunHealthBanner({ failures }: { failures: readonly TaskChatMcpStartupFailure[] }) { + const [open, setOpen] = useState(false); + const listId = `task-chat-run-health-${useId().replaceAll(":", "")}`; + if (failures.length === 0) return null; + const single = failures.length === 1 ? failures[0] : null; + return ( +
+ + {open ? ( +
+ {single ? null : failures.map((failure) => ( +

{failureLine(failure)}

+ ))} +

The agent ran without these tools. Fix the server's command or credentials, then try again.

+
+ ) : null} +
+ ); +} diff --git a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx index e24cb09c4b7..5b098c4cb4b 100644 --- a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx +++ b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx @@ -12,6 +12,7 @@ import { TaskChatAgentIdentity, TaskChatBubble } from "./TaskChatBubble"; import { TaskChatBubbleActions } from "./TaskChatBubbleActions"; import { formatTaskChatTimestamp } from "./task-chat-adapter"; import { TaskChatRunnerActivityGroup } from "./TaskChatRunnerActivityGroup"; +import { TaskChatRunHealthBanner, partitionRunHealthItems } from "./TaskChatRunHealthBanner"; import { TaskChatProtocolCard } from "./TaskChatProtocolCard"; import { TaskChatPlanPreviewCard } from "./TaskChatPlanPreviewCard"; import { TaskChatShimmerScope, useTaskChatShimmer } from "./TaskChatShimmer"; @@ -186,9 +187,10 @@ export function TaskChatRunnerTurn({ decision: TaskChatRuntimeRequestDecision, ) => void | Promise; }) { + const { failures: mcpStartupFailures, rest: turnItems } = partitionRunHealthItems(items); const terminal = isTerminalRunStatus(status); const waitingLabel = runnerWaitingLabel({ runId, status, execution, preparationLabel, startedAtMs }); - const yielded = items.some( + const yielded = turnItems.some( (item) => item.kind === "protocol" && item.surface === "run_result" && @@ -196,12 +198,12 @@ export function TaskChatRunnerTurn({ ); const observedFinal = suppressFinal ? undefined - : paperclipRunnerFinalResponse(items, { + : paperclipRunnerFinalResponse(turnItems, { allowFallback: terminal && allowSummaryFallback, }); const observedProviderText = Boolean( observedFinal && - items.some( + turnItems.some( (item) => item.kind === "message" && item.id === observedFinal.id && @@ -233,7 +235,7 @@ export function TaskChatRunnerTurn({ finalRef.current.providerText = observedProviderText; } const final = finalRef.current.item; - const timelineItems = paperclipRunnerTimelineItems(items); + const timelineItems = paperclipRunnerTimelineItems(turnItems); const currentActivityItems = currentActivityStatusItems(timelineItems); const timelineRows = buildTurnTimelineRows( omitProgressRepeatedByResponse(timelineItems, final?.text), @@ -249,6 +251,7 @@ export function TaskChatRunnerTurn({ {showHeader ? : null} + {activityUnavailable ? (
: null}
: null} + {item.items.length > 0 ? (
[failure.serverName, failure])); + for (const failure of second) byServer.set(failure.serverName, failure); + return [...byServer.values()]; +} + export function coalesceSettledTurns( items: readonly TaskChatItem[], metaById: ReadonlyMap, @@ -2062,9 +2074,11 @@ export function coalesceSettledTurns( ) { out.splice(heldIdx, 1); const parts = [...heldMeta.parts, ...meta.parts]; + const mcpStartupFailures = mergeMcpStartupFailures(held.mcpStartupFailures, item.mcpStartupFailures); out.push({ ...held, items: [...held.items, ...item.items], + ...(mcpStartupFailures ? { mcpStartupFailures } : {}), finalResponse: item.finalResponse ?? held.finalResponse, animateFold: held.animateFold || item.animateFold || undefined, summary: buildMergedTurnSummary(parts), diff --git a/ui/storybook/stories/tasks/task-chat-run-health.stories.tsx b/ui/storybook/stories/tasks/task-chat-run-health.stories.tsx new file mode 100644 index 00000000000..8da0e9443a3 --- /dev/null +++ b/ui/storybook/stories/tasks/task-chat-run-health.stories.tsx @@ -0,0 +1,211 @@ +import type { ReactNode } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; +import { TaskChatBubble } from "@/components/task-chat/TaskChatBubble"; +import { TaskChatRunnerTurn } from "@/components/task-chat/TaskChatRunnerTurn"; +import type { TaskChatMcpStartupFailure } from "@/components/task-chat/TaskChatRunHealthBanner"; +import type { TaskChatItem, TaskChatMessageItem } from "@/components/task-chat/task-chat-model"; + +const STARTED = Date.now() - 48_000; + +const request: TaskChatMessageItem = { + id: "request", + kind: "message", + author: "human", + authorName: "Alex", + isCurrentUser: true, + text: "can you check why the webhook retries are sending duplicate deliveries to acme? started after yesterday's deploy", + timestamp: "9:41 AM", +}; + +const PAPERCLIP_FAILED: TaskChatMcpStartupFailure = { + id: "mcp:paperclip", + serverName: "paperclip", + reason: "handshaking with MCP server failed: connection closed: initialize response", +}; +const GITHUB_FAILED: TaskChatMcpStartupFailure = { + id: "mcp:github", + serverName: "github", + reason: "program not found: github-mcp-server", +}; +const LINEAR_FAILED: TaskChatMcpStartupFailure = { + id: "mcp:linear", + serverName: "linear", + reason: "request timed out after 10s", +}; + +const runningItems: TaskChatItem[] = [ + { id: "think", kind: "thinking", lines: ["Looking for the delivery worker and its retry path."], streaming: false, channel: "summary", transcriptIndex: 1 }, + { id: "rg", kind: "tool", name: "Search", rawName: "rg", target: "rg -n \"retryDelivery\" server/src", status: "completed" }, + { id: "read", kind: "tool", name: "Read", rawName: "read_file", target: "server/src/services/webhooks/delivery-worker.ts", status: "completed" }, + { id: "test", kind: "tool", name: "Bash", rawName: "bash", target: "pnpm vitest run server/src/services/webhooks", status: "in_progress" }, +]; + +const answer: TaskChatMessageItem = { + id: "answer", + kind: "message", + author: "agent", + channel: "final", + text: "I couldn't reach the Paperclip tools in this run, so I can't post the findings to the task or update its status. From the code: the retry path re-enqueues a delivery before the first attempt records its event id, so a slow first attempt and its retry both send. I've drafted an idempotency check on the event id; the fix is in `delivery-worker.ts`.", +}; + +const completedItems: TaskChatItem[] = [ + ...runningItems.map((item) => (item.kind === "tool" ? { ...item, status: "completed" as const } : item)), + answer, +]; + +/** The task-chat item the transcript adapter builds from the runner's provider.notice. */ +function noticeItem(failure: TaskChatMcpStartupFailure): TaskChatItem { + const summary = `${failure.serverName} MCP server failed to start${failure.reason ? `: ${failure.reason}` : ""}`; + return { + id: `run-health:provider:${failure.id}`, + kind: "protocol", + surface: "provider_activity", + family: "provider_notice", + eventType: "provider.notice.recorded", + status: "informational", + title: "Provider notice", + summary, + details: [ + { label: "Severity", value: "warning" }, + { label: "Category", value: "mcp_server_startup_failed" }, + { label: "Scope", value: "session" }, + { label: "Summary", value: summary }, + { label: "server", value: failure.serverName, mono: false }, + ...(failure.reason ? [{ label: "reason", value: failure.reason, mono: false }] : []), + ], + steps: [], + links: [], + children: [], + }; +} + +function Run({ + failures, + status = "running", + items = runningItems, +}: { + failures: readonly TaskChatMcpStartupFailure[]; + status?: string; + items?: TaskChatItem[]; +}) { + const terminal = status !== "running"; + return ( + + ); +} + +function Task({ title, provisional = false, children }: { title: string; provisional?: boolean; children: ReactNode }) { + return ( +
+
+
+

{title}

+ {provisional ?

Provisional title from the first message

: null} +
+ + {children} +
+
+ ); +} + +function StateCard({ title, rule, children }: { title: string; rule: string; children: ReactNode }) { + return ( +
+
+

{title}

+

{rule}

+
+
{children}
+
+ ); +} + +function AllStates() { + return ( +
+
+
+

Run health

+

+ A one-line warning under the runner header when the harness could not start an MCP server. It comes from + Codex mcpServer/startupStatus/updated, mapped to a user-facing{" "} + provider.notice. +

+
+ + + + + + + + + + + + +
+
+ ); +} + +const meta = { + id: "tasks-task-chat-run-health", + title: "Tasks and conversations/Task creation and lifecycle/Task chat/Run health", + component: AllStates, + parameters: { + layout: "fullscreen", + throwPlayFunctionExceptions: true, + paperclip: { + capabilities: ["tasks/discussion", "runs-adapters/runs"], + status: "current", + implementation: "production", + boundary: "fixtures", + notes: "Production runner turn fed the same provider-notice items the transcript adapter builds from a Codex mcpServer/startupStatus/updated failure. No runner, provider or API is involved.", + }, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const AllRunHealthStates: Story = {}; + +export const InTaskChat: Story = { + render: () => ( + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const banner = await canvas.findByTestId("task-chat-run-health-banner"); + await expect(banner).toHaveTextContent("paperclip MCP server failed to start"); + const toggle = within(banner).getByRole("button"); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await userEvent.click(toggle); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + await expect(canvas.getByText(/The agent ran without these tools/)).toBeVisible(); + }, +}; + +export const ProvisionalTitleBeforeRename: Story = { + render: () => ( + + + + ), +}; + +export const Mobile: Story = { + render: InTaskChat.render, + globals: { viewport: { value: "mobile1", isRotated: false } }, +};