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 ae616ddbdc..15f1c298ea 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 @@ -692,7 +692,13 @@ pub fn normalize_codex_notification(method: &str, params: &Value) -> Vec push( @@ -714,11 +720,14 @@ pub fn normalize_codex_notification(method: &str, params: &Value) -> Vec "mcp", "dynamicToolCall" => "dynamic", _ => "process" }, "operation": if item_type == "commandExecution" { "execute" } else { "unknown" }, "name": provider_item.get("tool").or_else(|| provider_item.get("command")).and_then(Value::as_str).map(|value| bounded_text(value, 240)), "target": Value::Null, @@ -1242,6 +1251,66 @@ fn has_rfc_uri_scheme_prefix(value: &str) -> bool { mod tests { use super::*; + #[test] + fn preserves_codex_notice_text_from_current_and_legacy_payloads() { + for method in ["configWarning", "deprecationNotice", "warning"] { + for (params, expected) in [ + ( + json!({"summary": "Repository is not trusted", "message": "old message"}), + "Repository is not trusted", + ), + ( + json!({"summary": "", "message": "Legacy warning"}), + "Legacy warning", + ), + ( + json!({"details": "Additional warning details"}), + "Additional warning details", + ), + (json!({}), "Provider notice"), + ] { + let events = normalize_codex_notification(method, ¶ms); + assert_eq!(events[0].event_type, "provider.notice.recorded"); + assert_eq!(events[0].payload["summary"], expected); + } + } + let events = normalize_codex_notification( + "configWarning", + &json!({ + "summary": "x".repeat(MAX_TEXT_CHARS + 100), "accessToken": "not-for-the-log" + }), + ); + assert!( + events[0].payload["summary"] + .as_str() + .unwrap() + .chars() + .count() + <= MAX_TEXT_CHARS + ); + assert!(!events[0].payload.to_string().contains("not-for-the-log")); + } + + #[test] + fn preserves_dynamic_tool_identity_without_arguments() { + for method in ["item/started", "item/completed"] { + let events = normalize_codex_notification( + method, + &json!({"item": { + "id": "finish-1", "type": "dynamicToolCall", "tool": "paperclip_finish", + "status": if method == "item/started" { "inProgress" } else { "completed" }, + "arguments": {"secret": "not-for-the-log"} + }}), + ); + assert_eq!(events.len(), 1); + assert_eq!(events[0].payload["name"], "paperclip_finish"); + assert_eq!(events[0].payload["transport"], "dynamic"); + assert_eq!(events[0].payload["executionId"], "finish-1"); + assert!(events[0].event_type.starts_with("tool.execution.")); + assert!(!events[0].payload.to_string().contains("not-for-the-log")); + } + } + #[test] fn enforces_the_declared_safe_path_contract() { for location in [ diff --git a/packages/paperclip-runner/src/provider-events.test.ts b/packages/paperclip-runner/src/provider-events.test.ts index fdf797c654..570b69df85 100644 --- a/packages/paperclip-runner/src/provider-events.test.ts +++ b/packages/paperclip-runner/src/provider-events.test.ts @@ -33,6 +33,22 @@ function envelope( } describe("provider-neutral events", () => { + it("preserves Codex notice summaries with legacy and empty-message fallbacks", () => { + for (const method of ["configWarning", "deprecationNotice", "warning"]) { + for (const [params, expected] of [ + [{ summary: "Repository is not trusted", message: "old message" }, "Repository is not trusted"], + [{ summary: " ", message: "Legacy warning" }, "Legacy warning"], + [{ details: "Additional warning details" }, "Additional warning details"], + [{}, "Provider notice"], + ] as const) { + const [event] = canonicalProviderEventsFromCodex(method, params); + expect(event.eventType).toBe("provider.notice.recorded"); + expect(event.payload.summary).toBe(expected); + expect(validatePrpEvent(envelope(event)).ok).toBe(true); + } + } + }); + 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 05e6c3cbe0..dda2b04962 100644 --- a/packages/paperclip-runner/src/provider-events.ts +++ b/packages/paperclip-runner/src/provider-events.ts @@ -913,7 +913,9 @@ export function canonicalProviderEventsFromCodex( : "turn", recoverable: method !== "error", userActionable: method === "error" || method === "warning", - summary: text(params.message, "Provider notice").slice(0, 4000), + summary: [params.summary, params.message, params.details] + .map((value) => text(value).trim()) + .find(Boolean)?.slice(0, 4000) || "Provider notice", }, itemId, }, diff --git a/ui/src/components/task-chat/TaskChatProtocolActivityRow.test.tsx b/ui/src/components/task-chat/TaskChatProtocolActivityRow.test.tsx index 19ec43ff4b..fa9ad62506 100644 --- a/ui/src/components/task-chat/TaskChatProtocolActivityRow.test.tsx +++ b/ui/src/components/task-chat/TaskChatProtocolActivityRow.test.tsx @@ -164,6 +164,24 @@ describe("TaskChatProtocolActivityRow", () => { expect(row?.querySelector('[data-testid="task-chat-protocol-activity-icon"]')?.querySelectorAll("path")).toHaveLength(3); }); + it("shows a notice as a warning and full-width message without metadata or a disclosure", () => { + const summary = "Project-local configuration is disabled.\nTrust the repository to load its hooks."; + render({ + id: "notice", kind: "protocol", surface: "provider_activity", family: "provider_notice", + eventType: "provider.notice.recorded", status: "informational", title: "Provider notice", summary, + details: [ + { label: "Category", value: "configWarning" }, + { label: "Recoverable", value: "Yes" }, + { label: "Summary", value: summary }, + ], steps: [], links: [], children: [], + }); + expect(container.textContent).toBe(`Warning${summary}`); + expect(container.querySelector("p")?.textContent).toBe(summary); + expect(container.querySelector("dl")).toBeNull(); + expect(container.querySelector("button")).toBeNull(); + expect(container.querySelector('[data-testid="task-chat-protocol-activity-icon"]')).not.toBeNull(); + }); + it("does not make an informational row focusable when it has no details", () => { render({ id: "notice", diff --git a/ui/src/components/task-chat/TaskChatProtocolActivityRow.tsx b/ui/src/components/task-chat/TaskChatProtocolActivityRow.tsx index a6eba2d26b..8f0b41b048 100644 --- a/ui/src/components/task-chat/TaskChatProtocolActivityRow.tsx +++ b/ui/src/components/task-chat/TaskChatProtocolActivityRow.tsx @@ -1,5 +1,6 @@ import { useId, useState, type ReactNode } from "react"; import { + AlertTriangle, Check, ChevronRight, Circle, @@ -252,6 +253,20 @@ export function TaskChatProtocolActivityRow({ item }: { item: TaskChatProtocolIt const detailId = `task-chat-protocol-activity-${useId().replaceAll(":", "")}`; const presentation = protocolActivityPresentation(item); if (!presentation) return null; + if (item.surface === "provider_activity" && item.family === "provider_notice") { + const summary = item.summary + ?? item.details.find((entry) => entry.label === "Summary")?.value + ?? "The provider reported a notice without a message."; + return ( +
+
+ + {item.status === "failed" ? "Error" : "Warning"} +
+

{summary}

+
+ ); + } const detail = detailContent(item); const expandable = detail !== null; const Icon = presentation.icon; diff --git a/ui/src/components/task-chat/transcript-adapter.test.ts b/ui/src/components/task-chat/transcript-adapter.test.ts index a3867b9d98..de565984a8 100644 --- a/ui/src/components/task-chat/transcript-adapter.test.ts +++ b/ui/src/components/task-chat/transcript-adapter.test.ts @@ -35,6 +35,45 @@ import { providerActivityPresentation } from "./task-chat-activity-presentation" const TS = "2026-07-31T12:00:00.000Z"; +describe("completion tool feed visibility", () => { + it("keeps the full bounded notice text available when expanded", () => { + const summary = `${"Configuration context. ".repeat(30)}Use the project settings to fix this.`; + const [notice] = transcriptToTaskChatItems([{ + kind: "provider_activity", ts: TS, family: "provider_notice", eventType: "provider.notice.recorded", + status: "informational", title: "Provider notice", summary, + payload: { noticeId: "long-notice", summary }, + }], { runId: "notice", running: false }); + expect(notice.kind === "protocol" && notice.surface === "provider_activity" && + notice.details.find((detail) => detail.label === "Summary")?.value).toBe(summary); + }); + + it("keeps completion events inspectable but hides both tool representations from live and settled feed activity", () => { + for (const running of [true, false]) { + const entries: TranscriptEntry[] = [ + { kind: "tool_call", ts: TS, toolUseId: "legacy-finish", name: "paperclip_finish", input: {} }, + ...["paperclip_finish", "search_tasks"].map((name) => ({ + kind: "provider_activity" as const, ts: TS, family: "tool_execution" as const, + eventType: running ? "tool.execution.started" : "tool.execution.completed", + status: running ? "running" as const : "completed" as const, + title: "Tool execution", summary: name, + payload: { executionId: name, name, transport: "dynamic" }, + })), + { kind: "provider_activity", ts: TS, family: "provider_notice", eventType: "provider.notice.recorded", + status: "informational", title: "Provider notice", summary: "Repository is not trusted", + payload: { noticeId: "notice", summary: "Repository is not trusted" } }, + ]; + const parsed = transcriptToTaskChatItems(entries, { runId: "finish-visibility", running }); + expect(parsed).toHaveLength(4); + const activity = paperclipRunnerActivityItems(parsed); + expect(activity).toHaveLength(2); + expect(JSON.stringify(activity)).not.toContain("paperclip_finish"); + expect(JSON.stringify(activity)).toContain("search_tasks"); + expect(JSON.stringify(activity)).toContain("Repository is not trusted"); + expect(paperclipRunnerTimelineItems(parsed)).toEqual(activity); + } + }); +}); + describe("omitProgressRepeatedByResponseAcrossSegments", () => { const progress = (id: string, text: string): TaskChatItem => ({ id, diff --git a/ui/src/components/task-chat/transcript-adapter.ts b/ui/src/components/task-chat/transcript-adapter.ts index 5ca17a822f..50541e3ffb 100644 --- a/ui/src/components/task-chat/transcript-adapter.ts +++ b/ui/src/components/task-chat/transcript-adapter.ts @@ -376,7 +376,9 @@ function providerActivityItem( label: titleCaseKey(key), value: clip( value, - key === "message" || key === "summary" || key === "reason" ? 320 : 160, + entry.family === "provider_notice" && (key === "summary" || key === "message") + ? 4000 + : key === "message" || key === "summary" || key === "reason" ? 320 : 160, ), mono: /(?:id|model|target|reference|url|code|bytes)$/i.test(key), }); @@ -1170,6 +1172,13 @@ export function paperclipRunnerActivityItems( case "marker": return item.variant === "interrupted"; case "protocol": + // Completion is already represented by task state and the final answer. + // Keep its event in the inspector, but omit it from feed rows and counts. + if ( + item.surface === "provider_activity" && + item.family === "tool_execution" && + providerItemDetail(item, "Name") === "paperclip_finish" + ) return false; if ( hasAggregateWorkspaceChange && item.surface === "provider_activity" && diff --git a/ui/src/components/transcript/native-run-events.test.ts b/ui/src/components/transcript/native-run-events.test.ts index 4e7455ee2c..8ad8318ee8 100644 --- a/ui/src/components/transcript/native-run-events.test.ts +++ b/ui/src/components/transcript/native-run-events.test.ts @@ -72,6 +72,26 @@ function runResult(summary: string): Record { }; } +describe("provider notice presentation", () => { + it("preserves notice text as a notice rather than a synthetic tool call", () => { + const entries = nativeRunEventsToTranscript([ + event(1, "provider.notice.recorded", { + schema: "paperclip.provider.notice.v1", noticeId: "warning-1", + severity: "warning", category: "configWarning", summary: "Repository is not trusted", + }), + event(2, "provider.notice.recorded", { + schema: "paperclip.provider.notice.v1", noticeId: "error-1", + severity: "error", message: "Provider connection failed", + }), + ]); + expect(entries).toMatchObject([ + { kind: "provider_activity", family: "provider_notice", status: "informational", summary: "Repository is not trusted" }, + { kind: "provider_activity", family: "provider_notice", status: "failed", summary: "Provider connection failed" }, + ]); + expect(entries.some((entry) => entry.kind === "tool_call")).toBe(false); + }); +}); + describe("nativeRunEventsToTranscript", () => { it("projects the cross-language duplicate-delivery fixture exactly once", () => { const fixture = JSON.parse(readFileSync( diff --git a/ui/src/components/transcript/native-run-events.ts b/ui/src/components/transcript/native-run-events.ts index 2796d5bcfe..182eda7103 100644 --- a/ui/src/components/transcript/native-run-events.ts +++ b/ui/src/components/transcript/native-run-events.ts @@ -784,6 +784,22 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[] continue; } + // Notices are provider diagnostics, not tool calls. Preserve their message + // and category for the shared notice row instead of serializing an input blob. + if (event.eventType === "provider.notice.recorded" && payload.schema === "paperclip.provider.notice.v1") { + entries.push({ + kind: "provider_activity", + ts, + family: "provider_notice", + eventType: event.eventType, + status: payload.severity === "error" ? "failed" : "informational", + title: "Provider notice", + summary: text(payload.summary)?.trim() || text(payload.message)?.trim() || "Provider notice", + payload, + }); + continue; + } + const providerActivity = providerActivityPresentation(event, payload); if (providerActivity) { if (!startedToolIds.has(providerActivity.id)) {