From cb0b7525e0a6e6b26b3f5370ef817baf4186bc06 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:10:51 -0600 Subject: [PATCH] feat(task-chat): warn at the top of a run when an MCP server failed to start (#19289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Each agent run shows its activity in the task chat. The Paperclip runner turns harness events into that activity. > - Codex reports `mcpServer/startupStatus/updated` when an MCP server fails to start. The runner dropped this event at the notification allowlist and in the Rust normalizer. > - When an MCP server fails, the agent runs without its tools. The agent then says "I could not reach the server", and the user cannot see why. > - This pull request maps a failed MCP start to a user-facing provider notice and shows it as one warning line at the top of the run. > - The benefit is that the cause of a "cannot reach my tools" answer is visible next to that answer. ## Linked Issues or Issue Description Refs #19252 (the plan card, which set the pattern for promoting a dropped harness signal to a dedicated task-chat surface). **Problem description** Codex sends `mcpServer/startupStatus/updated` (`name`, `status`, `error`, `failureReason`) during `thread/start`. The TypeScript allowlist (`codex-thread-normalization.ts`) and the Rust normalizer (`runner-core/src/provider_events.rs`) both dropped it. A run with a broken MCP server showed no sign of the failure. The agent only said that it could not reach the tools. **Proposed solution** Map `status: "failed"` to `provider.notice.recorded` with `category: "mcp_server_startup_failed"`, `severity: "warning"`, `scope: "session"` and `userActionable: true`. Show these notices as one run-health line under the runner header: "github MCP server failed to start: No such file or directory (os error 2)". **Alternatives considered** An inline notice row in the timeline. It scrolls away and sits among tool rows. A run-level line next to the header is easier to connect to the agent's answer. ## What Changed - Runner (Rust, production Codex path): `normalize_codex_notification` maps a failed `mcpServer/startupStatus/updated` to a `provider.notice.recorded` notice. `starting`, `ready` and `cancelled` stay out of the transcript. The new helper removes the Codex wrapper text ("MCP client for \`x\` failed to start: MCP startup failed:") so that the line shows the real reason. `reauthenticationRequired` becomes "needs re-authentication". - Runner (TypeScript): the same mapping in `canonicalProviderEventsFromCodex`, and the method is added to `isSupportedCodexNotificationMethod`. - Server: the `view=conversation` run-event query keeps `mcp_server_startup_failed` notices. A settled run can then still show the warning. Other notices stay out of that view. - UI: new `TaskChatRunHealthBanner`. `partitionRunHealthItems` takes MCP failures out of the run's items, with one line per server where the latest report wins. `TaskChatRunnerTurn` (live runs) and `TaskChatTurn` (settled, historical and merged turns) show it under the header. `coalesceSettledTurns` merges the failures when it merges turns. - Storybook: `Tasks and conversations/…/Task chat/Run health` shows one failure, several failures, a finished run and a healthy run, all through the production runner turn. ## Verification - `pnpm --filter @paperclipai/paperclip-runner exec vitest run src/provider-events.test.ts src/drivers/codex src/protocol`: 508 tests pass. - `cargo test --release -p paperclip-runner-core --lib -- provider_events`: 20 tests pass. - `pnpm --filter @paperclipai/ui exec vitest run src/components/task-chat src/components/transcript src/components/TaskChatThread.test.tsx`: 1340 tests pass, including the new `TaskChatRunHealthBanner.test.tsx` (persisted event to rendered banner, deduplication, settled historical turn, merged turns). - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/task-cold-open.test.ts`: the conversation view keeps the MCP notice and still excludes other notices. - `pnpm check:token-gates` is clean. `node scripts/typecheck-low-memory.mjs` passes. - Live test on a local instance with Codex 0.160: a Codex agent with a preconfigured Codex home whose `config.toml` has `[mcp_servers.github] command = "github-mcp-server-not-installed"`. Codex reported the failure during `thread/start`. The persisted event had the summary "github MCP server failed to start: No such file or directory (os error 2)". The banner showed while the run was live. It also showed on the settled turn after "More run output", above the agent's answer that it could not reach the GitHub MCP server. ## Risks - Low risk. The change adds one notice category. It does not change how existing notices map or render. - A settled run whose transcript is deferred on a cold open (a successful run with a posted agent comment) shows the warning only after "More run output". This is the same as all other run activity under the current compact history. - Claude does not report MCP startup failures over ACP. `claude-agent-acp` reads MCP status only for OAuth. This change is therefore Codex-only. - Provisional task titles from provider session names are not part of this change. Codex app-server does not generate thread names (only its TUI does). Claude generates its title after the turn ends, and ACPX drops the title text. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), through Claude Code with tool use (shell, file edits, browser automation for Storybook and the live test). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../crates/runner-core/src/provider_events.rs | 119 ++++++++++ .../codex/codex-thread-normalization.ts | 1 + .../src/provider-events.test.ts | 42 ++++ .../paperclip-runner/src/provider-events.ts | 37 +++ server/src/__tests__/task-cold-open.test.ts | 8 +- .../services/heartbeat/conversation-events.ts | 5 + ui/src/components/TaskChatThread.test.tsx | 31 +++ ui/src/components/TaskChatThread.tsx | 19 +- .../TaskChatRunHealthBanner.test.tsx | 156 +++++++++++++ .../task-chat/TaskChatRunHealthBanner.tsx | 110 +++++++++ .../task-chat/TaskChatRunnerTurn.tsx | 11 +- ui/src/components/task-chat/TaskChatTurn.tsx | 3 + .../components/task-chat/task-chat-model.ts | 3 + .../task-chat/transcript-adapter.ts | 14 ++ .../tasks/task-chat-run-health.stories.tsx | 211 ++++++++++++++++++ 15 files changed, 759 insertions(+), 11 deletions(-) create mode 100644 ui/src/components/task-chat/TaskChatRunHealthBanner.test.tsx create mode 100644 ui/src/components/task-chat/TaskChatRunHealthBanner.tsx create mode 100644 ui/storybook/stories/tasks/task-chat-run-health.stories.tsx 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 } }, +};