From 5cb4f061dd185955255099ae95348b3d4a16d7c0 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:06:20 -0500 Subject: [PATCH] fix(ui): simplify provider notices and hide completion calls (#13109) ## Thinking Path > - Paperclip shows agent work in task feeds. > - Native tools and provider notices appear in that feed. > - A routine completion call adds no useful action for the user. > - A provider notice needs readable text to explain its warning. > - This change hides the completion call and displays the notice summary. > - Saved run events remain available for inspection. ## Linked Issues or Issue Description **What happened?** The feed showed paperclip_finish as a normal tool call. Provider notices showed a generic name or a large key-value table instead of a clear warning message. **Expected behavior** Hide the routine completion call from the task feed. Show a warning icon, a short severity heading, and the full notice summary across the row. **Steps to reproduce** 1. Open a native Codex task that calls paperclip_finish. 2. Inspect its tool activity in the task feed. 3. Inspect a run with a repository-trust provider notice. **Paperclip version or commit** Reproduced on the implementation checkout. Replayed onto master at 6abeb6733. The search found no duplicate PR for this display change. **Deployment mode** Local source checkout with native task feeds. ## What Changed - Hide paperclip_finish calls and results in task-feed adapters. - Preserve the raw events for run-log inspection. - Carry provider-notice text into the transcript. - Render notices with an icon, severity heading, and full-width summary. - Cover the display and transcript mapping with regression tests. ## Verification - The affected UI tests and the token gates passed on the implementation checkout. - Browser inspection confirmed the warning text is readable and completion calls are hidden. - Repository typecheck and build passed. Repository test groups passed after resource retests. - On this PR branch, 123 focused UI tests and the token gates passed. All GitHub checks passed; Greptile is 5/5 with no unresolved threads. ## Risks - The task feed hides one known internal tool. Raw run events remain unchanged. - Long warning text must wrap within the available width. - No provider warning classification, accounting, or recovery behavior changes here. ## Model Used OpenAI Codex, GPT-6 (`gpt-6-astra`). Used for reasoning, code edits, tool use, and test execution. The exact context-window limit is not exposed in this session. Real-provider acceptance used Codex CLI 0.153.4 with `gpt-5.6-sol`. ## 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 #` 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 - [x] 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 Co-authored-by: Paperclip --- .../crates/runner-core/src/provider_events.rs | 75 ++++++++++++++++++- .../src/provider-events.test.ts | 16 ++++ .../paperclip-runner/src/provider-events.ts | 4 +- .../TaskChatProtocolActivityRow.test.tsx | 18 +++++ .../task-chat/TaskChatProtocolActivityRow.tsx | 15 ++++ .../task-chat/transcript-adapter.test.ts | 39 ++++++++++ .../task-chat/transcript-adapter.ts | 11 ++- .../transcript/native-run-events.test.ts | 20 +++++ .../transcript/native-run-events.ts | 16 ++++ 9 files changed, 209 insertions(+), 5 deletions(-) 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)) {