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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-09 15:06:20 -05:00 committed by GitHub
parent 6abeb67334
commit 5cb4f061dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 209 additions and 5 deletions

View File

@ -692,7 +692,13 @@ pub fn normalize_codex_notification(method: &str, params: &Value) -> Vec<Normali
"scope": if method.contains("config") { "environment" } else { "turn" },
"recoverable": method != "error",
"userActionable": true,
"summary": bounded_text(string(params.get("message")), MAX_TEXT_CHARS),
"summary": bounded_text(
["summary", "message", "details"].iter()
.map(|key| string(params.get(*key)))
.find(|value| !value.trim().is_empty())
.unwrap_or("Provider notice"),
MAX_TEXT_CHARS,
),
}),
),
"item/agentMessage/delta" => push(
@ -714,11 +720,14 @@ pub fn normalize_codex_notification(method: &str, params: &Value) -> Vec<Normali
let item_type = string(provider_item.get("type"));
let provider_phase = string(provider_item.get("phase"));
let completed = method == "item/completed";
if matches!(item_type, "commandExecution" | "mcpToolCall") {
if matches!(
item_type,
"commandExecution" | "mcpToolCall" | "dynamicToolCall"
) {
let mut payload = json!({
"schema": "paperclip.tool.execution.v1",
"executionId": item_id,
"transport": if item_type == "mcpToolCall" { "mcp" } else { "process" },
"transport": match item_type { "mcpToolCall" => "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, &params);
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 [

View File

@ -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",

View File

@ -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,
},

View File

@ -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",

View File

@ -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 (
<div className="flex min-w-0 flex-col gap-1.5 py-1 text-xs" data-testid="task-chat-protocol-activity-row" data-activity-family="provider_notice">
<div className="flex items-center gap-2 text-muted-foreground">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" aria-hidden data-testid="task-chat-protocol-activity-icon" />
<span className="font-medium">{item.status === "failed" ? "Error" : "Warning"}</span>
</div>
<p className="min-w-0 whitespace-pre-wrap break-words text-foreground">{summary}</p>
</div>
);
}
const detail = detailContent(item);
const expandable = detail !== null;
const Icon = presentation.icon;

View File

@ -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,

View File

@ -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" &&

View File

@ -72,6 +72,26 @@ function runResult(summary: string): Record<string, unknown> {
};
}
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(

View File

@ -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)) {