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:
parent
6abeb67334
commit
5cb4f061dd
|
|
@ -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, ¶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 [
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" &&
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue