mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
feat(task-chat): tool rows that say what ran and how it went (#19265)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The task chat shows each tool call an agent makes as a short activity row. > - Real Claude runs showed Bash rows named "Terminal" with no command and Write rows stuck at "Preparing file…". No row showed an exit code or a duration, and a tool refused by policy looked like any other failure. > - Codex already reported exit codes, durations and parsed command actions, but the UI dropped them, and every Codex read looked like a shell command. > - This pull request gives both harnesses one row grammar: a verb icon, the real subject, an exit chip and a duration. > - The benefit is that a reviewer can see what ran and how it went without opening each row. ## Linked Issues or Issue Description Refs #19259 (this branch includes its `_meta` passthrough commit; merge #19259 first or together). **Problem or motivation** Claude tool rows hide what ran: the runner keeps the first ACP title ("Terminal", "Preparing file…") and drops the refined one. `exitCode` and `durationMs` are null on every Claude tool event. The UI ignores `tool.execution.progressed`, `exitCode` and `durationMs` for every harness. **Proposed solution** Read real tool facts in the runner (Claude `_meta.claudeCode`, raw input and result text; Codex `commandActions`), carry them in the canonical tool event, and render one row grammar in the task chat. **Alternatives considered** Enable Claude's `terminal_output` client capability to get `terminal_exit`. That changes how command output arrives, and Claude's own exit code is derived from the error flag anyway, so this PR reads the error flag and the "Exit code N" text instead. ## What Changed - **Runner (TS):** `createAcpxToolEventNormalizer` keeps the latest concrete title instead of the first placeholder. It merges tool facts across the lifecycle and measures the duration. New `acpxToolDetailFromRuntimeEvent` reads the native tool name, the command or pattern, the description, the exit code and a "Permission denied" reason. Exit 0 is inferred only for Claude `Bash`, where a non-zero exit is always a failed result. - **ACPX sidecar:** reads the facts before `rawInput` is dropped and sends a bounded `toolDetail`. A file path argument becomes an attested workspace-relative location. - **runner-core (Rust):** validates `toolDetail`. `normalize_acpx_tool_call` uses the native name, exit code, duration, subject, description and blocked reason. Codex `commandExecution` gets its operation, a single-file target and a subject from `commandActions`. The shell wrapper is removed, and a single search reads as `"TODO" in src`. - **Protocol:** `toolExecution` gains the optional fields `subject`, `description` and `blockedReason`. Generated validators and the manifest are regenerated. - **UI:** - `nativeRunEventsToTranscript` handles `tool.execution.progressed` and passes a canonical `detail` on `tool_call` entries. - The task-chat row shows `$ command`, a path or a pattern, a red `exit N` chip (exit 0 only for commands) and a duration (0 is hidden). - Three or more consecutive reads fold into "Read N files a, b, c +1". - A refused tool shows "Blocked" with its reason. - **Storybook:** `Tasks/Tool rows` compares the data recorded today with the new runner data for Claude and Codex. ## Verification - `cd packages/paperclip-runner && npx vitest run src/provider-events.test.ts src/cli src/drivers/acpx` (1,957 pass) - `cd packages/paperclip-runner/runner && cargo test -p paperclip-runner-core --lib provider_events` - `cd ui && npx vitest run src/components/transcript src/components/task-chat` (973 pass) - `pnpm check:token-gates`, `pnpm --filter paperclip-runner build` (includes the protocol coverage and replay-golden checks) - Live runs on a local instance with real harnesses: - Codex (gpt-5.6-sol): the rows read `$ node --test` with `exit 1` and then `exit 0`, plus real durations. A three-file `sed` shows as one command and does not claim a single file. - Claude (claude-sonnet-5-5 through ACPX): the rows show `Bash` and `Edit` instead of "Terminal" and "Preparing file…". Commands show their real text, `exit 0` and durations, and edits show `Edited temp.js`. - Not seen live: a real Claude `Write`, a folded read run and a Claude policy denial. Unit and Rust tests cover them. ## Risks - The tool-execution schema gains only optional fields, so older events stay valid. Older runs keep rendering with what they recorded. - Commands now cross the sidecar boundary as `subject` (bounded to 4,000 characters). Codex already recorded commands as the tool name, and server redaction still applies. - The tool lifecycle now keeps the latest concrete title. A provider that retitles a call to something less useful would show the newer title. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use, code execution and browser automation. ## 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 - [ ] 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
8464a8c9ae
commit
86a9d3ec6c
22 files changed
+1445
-58
No files matched your search
@@ -695,11 +695,28 @@ export interface PaperclipQuestionResponse {
|
||||
answers: Record<string, { selectedOptionIds?: string[]; text?: string; customText?: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical facts about one tool execution (from `paperclip.tool.execution.v1`).
|
||||
* When present on a `tool_call`, it is authoritative and replaces earlier
|
||||
* values for the same `toolUseId`; a later lifecycle event may refine it.
|
||||
*/
|
||||
export interface TranscriptToolDetail {
|
||||
operation?: string;
|
||||
/** Command line, search pattern, URL or query. */
|
||||
subject?: string;
|
||||
/** Workspace-relative file path. */
|
||||
target?: string;
|
||||
description?: string;
|
||||
exitCode?: number | null;
|
||||
durationMs?: number | null;
|
||||
blockedReason?: string;
|
||||
}
|
||||
|
||||
export type TranscriptEntry =
|
||||
| { kind: "assistant"; ts: string; text: string; delta?: boolean; channel?: "progress" | "final" | "unknown"; itemId?: string }
|
||||
| { kind: "thinking"; ts: string; text: string; delta?: boolean; lifecycle?: "started" | "completed"; channel?: "summary" | "detail" | "unknown"; itemId?: string }
|
||||
| { kind: "user"; ts: string; text: string }
|
||||
| { kind: "tool_call"; ts: string; name: string; input: unknown; toolUseId?: string; invocationId?: string; actionRequestId?: string; /** Delegation (subagent) execution that made this call. */ parentToolUseId?: string }
|
||||
| { kind: "tool_call"; ts: string; name: string; input: unknown; toolUseId?: string; invocationId?: string; actionRequestId?: string; /** Delegation (subagent) execution that made this call. */ parentToolUseId?: string; detail?: TranscriptToolDetail }
|
||||
| { kind: "tool_result"; ts: string; toolUseId: string; toolName?: string; content: string; isError: boolean; delta?: boolean }
|
||||
| { kind: "init"; ts: string; model: string; sessionId: string }
|
||||
| { kind: "result"; ts: string; text: string; inputTokens: number; outputTokens: number; cachedTokens: number; costUsd: number; subtype: string; isError: boolean; errors: string[] }
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
{
|
||||
"path": "schemas/provider-event.schema.json",
|
||||
"id": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json",
|
||||
"sha256": "fb8473133d40f5ac878cdb317b38e29c84e3cb0b1aa59c78f72220f3f7349ea3"
|
||||
"sha256": "ab37a2a97b35a79bd593edd1e97f874afd751f01bf564dac8847e979a5b81fa2"
|
||||
},
|
||||
{
|
||||
"path": "schemas/provider-failure.schema.json",
|
||||
|
||||
@@ -59,6 +59,9 @@
|
||||
"durationMs": { "type": ["integer", "null"], "minimum": 0 },
|
||||
"exitCode": { "type": ["integer", "null"] },
|
||||
"progress": { "$ref": "#/$defs/nullableShortText" },
|
||||
"subject": { "$ref": "#/$defs/nullableShortText" },
|
||||
"description": { "$ref": "#/$defs/nullableShortText" },
|
||||
"blockedReason": { "$ref": "#/$defs/nullableShortText" },
|
||||
"output": { "type": ["string", "null"], "maxLength": 65536 },
|
||||
"outputBytes": { "type": "integer", "minimum": 0 },
|
||||
"outputTruncated": { "type": "boolean" },
|
||||
|
||||
@@ -531,6 +531,9 @@ fn decode_runtime_event(payload: &Value) -> Result<AcpxEventPayload, LocalRunner
|
||||
bounded_optional_text(location, "path", 4_000, "runtime tool path")?;
|
||||
}
|
||||
}
|
||||
if let Some(detail) = payload.get("toolDetail").filter(|value| !value.is_null()) {
|
||||
validate_tool_detail(detail)?;
|
||||
}
|
||||
tool_operation = Some(match payload.get("toolOperation") {
|
||||
Some(Value::String(operation)) => {
|
||||
bounded_optional_text(payload, "kind", 4_000, "runtime tool kind")?;
|
||||
@@ -803,6 +806,46 @@ fn optional_object(
|
||||
Ok(Some(field.clone()))
|
||||
}
|
||||
|
||||
/// Sidecar-derived tool facts. Every field is optional display data with the
|
||||
/// same bounds as the canonical tool-execution schema.
|
||||
fn validate_tool_detail(detail: &Value) -> Result<(), LocalRunnerError> {
|
||||
let Some(object) = detail.as_object() else {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX runtime tool detail must be an object",
|
||||
));
|
||||
};
|
||||
for (key, value) in object {
|
||||
match key.as_str() {
|
||||
"toolName" => {
|
||||
bounded_optional_text(detail, "toolName", 240, "runtime tool name")?;
|
||||
}
|
||||
"subject" | "description" | "blockedReason" => {
|
||||
bounded_optional_text(detail, key, 4_000, "runtime tool detail")?;
|
||||
}
|
||||
"exitCode" => {
|
||||
if !value.is_i64() {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX runtime tool exit code must be an integer",
|
||||
));
|
||||
}
|
||||
}
|
||||
"durationMs" => {
|
||||
if !value.is_u64() {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX runtime tool duration must be a non-negative integer",
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX runtime tool detail has an unknown field",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn optional_array<'a>(
|
||||
value: &'a Value,
|
||||
key: &str,
|
||||
|
||||
@@ -1249,9 +1249,9 @@ pub fn normalize_codex_notification_in(
|
||||
"schema": "paperclip.tool.execution.v1",
|
||||
"executionId": item_id,
|
||||
"transport": match item_type { "mcpToolCall" => "mcp", "dynamicToolCall" => "dynamic", "builtinToolCall" => "builtin", _ => "process" },
|
||||
"operation": if item_type == "commandExecution" { "execute" } else { "unknown" },
|
||||
"operation": if item_type == "commandExecution" { codex_command_operation(provider_item) } 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,
|
||||
"target": if item_type == "commandExecution" { codex_command_target(provider_item, cwd) } else { Value::Null },
|
||||
"namespace": provider_item.get("server").and_then(Value::as_str).map(|value| bounded_text(value, 240)),
|
||||
"readOnly": provider_item.get("readOnlyHint").and_then(Value::as_bool),
|
||||
"status": provider_status(string(provider_item.get("status")), completed),
|
||||
@@ -1269,6 +1269,11 @@ pub fn normalize_codex_notification_in(
|
||||
) {
|
||||
object.extend(output);
|
||||
}
|
||||
if item_type == "commandExecution" {
|
||||
if let Some(subject) = codex_command_subject(provider_item, cwd) {
|
||||
payload["subject"] = Value::String(subject);
|
||||
}
|
||||
}
|
||||
push(
|
||||
&mut events,
|
||||
if completed {
|
||||
@@ -1705,7 +1710,17 @@ fn normalize_acpx_tool_call(
|
||||
let native_status = string(payload.get("status"));
|
||||
let status = provider_status(native_status, native_status == "completed");
|
||||
let terminal = status != "running";
|
||||
let raw_title = string(payload.get("title")).trim();
|
||||
let detail = payload.get("toolDetail").filter(|value| value.is_object());
|
||||
let detail_text = |key: &str| {
|
||||
detail
|
||||
.and_then(|detail| detail.get(key))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
};
|
||||
// The native tool name ("Bash", "Write", "mcp__server__tool") beats the
|
||||
// free-text display title ("Terminal", a command line, "Preparing file…").
|
||||
let raw_title = detail_text("toolName").unwrap_or_else(|| string(payload.get("title")).trim());
|
||||
let mcp = acpx_mcp_tool_identity(raw_title);
|
||||
let name = bounded_text(mcp.map_or(raw_title, |(_, name)| name), 240);
|
||||
let output = match payload.get("rawOutput").or_else(|| payload.get("output")) {
|
||||
@@ -1723,8 +1738,16 @@ fn normalize_acpx_tool_call(
|
||||
"namespace": mcp.map(|(namespace, _)| bounded_text(namespace, 240)),
|
||||
"readOnly": matches!(operation, "read" | "search" | "list"),
|
||||
"status": status,
|
||||
"durationMs": Value::Null,
|
||||
"exitCode": Value::Null,
|
||||
"durationMs": if terminal {
|
||||
detail.and_then(|detail| detail.get("durationMs")).and_then(Value::as_u64).map_or(Value::Null, Value::from)
|
||||
} else {
|
||||
Value::Null
|
||||
},
|
||||
"exitCode": if terminal {
|
||||
detail.and_then(|detail| detail.get("exitCode")).and_then(Value::as_i64).map_or(Value::Null, Value::from)
|
||||
} else {
|
||||
Value::Null
|
||||
},
|
||||
"progress": if terminal {
|
||||
Value::Null
|
||||
} else {
|
||||
@@ -1739,6 +1762,11 @@ fn normalize_acpx_tool_call(
|
||||
if let Some(input_updated) = payload.get("inputUpdated").and_then(Value::as_bool) {
|
||||
normalized["inputUpdated"] = Value::Bool(input_updated);
|
||||
}
|
||||
for key in ["subject", "description", "blockedReason"] {
|
||||
if let Some(value) = detail_text(key) {
|
||||
normalized[key] = Value::String(bounded_text(value, MAX_TEXT_CHARS));
|
||||
}
|
||||
}
|
||||
let parent = string(payload.get("parentToolCallId"));
|
||||
if !parent.is_empty() {
|
||||
let parent_item_id = acpx_opaque_item_id(parent, "", "tool");
|
||||
@@ -1812,6 +1840,236 @@ fn acpx_notice(
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_command_actions(item: &Value) -> &[Value] {
|
||||
item.get("commandActions")
|
||||
.and_then(Value::as_array)
|
||||
.map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
/// Codex parses each shell command into read, search, list and unknown
|
||||
/// actions. A command made only of reads is a read, and so on; anything
|
||||
/// mixed or unparsed stays an execution.
|
||||
fn codex_command_operation(item: &Value) -> &'static str {
|
||||
let actions = codex_command_actions(item);
|
||||
let kind = |action: &Value| match string(action.get("type")).to_ascii_lowercase().as_str() {
|
||||
"read" => "read",
|
||||
"search" => "search",
|
||||
"listfiles" | "list_files" => "list",
|
||||
_ => "execute",
|
||||
};
|
||||
match actions.first().map(kind) {
|
||||
Some(first) if actions.iter().all(|action| kind(action) == first) => first,
|
||||
_ => "execute",
|
||||
}
|
||||
}
|
||||
|
||||
/// The single file a read-only command touched, relative to the command's cwd.
|
||||
fn codex_command_target(item: &Value, workspace_root: Option<&str>) -> Value {
|
||||
let actions = codex_command_actions(item);
|
||||
if actions.len() != 1 {
|
||||
return Value::Null;
|
||||
}
|
||||
// One parsed action can still name several files (`sed -n 1,20p a b c`);
|
||||
// naming only the first would misstate what was read.
|
||||
if string(actions[0].get("type")).eq_ignore_ascii_case("read")
|
||||
&& codex_command_operands(string(actions[0].get("command"))) > 1
|
||||
{
|
||||
return Value::Null;
|
||||
}
|
||||
codex_workspace_relative_path(item, string(actions[0].get("path")), workspace_root)
|
||||
.map_or(Value::Null, Value::String)
|
||||
}
|
||||
|
||||
/// A Codex action path relative to the session workspace root. A relative
|
||||
/// path is resolved against the command's own cwd first, so `cat server.ts`
|
||||
/// run in `<root>/src` becomes `src/server.ts`. Returns None without a known
|
||||
/// root, or for a path outside it: host paths never reach a persisted event.
|
||||
fn codex_workspace_relative_path(
|
||||
item: &Value,
|
||||
path: &str,
|
||||
workspace_root: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let root = workspace_root?.trim().trim_end_matches('/');
|
||||
let path = path.trim();
|
||||
if root.is_empty() || !root.starts_with('/') || path.is_empty() || path.contains('\0') {
|
||||
return None;
|
||||
}
|
||||
let command_cwd = string(item.get("cwd")).trim();
|
||||
let base = if command_cwd.starts_with('/') {
|
||||
command_cwd
|
||||
} else {
|
||||
root
|
||||
};
|
||||
let absolute = if path.starts_with('/') {
|
||||
path.to_owned()
|
||||
} else {
|
||||
format!("{base}/{path}")
|
||||
};
|
||||
let mut segments: Vec<&str> = Vec::new();
|
||||
for segment in absolute.split('/') {
|
||||
match segment {
|
||||
"" | "." => {}
|
||||
".." => {
|
||||
segments.pop()?;
|
||||
}
|
||||
other => segments.push(other),
|
||||
}
|
||||
}
|
||||
let root_segments: Vec<&str> = root
|
||||
.split('/')
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.collect();
|
||||
if segments.len() < root_segments.len() || segments[..root_segments.len()] != root_segments[..]
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let relative = segments[root_segments.len()..].join("/");
|
||||
Some(if relative.is_empty() {
|
||||
".".to_owned()
|
||||
} else {
|
||||
bounded_text(&relative, 4_000)
|
||||
})
|
||||
}
|
||||
|
||||
/// Non-flag operands after the program name, ignoring `sed` line ranges.
|
||||
fn codex_command_operands(command: &str) -> usize {
|
||||
let words = shell_words(command);
|
||||
let program = words
|
||||
.first()
|
||||
.map(|word| word.rsplit('/').next().unwrap_or(word).to_owned())
|
||||
.unwrap_or_default();
|
||||
// `sed -n 1,20p FILE`: the script is not a file. `head -n 20 FILE`: the
|
||||
// flag's value is not a file. Every other non-flag word is an operand,
|
||||
// including numeric filenames.
|
||||
let is_sed_script = |word: &str| {
|
||||
word.trim_end_matches('p')
|
||||
.split(',')
|
||||
.all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit() || c == '$'))
|
||||
};
|
||||
let mut count = 0;
|
||||
let mut skip_next = false;
|
||||
for word in words.iter().skip(1) {
|
||||
if skip_next {
|
||||
skip_next = false;
|
||||
continue;
|
||||
}
|
||||
if word.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if word.starts_with('-') {
|
||||
skip_next = matches!(program.as_str(), "head" | "tail")
|
||||
&& matches!(word.as_str(), "-n" | "-c")
|
||||
|| program == "sed" && word == "-e";
|
||||
continue;
|
||||
}
|
||||
if program == "sed" && is_sed_script(word) {
|
||||
continue;
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Split a command into words the way a POSIX shell groups them: single and
|
||||
/// double quotes and backslash escapes keep spaces inside one word.
|
||||
fn shell_words(command: &str) -> Vec<String> {
|
||||
let mut words = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut in_word = false;
|
||||
let mut quote: Option<char> = None;
|
||||
let mut characters = command.chars();
|
||||
while let Some(character) = characters.next() {
|
||||
match (quote, character) {
|
||||
(Some(open), close) if close == open => quote = None,
|
||||
(Some('"'), '\\') | (None, '\\') => {
|
||||
if let Some(escaped) = characters.next() {
|
||||
current.push(escaped);
|
||||
}
|
||||
in_word = true;
|
||||
}
|
||||
(Some(_), other) => current.push(other),
|
||||
(None, '\'' | '"') => {
|
||||
quote = Some(character);
|
||||
in_word = true;
|
||||
}
|
||||
(None, other) if other.is_whitespace() => {
|
||||
if in_word {
|
||||
words.push(std::mem::take(&mut current));
|
||||
in_word = false;
|
||||
}
|
||||
}
|
||||
(None, other) => {
|
||||
current.push(other);
|
||||
in_word = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if in_word {
|
||||
words.push(current);
|
||||
}
|
||||
words
|
||||
}
|
||||
|
||||
/// The command a person typed, without the `/bin/zsh -lc '…'` wrapper. Codex
|
||||
/// already parsed each action's own command, so prefer those. A single search
|
||||
/// reads as its query: `"TODO" in src`.
|
||||
fn codex_command_subject(item: &Value, workspace_root: Option<&str>) -> Option<String> {
|
||||
let actions = codex_command_actions(item);
|
||||
if let [action] = actions {
|
||||
let query = string(action.get("query")).trim();
|
||||
if string(action.get("type")).eq_ignore_ascii_case("search") && !query.is_empty() {
|
||||
let scope = match codex_workspace_relative_path(
|
||||
item,
|
||||
string(action.get("path")),
|
||||
workspace_root,
|
||||
) {
|
||||
Some(path) if path != "." => format!(" in {path}"),
|
||||
_ => String::new(),
|
||||
};
|
||||
return Some(bounded_text(&format!("\"{query}\"{scope}"), MAX_TEXT_CHARS));
|
||||
}
|
||||
}
|
||||
let parsed: Vec<&str> = codex_command_actions(item)
|
||||
.iter()
|
||||
.map(|action| string(action.get("command")).trim())
|
||||
.collect();
|
||||
let joined = if !parsed.is_empty() && parsed.iter().all(|command| !command.is_empty()) {
|
||||
parsed.join(" && ")
|
||||
} else {
|
||||
unwrap_shell_command(string(item.get("command")).trim()).to_owned()
|
||||
};
|
||||
(!joined.is_empty()).then(|| bounded_text(&joined, MAX_TEXT_CHARS))
|
||||
}
|
||||
|
||||
fn unwrap_shell_command(command: &str) -> &str {
|
||||
for shell in [
|
||||
"/bin/zsh",
|
||||
"/bin/bash",
|
||||
"/bin/sh",
|
||||
"zsh",
|
||||
"bash",
|
||||
"sh",
|
||||
"/usr/bin/env bash",
|
||||
"/usr/bin/env zsh",
|
||||
] {
|
||||
for flag in [" -lc ", " -c "] {
|
||||
if let Some(rest) = command
|
||||
.strip_prefix(shell)
|
||||
.and_then(|rest| rest.strip_prefix(flag))
|
||||
{
|
||||
let rest = rest.trim();
|
||||
for quote in ['\'', '"'] {
|
||||
if rest.len() >= 2 && rest.starts_with(quote) && rest.ends_with(quote) {
|
||||
return &rest[1..rest.len() - 1];
|
||||
}
|
||||
}
|
||||
return rest;
|
||||
}
|
||||
}
|
||||
}
|
||||
command
|
||||
}
|
||||
|
||||
fn nonnegative_u64(value: Option<&Value>) -> u64 {
|
||||
value.and_then(Value::as_u64).unwrap_or(0)
|
||||
}
|
||||
@@ -2382,6 +2640,202 @@ mod tests {
|
||||
assert!(!events[0].payload.to_string().contains("secret-value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_commands_carry_their_parsed_subject_operation_and_target() {
|
||||
let read = normalize_codex_notification_in(
|
||||
"item/completed",
|
||||
&json!({"item": {
|
||||
"id": "exec-read", "type": "commandExecution", "status": "completed",
|
||||
"command": "/bin/zsh -lc 'sed -n 1,120p src/server.ts'",
|
||||
"cwd": "/work/repo", "exitCode": 0, "durationMs": 31,
|
||||
"commandActions": [{"type": "read", "command": "sed -n 1,120p src/server.ts", "name": "server.ts", "path": "/work/repo/src/server.ts"}],
|
||||
}}),
|
||||
Some("/work/repo"),
|
||||
);
|
||||
assert_eq!(read[0].payload["operation"], "read");
|
||||
assert_eq!(read[0].payload["target"], "src/server.ts");
|
||||
assert_eq!(read[0].payload["subject"], "sed -n 1,120p src/server.ts");
|
||||
assert_eq!(read[0].payload["exitCode"], 0);
|
||||
assert_eq!(read[0].payload["durationMs"], 31);
|
||||
|
||||
let run = normalize_codex_notification_in(
|
||||
"item/completed",
|
||||
&json!({"item": {
|
||||
"id": "exec-run", "type": "commandExecution", "status": "failed",
|
||||
"command": "/bin/bash -lc \"node --test\"", "cwd": "/work/repo", "exitCode": 1,
|
||||
"commandActions": [{"type": "unknown"}],
|
||||
}}),
|
||||
Some("/work/repo"),
|
||||
);
|
||||
assert_eq!(run[0].payload["operation"], "execute");
|
||||
assert_eq!(run[0].payload["target"], Value::Null);
|
||||
assert_eq!(run[0].payload["subject"], "node --test");
|
||||
assert_eq!(run[0].payload["exitCode"], 1);
|
||||
|
||||
let escape = normalize_codex_notification_in(
|
||||
"item/completed",
|
||||
&json!({"item": {
|
||||
"id": "exec-escape", "type": "commandExecution", "status": "completed",
|
||||
"command": "cat ../secret", "cwd": "/work/repo",
|
||||
"commandActions": [{"type": "read", "command": "cat ../secret", "path": "../secret"}],
|
||||
}}),
|
||||
Some("/work/repo"),
|
||||
);
|
||||
assert_eq!(escape[0].payload["target"], Value::Null);
|
||||
|
||||
let several = normalize_codex_notification_in(
|
||||
"item/completed",
|
||||
&json!({"item": {
|
||||
"id": "exec-several", "type": "commandExecution", "status": "completed",
|
||||
"command": "/bin/zsh -lc \"sed -n '1,20p' notes/a.md notes/b.md\"", "cwd": "/work/repo",
|
||||
"commandActions": [{"type": "read", "command": "sed -n '1,20p' notes/a.md notes/b.md", "path": "notes/a.md"}],
|
||||
}}),
|
||||
Some("/work/repo"),
|
||||
);
|
||||
assert_eq!(several[0].payload["operation"], "read");
|
||||
assert_eq!(several[0].payload["target"], Value::Null);
|
||||
|
||||
let quoted = normalize_codex_notification_in(
|
||||
"item/completed",
|
||||
&json!({"item": {
|
||||
"id": "exec-quoted", "type": "commandExecution", "status": "completed",
|
||||
"command": "cat \"docs/setup guide.md\"", "cwd": "/work/repo",
|
||||
"commandActions": [{"type": "read", "command": "cat \"docs/setup guide.md\"", "path": "docs/setup guide.md"}],
|
||||
}}),
|
||||
Some("/work/repo"),
|
||||
);
|
||||
assert_eq!(quoted[0].payload["target"], "docs/setup guide.md");
|
||||
assert_eq!(codex_command_operands("cat 2025 2026"), 2);
|
||||
assert_eq!(codex_command_operands("head -n 20 src/a.ts"), 1);
|
||||
assert_eq!(codex_command_operands("sed -n '1,20p' src/a.ts"), 1);
|
||||
assert_eq!(codex_command_operands("sed -n '$p' a.md b.md"), 2);
|
||||
assert_eq!(
|
||||
shell_words(r#"sed -n '1,20p' a\ b.md "c d.md""#),
|
||||
["sed", "-n", "1,20p", "a b.md", "c d.md"]
|
||||
);
|
||||
|
||||
let search = normalize_codex_notification_in(
|
||||
"item/completed",
|
||||
&json!({"item": {
|
||||
"id": "exec-search", "type": "commandExecution", "status": "completed",
|
||||
"command": "/bin/zsh -lc \"rg -n TODO src\"", "cwd": "/work/repo",
|
||||
"commandActions": [{"type": "search", "command": "rg -n TODO src", "query": "TODO", "path": "src"}],
|
||||
}}),
|
||||
Some("/work/repo"),
|
||||
);
|
||||
assert_eq!(search[0].payload["operation"], "search");
|
||||
assert_eq!(search[0].payload["subject"], "\"TODO\" in src");
|
||||
|
||||
for (path, expected) in [
|
||||
("/work/repo/src", "\"TODO\" in src"),
|
||||
("/work/repo", "\"TODO\""),
|
||||
("/etc", "\"TODO\""),
|
||||
("../outside", "\"TODO\""),
|
||||
] {
|
||||
let scoped = normalize_codex_notification_in(
|
||||
"item/completed",
|
||||
&json!({"item": {
|
||||
"id": "exec-search-abs", "type": "commandExecution", "status": "completed",
|
||||
"command": "rg TODO", "cwd": "/work/repo",
|
||||
"commandActions": [{"type": "search", "command": "rg TODO", "query": "TODO", "path": path}],
|
||||
}}),
|
||||
Some("/work/repo"),
|
||||
);
|
||||
assert_eq!(scoped[0].payload["subject"], expected, "path {path}");
|
||||
}
|
||||
|
||||
let nested = normalize_codex_notification_in(
|
||||
"item/completed",
|
||||
&json!({"item": {
|
||||
"id": "exec-nested", "type": "commandExecution", "status": "completed",
|
||||
"command": "cat server.ts", "cwd": "/work/repo/src",
|
||||
"commandActions": [{"type": "read", "command": "cat server.ts", "path": "server.ts"}],
|
||||
}}),
|
||||
Some("/work/repo"),
|
||||
);
|
||||
assert_eq!(nested[0].payload["target"], "src/server.ts");
|
||||
let rootless = normalize_codex_notification(
|
||||
"item/completed",
|
||||
&json!({"item": {
|
||||
"id": "exec-rootless", "type": "commandExecution", "status": "completed",
|
||||
"command": "cat src/server.ts", "cwd": "/work/repo",
|
||||
"commandActions": [{"type": "read", "command": "cat src/server.ts", "path": "/work/repo/src/server.ts"}],
|
||||
}}),
|
||||
);
|
||||
assert_eq!(rootless[0].payload["target"], Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acpx_tool_detail_supplies_native_name_outcome_and_refusal() {
|
||||
let completed = normalize_acpx_runtime_event(
|
||||
AcpxRuntimeEventKind::ToolCall,
|
||||
&json!({
|
||||
"type": "tool_call", "toolCallId": "toolu_1", "status": "completed", "title": "Terminal",
|
||||
"toolDetail": {"toolName": "Bash", "subject": "node --test", "description": "Run the tests", "exitCode": 0, "durationMs": 1840},
|
||||
}),
|
||||
Some("execute"),
|
||||
"fallback",
|
||||
"turn-1",
|
||||
0,
|
||||
);
|
||||
let payload = &completed[0].payload;
|
||||
assert_eq!(payload["name"], "Bash");
|
||||
assert_eq!(payload["subject"], "node --test");
|
||||
assert_eq!(payload["description"], "Run the tests");
|
||||
assert_eq!(payload["exitCode"], 0);
|
||||
assert_eq!(payload["durationMs"], 1840);
|
||||
|
||||
let running = normalize_acpx_runtime_event(
|
||||
AcpxRuntimeEventKind::ToolCall,
|
||||
&json!({"type": "tool_call", "toolCallId": "toolu_2", "status": "in_progress", "title": "Terminal",
|
||||
"toolDetail": {"toolName": "Bash", "exitCode": 1, "durationMs": 5}}),
|
||||
Some("execute"),
|
||||
"fallback",
|
||||
"turn-1",
|
||||
0,
|
||||
);
|
||||
assert_eq!(running[0].payload["exitCode"], Value::Null);
|
||||
assert_eq!(running[0].payload["durationMs"], Value::Null);
|
||||
|
||||
let blocked = normalize_acpx_runtime_event(
|
||||
AcpxRuntimeEventKind::ToolCall,
|
||||
&json!({"type": "tool_call", "toolCallId": "toolu_3", "status": "failed", "title": "rm -rf build",
|
||||
"toolDetail": {"toolName": "mcp__paperclip__delete", "blockedReason": "Denied by rule"}}),
|
||||
Some("unknown"),
|
||||
"fallback",
|
||||
"turn-1",
|
||||
0,
|
||||
);
|
||||
assert_eq!(blocked[0].payload["name"], "delete");
|
||||
assert_eq!(blocked[0].payload["namespace"], "paperclip");
|
||||
assert_eq!(blocked[0].payload["transport"], "mcp");
|
||||
assert_eq!(blocked[0].payload["blockedReason"], "Denied by rule");
|
||||
|
||||
let secret = normalize_acpx_runtime_event(
|
||||
AcpxRuntimeEventKind::ToolCall,
|
||||
&json!({"type": "tool_call", "toolCallId": "toolu_4", "status": "failed", "title": "curl",
|
||||
"toolDetail": {
|
||||
"toolName": "Bash",
|
||||
"subject": "curl -H 'Authorization: Bearer top-secret-value' https://example.test?token=top-secret-value",
|
||||
"description": "Call with password=top-secret-value",
|
||||
"blockedReason": "Denied: api_key=top-secret-value",
|
||||
}}),
|
||||
Some("execute"),
|
||||
"fallback",
|
||||
"turn-1",
|
||||
0,
|
||||
);
|
||||
assert!(!secret[0].payload.to_string().contains("top-secret-value"));
|
||||
let codex = normalize_codex_notification(
|
||||
"item/completed",
|
||||
&json!({"item": {
|
||||
"id": "exec-secret", "type": "commandExecution", "status": "completed",
|
||||
"command": "/bin/zsh -lc 'curl https://example.test?token=top-secret-value'", "cwd": "/work/repo",
|
||||
}}),
|
||||
);
|
||||
assert!(!codex[0].payload.to_string().contains("top-secret-value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_bounded_web_search_and_sleep_identity() {
|
||||
let search = normalize_codex_notification(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { classifyClaudeStatusBanner } from "../drivers/acpx/claude-status-banner
|
||||
import { resolveGrokDisableWebSearch } from "../drivers/acpx/grok-web-search.js";
|
||||
import { toolFileChangesFromAcpContent, workspaceRootsFor, type ToolFileChange } from "../tool-file-changes.js";
|
||||
import { redactObservabilityDiagnostic } from "../drivers/agent-observability.js";
|
||||
import { sidecarToolDetail } from "./acpx-tool-detail-redaction.js";
|
||||
import { resolveClaudeEffort } from "../drivers/acpx/claude-effort.js";
|
||||
import { acpxProviderFailure, acpxTerminalResult, type AcpxTerminalSessionFailure } from "../drivers/acpx/provider-failure.js";
|
||||
import { providerFailureFromError } from "../drivers/provider-failure.js";
|
||||
@@ -29,7 +30,7 @@ import type {
|
||||
|
||||
import { acpxProfileClientCapabilities, bindAcpxExtensionTurn, validateAcpxRichEvent, createAcpxProfileExtensionAdapter, type AcpxExtensionInput } from "../drivers/acpx/profile-extensions.js";
|
||||
import type { PaperclipQuestionSet } from "../contracts/question-set.js";
|
||||
import { boundedAcpMeta, canonicalProviderEventsFromAcpxRuntimeEvent, type AcpRuntimeEventShape, createAcpxToolEventNormalizer, createGrokMessageNormalizer } from "../provider-events.js";
|
||||
import { acpxToolDetailFromRuntimeEvent, acpxToolInputPath, boundedAcpMeta, canonicalProviderEventsFromAcpxRuntimeEvent, type AcpRuntimeEventShape, createAcpxToolEventNormalizer, createGrokMessageNormalizer } from "../provider-events.js";
|
||||
import { acpxOpaqueToolItemId, createClaudeSubagentLineageTracker } from "../claude-subagent-lineage.js";
|
||||
import { createPiMessageProjection, type PiProjectedMessageEvent } from "../drivers/acpx/pi-message-projection.js";
|
||||
import { createKimiMessageProjection, type KimiProjectedMessageEvent } from "../drivers/acpx/kimi-message-boundary.js";
|
||||
@@ -693,7 +694,7 @@ async function pumpTurn(
|
||||
for await (const event of runtimeTurn.events) {
|
||||
toolEvidence?.tool(event);
|
||||
const sanitized = sanitizeRuntimeEvent(
|
||||
normalizeMessage(normalizeToolEvent(boundRuntimeEventForNormalization(event))),
|
||||
normalizeMessage(normalizeToolEvent(withSidecarToolDetail(event, boundRuntimeEventForNormalization(event)))),
|
||||
);
|
||||
const lineage = trackSubagents && event.type === "tool_call" && typeof sanitized.toolCallId === "string"
|
||||
? trackSubagents({
|
||||
@@ -1053,6 +1054,25 @@ type BoundedRuntimeToolEvent = AcpRuntimeEvent & {
|
||||
paperclipFileChanges: ToolFileChange[] | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read tool facts (native name, command, exit, refusal reason) from the raw
|
||||
* update before rawInput is dropped, and attest a file-path argument as a
|
||||
* workspace-relative location when the provider sent no location.
|
||||
*/
|
||||
function withSidecarToolDetail(raw: AcpRuntimeEvent, bounded: AcpRuntimeEvent): AcpRuntimeEvent {
|
||||
if (raw.type !== "tool_call" || bounded.type !== "tool_call") return bounded;
|
||||
const detail = acpxToolDetailFromRuntimeEvent(raw);
|
||||
const inputPath = bounded.locations?.length ? undefined : acpxToolInputPath(raw);
|
||||
const locations = inputPath
|
||||
? safeAcpxLocations([{ path: inputPath }], openParams?.workingDirectory, bounded.kind, bounded.title)
|
||||
: undefined;
|
||||
return {
|
||||
...bounded,
|
||||
...(locations?.length ? { locations } : {}),
|
||||
paperclipToolDetail: detail,
|
||||
} as AcpRuntimeEvent;
|
||||
}
|
||||
|
||||
/** Re-key a single-child delegation event to the Rust tool identity. */
|
||||
function withDelegationIdentity(
|
||||
event: ReturnType<typeof canonicalProviderEventsFromAcpxRuntimeEvent>[number],
|
||||
@@ -1212,6 +1232,7 @@ function sanitizeRuntimeEvent(event: PiProjectedMessageEvent<AcpRuntimeEvent> &
|
||||
...(boundedTool.paperclipBoundedTool === true
|
||||
? boundedTool.paperclipOutput
|
||||
: safeOutput(event.rawOutput)),
|
||||
...sidecarToolDetail(record(event).paperclipToolDetail),
|
||||
...(boundedTool.paperclipBoundedTool === true && boundedTool.paperclipFileChanges
|
||||
? { fileChanges: boundedTool.paperclipFileChanges }
|
||||
: {}),
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { redactToolDetailText, sidecarToolDetail } from "./acpx-tool-detail-redaction.js";
|
||||
|
||||
const env = {} as NodeJS.ProcessEnv;
|
||||
|
||||
describe("tool detail redaction at the sidecar boundary", () => {
|
||||
it("removes an authorization header with its scheme and credential", () => {
|
||||
const text = redactToolDetailText("curl -H 'Authorization: Bearer top-secret-value' https://example.test/", env);
|
||||
expect(text).not.toContain("top-secret-value");
|
||||
expect(text).toContain("https://example.test/");
|
||||
});
|
||||
|
||||
it("removes bare bearer tokens and key=value secrets", () => {
|
||||
for (const raw of [
|
||||
"export TOKEN=top-secret-value",
|
||||
"curl https://example.test?token=top-secret-value&x=1",
|
||||
"login --password top=x password=top-secret-value",
|
||||
"use bearer top-secret-value now",
|
||||
"api_key: top-secret-value",
|
||||
]) {
|
||||
expect(redactToolDetailText(raw, env)).not.toContain("top-secret-value");
|
||||
}
|
||||
});
|
||||
|
||||
it("removes the run's observability header values", () => {
|
||||
expect(redactToolDetailText("echo hunter2-header", { OTEL_EXPORTER_OTLP_HEADERS: "x-api-key=hunter2-header" } as NodeJS.ProcessEnv))
|
||||
.not.toContain("hunter2-header");
|
||||
});
|
||||
|
||||
it("re-bounds each field after redaction so runner-core accepts the event", () => {
|
||||
const subject = `${"echo key=a ".repeat(400)}`.slice(0, 4_000);
|
||||
const { toolDetail } = sidecarToolDetail({
|
||||
toolName: "B".repeat(300),
|
||||
subject,
|
||||
description: "ok",
|
||||
exitCode: 2,
|
||||
durationMs: -5,
|
||||
unknown: "dropped",
|
||||
}, env);
|
||||
expect(Array.from(toolDetail!.subject as string).length).toBeLessThanOrEqual(4_000);
|
||||
expect(Array.from(toolDetail!.toolName as string).length).toBe(240);
|
||||
expect(toolDetail).toMatchObject({ description: "ok", exitCode: 2 });
|
||||
expect(toolDetail).not.toHaveProperty("durationMs");
|
||||
expect(toolDetail).not.toHaveProperty("unknown");
|
||||
expect(sidecarToolDetail(null, env)).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { redactObservabilityDiagnostic } from "../drivers/agent-observability.js";
|
||||
|
||||
/** Character limits runner-core enforces on a sidecar `toolDetail` field. */
|
||||
const TOOL_DETAIL_TEXT_LIMITS: Readonly<Record<string, number>> = {
|
||||
toolName: 240,
|
||||
subject: 4_000,
|
||||
description: 4_000,
|
||||
blockedReason: 4_000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove credentials from tool display text: the run's observability header
|
||||
* values, an authorization header with its scheme and credential, a bare
|
||||
* bearer token, and `key=value`-style secrets. Runner-core redacts again and
|
||||
* the server removes registered secret values before anything is stored. The scheme is consumed with its credential so
|
||||
* no token fragment is left without its marker.
|
||||
*/
|
||||
export function redactToolDetailText(value: string, environment: NodeJS.ProcessEnv = process.env): string {
|
||||
return redactObservabilityDiagnostic(value, environment)
|
||||
.replace(
|
||||
/\b(authorization|proxy-authorization)\s*[:=]\s*(?:(?:bearer|basic|token|digest|negotiate)\s+)?[^\s,;}\]'"]+/gi,
|
||||
"$1=[REDACTED]",
|
||||
)
|
||||
.replace(/\bbearer\s+[^\s,;}\]'"]+/gi, "Bearer [REDACTED]")
|
||||
.replace(
|
||||
/\b([A-Za-z0-9_-]*(?:key|token|secret|password|passwd|pwd)[A-Za-z0-9_-]*)\s*[:=]\s*[^\s,;&}\]'"]+/gi,
|
||||
(match, name: string) => (/\[REDACTED\]$/.test(match) ? match : `${name}=[REDACTED]`),
|
||||
);
|
||||
}
|
||||
|
||||
/** Bound by Unicode code points, matching runner-core's character count. */
|
||||
function boundCodePoints(value: string, limit: number): string {
|
||||
const points = Array.from(value);
|
||||
return points.length <= limit ? value : points.slice(0, limit).join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a tool detail for the sidecar boundary: redact every display
|
||||
* string, then bound it again (redaction can lengthen text) so a long command
|
||||
* can never make runner-core reject the event and fail the turn.
|
||||
*/
|
||||
export function sidecarToolDetail(
|
||||
value: unknown,
|
||||
environment: NodeJS.ProcessEnv = process.env,
|
||||
): { toolDetail?: Record<string, unknown> } {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
||||
const limit = TOOL_DETAIL_TEXT_LIMITS[key];
|
||||
if (typeof item === "string" && limit !== undefined) {
|
||||
const text = boundCodePoints(redactToolDetailText(item, environment), limit);
|
||||
if (text) output[key] = text;
|
||||
} else if ((key === "exitCode" || key === "durationMs") && typeof item === "number" && Number.isSafeInteger(item)) {
|
||||
if (key === "exitCode" || item >= 0) output[key] = item;
|
||||
}
|
||||
}
|
||||
return Object.keys(output).length > 0 ? { toolDetail: output } : {};
|
||||
}
|
||||
@@ -2065,6 +2065,15 @@ export const providerEventSchema = {
|
||||
"progress": {
|
||||
"$ref": "#/$defs/nullableShortText"
|
||||
},
|
||||
"subject": {
|
||||
"$ref": "#/$defs/nullableShortText"
|
||||
},
|
||||
"description": {
|
||||
"$ref": "#/$defs/nullableShortText"
|
||||
},
|
||||
"blockedReason": {
|
||||
"$ref": "#/$defs/nullableShortText"
|
||||
},
|
||||
"output": {
|
||||
"type": [
|
||||
"string",
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
import equalModule from "ajv/dist/runtime/equal.js";
|
||||
import ucs2LengthModule from "ajv/dist/runtime/ucs2length.js";
|
||||
|
||||
"use strict";export const authCredentialsValidator: unknown = validate20;const schema31 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/auth-credentials.schema.json","title":"PRP credential update notification","description":"Content-free evidence of changed credentials for the harness-auth binding in this normalized session. Credential-store detection does not prove an OAuth refresh succeeded.","type":"object","required":["schema","provider","bindingId","revision","detection"],"properties":{"schema":{"const":"paperclip.auth.credentials.updated.v1"},"provider":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"bindingId":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"revision":{"type":"integer","minimum":1,"maximum":9007199254740991},"detection":{"enum":["credential_store","native_hook"]}},"additionalProperties":false};const func1 = typeof ucs2LengthModule === "function" ? ucs2LengthModule : ucs2LengthModule.default;const pattern4 = new RegExp("^[A-Za-z0-9][A-Za-z0-9._:-]*$", "u");function validate20(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v1/auth-credentials.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate20.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.provider === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "provider"},message:"must have required property '"+"provider"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.bindingId === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "bindingId"},message:"must have required property '"+"bindingId"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.revision === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "revision"},message:"must have required property '"+"revision"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.detection === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "detection"},message:"must have required property '"+"detection"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}for(const key0 in data){if(!(((((key0 === "schema") || (key0 === "provider")) || (key0 === "bindingId")) || (key0 === "revision")) || (key0 === "detection"))){const err5 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data.schema !== undefined){if("paperclip.auth.credentials.updated.v1" !== data.schema){const err6 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.auth.credentials.updated.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.provider !== undefined){let data1 = data.provider;if(typeof data1 === "string"){if(func1(data1) > 160){const err7 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(func1(data1) < 1){const err8 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(!pattern4.test(data1)){const err9 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}else {const err10 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.bindingId !== undefined){let data2 = data.binLine truncated
|
||||
"use strict";export const authCredentialsValidator: unknown = validate20;const schema31 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/auth-credentials.schema.json","title":"PRP credential update notification","description":"Content-free evidence of changed credentials for the harness-auth binding in this normalized session. Credential-store detection does not prove an OAuth refresh succeeded.","type":"object","required":["schema","provider","bindingId","revision","detection"],"properties":{"schema":{"const":"paperclip.auth.credentials.updated.v1"},"provider":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"bindingId":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"revision":{"type":"integer","minimum":1,"maximum":9007199254740991},"detection":{"enum":["credential_store","native_hook"]}},"additionalProperties":false};const func1 = typeof ucs2LengthModule === "function" ? ucs2LengthModule : ucs2LengthModule.default;const pattern4 = new RegExp("^[A-Za-z0-9][A-Za-z0-9._:-]*$", "u");function validate20(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v1/auth-credentials.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate20.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.provider === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "provider"},message:"must have required property '"+"provider"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.bindingId === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "bindingId"},message:"must have required property '"+"bindingId"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.revision === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "revision"},message:"must have required property '"+"revision"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.detection === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "detection"},message:"must have required property '"+"detection"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}for(const key0 in data){if(!(((((key0 === "schema") || (key0 === "provider")) || (key0 === "bindingId")) || (key0 === "revision")) || (key0 === "detection"))){const err5 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data.schema !== undefined){if("paperclip.auth.credentials.updated.v1" !== data.schema){const err6 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.auth.credentials.updated.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.provider !== undefined){let data1 = data.provider;if(typeof data1 === "string"){if(func1(data1) > 160){const err7 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(func1(data1) < 1){const err8 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(!pattern4.test(data1)){const err9 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}else {const err10 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.bindingId !== undefined){let data2 = data.binLine truncated
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
canonicalProviderEventsFromAcpxRuntimeEvent,
|
||||
canonicalProviderEventsFromCodex,
|
||||
canonicalProviderEventsFromOpenCodePart,
|
||||
acpxToolDetailFromRuntimeEvent,
|
||||
boundedAcpMeta,
|
||||
createAcpxToolEventNormalizer,
|
||||
createGrokMessageNormalizer,
|
||||
@@ -671,6 +672,50 @@ describe("provider-neutral events", () => {
|
||||
).not.toHaveProperty("_meta");
|
||||
});
|
||||
|
||||
it("reads Claude tool facts and keeps the refined title with a measured duration", () => {
|
||||
let clock = 1_000;
|
||||
const normalize = createAcpxToolEventNormalizer({ now: () => clock });
|
||||
normalize({
|
||||
type: "tool_call", tag: "tool_call", toolCallId: "toolu_bash", title: "Terminal", kind: "execute", status: "pending",
|
||||
_meta: { claudeCode: { toolName: "Bash" } },
|
||||
} as never);
|
||||
const refined = normalize({
|
||||
type: "tool_call", tag: "tool_call_update", toolCallId: "toolu_bash", title: "node --test", kind: "execute",
|
||||
rawInput: { command: "node --test", description: "Run the unit tests" },
|
||||
_meta: { claudeCode: { toolName: "Bash", title: "Run the unit tests" } },
|
||||
} as never);
|
||||
expect(refined.title).toBe("node --test");
|
||||
clock = 2_840;
|
||||
const failed = normalize({
|
||||
type: "tool_call", tag: "tool_call_update", toolCallId: "toolu_bash", title: "tool call", status: "failed",
|
||||
content: [{ type: "content", content: { type: "text", text: "```console\nExit code 2\nnot ok 2\n```" } }],
|
||||
_meta: { claudeCode: { toolName: "Bash" } },
|
||||
} as never);
|
||||
expect(failed).toMatchObject({
|
||||
title: "node --test",
|
||||
paperclipToolDetail: { toolName: "Bash", subject: "node --test", description: "Run the unit tests", exitCode: 2, durationMs: 1_840 },
|
||||
});
|
||||
const [mapped] = canonicalProviderEventsFromAcpxRuntimeEvent(failed as never, "fallback", "turn-1");
|
||||
expect(mapped.payload).toMatchObject({ name: "Bash", operation: "execute", subject: "node --test", exitCode: 2, durationMs: 1_840 });
|
||||
expect(validatePrpEvent(envelope(mapped as never)).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("marks Claude permission denials as blocked instead of failed commands", () => {
|
||||
expect(acpxToolDetailFromRuntimeEvent({
|
||||
type: "tool_call", toolCallId: "toolu_rm", status: "failed",
|
||||
content: [{ type: "content", content: { type: "text", text: "Permission denied: Bash(rm:*) is denied by settings" } }],
|
||||
_meta: { claudeCode: { toolName: "Bash", toolResponse: { decisionReasonType: "rule", decisionReason: "Bash(rm:*) is denied by settings" } } },
|
||||
} as never)).toEqual({ toolName: "Bash", blockedReason: "Bash(rm:*) is denied by settings" });
|
||||
expect(acpxToolDetailFromRuntimeEvent({
|
||||
type: "tool_call", toolCallId: "toolu_ok", status: "completed", rawInput: { command: "ls" },
|
||||
_meta: { claudeCode: { toolName: "Bash" } },
|
||||
} as never)).toEqual({ toolName: "Bash", subject: "ls", exitCode: 0 });
|
||||
// Another ACP agent's completed command makes no exit-code claim.
|
||||
expect(acpxToolDetailFromRuntimeEvent({
|
||||
type: "tool_call", toolCallId: "cursor_ok", status: "completed", kind: "execute", rawInput: { command: "ls" },
|
||||
} as never)).toEqual({ subject: "ls" });
|
||||
});
|
||||
|
||||
it("bounds ACP _meta depth, breadth, string size and total size", () => {
|
||||
let deep: Record<string, unknown> = { leaf: true };
|
||||
for (let index = 0; index < 10; index += 1) deep = { next: deep };
|
||||
|
||||
@@ -225,6 +225,8 @@ interface AcpxToolLifecycleIdentity {
|
||||
kind?: string;
|
||||
locations?: unknown[];
|
||||
progress?: string;
|
||||
detail?: AcpxToolDetail;
|
||||
startedAt?: number;
|
||||
}
|
||||
|
||||
const MAX_ACPX_TOOL_LIFECYCLES = 512;
|
||||
@@ -356,6 +358,117 @@ export function boundedAcpMeta(
|
||||
return bounded && Object.keys(bounded).length > 0 ? bounded : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider-neutral facts about one ACP tool call, read from the raw update.
|
||||
* `subject` is the non-path argument a person needs to recognize the call
|
||||
* (a shell command, search pattern, URL or query); file paths travel as an
|
||||
* attested location instead. Every field is optional and bounded.
|
||||
*/
|
||||
export interface AcpxToolDetail {
|
||||
toolName?: string;
|
||||
subject?: string;
|
||||
description?: string;
|
||||
exitCode?: number;
|
||||
durationMs?: number;
|
||||
blockedReason?: string;
|
||||
}
|
||||
|
||||
const ACPX_TOOL_DETAIL_KEYS = ["toolName", "subject", "description", "exitCode", "durationMs", "blockedReason"] as const;
|
||||
const ACPX_TOOL_PATH_KEYS = ["file_path", "path", "notebook_path"] as const;
|
||||
|
||||
function boundedDetailText(value: unknown, maxBytes: number): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "").trim();
|
||||
return trimmed ? boundedAcpxLifecycleText(trimmed, maxBytes) : undefined;
|
||||
}
|
||||
|
||||
function acpxToolContentText(event: AcpRuntimeEventShape): string {
|
||||
const parts: string[] = [];
|
||||
if (typeof event.rawOutput === "string") parts.push(event.rawOutput);
|
||||
for (const entry of Array.isArray(event.content) ? event.content.slice(0, 16) : []) {
|
||||
const inner = record(record(entry).content);
|
||||
if (typeof inner.text === "string") parts.push(inner.text);
|
||||
}
|
||||
return parts.join("\n").slice(0, 8_000);
|
||||
}
|
||||
|
||||
/** The first file path argument of a raw tool input, if any. */
|
||||
export function acpxToolInputPath(event: AcpRuntimeEventShape): string | undefined {
|
||||
const input = record(event.rawInput);
|
||||
for (const key of ACPX_TOOL_PATH_KEYS) {
|
||||
const value = boundedDetailText(input[key], MAX_ACPX_TOOL_METADATA_BYTES);
|
||||
if (value) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read tool facts from an unbounded ACP tool update. Claude supplies the
|
||||
* native tool name in `_meta.claudeCode.toolName`, the command and its
|
||||
* description in `rawInput`, `terminal_exit` when the client advertises
|
||||
* terminal output, and a "Permission denied: <reason>" content block when a
|
||||
* rule or policy refused the call.
|
||||
*/
|
||||
export function acpxToolDetailFromRuntimeEvent(event: AcpRuntimeEventShape): AcpxToolDetail {
|
||||
if (event.type !== "tool_call") return {};
|
||||
const meta = record(event._meta);
|
||||
const claude = record(meta.claudeCode);
|
||||
const input = record(event.rawInput);
|
||||
const detail: AcpxToolDetail = {};
|
||||
const toolName = boundedDetailText(claude.toolName, 240);
|
||||
if (toolName) detail.toolName = toolName;
|
||||
const command = boundedDetailText(input.command, MAX_ACPX_TOOL_METADATA_BYTES);
|
||||
const pattern = boundedDetailText(input.pattern, 1_000);
|
||||
const subject = command
|
||||
?? (pattern ? `"${pattern}"` : undefined)
|
||||
?? boundedDetailText(input.url, 2_000)
|
||||
?? boundedDetailText(input.query, 1_000)
|
||||
?? boundedDetailText(input.skill, 240);
|
||||
if (subject) detail.subject = subject;
|
||||
const description = boundedDetailText(input.description, 1_000) ?? boundedDetailText(claude.title, 1_000);
|
||||
if (description && description !== subject) detail.description = description;
|
||||
const status = canonicalStatus(event.status, "running");
|
||||
const response = record(claude.toolResponse);
|
||||
const output = status === "running" ? "" : acpxToolContentText(event);
|
||||
const denied = /^Permission denied:\s*([\s\S]*)$/.exec(output.trim());
|
||||
const reason = boundedDetailText(response.decisionReason, 1_000)
|
||||
?? boundedDetailText(response.message, 1_000)
|
||||
?? (denied ? boundedDetailText(denied[1], 1_000) : undefined);
|
||||
if (status === "failed" && (denied || response.decisionReasonType !== undefined)) {
|
||||
detail.blockedReason = reason ?? "Permission denied";
|
||||
return detail;
|
||||
}
|
||||
const exit = integer(record(meta.terminal_exit).exit_code);
|
||||
// Claude Code reports a non-zero Bash exit as a failed tool result and
|
||||
// prefixes its text with "Exit code N", so its status implies the exit.
|
||||
// Other ACP agents may complete a failing command; claim nothing for them
|
||||
// unless they send terminal_exit.
|
||||
const shell = toolName === "Bash";
|
||||
if (exit !== null) detail.exitCode = exit;
|
||||
else if (shell && status === "completed") detail.exitCode = 0;
|
||||
else if (shell && status === "failed") {
|
||||
const code = /(?:^|\n)Exit code (-?\d+)\b/.exec(output);
|
||||
if (code) detail.exitCode = Number(code[1]);
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
function boundedToolDetail(value: unknown): AcpxToolDetail | undefined {
|
||||
const input = record(value);
|
||||
const detail: AcpxToolDetail = {};
|
||||
for (const key of ACPX_TOOL_DETAIL_KEYS) {
|
||||
const item = input[key];
|
||||
if (key === "exitCode" || key === "durationMs") {
|
||||
const number = integer(item);
|
||||
if (number !== null && (key === "exitCode" || number >= 0)) detail[key] = number;
|
||||
} else {
|
||||
const bounded = boundedDetailText(item, key === "toolName" ? 240 : key === "subject" ? MAX_ACPX_TOOL_METADATA_BYTES : 1_000);
|
||||
if (bounded) detail[key] = bounded;
|
||||
}
|
||||
}
|
||||
return Object.keys(detail).length > 0 ? detail : undefined;
|
||||
}
|
||||
|
||||
/** Bound any forwarded `_meta` on a runtime event, whatever its type. */
|
||||
function withBoundedAcpMeta<T extends AcpRuntimeEventShape>(event: T): T {
|
||||
if (!Object.prototype.hasOwnProperty.call(event, "_meta")) return event;
|
||||
@@ -389,7 +502,8 @@ export function createGrokMessageNormalizer<T extends AcpRuntimeEventShape>(): (
|
||||
|
||||
export function createAcpxToolEventNormalizer<
|
||||
T extends AcpRuntimeEventShape,
|
||||
>(): (event: T) => T {
|
||||
>(options: { now?: () => number } = {}): (event: T) => T {
|
||||
const now = options.now ?? Date.now;
|
||||
const lifecycle = new Map<string, AcpxToolLifecycleIdentity>();
|
||||
return (input) => {
|
||||
const event = withBoundedAcpMeta(input);
|
||||
@@ -414,7 +528,10 @@ export function createAcpxToolEventNormalizer<
|
||||
const incomingTitle = isGenericAcpToolName(boundedTitle)
|
||||
? undefined
|
||||
: boundedTitle?.trim();
|
||||
const title = previous.title ?? incomingTitle;
|
||||
// ACP titles are refined as input streams in: Claude first says
|
||||
// "Terminal" or "Preparing file…", then the command or path. Keep the
|
||||
// latest concrete title, not the first placeholder.
|
||||
const title = incomingTitle ?? previous.title;
|
||||
const kind =
|
||||
previous.kind && previous.kind !== "other"
|
||||
? previous.kind
|
||||
@@ -431,6 +548,16 @@ export function createAcpxToolEventNormalizer<
|
||||
? eventText
|
||||
: previous.progress;
|
||||
const status = canonicalStatus(boundedStatus, "running");
|
||||
const startedAt = previous.startedAt ?? now();
|
||||
// A sidecar computes the detail before rawInput is dropped; an in-process
|
||||
// driver hands over the raw event. Later updates refine earlier facts.
|
||||
const incomingDetail = boundedToolDetail(
|
||||
event.paperclipToolDetail ?? acpxToolDetailFromRuntimeEvent(event),
|
||||
);
|
||||
const detail: AcpxToolDetail = { ...previous.detail, ...incomingDetail };
|
||||
if (status !== "running") {
|
||||
detail.durationMs = detail.durationMs ?? Math.max(0, Math.round(now() - startedAt));
|
||||
}
|
||||
const normalized = {
|
||||
...event,
|
||||
toolCallId,
|
||||
@@ -440,6 +567,7 @@ export function createAcpxToolEventNormalizer<
|
||||
status: boundedStatus,
|
||||
tag: boundedTag,
|
||||
text: progress && status === "running" ? progress : boundedText,
|
||||
...(Object.keys(detail).length > 0 ? { paperclipToolDetail: detail } : {}),
|
||||
} as T;
|
||||
if (status === "running") {
|
||||
if (
|
||||
@@ -449,7 +577,7 @@ export function createAcpxToolEventNormalizer<
|
||||
const oldest = lifecycle.keys().next().value;
|
||||
if (oldest !== undefined) lifecycle.delete(oldest);
|
||||
}
|
||||
lifecycle.set(toolCallId, { title, kind, locations, progress });
|
||||
lifecycle.set(toolCallId, { title, kind, locations, progress, detail, startedAt });
|
||||
} else {
|
||||
lifecycle.delete(toolCallId);
|
||||
}
|
||||
@@ -1445,12 +1573,17 @@ export function canonicalProviderEventsFromAcpxRuntimeEvent(
|
||||
: event.rawOutput === undefined
|
||||
? ""
|
||||
: JSON.stringify(event.rawOutput);
|
||||
const detail = boundedToolDetail(event.paperclipToolDetail) ?? {};
|
||||
const title = isGenericAcpToolName(event.title)
|
||||
? ""
|
||||
: text(event.title).trim();
|
||||
const mcp = acpxMcpToolIdentity(title);
|
||||
const name = mcp?.name ?? title;
|
||||
// The native tool name ("Bash", "Write", "mcp__server__tool") beats the
|
||||
// display title, which is free text such as "Terminal" or a command line.
|
||||
const nativeName = detail.toolName ?? title;
|
||||
const mcp = acpxMcpToolIdentity(nativeName);
|
||||
const name = mcp?.name ?? nativeName;
|
||||
const operation = acpxToolOperation(event.kind, name);
|
||||
const inputPath = acpxToolInputPath(event);
|
||||
// ACP `diff` content: Claude Edit/Write send old/new text at tool_use time
|
||||
// and a hunk-exact replacement after the edit lands.
|
||||
const fileChanges = toolFileChangesFromAcpContent(
|
||||
@@ -1464,7 +1597,10 @@ export function canonicalProviderEventsFromAcpxRuntimeEvent(
|
||||
transport: mcp ? "mcp" : "builtin",
|
||||
operation,
|
||||
name: name || null,
|
||||
target: safeAcpLocation(event.locations?.[0]) ?? fileChanges?.[0]?.path ?? null,
|
||||
target:
|
||||
safeAcpLocation(event.locations?.[0]) ??
|
||||
fileChanges?.[0]?.path ??
|
||||
(inputPath ? safeAcpLocation({ path: inputPath }) : null),
|
||||
namespace: mcp?.namespace ?? null,
|
||||
readOnly: ["read", "search", "list"].includes(operation),
|
||||
...(Object.prototype.hasOwnProperty.call(event, "rawInput")
|
||||
@@ -1473,10 +1609,13 @@ export function canonicalProviderEventsFromAcpxRuntimeEvent(
|
||||
? { inputUpdated: event.inputUpdated }
|
||||
: {}),
|
||||
status,
|
||||
durationMs: null,
|
||||
exitCode: null,
|
||||
durationMs: status === "running" ? null : (detail.durationMs ?? null),
|
||||
exitCode: status === "running" ? null : (detail.exitCode ?? null),
|
||||
progress:
|
||||
status === "running" ? text(event.text).slice(0, 4000) || null : null,
|
||||
...(detail.subject ? { subject: detail.subject } : {}),
|
||||
...(detail.description ? { description: detail.description } : {}),
|
||||
...(detail.blockedReason ? { blockedReason: detail.blockedReason } : {}),
|
||||
...boundedOutput(output),
|
||||
...(parentToolCallId
|
||||
? { parentItemId: safeId(parentToolCallId, "acpx-item") }
|
||||
|
||||
@@ -67,7 +67,7 @@ describe("TaskChatLiveTail", () => {
|
||||
"Looking into the failing test.",
|
||||
);
|
||||
expect(toggle().getAttribute("aria-expanded")).toBe("false");
|
||||
expect(viewport().textContent).toContain("Read a file");
|
||||
expect(viewport().textContent).toContain("Read");
|
||||
expect(viewport().textContent).toContain("src/app.ts");
|
||||
expect(container.querySelector('[data-testid="task-chat-tool-card"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="task-chat-phase-child-rail"]')).toBeNull();
|
||||
@@ -170,12 +170,12 @@ describe("TaskChatLiveTail", () => {
|
||||
entries.push({ kind: "tool_result", ts: TS, toolUseId: "t1", content: "clean", isError: false });
|
||||
render(parse(entries));
|
||||
expect(viewport().querySelector("[data-activity-row]")).toBe(first);
|
||||
expect(viewport().textContent).toContain("Ran a command");
|
||||
expect(viewport().textContent).toContain("$ git status");
|
||||
expect(container.querySelector(".runner-activity-roll-in")).toBeNull();
|
||||
|
||||
entries.push({ kind: "tool_call", ts: TS, name: "Bash", toolUseId: "t2", input: { command: "pnpm test" } });
|
||||
render(parse(entries));
|
||||
expect(container.querySelector(".runner-activity-roll-in")?.textContent).toContain("Running a command");
|
||||
expect(container.querySelector(".runner-activity-roll-in")?.textContent).toContain("Running$ pnpm test");
|
||||
const outgoing = container.querySelector(".runner-activity-roll-out")!;
|
||||
expect(outgoing.getAttribute("aria-hidden")).toBe("true");
|
||||
flushSync(() => outgoing.dispatchEvent(new Event("webkitAnimationEnd", { bubbles: true })));
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { TaskChatRunnerActivityGroup, foldReadRuns, formatToolDuration } from "./TaskChatRunnerActivityGroup";
|
||||
import type { TaskChatActivityPhaseItem, TaskChatToolItem } from "./task-chat-model";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const tool = (id: string, fields: Partial<TaskChatToolItem>): TaskChatToolItem => ({
|
||||
id, kind: "tool", name: "Bash", rawName: "Bash", status: "completed", ...fields,
|
||||
});
|
||||
const read = (id: string, path: string) => tool(id, { name: "Read", rawName: "Read", operation: "read", target: path, exitCode: 0 });
|
||||
const phase = (items: TaskChatToolItem[]): TaskChatActivityPhaseItem => ({ id: "phase", kind: "activity_phase", items, active: false, summary: "" });
|
||||
|
||||
describe("tool row grammar", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("formats durations compactly", () => {
|
||||
expect([120, 1_840, 14_200, 123_000].map(formatToolDuration)).toEqual(["120ms", "1.8s", "14s", "2m 03s"]);
|
||||
});
|
||||
|
||||
it("folds three or more consecutive reads and leaves shorter runs alone", () => {
|
||||
const rows = foldReadRuns([read("a", "a.ts"), read("b", "b.ts"), read("c", "c.ts"), tool("x", { target: "ls" }), read("d", "d.ts")]);
|
||||
expect(rows.map((row) => row.kind)).toEqual(["reads", "single", "single"]);
|
||||
});
|
||||
|
||||
it("keeps failure and stop markers and folds only file reads", () => {
|
||||
expect(foldReadRuns([
|
||||
tool("s1", { name: "Skill", rawName: "Skill", target: "pdf" }),
|
||||
tool("s2", { name: "Skill", rawName: "Skill", target: "xlsx" }),
|
||||
tool("s3", { name: "Skill", rawName: "Skill", target: "docx" }),
|
||||
]).map((row) => row.kind)).toEqual(["single", "single", "single"]);
|
||||
act(() => root.render(
|
||||
<TaskChatRunnerActivityGroup
|
||||
defaultExpanded
|
||||
item={phase([
|
||||
tool("f1", { name: "Read", rawName: "Read", target: "src/missing.ts", status: "failed" }),
|
||||
tool("f2", { operation: "execute", target: "sleep 100", status: "interrupted" }),
|
||||
])}
|
||||
/>,
|
||||
));
|
||||
expect(container.querySelector('[data-activity-row="f1"]')?.textContent).toContain("Read · failed");
|
||||
expect(container.querySelector('[data-activity-row="f2"]')?.textContent).toContain("· stopped");
|
||||
});
|
||||
|
||||
it("shows the command with its exit code and duration, hides exit 0 on reads, and explains a refusal", () => {
|
||||
act(() => root.render(
|
||||
<TaskChatRunnerActivityGroup
|
||||
defaultExpanded
|
||||
item={phase([
|
||||
read("r1", "src/a.ts"), read("r2", "src/b.ts"), read("r3", "src/c.ts"), read("r4", "src/d.ts"),
|
||||
tool("t1", { operation: "execute", target: "node --test", status: "failed", exitCode: 1, durationMs: 1_840 }),
|
||||
tool("t2", { operation: "execute", target: "rm -rf build", status: "failed", blockedReason: "Denied by rule" }),
|
||||
])}
|
||||
/>,
|
||||
));
|
||||
const readRun = container.querySelector("[data-activity-read-run]");
|
||||
expect(readRun?.textContent).toContain("Read 4 files");
|
||||
expect(readRun?.textContent).toContain("a.ts, b.ts, c.ts +1");
|
||||
expect(readRun?.querySelector("[data-tool-exit-code]")).toBeNull();
|
||||
const command = container.querySelector('[data-activity-row="t1"]');
|
||||
expect(command?.textContent).toContain("$ node --test");
|
||||
expect(command?.querySelector("[data-tool-exit-code]")?.textContent).toBe("exit 1");
|
||||
expect(command?.querySelector("[data-tool-duration]")?.textContent).toBe("1.8s");
|
||||
const blocked = container.querySelector('[data-activity-row="t2"]');
|
||||
expect(blocked?.textContent).toContain("Blocked");
|
||||
expect(blocked?.querySelector("[data-tool-blocked-reason]")?.textContent).toBe("Denied by rule");
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
Brain,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
BookOpen,
|
||||
CirclePause,
|
||||
Gauge,
|
||||
ShieldX,
|
||||
} from "lucide-react";
|
||||
import { useReducedMotion } from "motion/react";
|
||||
import { MarkdownBody } from "@/components/MarkdownBody";
|
||||
@@ -33,6 +35,40 @@ import { useTaskChatShimmer } from "./TaskChatShimmer";
|
||||
import { TaskChatDiffCounts, TaskChatDiffView } from "./TaskChatDiffView";
|
||||
|
||||
type Activity = TaskChatActivityPhaseItem["items"][number];
|
||||
type ToolActivity = Extract<Activity, { kind: "tool" }>;
|
||||
|
||||
/** Families whose rows read as "<verb> <subject>", for example "Ran $ node --test". */
|
||||
const SUBJECT_FAMILIES = new Set(["terminal", "read", "edit", "grep", "search", "web"]);
|
||||
const READ_RUN_MIN = 3;
|
||||
|
||||
/** "120ms", "1.2s", "14s", "2m 03s". */
|
||||
export function formatToolDuration(ms: number): string {
|
||||
if (ms < 1_000) return `${Math.max(0, Math.round(ms))}ms`;
|
||||
if (ms < 10_000) return `${(ms / 1_000).toFixed(1)}s`;
|
||||
const seconds = Math.round(ms / 1_000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
function toolPresentation(item: ToolActivity) {
|
||||
// Classify by the native name only: a canonical operation is a weak guess
|
||||
// for MCP tools (Claude reports most of them as "execute").
|
||||
return toolActivityPresentation({
|
||||
name: item.rawName ?? item.name,
|
||||
target: item.target,
|
||||
});
|
||||
}
|
||||
|
||||
/** Only file reads fold; Skill, LSP and task lookups are also "read" family. */
|
||||
const FILE_READ_TOOLS = new Set(["read", "notebookread", "notebook_read"]);
|
||||
|
||||
function isReadTool(item: Activity): item is ToolActivity {
|
||||
return item.kind === "tool"
|
||||
&& item.status === "completed"
|
||||
&& !item.blockedReason
|
||||
&& Boolean(item.target?.trim())
|
||||
&& FILE_READ_TOOLS.has((item.rawName ?? item.name).trim().toLowerCase());
|
||||
}
|
||||
|
||||
interface ActivityRowPresentation {
|
||||
icon: ToolIcon;
|
||||
@@ -44,6 +80,12 @@ interface ActivityRowPresentation {
|
||||
iconClassName?: string;
|
||||
/** Short plain-text outcome after the target, e.g. "6 tests passed". */
|
||||
trailing?: ReactNode;
|
||||
/** Process exit code shown as a chip; null hides it. */
|
||||
exitCode?: number | null;
|
||||
/** Tool duration shown after the chip; null hides it. */
|
||||
durationMs?: number | null;
|
||||
/** Why a policy or rule refused the tool. */
|
||||
blocked?: string;
|
||||
}
|
||||
|
||||
function providerTrailing(item: Extract<Activity, { kind: "protocol" }>): Pick<ActivityRowPresentation, "iconClassName" | "trailing"> {
|
||||
@@ -62,25 +104,51 @@ function providerTrailing(item: Extract<Activity, { kind: "protocol" }>): Pick<A
|
||||
|
||||
function presentation(item: Activity, active: boolean): ActivityRowPresentation | null {
|
||||
if (item.kind === "tool") {
|
||||
const tool = toolActivityPresentation({
|
||||
name: item.rawName ?? item.name,
|
||||
target: item.target,
|
||||
});
|
||||
const tool = toolPresentation(item);
|
||||
const running =
|
||||
active && (item.status === "pending" || item.status === "in_progress");
|
||||
const label =
|
||||
item.status === "failed"
|
||||
? tool.failedLabel
|
||||
: item.status === "interrupted"
|
||||
? tool.interruptedLabel
|
||||
: running
|
||||
? tool.runningLabel
|
||||
: tool.completedLabel;
|
||||
const terminal = tool.family === "terminal";
|
||||
const subject = item.target?.trim();
|
||||
// With a concrete subject, the verb alone reads better: "Read README.md".
|
||||
// A finished command needs no verb at all: the icon and "$" say it ran.
|
||||
// A failure or stop keeps its marker ("Read · failed") unless an exit
|
||||
// chip already says how a command ended.
|
||||
const marker = item.status === "failed" || item.status === "interrupted" ? label.split(" · ").at(1) : undefined;
|
||||
const verb = subject && SUBJECT_FAMILIES.has(tool.family)
|
||||
? terminal && !running && (item.status === "completed" || item.exitCode != null)
|
||||
? ""
|
||||
: `${label.split(" ")[0]!}${marker ? ` · ${marker}` : ""}`
|
||||
: label;
|
||||
// Exit 0 is only news for commands; for reads and searches it is noise.
|
||||
const exitCode = item.exitCode ?? null;
|
||||
const shownExit = exitCode === 0 && !terminal ? null : exitCode;
|
||||
if (item.blockedReason) {
|
||||
return {
|
||||
icon: ShieldX,
|
||||
label: "Blocked",
|
||||
target: subject ? (terminal ? `$ ${subject}` : subject) : undefined,
|
||||
mono: true,
|
||||
running: false,
|
||||
blocked: item.blockedReason,
|
||||
};
|
||||
}
|
||||
return {
|
||||
icon: tool.icon,
|
||||
label:
|
||||
item.status === "failed"
|
||||
? tool.failedLabel
|
||||
: item.status === "interrupted"
|
||||
? tool.interruptedLabel
|
||||
: running
|
||||
? tool.runningLabel
|
||||
: tool.completedLabel,
|
||||
target: item.target,
|
||||
label: verb,
|
||||
target: subject ? (terminal ? `$ ${subject}` : subject) : undefined,
|
||||
mono: true,
|
||||
running,
|
||||
exitCode: running ? null : shownExit,
|
||||
// Codex reports 0 for calls it did not time; 0ms is never informative.
|
||||
durationMs: running || !item.durationMs ? null : item.durationMs,
|
||||
};
|
||||
}
|
||||
if (item.kind === "thinking") {
|
||||
@@ -150,6 +218,9 @@ function ActivityContent({
|
||||
const shimmer = useTaskChatShimmer(item.id);
|
||||
if (!row) return null;
|
||||
const Icon = row.icon;
|
||||
const exitCode = "exitCode" in row ? row.exitCode : null;
|
||||
const durationMs = "durationMs" in row ? row.durationMs : null;
|
||||
const blocked = "blocked" in row ? row.blocked : undefined;
|
||||
return (
|
||||
<span
|
||||
className="flex min-w-0 flex-1 items-center gap-2"
|
||||
@@ -166,10 +237,10 @@ function ActivityContent({
|
||||
className="flex size-5 shrink-0 items-center justify-center"
|
||||
data-activity-icon
|
||||
>
|
||||
<Icon className={cn("size-3.5", row.iconClassName)} aria-hidden="true" />
|
||||
<Icon className={cn("size-3.5", row.iconClassName, blocked && "text-destructive")} aria-hidden="true" />
|
||||
</span>
|
||||
<span data-conversation-tool-running={row.running && shimmer && (item.kind === "tool" || (item.kind === "protocol" && item.surface === "provider_activity" && item.family === "tool_execution")) ? "true" : undefined} className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden whitespace-nowrap">
|
||||
<span
|
||||
{row.label ? <span
|
||||
className={cn(
|
||||
"max-w-full shrink-0 truncate text-xs",
|
||||
row.running && "text-foreground",
|
||||
@@ -177,7 +248,7 @@ function ActivityContent({
|
||||
)}
|
||||
>
|
||||
{row.label}
|
||||
</span>
|
||||
</span> : null}
|
||||
{row.target ? (
|
||||
<span
|
||||
title={row.target}
|
||||
@@ -189,12 +260,35 @@ function ActivityContent({
|
||||
{row.target}
|
||||
</span>
|
||||
) : null}
|
||||
{blocked ? (
|
||||
<span title={blocked} className="min-w-0 truncate text-xs text-destructive" data-tool-blocked-reason>
|
||||
{blocked}
|
||||
</span>
|
||||
) : null}
|
||||
{row.trailing ? (
|
||||
<span className="min-w-0 truncate text-xs text-muted-foreground" data-testid="task-chat-activity-trailing">
|
||||
{row.trailing}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{exitCode != null || durationMs != null ? (
|
||||
<span className="flex shrink-0 items-center gap-2 text-(length:--text-micro) tabular-nums" data-tool-outcome>
|
||||
{exitCode != null ? (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-sm px-1 font-mono leading-4",
|
||||
exitCode === 0 ? "bg-muted text-muted-foreground" : "bg-destructive/10 text-destructive",
|
||||
)}
|
||||
data-tool-exit-code={exitCode}
|
||||
>
|
||||
exit {exitCode}
|
||||
</span>
|
||||
) : null}
|
||||
{durationMs != null ? (
|
||||
<span className="text-muted-foreground" data-tool-duration>{formatToolDuration(durationMs)}</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
{item.kind === "tool" && item.diff && (item.diff.added > 0 || item.diff.removed > 0) ? (
|
||||
<TaskChatDiffCounts
|
||||
added={item.diff.added}
|
||||
@@ -276,9 +370,11 @@ function ActivityDetails({ item }: { item: Activity }) {
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{item.description ? <p>{item.description}</p> : null}
|
||||
{item.target ? (
|
||||
<p className="break-all font-mono">{item.target}</p>
|
||||
) : null}
|
||||
{item.blockedReason ? <p className="text-destructive">Blocked: {item.blockedReason}</p> : null}
|
||||
{item.detail ? (
|
||||
<pre className="whitespace-pre-wrap break-words font-mono">
|
||||
{item.detail}
|
||||
@@ -299,7 +395,7 @@ function hasActivityDetails(item: Activity): boolean {
|
||||
if (item.kind === "thinking") return item.lines.some((line) => line.trim());
|
||||
if (item.kind === "tool")
|
||||
return Boolean(
|
||||
item.target?.trim() || item.detail?.trim() || item.diff || item.decision,
|
||||
item.target?.trim() || item.detail?.trim() || item.diff || item.decision || item.blockedReason || item.description,
|
||||
);
|
||||
if (item.kind === "marker") return Boolean(item.detail?.trim());
|
||||
return Boolean(
|
||||
@@ -362,6 +458,65 @@ function ExpandedActivity({
|
||||
);
|
||||
}
|
||||
|
||||
type HistoryRow = { kind: "single"; item: Activity; index: number } | { kind: "reads"; items: ToolActivity[]; index: number };
|
||||
|
||||
/** Fold runs of completed file reads into one row; everything else stays one row per activity. */
|
||||
export function foldReadRuns(activities: Activity[]): HistoryRow[] {
|
||||
const rows: HistoryRow[] = [];
|
||||
for (let index = 0; index < activities.length; ) {
|
||||
let end = index;
|
||||
while (end < activities.length && isReadTool(activities[end]!)) end += 1;
|
||||
if (end - index >= READ_RUN_MIN) {
|
||||
rows.push({ kind: "reads", items: activities.slice(index, end) as ToolActivity[], index: end - 1 });
|
||||
index = end;
|
||||
} else {
|
||||
rows.push({ kind: "single", item: activities[index]!, index });
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function readRunPreview(items: ToolActivity[]): string {
|
||||
const names = items.flatMap((item) => item.target?.trim() ? [item.target.trim().split("/").at(-1)!] : []);
|
||||
return names.length > 3 ? `${names.slice(0, 3).join(", ")} +${names.length - 3}` : names.join(", ");
|
||||
}
|
||||
|
||||
function ReadRunActivity({ items }: { items: ToolActivity[] }) {
|
||||
const id = `runner-reads:${items[0]!.id}`;
|
||||
const [open, setOpen] = useTaskChatExpansion(id, false);
|
||||
const listId = useId();
|
||||
return (
|
||||
<li className="min-w-0" data-activity-read-run={items.length}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full min-w-0 items-center gap-2 rounded-sm text-left text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? listId : undefined}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className="flex size-5 shrink-0 items-center justify-center">
|
||||
<BookOpen className="size-3.5" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden whitespace-nowrap">
|
||||
<span className="shrink-0 text-xs">Read {items.length} files</span>
|
||||
<span className="truncate font-mono text-xs text-muted-foreground">{readRunPreview(items)}</span>
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight className={cn("size-3.5 shrink-0", open && "rotate-90")} aria-hidden="true" />
|
||||
</button>
|
||||
{open ? (
|
||||
<ol id={listId} className="ml-2.5 flex min-w-0 flex-col border-l border-border pl-2" aria-label="Files read">
|
||||
{items.map((item) => (
|
||||
<ExpandedActivity key={item.id} item={item} active={false} />
|
||||
))}
|
||||
</ol>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** One rolling activity between commentary messages, with persistent optional history. */
|
||||
export function TaskChatRunnerActivityGroup({
|
||||
item,
|
||||
@@ -462,13 +617,17 @@ export function TaskChatRunnerActivityGroup({
|
||||
aria-label="Activity history"
|
||||
data-testid="task-chat-runner-activity-list"
|
||||
>
|
||||
{activities.map((activity, index) => (
|
||||
<ExpandedActivity
|
||||
key={activity.id}
|
||||
item={activity}
|
||||
active={item.active && index === activities.length - 1}
|
||||
/>
|
||||
))}
|
||||
{foldReadRuns(activities).map((row) =>
|
||||
row.kind === "reads" ? (
|
||||
<ReadRunActivity key={row.items[0]!.id} items={row.items} />
|
||||
) : (
|
||||
<ExpandedActivity
|
||||
key={row.item.id}
|
||||
item={row.item}
|
||||
active={item.active && row.index === activities.length - 1}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</ol>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -309,7 +309,7 @@ describe("TaskChatRunnerTurn", () => {
|
||||
const commentary = container.querySelector('[data-testid="task-chat-phase-interstitial"]');
|
||||
const activity = container.querySelector('[data-testid="task-chat-activity-viewport"]');
|
||||
expect(commentary?.textContent).toContain("Running the exact command now.");
|
||||
expect(activity?.textContent).toContain("Running a command");
|
||||
expect(activity?.textContent).toContain("Running$ ");
|
||||
expect(activity?.textContent).toContain("STREAM-$i");
|
||||
expect(container.querySelector('[data-testid="task-chat-current-activity"]')).toBeNull();
|
||||
expect(commentary?.compareDocumentPosition(activity!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
|
||||
@@ -227,6 +227,16 @@ export interface TaskChatToolItem {
|
||||
diff?: TaskChatDiff;
|
||||
/** Resolved permission decision badge, when one applied. */
|
||||
decision?: "allowed" | "rejected";
|
||||
/** Canonical operation (read, search, list, execute, edit) when known. */
|
||||
operation?: string;
|
||||
/** Provider's short description of the call, such as Claude's Bash description. */
|
||||
description?: string;
|
||||
/** Process exit code, when the harness reported one. */
|
||||
exitCode?: number | null;
|
||||
/** Wall-clock tool duration in milliseconds, when known. */
|
||||
durationMs?: number | null;
|
||||
/** Why the harness or a policy refused to run the tool. */
|
||||
blockedReason?: string;
|
||||
/** Execution id of the delegation (subagent) that made this call. */
|
||||
parentToolUseId?: string;
|
||||
}
|
||||
|
||||
@@ -658,6 +658,24 @@ export function reconcileSegmentRuntimeRequests(
|
||||
* assistant deltas and threading tool_result/diff onto their tool_call. Mirrors
|
||||
* buildAssistantPartsFromTranscript's grouping, emitting our presentation model.
|
||||
*/
|
||||
/**
|
||||
* Apply canonical tool facts from the runner. The subject (command, pattern,
|
||||
* URL) or the workspace-relative path becomes the row target; the generic
|
||||
* "operation: …" input summary is never shown when canonical data exists.
|
||||
*/
|
||||
function applyToolDetail(tool: TaskChatToolItem, detail: NonNullable<Extract<TranscriptEntry, { kind: "tool_call" }>["detail"]>) {
|
||||
if (detail.operation) tool.operation = detail.operation;
|
||||
const subject = detail.operation === "read" || detail.operation === "list" || detail.operation === "edit"
|
||||
? detail.target ?? detail.subject
|
||||
: detail.subject ?? detail.target;
|
||||
// Older runs carry no subject; keep the input summary they already had.
|
||||
if (subject) tool.target = clip(subject, 400);
|
||||
if (detail.description) tool.description = detail.description;
|
||||
if (detail.exitCode !== undefined) tool.exitCode = detail.exitCode;
|
||||
if (detail.durationMs !== undefined) tool.durationMs = detail.durationMs;
|
||||
if (detail.blockedReason) tool.blockedReason = detail.blockedReason;
|
||||
}
|
||||
|
||||
export function transcriptToTaskChatItems(
|
||||
entries: readonly TranscriptEntry[],
|
||||
{ runId, agentName, running }: TranscriptAdapterOptions,
|
||||
@@ -808,7 +826,11 @@ export function transcriptToTaskChatItems(
|
||||
// generic name must never displace the initial call's real one. ACP
|
||||
// also retitles a call to its invocation once known ("Terminal" →
|
||||
// "ls -la"); that is detail for the target slot, not a new identity.
|
||||
if (!isGenericToolName(entry.name)) {
|
||||
if (entry.detail && !isGenericToolName(entry.name)) {
|
||||
// Canonical runner data: the latest native name is authoritative.
|
||||
existing.name = toolDisplayName(entry.name);
|
||||
existing.rawName = entry.name ?? undefined;
|
||||
} else if (!isGenericToolName(entry.name)) {
|
||||
if (isGenericToolName(existing.rawName)) {
|
||||
existing.name = toolDisplayName(entry.name);
|
||||
existing.rawName = entry.name ?? undefined;
|
||||
@@ -816,9 +838,10 @@ export function transcriptToTaskChatItems(
|
||||
existing.target = clip(entry.name!, TARGET_MAX);
|
||||
}
|
||||
}
|
||||
if (entry.detail) applyToolDetail(existing, entry.detail);
|
||||
lastToolIndex = existingIndex!;
|
||||
} else {
|
||||
items.push({
|
||||
const tool: TaskChatToolItem = {
|
||||
id: `${runId}:tool:${toolCallId}`,
|
||||
kind: "tool",
|
||||
name: toolDisplayName(entry.name),
|
||||
@@ -826,7 +849,9 @@ export function transcriptToTaskChatItems(
|
||||
target: summarizeToolInput(entry.input),
|
||||
status: "in_progress",
|
||||
...(entry.parentToolUseId ? { parentToolUseId: entry.parentToolUseId } : {}),
|
||||
});
|
||||
};
|
||||
if (entry.detail) applyToolDetail(tool, entry.detail);
|
||||
items.push(tool);
|
||||
toolIndexById.set(toolCallId, items.length - 1);
|
||||
lastToolIndex = items.length - 1;
|
||||
}
|
||||
|
||||
@@ -1189,6 +1189,55 @@ describe("nativeRunEventsToTranscript", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool execution facts", () => {
|
||||
const tool = (seq: number, eventType: string, fields: Record<string, unknown>) =>
|
||||
event(seq, eventType, {
|
||||
schema: "paperclip.tool.execution.v1",
|
||||
executionId: "toolu_bash",
|
||||
transport: "builtin",
|
||||
operation: "execute",
|
||||
name: "Bash",
|
||||
target: null,
|
||||
status: "running",
|
||||
durationMs: null,
|
||||
exitCode: null,
|
||||
output: null,
|
||||
outputBytes: 0,
|
||||
outputTruncated: false,
|
||||
outputDigest: null,
|
||||
...fields,
|
||||
});
|
||||
|
||||
it("carries the refined command, exit code, duration and refusal into the task-chat row", () => {
|
||||
const transcript = nativeRunEventsToTranscript([
|
||||
tool(1, "tool.execution.started", { name: "Terminal" }),
|
||||
tool(2, "tool.execution.progressed", { subject: "node --test", description: "Run the unit tests" }),
|
||||
tool(3, "tool.execution.completed", {
|
||||
subject: "node --test", description: "Run the unit tests", status: "failed", exitCode: 1, durationMs: 1840,
|
||||
output: "not ok 2", outputBytes: 8,
|
||||
}),
|
||||
event(4, "tool.execution.completed", {
|
||||
schema: "paperclip.tool.execution.v1", executionId: "toolu_rm", transport: "builtin", operation: "execute",
|
||||
name: "Bash", target: null, status: "failed", durationMs: 3, exitCode: null, subject: "rm -rf build",
|
||||
blockedReason: "Denied by rule", output: null, outputBytes: 0, outputTruncated: false, outputDigest: null,
|
||||
}),
|
||||
event(5, "tool.execution.completed", {
|
||||
schema: "paperclip.tool.execution.v1", executionId: "exec-read", transport: "process", operation: "read",
|
||||
name: "/bin/zsh -lc 'cat src/db.ts'", target: "src/db.ts", subject: "cat src/db.ts", status: "completed",
|
||||
durationMs: 19, exitCode: 0, output: "", outputBytes: 0, outputTruncated: false, outputDigest: null,
|
||||
}),
|
||||
]);
|
||||
const tools = transcriptToTaskChatItems(transcript, { runId: RUN_ID, running: false }).filter((item) => item.kind === "tool");
|
||||
expect(tools).toEqual([
|
||||
expect.objectContaining({
|
||||
rawName: "Bash", target: "node --test", description: "Run the unit tests", status: "failed", exitCode: 1, durationMs: 1840,
|
||||
}),
|
||||
expect.objectContaining({ rawName: "Bash", target: "rm -rf build", blockedReason: "Denied by rule" }),
|
||||
expect.objectContaining({ rawName: "Read", operation: "read", target: "src/db.ts", exitCode: 0, durationMs: 19 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edit diffs", () => {
|
||||
const execution = (status: string, fileChanges?: unknown[]) => ({
|
||||
schema: "paperclip.tool.execution.v1",
|
||||
@@ -1235,6 +1284,16 @@ describe("edit diffs", () => {
|
||||
expect(tool).toMatchObject({ kind: "tool", target: "src/price.js", diff: { path: "src/price.js", added: 1, removed: 1 } });
|
||||
});
|
||||
|
||||
it("attaches an edit's patch once when later events refine the call", () => {
|
||||
const entries = nativeRunEventsToTranscript([
|
||||
event(1, "tool.execution.started", execution("running", [change("@@ -1 +1 @@\n-a\n+b\n", 1, 1)])),
|
||||
event(2, "tool.execution.progressed", { ...execution("running"), subject: "edit price" }),
|
||||
event(3, "tool.execution.completed", { ...execution("completed", [change("@@ -1 +1 @@\n-a\n+b\n", 1, 1)]), durationMs: 40 }),
|
||||
]);
|
||||
expect(entries.filter((entry) => entry.kind === "tool_call").length).toBeGreaterThan(1);
|
||||
expect(entries.filter((entry) => entry.kind === "diff" && entry.changeType === "file_header")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("projects the harness change set and ends the turn with it", () => {
|
||||
const entries = nativeRunEventsToTranscript([
|
||||
event(1, "workspace.change.updated", {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { HeartbeatRunEvent } from "@paperclipai/shared";
|
||||
import type { TranscriptEntry } from "@/adapters";
|
||||
|
||||
type TranscriptToolDetail = NonNullable<Extract<TranscriptEntry, { kind: "tool_call" }>["detail"]>;
|
||||
import { isRunLogOnlyProviderEvent } from "./run-log-only-events";
|
||||
import { workspaceChangeEntry } from "@/adapters/paperclip-runner";
|
||||
import { createUnifiedDiffClassifier } from "@/components/task-chat/diff-lines";
|
||||
@@ -644,6 +646,27 @@ function serializedNativeToolResult(item: NativeToolItemDetails): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Names the task-chat tool taxonomy already knows, for a process that only read, searched or listed. */
|
||||
const PROCESS_OPERATION_NAMES: Record<string, string> = { read: "Read", search: "Grep", list: "Glob" };
|
||||
|
||||
function toolExecutionDetail(payload: Record<string, unknown>): TranscriptToolDetail {
|
||||
const status = text(payload.status);
|
||||
const number = (value: unknown) => (typeof value === "number" && Number.isSafeInteger(value) ? value : null);
|
||||
return {
|
||||
...(text(payload.operation) ? { operation: text(payload.operation)! } : {}),
|
||||
...(text(payload.subject) ? { subject: text(payload.subject)! } : {}),
|
||||
...(text(payload.target) ? { target: text(payload.target)! } : {}),
|
||||
...(text(payload.description) ? { description: text(payload.description)! } : {}),
|
||||
...(text(payload.blockedReason) ? { blockedReason: text(payload.blockedReason)! } : {}),
|
||||
...(status && status !== "running" && number(payload.exitCode) !== null
|
||||
? { exitCode: number(payload.exitCode) }
|
||||
: {}),
|
||||
...(status && status !== "running" && number(payload.durationMs) !== null
|
||||
? { durationMs: number(payload.durationMs) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function toolPresentation(
|
||||
payload: Record<string, unknown>,
|
||||
item?: NativeToolItemDetails,
|
||||
@@ -653,17 +676,16 @@ function toolPresentation(
|
||||
const reportedName = text(payload.name) ?? item?.name ?? null;
|
||||
if (transport === "process") {
|
||||
return {
|
||||
name: "Bash",
|
||||
// A read or listing without one concrete file stays a visible command.
|
||||
name: (operation && (operation === "search" || text(payload.target)) && PROCESS_OPERATION_NAMES[operation]) || "Bash",
|
||||
input: reportedName ? { command: reportedName } : { operation: operation ?? "execute" },
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: reportedName ?? operation ?? "Tool",
|
||||
input: item?.input ?? {
|
||||
...(operation ? { operation } : {}),
|
||||
...(text(payload.namespace) ? { namespace: text(payload.namespace) } : {}),
|
||||
...(text(payload.target) ? { target: text(payload.target) } : {}),
|
||||
},
|
||||
// Operation and namespace are classification data, not something a
|
||||
// person reads; only a concrete target belongs in the row's input summary.
|
||||
input: item?.input ?? (text(payload.target) ? { path: text(payload.target) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -715,6 +737,7 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
|
||||
if (id) delegationIds.add(id);
|
||||
}
|
||||
const startedToolIds = new Set<string>();
|
||||
const toolSignatures = new Map<string, string>();
|
||||
const completedToolIds = new Set<string>();
|
||||
let usageSummary: {
|
||||
ts: string;
|
||||
@@ -1075,14 +1098,28 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.eventType === "tool.execution.started" || event.eventType === "tool.execution.completed") {
|
||||
if (
|
||||
event.eventType === "tool.execution.started" ||
|
||||
event.eventType === "tool.execution.progressed" ||
|
||||
event.eventType === "tool.execution.completed"
|
||||
) {
|
||||
if (payload.schema !== TOOL_EXECUTION_SCHEMA) continue;
|
||||
const executionId = text(payload.executionId);
|
||||
if (!executionId || delegationIds.has(executionId)) continue;
|
||||
const nativeToolItem = nativeToolItemsById.get(executionId);
|
||||
const presentation = toolPresentation(payload, nativeToolItem);
|
||||
const parentToolUseId = text(payload.parentItemId);
|
||||
if (!startedToolIds.has(executionId)) {
|
||||
const detail = toolExecutionDetail(payload);
|
||||
// Providers refine a call as its input streams in ("Terminal" becomes
|
||||
// "node --test") and report the outcome at the end. Re-emit the call
|
||||
// whenever its canonical facts change; the adapter merges by id.
|
||||
const signature = JSON.stringify([presentation.name, detail]);
|
||||
const refines = Object.keys(detail).some((key) => key !== "operation");
|
||||
if (!startedToolIds.has(executionId) || (refines && toolSignatures.get(executionId) !== signature)) {
|
||||
// The latest patch is attached once, with the call's first row; a
|
||||
// refinement only updates the call's facts.
|
||||
const firstEmission = !toolSignatures.has(executionId);
|
||||
toolSignatures.set(executionId, signature);
|
||||
startedToolIds.add(executionId);
|
||||
const fileChanges = fileChangesByExecutionId.get(executionId);
|
||||
const editedPath = text(record(fileChanges?.[0])?.path);
|
||||
@@ -1095,9 +1132,10 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
|
||||
? { ...presentation.input as Record<string, unknown>, path: editedPath }
|
||||
: presentation.input,
|
||||
toolUseId: executionId,
|
||||
detail,
|
||||
...(parentToolUseId ? { parentToolUseId } : {}),
|
||||
});
|
||||
entries.push(...fileChangeDiffEntries(fileChanges, executionId, ts));
|
||||
if (firstEmission) entries.push(...fileChangeDiffEntries(fileChanges, executionId, ts));
|
||||
}
|
||||
if (event.eventType === "tool.execution.completed" && !completedToolIds.has(executionId)) {
|
||||
completedToolIds.add(executionId);
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type {
|
||||
TaskChatActivityPhaseItem,
|
||||
TaskChatMessageItem,
|
||||
TaskChatToolItem,
|
||||
} from "@/components/task-chat/task-chat-model";
|
||||
import { TaskChatRunnerActivityGroup } from "@/components/task-chat/TaskChatRunnerActivityGroup";
|
||||
|
||||
type Tool = TaskChatToolItem;
|
||||
const tool = (id: string, fields: Partial<Tool> & Pick<Tool, "name">): Tool => ({
|
||||
kind: "tool",
|
||||
id,
|
||||
status: "completed",
|
||||
...fields,
|
||||
});
|
||||
|
||||
function message(id: string, text: string): TaskChatMessageItem {
|
||||
return { id, kind: "message", author: "agent", text };
|
||||
}
|
||||
|
||||
function phase(id: string, items: TaskChatActivityPhaseItem["items"], text?: string, active = false): TaskChatActivityPhaseItem {
|
||||
return {
|
||||
id,
|
||||
kind: "activity_phase",
|
||||
items,
|
||||
active,
|
||||
summary: "",
|
||||
...(text ? { interstitial: message(`${id}:message`, text) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The same Claude calls as the runner records them today: the first ACP title
|
||||
* sticks ("Terminal", "Preparing file…"), absolute Read paths are dropped, and
|
||||
* there is no exit code or duration. Historical runs keep rendering this way.
|
||||
*/
|
||||
const claudeToday = phase("claude-today", [
|
||||
tool("c1", { name: "Read", rawName: "Read", operation: "read" }),
|
||||
tool("c2", { name: "Read", rawName: "Read", operation: "read" }),
|
||||
tool("c3", { name: "Read", rawName: "Read", operation: "read" }),
|
||||
tool("c4", { name: "Preparing file…", rawName: "Preparing file…", operation: "edit" }),
|
||||
tool("c5", { name: "Terminal", rawName: "Terminal", operation: "execute", status: "failed", detail: "not ok 2 - converts negative temperatures" }),
|
||||
tool("c6", { name: "Edit", rawName: "Edit", operation: "edit" }),
|
||||
tool("c7", { name: "Terminal", rawName: "Terminal", operation: "execute" }),
|
||||
tool("c8", { name: "Terminal", rawName: "Terminal", operation: "execute", status: "failed", detail: "Permission to use Bash with command rm -rf node_modules has been denied." }),
|
||||
], "I'll add the conversion helper, then run the tests.");
|
||||
|
||||
/** The same Claude run with real names, subjects, outcomes and a folded read run. */
|
||||
const claudeProposed = phase("claude-proposed", [
|
||||
tool("p1", { name: "Read", rawName: "Read", operation: "read", target: "README.md", durationMs: 40 }),
|
||||
tool("p2", { name: "Read", rawName: "Read", operation: "read", target: "package.json", durationMs: 31 }),
|
||||
tool("p3", { name: "Read", rawName: "Read", operation: "read", target: "src/temp.js", durationMs: 38 }),
|
||||
tool("p4", { name: "Write", rawName: "Write", operation: "edit", target: "src/convert.js", durationMs: 112, diff: { path: "src/convert.js", added: 24, removed: 0 } }),
|
||||
tool("p5", {
|
||||
name: "Bash", rawName: "Bash", operation: "execute", target: "node --test", description: "Run the unit tests",
|
||||
status: "failed", exitCode: 1, durationMs: 1_840, detail: "not ok 2 - converts negative temperatures\n# tests 6\n# pass 5\n# fail 1",
|
||||
}),
|
||||
tool("p6", { name: "Edit", rawName: "Edit", operation: "edit", target: "src/convert.js", durationMs: 96, diff: { path: "src/convert.js", added: 2, removed: 1 } }),
|
||||
tool("p7", { name: "Bash", rawName: "Bash", operation: "execute", target: "node --test", description: "Run the unit tests again", exitCode: 0, durationMs: 1_620, detail: "# tests 6\n# pass 6" }),
|
||||
tool("p8", {
|
||||
name: "Bash", rawName: "Bash", operation: "execute", target: "rm -rf node_modules", status: "failed",
|
||||
blockedReason: "Permission denied by the workspace policy",
|
||||
}),
|
||||
], "I'll add the conversion helper, then run the tests.");
|
||||
|
||||
/** A Codex run: commandActions already classify reads and searches. */
|
||||
const codexToday = phase("codex-today", [
|
||||
tool("x1", { name: "Bash", rawName: "Bash", target: "/bin/zsh -lc 'sed -n 1,120p src/server.ts'", exitCode: 0, durationMs: 31 }),
|
||||
tool("x2", { name: "Bash", rawName: "Bash", target: "/bin/zsh -lc 'sed -n 1,80p src/routes.ts'", exitCode: 0, durationMs: 24 }),
|
||||
tool("x3", { name: "Bash", rawName: "Bash", target: "/bin/zsh -lc 'cat src/db.ts'", exitCode: 0, durationMs: 19 }),
|
||||
tool("x4", { name: "Bash", rawName: "Bash", target: "/bin/zsh -lc 'cat test/server.test.ts'", exitCode: 0, durationMs: 20 }),
|
||||
tool("x5", { name: "Bash", rawName: "Bash", target: "/bin/zsh -lc 'rg -n \"TODO\" src'", exitCode: 0, durationMs: 52 }),
|
||||
tool("x6", { name: "Bash", rawName: "Bash", target: "/bin/zsh -lc 'pnpm vitest run test/server.test.ts'", exitCode: 0, durationMs: 14310 }),
|
||||
], "Reading the server before changing the handler.");
|
||||
|
||||
const codexProposed = phase("codex-proposed", [
|
||||
tool("y1", { name: "Read", rawName: "Read", operation: "read", target: "src/server.ts", exitCode: 0, durationMs: 22 }),
|
||||
tool("y2", { name: "Read", rawName: "Read", operation: "read", target: "src/routes.ts", exitCode: 0, durationMs: 19 }),
|
||||
tool("y3", { name: "Read", rawName: "Read", operation: "read", target: "src/db.ts", exitCode: 0, durationMs: 17 }),
|
||||
tool("y4", { name: "Read", rawName: "Read", operation: "read", target: "test/server.test.ts", exitCode: 0, durationMs: 18 }),
|
||||
tool("y5", { name: "Grep", rawName: "Grep", operation: "search", target: "\"TODO\" in src", exitCode: 0, durationMs: 48 }),
|
||||
tool("y6", { name: "Bash", rawName: "Bash", operation: "execute", target: "pnpm vitest run test/server.test.ts", exitCode: 0, durationMs: 14_200, detail: "✓ test/server.test.ts (9 tests) 1.31s" }),
|
||||
], "Reading the server before changing the handler.");
|
||||
|
||||
const liveClaude = phase("live-claude", [
|
||||
...claudeProposed.items.slice(0, 4),
|
||||
tool("live", { name: "Bash", rawName: "Bash", operation: "execute", target: "node --test", description: "Run the unit tests", status: "in_progress" }),
|
||||
], undefined, true);
|
||||
|
||||
function Column({ title, note, children }: { title: string; note: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="flex min-w-0 flex-1 flex-col gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
<p className="text-xs text-muted-foreground">{note}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-background p-4">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolRowsPreview({ mode = "compare", narrow = false }: { mode?: "compare" | "live" | "proposed"; narrow?: boolean }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-6 text-foreground">
|
||||
<div className={narrow ? "mx-auto flex w-full max-w-sm flex-col gap-8" : "mx-auto flex w-full max-w-5xl flex-col gap-8"}>
|
||||
<header>
|
||||
<h2 className="text-lg font-semibold">Tool rows that say what ran and how it went</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
One row grammar for Claude and Codex: verb icon, the real subject, an exit chip and the duration.
|
||||
Three or more reads in a row fold into one line. A refused tool says why.
|
||||
</p>
|
||||
</header>
|
||||
{mode === "live" ? (
|
||||
<Column title="Live, collapsed" note="The rolling line shows the command while it runs. Outcome chips appear when it ends.">
|
||||
<TaskChatRunnerActivityGroup item={liveClaude} />
|
||||
</Column>
|
||||
) : null}
|
||||
{mode === "proposed" ? (
|
||||
<Column title="Claude, proposed" note="Expanded history.">
|
||||
<TaskChatRunnerActivityGroup item={claudeProposed} defaultExpanded />
|
||||
</Column>
|
||||
) : null}
|
||||
{mode === "compare" ? (
|
||||
<>
|
||||
<div className={narrow ? "flex flex-col gap-6" : "flex gap-6"}>
|
||||
<Column title="Claude, data recorded today" note="First ACP titles stick (“Terminal”, “Preparing file…”). No command, no exit code, no duration; a denial looks like any failure. Old runs keep rendering like this.">
|
||||
<TaskChatRunnerActivityGroup item={claudeToday} defaultExpanded />
|
||||
</Column>
|
||||
<Column title="Claude, new runner data" note="Tool name from _meta.claudeCode, subject from rawInput, exit from the result, duration measured by the runner.">
|
||||
<TaskChatRunnerActivityGroup item={claudeProposed} defaultExpanded />
|
||||
</Column>
|
||||
</div>
|
||||
<div className={narrow ? "flex flex-col gap-6" : "flex gap-6"}>
|
||||
<Column title="Codex, data recorded today" note="Codex already records exit code and duration; the shell wrapper hides what the command did.">
|
||||
<TaskChatRunnerActivityGroup item={codexToday} defaultExpanded />
|
||||
</Column>
|
||||
<Column title="Codex, new runner data" note="commandActions turn sed/cat into reads and rg into a search; the wrapper is dropped.">
|
||||
<TaskChatRunnerActivityGroup item={codexProposed} defaultExpanded />
|
||||
</Column>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ToolRowsPreview } from "../../prototypes/tool-rows/ToolRowsPreview";
|
||||
|
||||
const meta = {
|
||||
id: "tasks-task-chat-tool-rows",
|
||||
title: "Tasks and conversations/Task creation and lifecycle/Tasks/Tool rows",
|
||||
component: ToolRowsPreview,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Production runner activity group with Claude and Codex tool rows: verb icon, real subject, exit chip and duration. Runs of three or more completed reads fold into one row. A refused tool shows the reason.",
|
||||
},
|
||||
},
|
||||
paperclip: {
|
||||
"capabilities": [
|
||||
"tasks/discussion",
|
||||
"runs-adapters/runs"
|
||||
],
|
||||
"status": "current",
|
||||
"implementation": "production",
|
||||
"boundary": "fixtures",
|
||||
"notes": "Fixtures mirror real Claude (ACPX) and Codex (app-server) runs. The “today” columns reproduce the current mapping of the same calls."
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof ToolRowsPreview>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Compare: Story = { name: "01 · Today vs proposed" };
|
||||
export const Live: Story = { name: "02 · Live rolling row", args: { mode: "live" } };
|
||||
export const Proposed: Story = { name: "03 · Proposed only", args: { mode: "proposed" } };
|
||||
export const Mobile: Story = { name: "04 · Narrow", args: { narrow: true } };
|
||||
Reference in new issue
Block a user