feat(task-chat): show web searches, model switches, waits and verification checks as dedicated rows (#19262)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents run through the native runner. The runner turns harness
activity into typed canonical events such as `research.*`,
`model.route.changed`, `wait.*` and the run's own verification checks.
> - The task chat flattened these events into generic tool rows. A web
search showed as "Ran Research eventType: research.started, summary: …".
The run's verification checks were dropped completely.
> - Live tests found a second break: the Rust runner core forwarded
Codex `webSearch` items without their query, action or results. The
canonical `research.*` events therefore had `query: null`.
> - This pull request keeps the full payload for these families (as
#19252 did for plans), adds small dedicated rows, and keeps the
web-search fields through the durable runner.
> - The benefit is that users see what the agent searched for, which
model actually ran, how long a wait lasts, and whether each check
passed, without opening the run log.

## Linked Issues or Issue Description

Refs #19252 (same pattern for the plan card)

**Subsystem affected**
Task chat activity rows (UI), the native run-event transcript mapping,
and the Rust runner core Codex normalizer.

**Problem or motivation**
Provider activity that the runner already maps to canonical events
reaches the task chat as unreadable generic rows. Verification checks
never appear. For Codex, the web-search query and action are lost before
they reach the canonical event.

**Proposed solution**
Keep `research`, `model_identity`, `artifact`, `review` and `wait`
events as `provider_activity` entries with their full payload. Project
the runner's `verification` items as a new `verification` family. Render
dedicated rows: a web search with its quoted query and source-domain
chips, a model switch as `from → to · reason`, a wait with a live
countdown, and green/red check lines. Keep bounded web-search and sleep
fields in the Rust normalizer.

**Alternatives considered**
A separate "Checks" block beside the final answer. We kept the checks in
the activity group, because the group summary already leads with "Check
failed" when a check fails.

## What Changed

- `ui/src/components/transcript/native-run-events.ts`: keep research,
model, artifact, review and wait events as `provider_activity` with the
full payload. Map `item.completed` items of kind `verification` to the
new `verification` family. Context, hook, safety and delegation stay on
the generic path, because other work items own them.
- `packages/adapter-utils/src/types.ts`,
`ui/src/components/task-chat/task-chat-model.ts`,
`ui/src/lib/issue-chat-messages.ts`: add the `verification`
provider-activity family.
- `ui/src/components/task-chat/task-chat-activity-presentation.ts`: add
labels: "Searched the web “query”", "Opened a web page host/path",
"Switched models a → b · reason", "Waiting 30s" / "Waited 30s", and
"Check passed" / "Check failed" / "Check not run".
- `ui/src/components/task-chat/TaskChatRunnerActivityGroup.tsx` and
`TaskChatProtocolActivityRow.tsx`: show up to three source-domain chips
under a finished search, a live countdown for a running wait, and a
check line with a tinted icon and its detail.
- `ui/src/components/task-chat/completed-activity-summary.ts`: the group
summary leads with "Check failed" when a check fails, and says "Switched
models" for a reroute.
- `ui/src/components/task-chat/transcript-adapter.ts`: verification
detail keys, and a `startedAt` timestamp that stays across lifecycle
merges (used by the countdown).
-
`packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs`:
keep bounded `query`, `action` and http(s) `results` for Codex
`webSearch` items, and `durationMs` for `sleep` items. Other item types
do not change.
- `ui/storybook/stories/tasks/task-chat-provider-activity.stories.tsx`:
new story. Its fixtures are persisted run events that go through the
production transcript path.

## Verification

- `pnpm --filter @paperclipai/ui exec vitest run
src/components/transcript src/components/task-chat
src/components/TaskChatThread.test.tsx src/adapters/paperclip-runner
src/lib`: 275 files and 3669 tests pass.
- `cargo test -p paperclip-runner-core`: all tests pass, including the
new `keeps_bounded_web_search_and_sleep_identity`.
- UI typecheck is clean. `pnpm check:token-gates` is clean. `cargo fmt
--check` is clean.
- Storybook: open "Tasks and conversations / … / Task chat / Provider
activity". It shows a settled run, a live search, a live wait countdown
and a narrow layout.
- Live test on a local worktree instance with a real Codex agent
(gpt-5.6-sol, Codex app-server 0.160). The task asked for a web search,
a parser with tests, a pause, and two checks. The task chat showed
"Searched the web “ISO 8601 week date first week Thursday conversion
calendar date”" and two green check rows ("node --test week-date.test.js
· 8 tests passed; 0 failed.", "node --check week-date.js"). The group
summary read "Ran checks". Before the runner fix, the same search had
`query: null`.

Not verified live:
- The source-domain chips. Codex 0.160 sends `results: null`, because it
keeps search results on the server. The chips appear only when a
provider reports sources. Unit tests and the story cover them.
- The wait countdown and the model-switch row. In the live run Codex
paused with a shell `sleep`, not a native `sleep` item, and no reroute
occurred. Unit tests and the story cover both.
- Claude. Its WebSearch and WebFetch calls arrive as generic tool
executions, because ACPX drops the tool metadata. They show as tool
rows, as before. The ACPX `_meta` work is separate.

## Risks

- Low risk. The change only affects how canonical events display. The
generic path stays for the families that this PR does not change.
- The Rust normalizer now persists the web-search query and the source
URLs/titles in the run events. This data was already intended for the
canonical `research.v1` event, and it is bounded (4,000 characters per
text field, 64 results, http(s) URLs only).
- A search that Codex reports with `action: other` and no query shows as
"Searched the web" with no subject.

> 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`) in Claude Code, with tool use
(shell, file edit, browser automation) and extended reasoning.

## 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
- [ ] 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 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-05 16:37:05 +00:00
1 parent 053ae3920f
commit b0fa2ef9bf
16 files changed
+909 -32

No files matched your search

+1
View File
@@ -619,6 +619,7 @@ export type ProviderActivityFamily =
| "safety"
| "terminal"
| "wait"
| "verification"
| "provider_notice";
export type ProviderActivityStatus =
@@ -695,6 +695,64 @@ fn provider_performance_spans(params: &Value) -> Vec<Value> {
}).collect()
}
fn optional_bounded_text(value: Option<&Value>, max_chars: usize) -> Value {
match value.and_then(Value::as_str) {
Some(text) if !text.trim().is_empty() => Value::String(bounded_text(text, max_chars)),
_ => Value::Null,
}
}
/// Bounded display fields for Codex activity items that become canonical
/// research and wait events. Only typed, user-visible fields are kept; result
/// entries keep an http(s) URL, title and snippet.
fn codex_activity_item_fields(item_type: &str, provider_item: &Value) -> Option<Value> {
match item_type {
"webSearch" => {
let action = provider_item
.get("action")
.filter(|value| value.is_object())
.map(|action| {
json!({
"type": bounded_text(string(action.get("type")), 64),
"query": optional_bounded_text(action.get("query"), MAX_TEXT_CHARS),
"url": optional_bounded_text(action.get("url"), 8_192),
"pattern": optional_bounded_text(action.get("pattern"), MAX_TEXT_CHARS),
})
});
let results = provider_item
.get("results")
.and_then(Value::as_array)
.map(|results| {
results
.iter()
.take(64)
.filter_map(|result| {
let url = string(result.get("url"));
if !(url.starts_with("http://") || url.starts_with("https://")) || url.len() > 8_192 {
return None;
}
Some(json!({
"url": url,
"title": optional_bounded_text(result.get("title"), MAX_TEXT_CHARS),
"snippet": optional_bounded_text(result.get("snippet"), MAX_TEXT_CHARS),
"ref_id": optional_bounded_text(result.get("ref_id").or_else(|| result.get("refId")), 240),
}))
})
.collect::<Vec<_>>()
});
Some(json!({
"query": optional_bounded_text(provider_item.get("query"), MAX_TEXT_CHARS),
"action": action.unwrap_or(Value::Null),
"results": results.map(Value::Array).unwrap_or(Value::Null),
}))
}
"sleep" => Some(json!({
"durationMs": provider_item.get("durationMs").and_then(Value::as_u64),
})),
_ => None,
}
}
pub fn normalize_codex_notification(method: &str, params: &Value) -> Vec<NormalizedProviderEvent> {
let mut events = Vec::new();
let push = |events: &mut Vec<NormalizedProviderEvent>,
@@ -987,6 +1045,16 @@ pub fn normalize_codex_notification(method: &str, params: &Value) -> Vec<Normali
.insert("item".to_owned(), json!({ "name": name }));
}
}
// Web research and intentional waits are already user-visible
// activity. Keep their bounded identity (query, action, cited
// sources, planned duration) so the driver's canonical
// `research.*` and `wait.*` events are not empty.
if let Some(activity) = codex_activity_item_fields(item_type, provider_item) {
payload
.as_object_mut()
.expect("item payload is an object")
.insert("item".to_owned(), activity);
}
if !provider_phase.is_empty() {
payload
.as_object_mut()
@@ -1826,6 +1894,44 @@ mod tests {
assert!(!events[0].payload.to_string().contains("secret-value"));
}
#[test]
fn keeps_bounded_web_search_and_sleep_identity() {
let search = normalize_codex_notification(
"item/completed",
&json!({
"item": {
"id": "ws-1",
"type": "webSearch",
"query": "iso week date",
"action": {"type": "search", "query": "iso week date", "queries": null, "token": "secret-value"},
"results": [
{"url": "https://example.com/a", "title": "A", "snippet": "first", "raw": "secret-value"},
{"url": "file:///etc/passwd", "title": "local"},
],
},
}),
);
assert_eq!(search[0].event_type, "item.completed");
let item = &search[0].payload["item"];
assert_eq!(item["query"], "iso week date");
assert_eq!(item["action"]["type"], "search");
assert_eq!(item["results"].as_array().map(Vec::len), Some(1));
assert_eq!(item["results"][0]["url"], "https://example.com/a");
assert!(!search[0].payload.to_string().contains("secret-value"));
let sleep = normalize_codex_notification(
"item/started",
&json!({"item": {"id": "sleep-1", "type": "sleep", "durationMs": 15000}}),
);
assert_eq!(sleep[0].payload["item"]["durationMs"], 15000);
let message = normalize_codex_notification(
"item/completed",
&json!({"item": {"id": "m-1", "type": "agentMessage", "text": "hi", "query": "x"}}),
);
assert!(message[0].payload.get("item").is_none());
}
#[test]
fn bounds_and_redacts_command_output() {
let events = normalize_codex_notification(