mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 12:33:05 +01:00
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:
1 parent
053ae3920f
commit
b0fa2ef9bf
16 files changed
+909
-32
No files matched your search
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user