mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
feat(task-chat): show an inline diff for every file edit (#19269)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The task chat shows what an agent did during a run, including each tool call > - Codex and Claude both report the patch for every file edit, but Paperclip dropped it. Claude's ACP `diff` content was never read. The Codex `fileChange` item became a generic item with no patch. Codex `turn/diff/updated` had no native-runner mapping > - A reviewer could not see what an agent changed without opening the workspace > - This pull request carries a bounded patch on each edit call and shows it in the edit row. Each turn that changed files ends with a "Changed in this turn" card > - The benefit is that a reviewer can check every change in the conversation, with the same view for Codex and Claude ## Linked Issues or Issue Description Refs #19252 (precedent: canonical `plan.updated` events as a docked plan card). **Subsystem affected** Paperclip runner (Rust and TypeScript canonical event mapping), PRP provider-event schema, and the task-chat UI. **Problem or motivation** Edit rows say "Edited a file" with no content. Claude Edit/Write `diff` content is lost after the ACPX sidecar. The Codex native runner drops `fileChange.changes` and `turn/diff/updated`. The existing workspace-changes card showed a raw text patch in a dialog, and only on the in-process Codex path. **Proposed solution** Add an optional, bounded `fileChanges` list to `paperclip.tool.execution.v1`. Fill it from Codex `fileChange` items and Claude ACP `diff` content. Map Codex `turn/diff/updated` to `workspace.change.updated` on the native runner. In the UI, show `+N −M` on each edit row, open a compact colored diff, and end the turn with the change set. **Alternatives considered** A runner-side git diff at turn end. It needs workspace access and extra cost on every turn. The harness patches are already available. For Claude, the UI builds the turn card from the edit rows instead of new per-turn runner state. ## What Changed - Protocol: optional `fileChanges[]` on `toolExecution` in `provider-event.schema.json`, with the regenerated schema bundle, validators and manifest. Paths are workspace-relative. A patch is at most 32 KiB, and a call has at most 32 files. - Runner (Rust): new `tool_file_changes.rs`. A Codex `fileChange` item now also emits `tool.execution.*` with `fileChanges`; the native item event stays for the recovery and progress projections. `turn/diff/updated` becomes `workspace.change.updated`. `normalize_acpx_tool_call` re-validates the sidecar's `fileChanges`. `normalize_codex_notification_in` resolves absolute paths against the session cwd. - Runner (Rust): `has_bounded_rich_display_shape` accepts schema-valid workspace diffs. Without this change, durable storage cut each patch string to a 4 KiB preview. Patches use the uncapped secret redactor. - Runner (TS): new `tool-file-changes.ts`. The ACPX sidecar computes `fileChanges` from ACP `diff` content with the working directory. The in-process Codex and ACPX mappers do the same, with an optional `context` argument. - UI: new `TaskChatDiffView` (colored rows, hunk labels, a height cap, "Show N more lines"). Edit rows show counts and the diff. Collapsed activity groups show edit totals. The workspace card is now "Changed in this turn", with in-place per-file diffs, and it sits at the end of the turn. When a harness sends no whole-turn diff (Claude), the card is built from the edit rows. A change set is marked complete when its run ends. - UI: `native-run-events.ts` maps `workspace.change.updated` and attaches each edit's latest patch to its row (the `diff` transcript entries now carry `toolUseId`). - Design tokens: `--diff-add-*`, `--diff-remove-*` and `--sz-edit-diff-max-h` come from the status icon hues. - Storybook: `Tasks/Edit diffs` story. Docs: `doc/run-log-events.md`. ## Verification - Real runs on a local instance (`test-drive`, native runner): - Codex (`gpt-5.6-sol`): the "Applied a patch" rows show `+9` and `+35 −2` with their diffs. The turn ends with "Changed in this turn", 2 files, `+42`, from `turn/diff/updated`. - Claude (`claude-opus-5-5` via ACPX): the Write and Edit rows show `+5`, `+14 −1` and `+9` with correct hunks. The turn card is built from the edit rows. - Codex model note: with `gpt-5.4`, pinned Codex 0.160 reports "model metadata not found". The model then wrote files through the shell, so no `fileChange` items occurred. - `pnpm check:token-gates`: all gates clean. - `pnpm --filter @paperclipai/ui exec vitest run src/components/task-chat src/components/transcript src/adapters`: 1073 passed. - `packages/paperclip-runner`: `vitest run src/provider-events.test.ts src/tool-file-changes.test.ts src/cli/acpx-runtime-sidecar.test.ts` passed. `check:protocol-types` and `check:protocol-manifest` match. - `cargo test -p paperclip-runner-core --lib provider_events tool_file_changes`: passed. `cargo fmt --check`: clean. - Storybook: open `Tasks and conversations/…/Tasks/Edit diffs`. ## Risks - Larger payloads: an edit call can carry up to 96 KiB of patch in Rust, and the sidecar keeps 48 KiB under its 128 KiB frame. Above the budget, a file keeps its counts and drops its patch (`diffTruncated`). - Redaction: each patch goes through the shared secret redactor. Before this change, a 4 KiB preview cap also applied. - Claude turn card: it joins each edit's patch in order. It is not a net diff. For a file the turn created, the counts are net and exact. For an existing file, the counts are summed over its edits. - The workspace card moves to the end of the turn. One adapter test changed to match. - Local only: four Linux cgroup `task_memory` Rust tests fail on macOS. They do not touch these modules. ## Model Used Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with tool use (shell, browser and file editing) and a long context window. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3fca8a1500
commit
c6996799c4
38 files changed
+2486
-195
No files matched your search
@@ -127,6 +127,19 @@ native run. Metadata adds an exit only when it is an integer. Conflicting suppli
|
||||
legacy exits stay unknown. Existing numeric `state.exit` values remain supported;
|
||||
metadata on other tools cannot add process-exit proof.
|
||||
|
||||
An edit tool call can carry `fileChanges` on its `tool.execution.*` payload.
|
||||
Each entry names one workspace-relative `path`, its `operation` (`create`,
|
||||
`modify`, `delete` or `rename`), `previousPath`, `additions`, `deletions`, a
|
||||
unified `diff` and `diffTruncated`. The runner drops paths outside the working
|
||||
directory, redacts secrets in each patch, and bounds a patch to 32 KiB and one
|
||||
call to 32 files. Counts cover the whole patch even when the patch is shortened.
|
||||
Codex `fileChange` items produce these rows. Claude Edit and Write calls produce
|
||||
them from the ACP `diff` content. Codex `turn/diff/updated` also records the
|
||||
whole-turn diff as `workspace.change.updated` (`paperclip.workspace.diff.v1`).
|
||||
The task chat shows each patch under its edit row. It ends the turn with the
|
||||
change set, or with one built from the edit rows when the harness reports no
|
||||
whole-turn diff.
|
||||
|
||||
`auth.credentials.updated` is a runner-produced PRP event for a change to a
|
||||
supported credential store, including account identity changes and later token
|
||||
refreshes on the new account. The bundled transport awaits a host commit callback
|
||||
|
||||
@@ -637,6 +637,8 @@ export interface TranscriptWorkspaceChangeFile {
|
||||
deletions: number | null;
|
||||
binary: boolean;
|
||||
diff: string | null;
|
||||
/** The producer shortened or omitted this patch; counts still cover all of it. */
|
||||
diffTruncated?: boolean;
|
||||
}
|
||||
|
||||
export interface TranscriptRunVerification {
|
||||
@@ -704,7 +706,7 @@ export type TranscriptEntry =
|
||||
| { kind: "stderr"; ts: string; text: string }
|
||||
| { kind: "system"; ts: string; text: string }
|
||||
| { kind: "stdout"; ts: string; text: string }
|
||||
| { kind: "diff"; ts: string; changeType: "add" | "remove" | "context" | "hunk" | "file_header" | "truncation"; text: string }
|
||||
| { kind: "diff"; ts: string; changeType: "add" | "remove" | "context" | "hunk" | "file_header" | "truncation"; text: string; toolUseId?: string; additions?: number; deletions?: number }
|
||||
| { kind: "provider_activity"; ts: string; family: ProviderActivityFamily; eventType: string; status: ProviderActivityStatus; title: string; summary: string; payload: Record<string, unknown> }
|
||||
| { kind: "workspace_change"; ts: string; changeSetId: string; revision: number; source: "harness_reported" | "runner_verified"; complete: boolean; files: TranscriptWorkspaceChangeFile[]; totals: { files: number; additions: number | null; deletions: number | null }; patchArtifactRef: string | null }
|
||||
| { kind: "workspace_file_reference"; ts: string; referenceId: string; source: "harness_reported" | "runner_verified"; path: string; displayName: string; mediaType: string | null; presentation: "document" | "code" | "image" | "generic"; line: number | null; preview: string | null; previewTruncated: boolean; contentDigest: string | null }
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
{
|
||||
"path": "schemas/provider-event.schema.json",
|
||||
"id": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json",
|
||||
"sha256": "14143d53f28969fa3f940def7331a45ee0eed560dd343506ac41b6a3dfe59985"
|
||||
"sha256": "90dc4ad1d2387603e01d3c5d7e185cf4ffcc93676f930575ff290d90502d6582"
|
||||
},
|
||||
{
|
||||
"path": "schemas/provider-failure.schema.json",
|
||||
@@ -150,7 +150,7 @@
|
||||
{
|
||||
"path": "schemas/workspace-diff.schema.json",
|
||||
"id": "https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json",
|
||||
"sha256": "f71b21f41cfc4c40aeaa503d1981d29355b4dd8b15036fcf3d8df5c35f8b8f6f"
|
||||
"sha256": "883f9d8e40ec6af2d41bd2998e3e1146f35aab3a63f683ffdbcabe3d4e72479b"
|
||||
},
|
||||
{
|
||||
"path": "schemas/workspace-file-reference.schema.json",
|
||||
|
||||
@@ -62,7 +62,26 @@
|
||||
"output": { "type": ["string", "null"], "maxLength": 65536 },
|
||||
"outputBytes": { "type": "integer", "minimum": 0 },
|
||||
"outputTruncated": { "type": "boolean" },
|
||||
"outputDigest": { "type": ["string", "null"], "pattern": "^sha256:[a-f0-9]{64}$" }
|
||||
"outputDigest": { "type": ["string", "null"], "pattern": "^sha256:[a-f0-9]{64}$" },
|
||||
"fileChanges": {
|
||||
"description": "Files changed by this one edit call, each with a bounded patch. Paths are workspace-relative.",
|
||||
"type": "array",
|
||||
"maxItems": 32,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["path", "operation", "previousPath", "additions", "deletions", "diff", "diffTruncated"],
|
||||
"properties": {
|
||||
"path": { "type": "string", "minLength": 1, "maxLength": 1024, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$" },
|
||||
"operation": { "enum": ["create", "modify", "delete", "rename"] },
|
||||
"previousPath": { "$ref": "#/$defs/safePath" },
|
||||
"additions": { "type": "integer", "minimum": 0 },
|
||||
"deletions": { "type": "integer", "minimum": 0 },
|
||||
"diff": { "type": ["string", "null"], "maxLength": 32768 },
|
||||
"diffTruncated": { "type": "boolean" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
|
||||
@@ -28,7 +28,11 @@
|
||||
"additions": { "type": ["integer", "null"], "minimum": 0 },
|
||||
"deletions": { "type": ["integer", "null"], "minimum": 0 },
|
||||
"binary": { "type": "boolean" },
|
||||
"diff": { "type": ["string", "null"], "maxLength": 262144 }
|
||||
"diff": { "type": ["string", "null"], "maxLength": 262144 },
|
||||
"diffTruncated": {
|
||||
"description": "The producer shortened or omitted this file's patch; additions and deletions still cover all of it.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
|
||||
@@ -243,6 +243,21 @@ fn rich_event_schema(event_type: &str) -> Option<&'static str> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Workspace change sets carry complete bounded patches. Validate them against
|
||||
/// their exact schema so durable storage keeps the patch instead of a preview.
|
||||
fn workspace_diff_validator() -> Option<&'static jsonschema::Validator> {
|
||||
static VALIDATOR: OnceLock<Option<jsonschema::Validator>> = OnceLock::new();
|
||||
VALIDATOR
|
||||
.get_or_init(|| {
|
||||
let schema: Value = serde_json::from_str(include_str!(
|
||||
"../../../../protocol/schemas/workspace-diff.schema.json"
|
||||
))
|
||||
.ok()?;
|
||||
jsonschema::validator_for(&schema).ok()
|
||||
})
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
fn rich_event_validators(
|
||||
) -> Result<&'static BTreeMap<String, jsonschema::Validator>, LocalRunnerError> {
|
||||
static VALIDATORS: OnceLock<Result<BTreeMap<String, jsonschema::Validator>, String>> =
|
||||
@@ -369,6 +384,14 @@ fn decode_rich_event(value: &Value) -> Result<AcpxEventPayload, LocalRunnerError
|
||||
// Durable storage uses this same exact schema gate before lifting diagnostic
|
||||
// preview limits. This does not admit an event or grant mutation authority.
|
||||
pub(crate) fn has_bounded_rich_display_shape(event_type: &str, payload: &Value) -> bool {
|
||||
if matches!(
|
||||
event_type,
|
||||
"workspace.change.updated" | "workspace.diff.recorded"
|
||||
) {
|
||||
return serde_json::to_vec(payload)
|
||||
.is_ok_and(|bytes| bytes.len() <= MAX_EVENT_PAYLOAD_BYTES)
|
||||
&& workspace_diff_validator().is_some_and(|validator| validator.is_valid(payload));
|
||||
}
|
||||
let Some(schema) = rich_event_schema(event_type) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -41,6 +41,7 @@ pub mod replay;
|
||||
mod stable_identity;
|
||||
pub mod stdio_mcp;
|
||||
pub mod task_memory;
|
||||
pub mod tool_file_changes;
|
||||
|
||||
use std::error::Error;
|
||||
use std::fmt::{self, Display, Formatter};
|
||||
|
||||
@@ -30,8 +30,8 @@ use crate::provider_bridge::{
|
||||
TOOL_SET_SCHEMA,
|
||||
};
|
||||
use crate::provider_events::{
|
||||
normalize_codex_notification, normalized_codex_terminal_event_type, with_terminal_outcome,
|
||||
NormalizedProviderEvent,
|
||||
normalize_codex_notification, normalize_codex_notification_in,
|
||||
normalized_codex_terminal_event_type, with_terminal_outcome, NormalizedProviderEvent,
|
||||
};
|
||||
use crate::stable_identity::{is_stable_id, DURABLE_STABLE_ID_CHARS, SHORT_STABLE_ID_CHARS};
|
||||
|
||||
@@ -723,10 +723,12 @@ fn normalize_provider_notification(
|
||||
return Ok(events);
|
||||
}
|
||||
if method != "paperclip/runResult" {
|
||||
return Ok(normalize_codex_notification(method, params)
|
||||
.into_iter()
|
||||
.map(|event| relabel_provider_event(event, &state.config.provider))
|
||||
.collect());
|
||||
return Ok(
|
||||
normalize_codex_notification_in(method, params, Some(&state.config.cwd))
|
||||
.into_iter()
|
||||
.map(|event| relabel_provider_event(event, &state.config.provider))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
let (result, fingerprint, disposition) = validate_proxy_run_result(state, params)?;
|
||||
match (
|
||||
|
||||
@@ -770,6 +770,16 @@ fn codex_activity_item_fields(item_type: &str, provider_item: &Value) -> Option<
|
||||
}
|
||||
|
||||
pub fn normalize_codex_notification(method: &str, params: &Value) -> Vec<NormalizedProviderEvent> {
|
||||
normalize_codex_notification_in(method, params, None)
|
||||
}
|
||||
|
||||
/// As [`normalize_codex_notification`], resolving provider file paths against
|
||||
/// the session working directory so edit rows and turn diffs stay workspace-relative.
|
||||
pub fn normalize_codex_notification_in(
|
||||
method: &str,
|
||||
params: &Value,
|
||||
cwd: Option<&str>,
|
||||
) -> Vec<NormalizedProviderEvent> {
|
||||
let mut events = Vec::new();
|
||||
let push = |events: &mut Vec<NormalizedProviderEvent>,
|
||||
event_type: &str,
|
||||
@@ -841,6 +851,17 @@ pub fn normalize_codex_notification(method: &str, params: &Value) -> Vec<Normali
|
||||
}),
|
||||
);
|
||||
}
|
||||
"turn/diff/updated" => {
|
||||
let turn_id = stable_id(string(params.get("turnId")), "codex-turn");
|
||||
if let Some(diff) = params.get("diff").and_then(Value::as_str) {
|
||||
push(
|
||||
&mut events,
|
||||
"workspace.change.updated",
|
||||
EventPriority::P2,
|
||||
crate::tool_file_changes::codex_turn_diff_snapshot(&turn_id, diff, cwd),
|
||||
);
|
||||
}
|
||||
}
|
||||
"turn/plan/updated" => {
|
||||
let plan_id = stable_id(string(params.get("turnId")), "codex-plan");
|
||||
let steps = params
|
||||
@@ -1020,6 +1041,50 @@ pub fn normalize_codex_notification(method: &str, params: &Value) -> Vec<Normali
|
||||
let item_type = string(provider_item.get("type"));
|
||||
let provider_phase = string(provider_item.get("phase"));
|
||||
let completed = method == "item/completed";
|
||||
if item_type == "fileChange" {
|
||||
let file_changes =
|
||||
crate::tool_file_changes::codex_file_changes(provider_item.get("changes"), cwd);
|
||||
let mut payload = json!({
|
||||
"schema": "paperclip.tool.execution.v1",
|
||||
"executionId": item_id,
|
||||
"transport": "builtin",
|
||||
"operation": "edit",
|
||||
"name": "apply_patch",
|
||||
"target": file_changes.as_ref().and_then(|files| files.get(0)).and_then(|file| file.get("path")).cloned().unwrap_or(Value::Null),
|
||||
"namespace": Value::Null,
|
||||
"readOnly": false,
|
||||
"status": provider_status(string(provider_item.get("status")), completed),
|
||||
"durationMs": Value::Null,
|
||||
"exitCode": Value::Null,
|
||||
"progress": Value::Null,
|
||||
});
|
||||
if let (Some(object), Value::Object(output)) =
|
||||
(payload.as_object_mut(), bounded_output(""))
|
||||
{
|
||||
object.extend(output);
|
||||
}
|
||||
if let Some(file_changes) = file_changes {
|
||||
payload["fileChanges"] = file_changes;
|
||||
}
|
||||
push(
|
||||
&mut events,
|
||||
if completed {
|
||||
"tool.execution.completed"
|
||||
} else if method == "item/updated" {
|
||||
"tool.execution.progressed"
|
||||
} else {
|
||||
"tool.execution.started"
|
||||
},
|
||||
if completed {
|
||||
EventPriority::P1
|
||||
} else {
|
||||
EventPriority::P2
|
||||
},
|
||||
payload,
|
||||
);
|
||||
}
|
||||
// The fileChange item event above stays alongside the edit row:
|
||||
// recovery and progress projections classify the native item kind.
|
||||
if matches!(
|
||||
item_type,
|
||||
"commandExecution" | "mcpToolCall" | "dynamicToolCall" | "builtinToolCall"
|
||||
@@ -1490,6 +1555,14 @@ fn normalize_acpx_tool_call(
|
||||
if let Some(input_updated) = payload.get("inputUpdated").and_then(Value::as_bool) {
|
||||
normalized["inputUpdated"] = Value::Bool(input_updated);
|
||||
}
|
||||
if let Some(file_changes) =
|
||||
crate::tool_file_changes::sanitize_file_changes(payload.get("fileChanges"))
|
||||
{
|
||||
if normalized["target"].is_null() {
|
||||
normalized["target"] = file_changes[0]["path"].clone();
|
||||
}
|
||||
normalized["fileChanges"] = file_changes;
|
||||
}
|
||||
if let (Some(object), Value::Object(output)) =
|
||||
(normalized.as_object_mut(), bounded_output(&output))
|
||||
{
|
||||
@@ -1771,6 +1844,60 @@ mod tests {
|
||||
assert!(!events[0].payload.to_string().contains("not-for-the-log"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_codex_file_changes_and_turn_diffs_to_workspace_relative_patches() {
|
||||
let started = normalize_codex_notification_in(
|
||||
"item/started",
|
||||
&json!({"item": {"id": "call-1", "type": "fileChange", "status": "inProgress", "changes": [
|
||||
{"path": "/w/repo/a.js", "kind": {"type": "update", "move_path": null}, "diff": "@@ -1 +1 @@\n-a\n+b\n"}
|
||||
]}}),
|
||||
Some("/w/repo"),
|
||||
);
|
||||
assert_eq!(started[0].event_type, "tool.execution.started");
|
||||
assert_eq!(started[0].payload["operation"], "edit");
|
||||
assert_eq!(started[0].payload["target"], "a.js");
|
||||
assert_eq!(started[0].payload["fileChanges"][0]["additions"], 1);
|
||||
// The native item event remains for recovery and progress projections.
|
||||
assert_eq!(started[1].event_type, "item.started");
|
||||
assert!(crate::acpx_event_payload::has_bounded_rich_display_shape(
|
||||
"tool.execution.started",
|
||||
&started[0].payload
|
||||
));
|
||||
|
||||
let diff = normalize_codex_notification_in(
|
||||
"turn/diff/updated",
|
||||
&json!({"turnId": "turn-1", "diff": "diff --git a//w/repo/a.js b//w/repo/a.js\n--- a//w/repo/a.js\n+++ b//w/repo/a.js\n@@ -1 +1 @@\n-a\n+b\n"}),
|
||||
Some("/w/repo"),
|
||||
);
|
||||
assert_eq!(diff[0].event_type, "workspace.change.updated");
|
||||
assert_eq!(diff[0].payload["files"][0]["path"], "a.js");
|
||||
assert!(crate::acpx_event_payload::has_bounded_rich_display_shape(
|
||||
"workspace.change.updated",
|
||||
&diff[0].payload
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwards_sanitized_acpx_file_changes() {
|
||||
let events = normalize_acpx_tool_call(
|
||||
&json!({"type": "tool_call", "tag": "tool_call_update", "status": "completed", "title": "Edit",
|
||||
"fileChanges": [{"path": "price.js", "operation": "modify", "previousPath": null,
|
||||
"additions": 1, "deletions": 1, "diff": "@@ -1 +1 @@\n-a\n+b\n", "diffTruncated": false},
|
||||
{"path": "/etc/passwd", "operation": "modify", "diff": ""}]}),
|
||||
"tool-1",
|
||||
"edit",
|
||||
);
|
||||
assert_eq!(events[0].payload["target"], "price.js");
|
||||
assert_eq!(
|
||||
events[0].payload["fileChanges"].as_array().unwrap().len(),
|
||||
1
|
||||
);
|
||||
assert!(crate::acpx_event_payload::has_bounded_rich_display_shape(
|
||||
"tool.execution.completed",
|
||||
&events[0].payload
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_dynamic_tool_identity_without_arguments() {
|
||||
for method in ["item/started", "item/completed"] {
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
//! Bounded per-edit file changes for `paperclip.tool.execution.v1`
|
||||
//! (`fileChanges`) and the Codex whole-turn diff projection
|
||||
//! (`paperclip.workspace.diff.v1`). Paths are workspace-relative and every
|
||||
//! patch passes through the shared secret redaction before it is emitted.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::durable::redact_sensitive_text_values;
|
||||
|
||||
const MAX_FILE_CHANGES: usize = 32;
|
||||
/// UTF-8 bytes of one file's patch (also within the schema's 32768-character bound).
|
||||
const MAX_FILE_DIFF_BYTES: usize = 32 * 1024;
|
||||
const MAX_TURN_FILES: usize = 2_000;
|
||||
/// Serialized bytes of the listed file entries in one turn snapshot. Entries
|
||||
/// past it are counted in the totals but not listed, so the snapshot stays a
|
||||
/// bounded display payload instead of falling back to 4 KiB string previews.
|
||||
const MAX_TURN_ENTRIES_BYTES: usize = 240 * 1024;
|
||||
const MAX_TURN_FILE_DIFF_BYTES: usize = 64 * 1024;
|
||||
/// Serialized patch bytes in one turn snapshot, below the 256 KiB durable
|
||||
/// display payload bound with room for the file metadata.
|
||||
const MAX_TURN_DIFF_TOTAL_BYTES: usize = 160 * 1024;
|
||||
const MAX_PATH_CHARS: usize = 1_024;
|
||||
/// Serialized patch bytes for one edit call, below the durable display payload
|
||||
/// bound with room for tool output and the other fields.
|
||||
const MAX_CALL_DIFF_BYTES: usize = 96 * 1024;
|
||||
|
||||
/// Bytes a string takes once serialized as JSON (escapes included).
|
||||
fn serialized_len(value: &str) -> usize {
|
||||
serde_json::to_string(value).map_or(usize::MAX, |encoded| encoded.len())
|
||||
}
|
||||
|
||||
/// Keep whole patches in order until the call budget runs out; later files keep
|
||||
/// their counts and report a shortened (absent) patch.
|
||||
fn within_call_budget(mut files: Vec<Value>) -> Vec<Value> {
|
||||
let mut budget = MAX_CALL_DIFF_BYTES;
|
||||
for file in &mut files {
|
||||
let size = file["diff"].as_str().map_or(0, serialized_len);
|
||||
if size > budget {
|
||||
file["diff"] = Value::Null;
|
||||
file["diffTruncated"] = Value::Bool(true);
|
||||
} else {
|
||||
budget -= size;
|
||||
}
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
static TURN_DIFF_REVISION: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn normalize_relative(candidate: &str) -> Option<String> {
|
||||
let mut parts: Vec<&str> = Vec::new();
|
||||
for part in candidate.split('/') {
|
||||
match part {
|
||||
"" | "." => continue,
|
||||
".." => return None,
|
||||
part => parts.push(part),
|
||||
}
|
||||
}
|
||||
let joined = parts.join("/");
|
||||
(!joined.is_empty() && joined.chars().count() <= MAX_PATH_CHARS).then_some(joined)
|
||||
}
|
||||
|
||||
/// Make a provider path workspace-relative. Absolute paths must sit under the
|
||||
/// working directory; anything that escapes it is dropped.
|
||||
pub fn workspace_relative_path(value: &str, cwd: Option<&str>) -> Option<String> {
|
||||
let value = value
|
||||
.trim()
|
||||
.trim_start_matches("file://")
|
||||
.replace('\\', "/");
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let windows_drive =
|
||||
value.len() > 2 && value.as_bytes()[1] == b':' && value.as_bytes()[0].is_ascii_alphabetic();
|
||||
if value.starts_with('/') || windows_drive {
|
||||
let root = cwd?.replace('\\', "/");
|
||||
let root = root.trim_end_matches('/');
|
||||
let rest = value.strip_prefix(root)?;
|
||||
if !rest.starts_with('/') {
|
||||
return None;
|
||||
}
|
||||
return normalize_relative(rest);
|
||||
}
|
||||
normalize_relative(&value)
|
||||
}
|
||||
|
||||
fn count_changes(diff: &str) -> (u64, u64) {
|
||||
let mut additions = 0;
|
||||
let mut deletions = 0;
|
||||
let mut in_hunk = false;
|
||||
for line in diff.lines() {
|
||||
if line.starts_with("@@") {
|
||||
in_hunk = true;
|
||||
continue;
|
||||
}
|
||||
if !in_hunk && (line.starts_with("+++ ") || line.starts_with("--- ")) {
|
||||
continue;
|
||||
}
|
||||
if line.starts_with('+') {
|
||||
additions += 1;
|
||||
} else if line.starts_with('-') {
|
||||
deletions += 1;
|
||||
}
|
||||
}
|
||||
(additions, deletions)
|
||||
}
|
||||
|
||||
/// Redact, then bound at a line boundary. Returns the patch and whether it was cut.
|
||||
fn bounded_diff(diff: &str, max_bytes: usize) -> (String, bool) {
|
||||
let redacted = redact_sensitive_text_values(diff);
|
||||
if redacted.len() <= max_bytes {
|
||||
return (redacted, false);
|
||||
}
|
||||
let mut end = max_bytes;
|
||||
while !redacted.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
let prefix = &redacted[..end];
|
||||
let cut = prefix.rfind('\n').unwrap_or(prefix.len());
|
||||
(prefix[..cut].to_owned(), true)
|
||||
}
|
||||
|
||||
fn one_sided_hunk(content: &str, added: bool) -> String {
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let marker = if added { '+' } else { '-' };
|
||||
let header = if added {
|
||||
format!("@@ -0,0 +1,{} @@", lines.len())
|
||||
} else {
|
||||
format!("@@ -1,{} +0,0 @@", lines.len())
|
||||
};
|
||||
let mut out = header;
|
||||
for line in lines {
|
||||
out.push('\n');
|
||||
out.push(marker);
|
||||
out.push_str(line);
|
||||
}
|
||||
out.push('\n');
|
||||
out
|
||||
}
|
||||
|
||||
fn file_change(
|
||||
path: String,
|
||||
operation: &str,
|
||||
previous_path: Option<String>,
|
||||
diff: Option<&str>,
|
||||
) -> Value {
|
||||
let (additions, deletions) = diff.map(count_changes).unwrap_or((0, 0));
|
||||
let (diff, truncated) = match diff {
|
||||
Some(diff) => {
|
||||
let (bounded, truncated) = bounded_diff(diff, MAX_FILE_DIFF_BYTES);
|
||||
(Value::String(bounded), truncated)
|
||||
}
|
||||
None => (Value::Null, false),
|
||||
};
|
||||
json!({
|
||||
"path": path,
|
||||
"operation": operation,
|
||||
"previousPath": previous_path,
|
||||
"additions": additions,
|
||||
"deletions": deletions,
|
||||
"diff": diff,
|
||||
"diffTruncated": truncated,
|
||||
})
|
||||
}
|
||||
|
||||
/// `fileChanges` for a Codex `fileChange` item (`changes[]` of `{ path, kind, diff }`).
|
||||
/// Codex reports an added or deleted file's content rather than a patch.
|
||||
pub fn codex_file_changes(changes: Option<&Value>, cwd: Option<&str>) -> Option<Value> {
|
||||
let files: Vec<Value> = changes?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.take(MAX_FILE_CHANGES)
|
||||
.filter_map(|change| {
|
||||
let path = workspace_relative_path(change.get("path")?.as_str()?, cwd)?;
|
||||
let kind = change.get("kind");
|
||||
let kind_type = kind
|
||||
.and_then(|kind| {
|
||||
kind.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| kind.as_str())
|
||||
})
|
||||
.unwrap_or("update")
|
||||
.to_ascii_lowercase();
|
||||
let move_path = kind
|
||||
.and_then(|kind| kind.get("move_path").or_else(|| kind.get("movePath")))
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|value| workspace_relative_path(value, cwd));
|
||||
let raw = change.get("diff").and_then(Value::as_str);
|
||||
let operation = if move_path.is_some() {
|
||||
"rename"
|
||||
} else if kind_type.contains("add") {
|
||||
"create"
|
||||
} else if kind_type.contains("delete") {
|
||||
"delete"
|
||||
} else {
|
||||
"modify"
|
||||
};
|
||||
let diff = raw.map(|raw| {
|
||||
let has_hunk = raw.lines().any(|line| line.starts_with("@@ "));
|
||||
match operation {
|
||||
"create" if !has_hunk => one_sided_hunk(raw, true),
|
||||
"delete" if !has_hunk => one_sided_hunk(raw, false),
|
||||
_ => raw.to_owned(),
|
||||
}
|
||||
});
|
||||
Some(match move_path {
|
||||
Some(target) => file_change(target, operation, Some(path), diff.as_deref()),
|
||||
None => file_change(path, operation, None, diff.as_deref()),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
(!files.is_empty()).then(|| Value::Array(within_call_budget(files)))
|
||||
}
|
||||
|
||||
/// Re-validate `fileChanges` computed by the ACPX sidecar before they cross
|
||||
/// into a canonical event: closed shape, safe paths, bounded redacted patches.
|
||||
pub fn sanitize_file_changes(value: Option<&Value>) -> Option<Value> {
|
||||
let files: Vec<Value> = value?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.take(MAX_FILE_CHANGES)
|
||||
.filter_map(|change| {
|
||||
let path = workspace_relative_path(change.get("path")?.as_str()?, None)?;
|
||||
let operation = match change.get("operation")?.as_str()? {
|
||||
operation @ ("create" | "modify" | "delete" | "rename") => operation,
|
||||
_ => return None,
|
||||
};
|
||||
let previous_path = change
|
||||
.get("previousPath")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|value| workspace_relative_path(value, None));
|
||||
let diff = change.get("diff").and_then(Value::as_str);
|
||||
let mut sanitized = file_change(path, operation, previous_path, diff);
|
||||
if change.get("diffTruncated").and_then(Value::as_bool) == Some(true) {
|
||||
sanitized["diffTruncated"] = Value::Bool(true);
|
||||
}
|
||||
// Counts from the sidecar cover the whole patch even when it was bounded.
|
||||
for key in ["additions", "deletions"] {
|
||||
if let Some(count) = change.get(key).and_then(Value::as_u64) {
|
||||
sanitized[key] = json!(count);
|
||||
}
|
||||
}
|
||||
Some(sanitized)
|
||||
})
|
||||
.collect();
|
||||
(!files.is_empty()).then(|| Value::Array(within_call_budget(files)))
|
||||
}
|
||||
|
||||
fn diff_header_path(value: &str) -> Option<&str> {
|
||||
let value = value.trim();
|
||||
if value == "/dev/null" {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
value
|
||||
.strip_prefix("a/")
|
||||
.or_else(|| value.strip_prefix("b/"))
|
||||
.unwrap_or(value),
|
||||
)
|
||||
}
|
||||
|
||||
/// Codex writes absolute paths into the turn diff unless cwd-relative diffs
|
||||
/// are enabled, so `a//w/repo/x` arrives as `/w/repo/x` or `w/repo/x`.
|
||||
fn turn_diff_path(raw: &str, cwd: Option<&str>) -> Option<String> {
|
||||
if let Some(root) = cwd.map(|root| root.trim_end_matches('/')) {
|
||||
let rooted = if raw.starts_with('/') {
|
||||
raw.to_owned()
|
||||
} else {
|
||||
format!("/{raw}")
|
||||
};
|
||||
if root.starts_with('/') && rooted.starts_with(&format!("{root}/")) {
|
||||
return workspace_relative_path(&rooted, cwd);
|
||||
}
|
||||
}
|
||||
workspace_relative_path(raw, cwd)
|
||||
}
|
||||
|
||||
/// Project a Codex `turn/diff/updated` unified diff into a
|
||||
/// `paperclip.workspace.diff.v1` snapshot for the turn's change set.
|
||||
pub fn codex_turn_diff_snapshot(turn_id: &str, diff: &str, cwd: Option<&str>) -> Value {
|
||||
struct File {
|
||||
lines: Vec<String>,
|
||||
old: Option<String>,
|
||||
new: Option<String>,
|
||||
rename_from: Option<String>,
|
||||
rename_to: Option<String>,
|
||||
binary: bool,
|
||||
seen_old: bool,
|
||||
seen_new: bool,
|
||||
}
|
||||
let mut files: Vec<File> = Vec::new();
|
||||
for line in diff.lines() {
|
||||
if line.starts_with("diff --git ") {
|
||||
files.push(File {
|
||||
lines: Vec::new(),
|
||||
old: None,
|
||||
new: None,
|
||||
rename_from: None,
|
||||
rename_to: None,
|
||||
binary: false,
|
||||
seen_old: false,
|
||||
seen_new: false,
|
||||
});
|
||||
if let Some(file) = files.last_mut() {
|
||||
// Fallback paths from `diff --git a/x b/y` when ---/+++ are absent.
|
||||
if let Some(rest) = line.strip_prefix("diff --git ") {
|
||||
if let Some((left, right)) = rest.split_once(" b/") {
|
||||
file.old = diff_header_path(left).map(str::to_owned);
|
||||
file.new = Some(right.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(file) = files.last_mut() else {
|
||||
continue;
|
||||
};
|
||||
if let Some(path) = line.strip_prefix("--- ") {
|
||||
if !file.seen_old && file.lines.iter().all(|line| !line.starts_with("@@")) {
|
||||
file.old = diff_header_path(path).map(str::to_owned);
|
||||
file.seen_old = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(path) = line.strip_prefix("+++ ") {
|
||||
if !file.seen_new && file.lines.iter().all(|line| !line.starts_with("@@")) {
|
||||
file.new = diff_header_path(path).map(str::to_owned);
|
||||
file.seen_new = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(path) = line.strip_prefix("rename from ") {
|
||||
file.rename_from = Some(path.to_owned());
|
||||
} else if let Some(path) = line.strip_prefix("rename to ") {
|
||||
file.rename_to = Some(path.to_owned());
|
||||
} else if line.starts_with("Binary files ") || line == "GIT binary patch" {
|
||||
file.binary = true;
|
||||
}
|
||||
file.lines.push(line.to_owned());
|
||||
}
|
||||
let mut budget = MAX_TURN_DIFF_TOTAL_BYTES;
|
||||
let mut projected: Vec<Value> = files
|
||||
.into_iter()
|
||||
.filter_map(|file| {
|
||||
let created = file.seen_old && file.old.is_none();
|
||||
let deleted = file.seen_new && file.new.is_none();
|
||||
let raw_path = file
|
||||
.rename_to
|
||||
.clone()
|
||||
.or(file.new.clone())
|
||||
.or(file.old.clone())?;
|
||||
let path = turn_diff_path(&raw_path, cwd)?;
|
||||
let previous_path = file
|
||||
.rename_from
|
||||
.as_deref()
|
||||
.and_then(|value| turn_diff_path(value, cwd));
|
||||
let operation = if previous_path.is_some() {
|
||||
"rename"
|
||||
} else if created {
|
||||
"create"
|
||||
} else if deleted {
|
||||
"delete"
|
||||
} else {
|
||||
"modify"
|
||||
};
|
||||
let body = file
|
||||
.lines
|
||||
.iter()
|
||||
.skip_while(|line| !line.starts_with("@@"))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let (additions, deletions) = count_changes(&body);
|
||||
let mut diff_truncated = false;
|
||||
let diff = if file.binary || body.is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
let (bounded, truncated) =
|
||||
bounded_diff(&format!("{body}\n"), MAX_TURN_FILE_DIFF_BYTES);
|
||||
let size = serialized_len(&bounded);
|
||||
if size > budget {
|
||||
diff_truncated = true;
|
||||
Value::Null
|
||||
} else {
|
||||
budget -= size;
|
||||
diff_truncated = truncated;
|
||||
Value::String(bounded)
|
||||
}
|
||||
};
|
||||
Some(json!({
|
||||
"path": path,
|
||||
"operation": operation,
|
||||
"previousPath": previous_path,
|
||||
"additions": if file.binary { Value::Null } else { json!(additions) },
|
||||
"deletions": if file.binary { Value::Null } else { json!(deletions) },
|
||||
"binary": file.binary,
|
||||
"diff": diff,
|
||||
"diffTruncated": diff_truncated,
|
||||
}))
|
||||
})
|
||||
.take(MAX_TURN_FILES)
|
||||
.collect();
|
||||
let unknown = projected.iter().any(|file| file["additions"].is_null());
|
||||
let sum = |key: &str| -> Value {
|
||||
if unknown {
|
||||
Value::Null
|
||||
} else {
|
||||
json!(projected
|
||||
.iter()
|
||||
.filter_map(|file| file[key].as_u64())
|
||||
.sum::<u64>())
|
||||
}
|
||||
};
|
||||
let totals = json!({ "files": projected.len(), "additions": sum("additions"), "deletions": sum("deletions") });
|
||||
// Over the entry bound, drop patch bodies (from the last file back) before
|
||||
// dropping any file, so names and counts stay visible as long as possible.
|
||||
let entry_len =
|
||||
|file: &Value| serde_json::to_string(file).map_or(usize::MAX, |encoded| encoded.len() + 1);
|
||||
let mut entries_bytes: usize = projected.iter().map(entry_len).sum();
|
||||
for file in projected.iter_mut().rev() {
|
||||
if entries_bytes <= MAX_TURN_ENTRIES_BYTES {
|
||||
break;
|
||||
}
|
||||
if file["diff"].is_string() {
|
||||
let before = entry_len(file);
|
||||
file["diff"] = Value::Null;
|
||||
file["diffTruncated"] = Value::Bool(true);
|
||||
entries_bytes = entries_bytes - before + entry_len(file);
|
||||
}
|
||||
}
|
||||
let mut listed_bytes = 0usize;
|
||||
let listed: Vec<&Value> = projected
|
||||
.iter()
|
||||
.take_while(|file| {
|
||||
listed_bytes += entry_len(file);
|
||||
listed_bytes <= MAX_TURN_ENTRIES_BYTES
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"schema": "paperclip.workspace.diff.v1",
|
||||
"changeSetId": format!("{turn_id}:workspace"),
|
||||
"revision": TURN_DIFF_REVISION.fetch_add(1, Ordering::Relaxed) + 1,
|
||||
"source": "harness_reported",
|
||||
"complete": false,
|
||||
"totals": totals,
|
||||
"files": listed,
|
||||
"patchArtifactRef": Value::Null,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn relativizes_only_paths_inside_the_workspace() {
|
||||
assert_eq!(
|
||||
workspace_relative_path("/w/repo/src/a.ts", Some("/w/repo")).as_deref(),
|
||||
Some("src/a.ts")
|
||||
);
|
||||
assert_eq!(
|
||||
workspace_relative_path("src/./a.ts", None).as_deref(),
|
||||
Some("src/a.ts")
|
||||
);
|
||||
assert_eq!(
|
||||
workspace_relative_path("/w/repository/a.ts", Some("/w/repo")),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
workspace_relative_path("/etc/passwd", Some("/w/repo")),
|
||||
None
|
||||
);
|
||||
assert_eq!(workspace_relative_path("../x", None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_codex_changes_with_counts_and_one_sided_new_files() {
|
||||
let changes = codex_file_changes(
|
||||
Some(&json!([
|
||||
{"path": "/w/repo/a.js", "kind": {"type": "update", "move_path": null}, "diff": "@@ -1 +1 @@\n-a\n+b\n"},
|
||||
{"path": "b.js", "kind": {"type": "add"}, "diff": "one\ntwo\n"},
|
||||
{"path": "/elsewhere/c.js", "kind": {"type": "add"}, "diff": "x"}
|
||||
])),
|
||||
Some("/w/repo"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(changes.as_array().unwrap().len(), 2);
|
||||
assert_eq!(changes[0]["path"], "a.js");
|
||||
assert_eq!(changes[0]["additions"], 1);
|
||||
assert_eq!(changes[0]["deletions"], 1);
|
||||
assert_eq!(changes[1]["operation"], "create");
|
||||
assert_eq!(changes[1]["diff"], "@@ -0,0 +1,2 @@\n+one\n+two\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitizes_sidecar_changes_and_bounds_patches() {
|
||||
let big = format!("@@ -0,0 +1,5000 @@\n{}", "+line of text\n".repeat(5_000));
|
||||
let changes = sanitize_file_changes(Some(&json!([
|
||||
{"path": "ok.txt", "operation": "create", "previousPath": null, "additions": 5000, "deletions": 0, "diff": big, "diffTruncated": false},
|
||||
{"path": "/abs.txt", "operation": "modify", "diff": ""},
|
||||
{"path": "x.txt", "operation": "explode", "diff": ""}
|
||||
])))
|
||||
.unwrap();
|
||||
assert_eq!(changes.as_array().unwrap().len(), 1);
|
||||
assert_eq!(changes[0]["additions"], 5000);
|
||||
assert_eq!(changes[0]["diffTruncated"], true);
|
||||
assert!(changes[0]["diff"].as_str().unwrap().len() <= MAX_FILE_DIFF_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budgets_serialized_bytes_and_keeps_counts() {
|
||||
let wide = format!(
|
||||
"@@ -0,0 +1,3000 @@\n{}",
|
||||
"+中文内容中文内容\n".repeat(3_000)
|
||||
);
|
||||
let change = |path: &str| {
|
||||
json!({"path": path, "operation": "create", "previousPath": null,
|
||||
"additions": 3000, "deletions": 0, "diff": wide, "diffTruncated": false})
|
||||
};
|
||||
let changes = sanitize_file_changes(Some(&json!([
|
||||
change("a"),
|
||||
change("b"),
|
||||
change("c"),
|
||||
change("d")
|
||||
])))
|
||||
.unwrap();
|
||||
let total: usize = changes
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|file| file["diff"].as_str().map(serialized_len))
|
||||
.sum();
|
||||
assert!(total <= MAX_CALL_DIFF_BYTES);
|
||||
assert!(changes
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|file| file["additions"] == 3000));
|
||||
assert!(changes[3]["diff"].is_null());
|
||||
assert_eq!(changes[3]["diffTruncated"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_header_like_lines_inside_a_hunk() {
|
||||
assert_eq!(count_changes("@@ -1,2 +1,2 @@\n--- old\n+++ new\n"), (1, 1));
|
||||
let snapshot = codex_turn_diff_snapshot(
|
||||
"t",
|
||||
"diff --git a/q.sql b/q.sql\n--- a/q.sql\n+++ b/q.sql\n@@ -1 +1 @@\n--- old\n+++ new\n",
|
||||
None,
|
||||
);
|
||||
assert_eq!(snapshot["files"][0]["additions"], 1);
|
||||
assert_eq!(snapshot["files"][0]["deletions"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marks_shortened_turn_patches_and_bounds_listed_entries() {
|
||||
let big = format!(
|
||||
"diff --git a/big.txt b/big.txt\n--- a/big.txt\n+++ b/big.txt\n@@ -0,0 +1,9000 @@\n{}",
|
||||
"+0123456789\n".repeat(9_000)
|
||||
);
|
||||
let snapshot = codex_turn_diff_snapshot("t", &big, None);
|
||||
assert_eq!(snapshot["files"][0]["diffTruncated"], true);
|
||||
assert_eq!(snapshot["files"][0]["additions"], 9000);
|
||||
let many: String = (0..2_000)
|
||||
.map(|index| format!("diff --git a/dir/file-{index:04}-with-a-long-name.txt b/dir/file-{index:04}-with-a-long-name.txt\n--- a/dir/file-{index:04}-with-a-long-name.txt\n+++ b/dir/file-{index:04}-with-a-long-name.txt\n@@ -1 +1 @@\n-a\n+b\n"))
|
||||
.collect();
|
||||
let snapshot = codex_turn_diff_snapshot("t", &many, None);
|
||||
assert_eq!(snapshot["totals"]["files"], 2000);
|
||||
let listed = snapshot["files"].as_array().unwrap();
|
||||
assert!(listed.len() < 2000);
|
||||
// Patch bodies go before any file entry does.
|
||||
assert!(listed
|
||||
.iter()
|
||||
.all(|file| file["diff"].is_null() && file["diffTruncated"] == true));
|
||||
assert!(crate::acpx_event_payload::has_bounded_rich_display_shape(
|
||||
"workspace.change.updated",
|
||||
&snapshot
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projects_a_codex_turn_diff() {
|
||||
let diff = "diff --git a/a.js b/a.js\nindex 1..2 100644\n--- a/a.js\n+++ b/a.js\n@@ -1,2 +1,2 @@\n x\n-y\n+z\ndiff --git a/new.js b/new.js\nnew file mode 100644\n--- /dev/null\n+++ b/new.js\n@@ -0,0 +1,1 @@\n+hello\n";
|
||||
let snapshot = codex_turn_diff_snapshot("turn-1", diff, Some("/w/repo"));
|
||||
assert_eq!(snapshot["changeSetId"], "turn-1:workspace");
|
||||
assert_eq!(snapshot["files"][0]["path"], "a.js");
|
||||
assert_eq!(snapshot["files"][0]["operation"], "modify");
|
||||
assert_eq!(snapshot["files"][1]["operation"], "create");
|
||||
assert_eq!(
|
||||
snapshot["totals"],
|
||||
json!({"files": 2, "additions": 2, "deletions": 1})
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot["files"][0]["diff"],
|
||||
"@@ -1,2 +1,2 @@\n x\n-y\n+z\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { bindAcpxCandidateAdmission } from "../drivers/acpx/candidate-admission.js";
|
||||
import { toolFileChangesFromAcpContent } from "../tool-file-changes.js";
|
||||
import { KIMI_DISTRIBUTION_CLOSURE_SHA256 } from "../drivers/acpx/kimi-closure-pins.js";
|
||||
import { ANTIGRAVITY_DISTRIBUTION_PINS } from "../drivers/acpx/generated-profiles.js";
|
||||
import { canonicalNativeRuntimeContextDigest } from "../contracts/runtime-context.js";
|
||||
@@ -204,9 +205,11 @@ describe("qualified ACPX runtime sidecar", () => {
|
||||
const stableIdentity = new Function("createHash", "acpxProfileActivity", "openParams", "initializedAgent", `${stripTypeScriptTypes(source.slice(identityStart, source.indexOf("\nfunction canonicalJson", identityStart)))}; return stableProviderIdentity;`)(createHash, acpxProfileActivity, { agent: "cursor" }, "cursor");
|
||||
const boundStart = source.indexOf("function boundRuntimeEventForNormalization(");
|
||||
const bound = new Function("boundedOptionalText", "stableProviderIdentity", "safeAcpxLocations", "openParams", "safeOutput",
|
||||
"toolFileChangesFromAcpContent", "sidecarWorkspaceRoots",
|
||||
`${stripTypeScriptTypes(source.slice(boundStart, source.indexOf("\nfunction sanitizeRuntimeEvent", boundStart)))}; return boundRuntimeEventForNormalization;`)(
|
||||
(value: unknown, fallback: string, max: number) => typeof value === "string" ? value.slice(0, max) : fallback,
|
||||
stableIdentity, () => [], null, () => ({ output: null, outputBytes: 0, outputTruncated: false, outputDigest: null }),
|
||||
toolFileChangesFromAcpContent, () => [],
|
||||
);
|
||||
const start = source.indexOf(" const { signal } = context;", source.indexOf("async function waitForPermission"));
|
||||
const end = source.indexOf("\nasync function waitForInput", start);
|
||||
@@ -745,6 +748,7 @@ describe("qualified ACPX runtime sidecar", () => {
|
||||
.replace(" } as BoundedRuntimeToolEvent;", " };");
|
||||
const bound = new Function(
|
||||
"boundedOptionalText", "stableProviderIdentity", "safeAcpxLocations", "openParams", "safeOutput",
|
||||
"toolFileChangesFromAcpContent", "sidecarWorkspaceRoots",
|
||||
`return (${functionSource});`,
|
||||
)(
|
||||
(value: unknown, fallback: string, max: number) => typeof value === "string" ? value.slice(0, max) : fallback,
|
||||
@@ -752,6 +756,8 @@ describe("qualified ACPX runtime sidecar", () => {
|
||||
() => [],
|
||||
null,
|
||||
() => ({ output: null, outputBytes: 0, outputTruncated: false, outputDigest: null }),
|
||||
toolFileChangesFromAcpContent,
|
||||
() => ["/work/repo"],
|
||||
) as (event: Record<string, unknown>) => Record<string, unknown>;
|
||||
const bounded = bound({
|
||||
type: "tool_call", toolCallId: "provider-tool", title: "search", kind: "other",
|
||||
@@ -763,6 +769,16 @@ describe("qualified ACPX runtime sidecar", () => {
|
||||
expect(canonical.payload).toMatchObject({ inputUpdated: true });
|
||||
expect(JSON.stringify(canonical.payload)).not.toContain("must not cross");
|
||||
expect(source).toContain("? boundedTool.inputUpdated");
|
||||
|
||||
// ACP diff content crosses as bounded, workspace-relative fileChanges.
|
||||
const edit = bound({
|
||||
type: "tool_call", toolCallId: "edit-tool", title: "Edit", kind: "edit", status: "completed",
|
||||
content: [{ type: "diff", path: "/work/repo/a.js", oldText: "a", newText: "b" }],
|
||||
});
|
||||
expect(edit.paperclipFileChanges).toEqual([
|
||||
expect.objectContaining({ path: "a.js", operation: "modify", additions: 1, deletions: 1 }),
|
||||
]);
|
||||
expect(edit).not.toHaveProperty("content");
|
||||
});
|
||||
|
||||
it.each(["paperclip_finish", "paperclip_block"])(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
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 { resolveClaudeEffort } from "../drivers/acpx/claude-effort.js";
|
||||
import { acpxProviderFailure, acpxTerminalResult, type AcpxTerminalSessionFailure } from "../drivers/acpx/provider-failure.js";
|
||||
@@ -1016,8 +1017,19 @@ type BoundedRuntimeToolEvent = AcpRuntimeEvent & {
|
||||
paperclipBoundedTool: true;
|
||||
inputUpdated: boolean;
|
||||
paperclipOutput: Record<string, unknown>;
|
||||
paperclipFileChanges: ToolFileChange[] | null;
|
||||
};
|
||||
|
||||
let cachedWorkspaceRoots: { directory: string; roots: string[] } | null = null;
|
||||
function sidecarWorkspaceRoots(): string[] {
|
||||
const directory = openParams?.workingDirectory;
|
||||
if (!directory) return [];
|
||||
if (cachedWorkspaceRoots?.directory !== directory) {
|
||||
cachedWorkspaceRoots = { directory, roots: workspaceRootsFor(directory) };
|
||||
}
|
||||
return cachedWorkspaceRoots.roots;
|
||||
}
|
||||
|
||||
function boundRuntimeEventForNormalization(
|
||||
event: AcpRuntimeEvent,
|
||||
): AcpRuntimeEvent {
|
||||
@@ -1045,6 +1057,13 @@ function boundRuntimeEventForNormalization(
|
||||
inputUpdated: Object.prototype.hasOwnProperty.call(event, "rawInput") && event.rawInput !== undefined,
|
||||
paperclipBoundedTool: true,
|
||||
paperclipOutput: safeOutput(event.rawOutput),
|
||||
// ACP `diff` content (Claude Edit/Write) as bounded workspace-relative
|
||||
// patches; runner-core re-validates them before the canonical event.
|
||||
paperclipFileChanges: toolFileChangesFromAcpContent(
|
||||
event.content,
|
||||
event.locations,
|
||||
sidecarWorkspaceRoots(),
|
||||
),
|
||||
} as BoundedRuntimeToolEvent;
|
||||
}
|
||||
|
||||
@@ -1146,6 +1165,9 @@ function sanitizeRuntimeEvent(event: PiProjectedMessageEvent<AcpRuntimeEvent> &
|
||||
...(boundedTool.paperclipBoundedTool === true
|
||||
? boundedTool.paperclipOutput
|
||||
: safeOutput(event.rawOutput)),
|
||||
...(boundedTool.paperclipBoundedTool === true && boundedTool.paperclipFileChanges
|
||||
? { fileChanges: boundedTool.paperclipFileChanges }
|
||||
: {}),
|
||||
},
|
||||
128 * 1024,
|
||||
toolCallIdentity,
|
||||
|
||||
@@ -91,6 +91,7 @@ import {
|
||||
type AcpxRecoveryWorkspaceLease,
|
||||
} from "./runtime-sandbox.js";
|
||||
|
||||
import { workspaceRootsFor } from "../../tool-file-changes.js";
|
||||
import { AcpxTurnControlLedger, parseAcpxTurnControl, type AcpxTurnControlMode } from "./turn-controls.js";
|
||||
import { acpxUsageEstimateNotice, persistedAcpxTurnUsage, persistedCursorUsageNotice } from "./usage-accounting.js";
|
||||
|
||||
@@ -782,6 +783,7 @@ class CodexAcpxSession implements HarnessSession {
|
||||
#usage: Record<string, unknown> | null = null;
|
||||
#assistantText = "";
|
||||
#assistantMessageId: string | null = null;
|
||||
#workspaceRoots: string[] | null = null;
|
||||
#closed = false;
|
||||
#closingStarted = false;
|
||||
#eventStreamClosed = false;
|
||||
@@ -1738,10 +1740,12 @@ class CodexAcpxSession implements HarnessSession {
|
||||
cost: event.cost,
|
||||
});
|
||||
}
|
||||
this.#workspaceRoots ??= workspaceRootsFor(this.#input.workingDirectory);
|
||||
for (const canonical of canonicalProviderEventsFromAcpxRuntimeEvent(
|
||||
event,
|
||||
fallbackItemId,
|
||||
turnId,
|
||||
{ workingDirectory: this.#workspaceRoots },
|
||||
)) {
|
||||
this.#emit(canonical.eventType, canonical.payload, {
|
||||
turnId,
|
||||
|
||||
@@ -265,6 +265,7 @@ async function mapNotificationBody(state: CodexSessionState, notification: Codex
|
||||
for (const canonical of canonicalProviderEventsFromCodex(
|
||||
notification.method,
|
||||
params,
|
||||
{ workingDirectory: state.workspaceRoots },
|
||||
)) {
|
||||
state.emit(canonical.eventType, canonical.payload, {
|
||||
turnId: turnId || undefined,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type CodexUsageBaseline, codexRunUsage } from "./codex-usage-baseline.js";
|
||||
import { workspaceRootsFor } from "../../tool-file-changes.js";
|
||||
import type {
|
||||
HarnessRuntimeRequest,
|
||||
HarnessSessionRecoveryOptions,
|
||||
@@ -150,6 +151,12 @@ export class CodexSessionState {
|
||||
readonly restoredTerminalTurns = new Set<string>();
|
||||
readonly workspaceChangesByTurn = new Map<string, Record<string, unknown>>();
|
||||
readonly emittedFileReferences = new Set<string>();
|
||||
#workspaceRoots: string[] | null = null;
|
||||
/** Working directory and its realpath, for making provider file paths relative. */
|
||||
get workspaceRoots(): string[] {
|
||||
this.#workspaceRoots ??= workspaceRootsFor(this.opened.context.workingDirectory);
|
||||
return this.#workspaceRoots;
|
||||
}
|
||||
readonly itemChannels = new Map<
|
||||
string,
|
||||
"progress" | "final" | "summary" | "detail" | "unknown"
|
||||
|
||||
@@ -2085,6 +2085,61 @@ export const providerEventSchema = {
|
||||
"null"
|
||||
],
|
||||
"pattern": "^sha256:[a-f0-9]{64}$"
|
||||
},
|
||||
"fileChanges": {
|
||||
"description": "Files changed by this one edit call, each with a bounded patch. Paths are workspace-relative.",
|
||||
"type": "array",
|
||||
"maxItems": 32,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"path",
|
||||
"operation",
|
||||
"previousPath",
|
||||
"additions",
|
||||
"deletions",
|
||||
"diff",
|
||||
"diffTruncated"
|
||||
],
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1024,
|
||||
"pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"
|
||||
},
|
||||
"operation": {
|
||||
"enum": [
|
||||
"create",
|
||||
"modify",
|
||||
"delete",
|
||||
"rename"
|
||||
]
|
||||
},
|
||||
"previousPath": {
|
||||
"$ref": "#/$defs/safePath"
|
||||
},
|
||||
"additions": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"deletions": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"diff": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 32768
|
||||
},
|
||||
"diffTruncated": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -2932,6 +2987,10 @@ export const workspaceDiffSchema = {
|
||||
"null"
|
||||
],
|
||||
"maxLength": 262144
|
||||
},
|
||||
"diffTruncated": {
|
||||
"description": "The producer shortened or omitted this file's patch; additions and deletions still cover all of it.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -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
|
||||
@@ -988,3 +988,59 @@ describe("Grok output message boundaries", () => {
|
||||
expect(normalize(reasoning)).toBe(reasoning);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edit file changes", () => {
|
||||
it("turns a Codex fileChange item into a schema-valid edit row with its patch", () => {
|
||||
const [started] = canonicalProviderEventsFromCodex(
|
||||
"item/started",
|
||||
{
|
||||
item: {
|
||||
id: "call-1",
|
||||
type: "fileChange",
|
||||
status: "inProgress",
|
||||
changes: [
|
||||
{ path: "/work/repo/src/a.js", kind: { type: "update", move_path: null }, diff: "@@ -1 +1 @@\n-a\n+b\n" },
|
||||
{ path: "src/new.js", kind: { type: "add" }, diff: "x\ny\n" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ workingDirectory: "/work/repo" },
|
||||
);
|
||||
expect(started).toMatchObject({
|
||||
eventType: "tool.execution.started",
|
||||
payload: {
|
||||
operation: "edit",
|
||||
name: "apply_patch",
|
||||
target: "src/a.js",
|
||||
fileChanges: [
|
||||
{ path: "src/a.js", operation: "modify", additions: 1, deletions: 1 },
|
||||
{ path: "src/new.js", operation: "create", additions: 2, deletions: 0, diff: "@@ -0,0 +1,2 @@\n+x\n+y\n" },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(validatePrpEvent(envelope(started!)).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("maps Claude ACP diff content to fileChanges and names the edited file", () => {
|
||||
const [event] = canonicalProviderEventsFromAcpxRuntimeEvent(
|
||||
{
|
||||
type: "tool_call",
|
||||
tag: "tool_call_update",
|
||||
toolCallId: "tool-1",
|
||||
title: "Edit price.js",
|
||||
kind: "edit",
|
||||
status: "completed",
|
||||
locations: [{ path: "/work/repo/price.js", line: 3 }],
|
||||
content: [{ type: "diff", path: "/work/repo/price.js", oldText: "a\nb", newText: "a\nc" }],
|
||||
},
|
||||
"fallback",
|
||||
"turn-1",
|
||||
{ workingDirectory: ["/work/repo"] },
|
||||
);
|
||||
expect(event?.payload).toMatchObject({
|
||||
target: "price.js",
|
||||
fileChanges: [{ path: "price.js", operation: "modify", additions: 1, deletions: 1, diff: "@@ -3,2 +3,2 @@\n a\n-b\n+c\n" }],
|
||||
});
|
||||
expect(validatePrpEvent(envelope(event!)).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,15 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { redactCodexDiagnostic } from "./drivers/codex/diagnostic-redaction.js";
|
||||
import {
|
||||
toolFileChangesFromAcpContent,
|
||||
toolFileChangesFromCodexChanges,
|
||||
type WorkspaceRoots,
|
||||
} from "./tool-file-changes.js";
|
||||
|
||||
/** Context a canonical mapper needs to make provider file paths workspace-relative. */
|
||||
export interface CanonicalProviderEventContext {
|
||||
workingDirectory?: WorkspaceRoots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural subset of an ACP runtime event consumed by the canonical event
|
||||
@@ -711,6 +721,7 @@ function researchSources(
|
||||
export function canonicalProviderEventsFromCodex(
|
||||
method: string,
|
||||
paramsValue: unknown,
|
||||
context: CanonicalProviderEventContext = {},
|
||||
): CanonicalProviderEvent[] {
|
||||
const params = record(paramsValue);
|
||||
const item = record(params.item);
|
||||
@@ -767,6 +778,34 @@ export function canonicalProviderEventsFromCodex(
|
||||
},
|
||||
];
|
||||
}
|
||||
if ((method === "item/started" || completed) && type === "fileChange") {
|
||||
// The workspace-change projection keeps the whole-turn view; the edit row
|
||||
// carries this one patch so each change can be reviewed where it happened.
|
||||
const fileChanges = toolFileChangesFromCodexChanges(item.changes, context.workingDirectory);
|
||||
const status = completed ? canonicalStatus(item.status, "completed") : "running";
|
||||
return [
|
||||
{
|
||||
eventType: completed ? "tool.execution.completed" : "tool.execution.started",
|
||||
payload: {
|
||||
schema: "paperclip.tool.execution.v1",
|
||||
executionId: itemId,
|
||||
transport: "builtin",
|
||||
operation: "edit",
|
||||
name: "apply_patch",
|
||||
target: fileChanges?.[0]?.path ?? null,
|
||||
namespace: null,
|
||||
readOnly: false,
|
||||
status,
|
||||
durationMs: null,
|
||||
exitCode: null,
|
||||
progress: null,
|
||||
...boundedOutput(""),
|
||||
...(fileChanges ? { fileChanges } : {}),
|
||||
},
|
||||
itemId,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (
|
||||
method === "item/commandExecution/outputDelta" ||
|
||||
method === "item/mcpToolCall/progress"
|
||||
@@ -1229,6 +1268,7 @@ export function canonicalProviderEventsFromAcpxRuntimeEvent(
|
||||
event: AcpRuntimeEventShape,
|
||||
fallbackItemId: string,
|
||||
turnId?: string,
|
||||
context: CanonicalProviderEventContext = {},
|
||||
): CanonicalProviderEvent[] {
|
||||
const runtimeType = text(record(event).type);
|
||||
if (
|
||||
@@ -1316,13 +1356,20 @@ export function canonicalProviderEventsFromAcpxRuntimeEvent(
|
||||
const mcp = acpxMcpToolIdentity(title);
|
||||
const name = mcp?.name ?? title;
|
||||
const operation = acpxToolOperation(event.kind, name);
|
||||
// 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(
|
||||
event.content,
|
||||
event.locations,
|
||||
context.workingDirectory,
|
||||
);
|
||||
const payload = {
|
||||
schema: "paperclip.tool.execution.v1",
|
||||
executionId: itemId,
|
||||
transport: mcp ? "mcp" : "builtin",
|
||||
operation,
|
||||
name: name || null,
|
||||
target: safeAcpLocation(event.locations?.[0]),
|
||||
target: safeAcpLocation(event.locations?.[0]) ?? fileChanges?.[0]?.path ?? null,
|
||||
namespace: mcp?.namespace ?? null,
|
||||
readOnly: ["read", "search", "list"].includes(operation),
|
||||
...(Object.prototype.hasOwnProperty.call(event, "rawInput")
|
||||
@@ -1336,6 +1383,7 @@ export function canonicalProviderEventsFromAcpxRuntimeEvent(
|
||||
progress:
|
||||
status === "running" ? text(event.text).slice(0, 4000) || null : null,
|
||||
...boundedOutput(output),
|
||||
...(fileChanges ? { fileChanges } : {}),
|
||||
};
|
||||
const terminal = status !== "running";
|
||||
return [
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MAX_TOOL_CALL_DIFF_BYTES,
|
||||
MAX_TOOL_FILE_DIFF_BYTES,
|
||||
toolFileChangesFromAcpContent,
|
||||
toolFileChangesFromCodexChanges,
|
||||
unifiedHunkFromTexts,
|
||||
} from "./tool-file-changes.js";
|
||||
|
||||
describe("unifiedHunkFromTexts", () => {
|
||||
it("keeps unchanged lines as context and marks the edit", () => {
|
||||
expect(unifiedHunkFromTexts("a\nb\nc", "a\nB\nc", 10)).toBe(
|
||||
["@@ -10,3 +10,3 @@", " a", "-b", "+B", " c"].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("presents a new file as additions only", () => {
|
||||
expect(unifiedHunkFromTexts(null, "one\ntwo\n")).toBe(["@@ -0,0 +1,2 @@", "+one", "+two"].join("\n"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolFileChangesFromAcpContent", () => {
|
||||
const cwd = "/work/repo";
|
||||
|
||||
it("maps Claude Edit diff blocks to a workspace-relative patch with counts", () => {
|
||||
const [change] = toolFileChangesFromAcpContent(
|
||||
[{ type: "diff", path: "/work/repo/src/a.ts", oldText: "x = 1\ny = 2", newText: "x = 1\ny = 3\nz = 4" }],
|
||||
[{ path: "/work/repo/src/a.ts", line: 7 }],
|
||||
cwd,
|
||||
)!;
|
||||
expect(change).toMatchObject({ path: "src/a.ts", operation: "modify", additions: 2, deletions: 1, diffTruncated: false });
|
||||
expect(change.diff).toContain("@@ -7,2 +7,3 @@");
|
||||
});
|
||||
|
||||
it("treats a Write with no old text as a created file", () => {
|
||||
const [change] = toolFileChangesFromAcpContent(
|
||||
[{ type: "diff", path: "/work/repo/new.js", oldText: null, newText: "a\nb" }],
|
||||
[],
|
||||
cwd,
|
||||
)!;
|
||||
expect(change).toMatchObject({ path: "new.js", operation: "create", additions: 2, deletions: 0 });
|
||||
});
|
||||
|
||||
it("joins several hunks for one file and drops paths outside the workspace", () => {
|
||||
const changes = toolFileChangesFromAcpContent(
|
||||
[
|
||||
{ type: "diff", path: "/work/repo/a.ts", oldText: "1", newText: "2" },
|
||||
{ type: "diff", path: "/work/repo/a.ts", oldText: "3", newText: "4" },
|
||||
{ type: "diff", path: "/etc/passwd", oldText: "x", newText: "y" },
|
||||
{ type: "content", content: { type: "text", text: "ignored" } },
|
||||
],
|
||||
[],
|
||||
[cwd],
|
||||
)!;
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(changes[0]).toMatchObject({ path: "a.ts", additions: 2, deletions: 2 });
|
||||
expect(changes[0].diff!.match(/^@@/gm)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns null when the call carried no diff content", () => {
|
||||
expect(toolFileChangesFromAcpContent([{ type: "content" }], [], cwd)).toBeNull();
|
||||
expect(toolFileChangesFromAcpContent(undefined, [], cwd)).toBeNull();
|
||||
});
|
||||
|
||||
it("bounds a large patch at a line boundary and keeps full counts", () => {
|
||||
const newText = Array.from({ length: 4_000 }, (_, index) => `line ${index} ${"x".repeat(20)}`).join("\n");
|
||||
const [change] = toolFileChangesFromAcpContent([{ type: "diff", path: "big.txt", oldText: null, newText }], [], cwd)!;
|
||||
expect(change.additions).toBe(4_000);
|
||||
expect(change.diffTruncated).toBe(true);
|
||||
expect(Buffer.byteLength(change.diff!)).toBeLessThanOrEqual(MAX_TOOL_FILE_DIFF_BYTES);
|
||||
expect(change.diff!.endsWith("x")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolFileChangesFromCodexChanges", () => {
|
||||
it("keeps Codex update patches and turns added-file content into a hunk", () => {
|
||||
const changes = toolFileChangesFromCodexChanges(
|
||||
[
|
||||
{ path: "/work/repo/a.js", kind: { type: "update", move_path: null }, diff: "@@ -1 +1 @@\n-a\n+b\n" },
|
||||
{ path: "b.js", kind: { type: "add" }, diff: "one\ntwo\n" },
|
||||
],
|
||||
"/work/repo",
|
||||
)!;
|
||||
expect(changes[0]).toMatchObject({ path: "a.js", operation: "modify", additions: 1, deletions: 1 });
|
||||
expect(changes[1]).toMatchObject({ path: "b.js", operation: "create", additions: 2, deletions: 0 });
|
||||
expect(changes[1].diff).toBe("@@ -0,0 +1,2 @@\n+one\n+two\n");
|
||||
});
|
||||
|
||||
it("reports a moved file as a rename", () => {
|
||||
const [change] = toolFileChangesFromCodexChanges(
|
||||
[{ path: "old.js", kind: { type: "update", move_path: "new.js" }, diff: "" }],
|
||||
"/work/repo",
|
||||
)!;
|
||||
expect(change).toMatchObject({ path: "new.js", previousPath: "old.js", operation: "rename" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("byte budgets and hunk content", () => {
|
||||
it("keeps every file's counts when multibyte patches exceed the call budget", () => {
|
||||
const wide = Array.from({ length: 2_000 }, () => "\u4e2d\u6587\u5185\u5bb9\u4e2d\u6587").join("\n");
|
||||
const changes = toolFileChangesFromAcpContent(
|
||||
[1, 2, 3].map((index) => ({ type: "diff", path: `f${index}.txt`, oldText: null, newText: wide })),
|
||||
[],
|
||||
"/work/repo",
|
||||
)!;
|
||||
expect(changes.map((change) => change.additions)).toEqual([2_000, 2_000, 2_000]);
|
||||
const bytes = changes.reduce((sum, change) => sum + (change.diff === null ? 0 : Buffer.byteLength(JSON.stringify(change.diff))), 0);
|
||||
expect(bytes).toBeLessThanOrEqual(MAX_TOOL_CALL_DIFF_BYTES);
|
||||
expect(changes.some((change) => change.diff === null && change.diffTruncated)).toBe(true);
|
||||
for (const change of changes) expect(change.diff ?? "").not.toContain("\uFFFD");
|
||||
});
|
||||
|
||||
it("counts removed and added lines that look like file headers inside a hunk", () => {
|
||||
const [change] = toolFileChangesFromCodexChanges(
|
||||
[{ path: "q.sql", kind: { type: "update", move_path: null }, diff: "@@ -1,2 +1,2 @@\n--- old comment\n+++ new value\n keep\n" }],
|
||||
"/work/repo",
|
||||
)!;
|
||||
expect(change).toMatchObject({ additions: 1, deletions: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("hunk line numbers", () => {
|
||||
it("continues after the previous hunk when ACP reports one location per file", () => {
|
||||
const [change] = toolFileChangesFromAcpContent(
|
||||
[
|
||||
{ type: "diff", path: "/work/repo/a.ts", oldText: "a\nb", newText: "a\nB" },
|
||||
{ type: "diff", path: "/work/repo/a.ts", oldText: "c", newText: "C" },
|
||||
],
|
||||
[{ path: "/work/repo/a.ts", line: 10 }],
|
||||
"/work/repo",
|
||||
)!;
|
||||
expect(change.diff!.match(/^@@[^@]+@@/gm)).toEqual(["@@ -10,2 +10,2 @@", "@@ -12,1 +12,1 @@"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
import { realpathSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* One file touched by a single edit tool call, carried on the canonical
|
||||
* `paperclip.tool.execution.v1` payload so the edit row can show its own diff.
|
||||
* Paths are workspace-relative; the patch is bounded per file and per call.
|
||||
*/
|
||||
export interface ToolFileChange {
|
||||
path: string;
|
||||
operation: "create" | "modify" | "delete" | "rename";
|
||||
previousPath: string | null;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
diff: string | null;
|
||||
diffTruncated: boolean;
|
||||
}
|
||||
|
||||
export const MAX_TOOL_FILE_CHANGES = 32;
|
||||
/** UTF-8 bytes of one file's patch (also within the schema's 32768-character bound). */
|
||||
export const MAX_TOOL_FILE_DIFF_BYTES = 32 * 1024;
|
||||
/**
|
||||
* Serialized JSON bytes of all patches in one edit call. The ACPX sidecar frame
|
||||
* is 128 KiB including up to 64 KiB of tool output, so patches stay well below
|
||||
* the remainder; a file over budget keeps its counts and drops only its patch.
|
||||
*/
|
||||
export const MAX_TOOL_CALL_DIFF_BYTES = 48 * 1024;
|
||||
|
||||
function serializedBytes(value: string): number {
|
||||
return Buffer.byteLength(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function withinCallBudget(files: ToolFileChange[]): ToolFileChange[] {
|
||||
let budget = MAX_TOOL_CALL_DIFF_BYTES;
|
||||
return files.map((file) => {
|
||||
const size = file.diff === null ? 0 : serializedBytes(file.diff);
|
||||
if (size > budget) return { ...file, diff: null, diffTruncated: true };
|
||||
budget -= size;
|
||||
return file;
|
||||
});
|
||||
}
|
||||
|
||||
/** Cut a patch to at most `maxBytes` UTF-8 bytes, at the last whole line. */
|
||||
function boundedPatch(diff: string, maxBytes: number): { diff: string; truncated: boolean } {
|
||||
const bytes = Buffer.from(diff, "utf8");
|
||||
if (bytes.length <= maxBytes) return { diff, truncated: false };
|
||||
const prefix = bytes.subarray(0, maxBytes).toString("utf8").replace(/\uFFFD+$/u, "");
|
||||
const cut = prefix.lastIndexOf("\n");
|
||||
return { diff: cut > 0 ? prefix.slice(0, cut) : prefix, truncated: true };
|
||||
}
|
||||
|
||||
const MAX_LCS_CELLS = 4_000_000;
|
||||
|
||||
/** Workspace roots an absolute provider path may be relative to (e.g. a path and its realpath). */
|
||||
export type WorkspaceRoots = string | readonly string[] | undefined;
|
||||
|
||||
/** A working directory and its resolved realpath, for providers that report either. */
|
||||
export function workspaceRootsFor(directory: string | null | undefined): string[] {
|
||||
if (!directory) return [];
|
||||
try {
|
||||
const resolved = realpathSync(directory);
|
||||
return resolved === directory ? [directory] : [directory, resolved];
|
||||
} catch {
|
||||
return [directory];
|
||||
}
|
||||
}
|
||||
|
||||
function relativeWorkspacePath(value: unknown, workingDirectory: WorkspaceRoots): string | null {
|
||||
if (typeof value !== "string" || !value.trim()) return null;
|
||||
let candidate = value.trim().replace(/^file:\/\//, "").replaceAll("\\", "/");
|
||||
if (/^[A-Za-z]:\//.test(candidate) || candidate.startsWith("/")) {
|
||||
const roots = typeof workingDirectory === "string" ? [workingDirectory] : workingDirectory ?? [];
|
||||
const absolute = path.posix.normalize(candidate);
|
||||
const relative = roots
|
||||
.map((root) => path.posix.relative(root.replaceAll("\\", "/").replace(/\/+$/, ""), absolute))
|
||||
.find((value) => value && value !== ".." && !value.startsWith("../") && !path.posix.isAbsolute(value));
|
||||
if (!relative) return null;
|
||||
candidate = relative;
|
||||
}
|
||||
candidate = path.posix.normalize(candidate);
|
||||
if (
|
||||
!candidate || candidate === "." || candidate.length > 1_024 ||
|
||||
candidate.startsWith("/") || candidate.split("/").includes("..")
|
||||
) return null;
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/** Bound a unified diff at a line boundary, keeping counts from the full patch. */
|
||||
export function boundedToolFileChange(change: Omit<ToolFileChange, "diffTruncated" | "additions" | "deletions"> & {
|
||||
additions?: number;
|
||||
deletions?: number;
|
||||
}): ToolFileChange {
|
||||
const diff = change.diff ?? null;
|
||||
let additions = change.additions ?? 0;
|
||||
let deletions = change.deletions ?? 0;
|
||||
if (diff !== null && change.additions === undefined && change.deletions === undefined) {
|
||||
// `---`/`+++` are file headers only before a hunk; inside one they are
|
||||
// changed lines such as a removed `-- comment`.
|
||||
let inHunk = false;
|
||||
for (const line of diff.split("\n")) {
|
||||
if (line.startsWith("diff --git ")) inHunk = false;
|
||||
else if (line.startsWith("@@")) inHunk = true;
|
||||
else if (!inHunk && (line.startsWith("+++ ") || line.startsWith("--- "))) continue;
|
||||
else if (line.startsWith("+")) additions += 1;
|
||||
else if (line.startsWith("-")) deletions += 1;
|
||||
}
|
||||
}
|
||||
const bounded = diff === null ? null : boundedPatch(diff, MAX_TOOL_FILE_DIFF_BYTES);
|
||||
return { ...change, additions, deletions, diff: bounded?.diff ?? null, diffTruncated: bounded?.truncated ?? false };
|
||||
}
|
||||
|
||||
function splitLines(value: string): string[] {
|
||||
if (!value) return [];
|
||||
const lines = value.split("\n");
|
||||
if (lines.at(-1) === "") lines.pop();
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified hunk for one old/new text pair (an ACP `diff` content block). Uses a
|
||||
* bounded LCS; very large blocks fall back to a whole-block replacement.
|
||||
*/
|
||||
export function unifiedHunkFromTexts(oldText: string | null, newText: string, newStart = 1): string {
|
||||
const before = splitLines(oldText ?? "");
|
||||
const after = splitLines(newText);
|
||||
const rows: string[] = [];
|
||||
if (before.length * after.length > MAX_LCS_CELLS) {
|
||||
for (const line of before) rows.push(`-${line}`);
|
||||
for (const line of after) rows.push(`+${line}`);
|
||||
} else {
|
||||
const n = before.length;
|
||||
const m = after.length;
|
||||
const table: Uint32Array[] = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
|
||||
for (let i = n - 1; i >= 0; i -= 1) {
|
||||
for (let j = m - 1; j >= 0; j -= 1) {
|
||||
table[i]![j] = before[i] === after[j]
|
||||
? table[i + 1]![j + 1]! + 1
|
||||
: Math.max(table[i + 1]![j]!, table[i]![j + 1]!);
|
||||
}
|
||||
}
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n || j < m) {
|
||||
if (i < n && j < m && before[i] === after[j]) {
|
||||
rows.push(` ${before[i]}`);
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if (i < n && (j >= m || table[i + 1]![j]! >= table[i]![j + 1]!)) {
|
||||
rows.push(`-${before[i]}`);
|
||||
i += 1;
|
||||
} else {
|
||||
rows.push(`+${after[j]}`);
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
const start = Math.max(newStart, 1);
|
||||
const oldStart = before.length === 0 ? 0 : start;
|
||||
const newHeaderStart = after.length === 0 ? 0 : start;
|
||||
return [`@@ -${oldStart},${before.length} +${newHeaderStart},${after.length} @@`, ...rows].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* File changes from ACP tool content (`{ type: "diff", path, oldText, newText }`).
|
||||
* Several blocks for one path (one per hunk after Claude's PostToolUse update)
|
||||
* join into one patch. Paths outside the workspace are dropped.
|
||||
*/
|
||||
export function toolFileChangesFromAcpContent(
|
||||
content: unknown,
|
||||
locations: unknown,
|
||||
workingDirectory: WorkspaceRoots,
|
||||
): ToolFileChange[] | null {
|
||||
if (!Array.isArray(content)) return null;
|
||||
const lineByPath = new Map<string, number[]>();
|
||||
if (Array.isArray(locations)) {
|
||||
for (const value of locations) {
|
||||
if (!value || typeof value !== "object") continue;
|
||||
const location = value as Record<string, unknown>;
|
||||
const filePath = relativeWorkspacePath(location.path, workingDirectory);
|
||||
if (filePath && typeof location.line === "number" && Number.isSafeInteger(location.line)) {
|
||||
lineByPath.set(filePath, [...(lineByPath.get(filePath) ?? []), location.line]);
|
||||
}
|
||||
}
|
||||
}
|
||||
const byPath = new Map<string, { hunks: string[]; created: boolean; nextLine: number }>();
|
||||
let blocks = 0;
|
||||
for (const value of content) {
|
||||
if (!value || typeof value !== "object") continue;
|
||||
const block = value as Record<string, unknown>;
|
||||
if (block.type !== "diff" || typeof block.newText !== "string") continue;
|
||||
const filePath = relativeWorkspacePath(block.path, workingDirectory);
|
||||
if (!filePath) continue;
|
||||
const oldText = typeof block.oldText === "string" ? block.oldText : null;
|
||||
const entry = byPath.get(filePath) ?? { hunks: [], created: true, nextLine: 1 };
|
||||
// ACP may report one location per hunk or one per file. Without a location
|
||||
// for this hunk, continue after the previous one so later hunks never all
|
||||
// read "line 1".
|
||||
const start = lineByPath.get(filePath)?.[entry.hunks.length] ?? entry.nextLine;
|
||||
entry.hunks.push(unifiedHunkFromTexts(oldText, block.newText, start));
|
||||
entry.nextLine = start + splitLines(block.newText).length;
|
||||
entry.created &&= oldText === null;
|
||||
byPath.set(filePath, entry);
|
||||
blocks += 1;
|
||||
if (blocks >= 256) break;
|
||||
}
|
||||
if (byPath.size === 0) return null;
|
||||
return withinCallBudget([...byPath.entries()].slice(0, MAX_TOOL_FILE_CHANGES).map(([filePath, entry]) =>
|
||||
boundedToolFileChange({
|
||||
path: filePath,
|
||||
operation: entry.created ? "create" : "modify",
|
||||
previousPath: null,
|
||||
diff: `${entry.hunks.join("\n")}\n`,
|
||||
})));
|
||||
}
|
||||
|
||||
/** File changes from a Codex `fileChange` item's `changes[]` (`{ path, kind, diff }`). */
|
||||
export function toolFileChangesFromCodexChanges(
|
||||
changes: unknown,
|
||||
workingDirectory: WorkspaceRoots,
|
||||
): ToolFileChange[] | null {
|
||||
if (!Array.isArray(changes)) return null;
|
||||
const files = changes.slice(0, MAX_TOOL_FILE_CHANGES).flatMap((value): ToolFileChange[] => {
|
||||
if (!value || typeof value !== "object") return [];
|
||||
const change = value as Record<string, unknown>;
|
||||
const filePath = relativeWorkspacePath(change.path, workingDirectory);
|
||||
if (!filePath) return [];
|
||||
const kind = change.kind && typeof change.kind === "object" ? change.kind as Record<string, unknown> : {};
|
||||
const kindText = (typeof change.kind === "string" ? change.kind : typeof kind.type === "string" ? kind.type : "update").toLowerCase();
|
||||
const movePath = relativeWorkspacePath(kind.move_path ?? kind.movePath, workingDirectory);
|
||||
const rawDiff = typeof change.diff === "string" ? change.diff : null;
|
||||
const operation: ToolFileChange["operation"] = movePath
|
||||
? "rename"
|
||||
: kindText.includes("add") ? "create" : kindText.includes("delete") ? "delete" : "modify";
|
||||
// Codex reports a created file's full content and a deleted file's prior
|
||||
// content rather than a patch. Present both as a one-sided hunk.
|
||||
const diff = rawDiff === null
|
||||
? null
|
||||
: (operation === "create" || operation === "delete") && !/^@@ /m.test(rawDiff)
|
||||
? `${operation === "create" ? unifiedHunkFromTexts(null, rawDiff) : unifiedHunkFromTexts(rawDiff, "")}\n`
|
||||
: rawDiff;
|
||||
return [boundedToolFileChange({
|
||||
path: movePath ?? filePath,
|
||||
operation,
|
||||
previousPath: movePath ? filePath : null,
|
||||
diff,
|
||||
})];
|
||||
});
|
||||
return files.length > 0 ? withinCallBudget(files) : null;
|
||||
}
|
||||
|
||||
@@ -343,7 +343,7 @@ function nullableNumber(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
|
||||
}
|
||||
|
||||
function workspaceChangeEntry(payload: JsonRecord, ts: string): TranscriptEntry {
|
||||
export function workspaceChangeEntry(payload: JsonRecord, ts: string): TranscriptEntry {
|
||||
const totals = record(payload.totals);
|
||||
const files = (Array.isArray(payload.files) ? payload.files : [])
|
||||
.map(record)
|
||||
@@ -363,6 +363,7 @@ function workspaceChangeEntry(payload: JsonRecord, ts: string): TranscriptEntry
|
||||
deletions: nullableNumber(file.deletions),
|
||||
binary: file.binary === true,
|
||||
diff: nullableText(file.diff),
|
||||
...(file.diffTruncated === true ? { diffTruncated: true } : {}),
|
||||
};
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { hunkLabel } from "./diff-lines";
|
||||
import type { TaskChatDiffLine } from "./task-chat-model";
|
||||
|
||||
/** `+12 −3` in the diff hues. Unknown counts are omitted, not shown as zero. */
|
||||
export function TaskChatDiffCounts({
|
||||
added,
|
||||
removed,
|
||||
className,
|
||||
}: {
|
||||
added: number | null | undefined;
|
||||
removed: number | null | undefined;
|
||||
className?: string;
|
||||
}) {
|
||||
if (added == null && removed == null) return null;
|
||||
// A pure addition or deletion reads cleaner without the empty side.
|
||||
const showAdded = added != null && (added > 0 || !removed);
|
||||
const showRemoved = removed != null && (removed > 0 || !added);
|
||||
return (
|
||||
<span
|
||||
className={cn("inline-flex shrink-0 items-center gap-1 font-mono tabular-nums", className)}
|
||||
data-testid="task-chat-diff-counts"
|
||||
>
|
||||
{showAdded ? <span className="text-(--diff-add-fg)">+{added}</span> : null}
|
||||
{showRemoved ? <span className="text-(--diff-remove-fg)">−{removed}</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const MARKER: Record<TaskChatDiffLine["kind"], string> = {
|
||||
add: "+",
|
||||
remove: "−",
|
||||
context: " ",
|
||||
hunk: "",
|
||||
file: "",
|
||||
};
|
||||
|
||||
/**
|
||||
* Compact unified diff for one file. Long diffs show the first `previewLines`
|
||||
* lines with a control to reveal the rest; the block scrolls inside a fixed
|
||||
* maximum height so a large edit never pushes the conversation away.
|
||||
*/
|
||||
export function TaskChatDiffView({
|
||||
lines,
|
||||
truncated = false,
|
||||
previewLines = 24,
|
||||
className,
|
||||
}: {
|
||||
lines: readonly TaskChatDiffLine[];
|
||||
truncated?: boolean;
|
||||
previewLines?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const visible = showAll ? lines : lines.slice(0, previewLines);
|
||||
const hidden = lines.length - visible.length;
|
||||
return (
|
||||
<div
|
||||
className={cn("min-w-0 overflow-hidden rounded-sm border border-border bg-muted/30", className)}
|
||||
data-testid="task-chat-diff-view"
|
||||
>
|
||||
<div className="max-h-(--sz-edit-diff-max-h) overflow-auto">
|
||||
<table className="w-full border-collapse font-mono text-(length:--text-micro) leading-relaxed">
|
||||
<tbody>
|
||||
{visible.map((line, index) =>
|
||||
line.kind === "file" ? (
|
||||
<tr key={index} className="border-t border-border">
|
||||
<td colSpan={2} className="truncate px-2 py-1 font-medium text-foreground">
|
||||
{line.text}
|
||||
</td>
|
||||
</tr>
|
||||
) : line.kind === "hunk" ? (
|
||||
<tr key={index} className="bg-muted/60 text-muted-foreground">
|
||||
<td colSpan={2} className="whitespace-pre px-2 py-0.5">
|
||||
{hunkLabel(line.text)}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
<tr
|
||||
key={index}
|
||||
className={cn(
|
||||
line.kind === "add" && "bg-(--diff-add-bg)",
|
||||
line.kind === "remove" && "bg-(--diff-remove-bg)",
|
||||
)}
|
||||
>
|
||||
<td
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"w-4 select-none pl-2 align-top",
|
||||
line.kind === "add" && "text-(--diff-add-fg)",
|
||||
line.kind === "remove" && "text-(--diff-remove-fg)",
|
||||
line.kind === "context" && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{MARKER[line.kind]}
|
||||
</td>
|
||||
<td
|
||||
className={cn(
|
||||
"whitespace-pre pr-3 align-top",
|
||||
line.kind === "context" ? "text-muted-foreground" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">{line.kind === "add" ? "Added: " : line.kind === "remove" ? "Removed: " : ""}</span>
|
||||
{line.text || " "}
|
||||
</td>
|
||||
</tr>
|
||||
),
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{hidden > 0 || truncated ? (
|
||||
<div className="flex items-center gap-2 border-t border-border px-2 py-1 text-(length:--text-micro) text-muted-foreground">
|
||||
{hidden > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-foreground underline-offset-2 hover:underline"
|
||||
onClick={() => setShowAll(true)}
|
||||
>
|
||||
Show {hidden} more {hidden === 1 ? "line" : "lines"}
|
||||
</button>
|
||||
) : null}
|
||||
{truncated && hidden === 0 ? <span>Diff shortened for display.</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -73,7 +73,7 @@ describe("TaskChatLiveTail", () => {
|
||||
expect(container.querySelector('[data-testid="task-chat-phase-child-rail"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps tool diff bodies out of the activity feed", () => {
|
||||
it("shows an edit's diff only when its row is opened", () => {
|
||||
const items = parse([
|
||||
{ kind: "tool_call", ts: TS, name: "Edit", toolUseId: "t1", input: { file_path: "a.ts" } },
|
||||
{ kind: "diff", ts: TS, changeType: "add", text: "const x = 1;" },
|
||||
@@ -83,12 +83,11 @@ describe("TaskChatLiveTail", () => {
|
||||
|
||||
expect(toggle().getAttribute("aria-expanded")).toBe("false");
|
||||
expect(container.textContent).not.toContain("const x = 1;");
|
||||
expect(container.textContent).not.toContain("+1 −1");
|
||||
|
||||
expandFirstDetail();
|
||||
|
||||
expect(container.textContent).toContain("+1 −1");
|
||||
expect(container.textContent).not.toContain("const x = 1;");
|
||||
expect(container.querySelector('[data-testid="task-chat-diff-counts"]')?.textContent).toBe("+1−1");
|
||||
expect(container.querySelector('[data-testid="task-chat-diff-view"]')?.textContent).toContain("const x = 1;");
|
||||
expect(container.querySelector('[data-testid="task-chat-runner-activity-detail"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -422,7 +422,26 @@ describe("TaskChatProtocolCard", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("opens a workspace diff review dialog", async () => {
|
||||
it("says when a change set counts more files than it lists", () => {
|
||||
renderCard(root, {
|
||||
id: "workspace-large",
|
||||
kind: "protocol",
|
||||
surface: "workspace_change",
|
||||
changeSetId: "changes-large",
|
||||
revision: 1,
|
||||
source: "harness_reported",
|
||||
complete: true,
|
||||
files: [
|
||||
{ path: "a.ts", operation: "modify", previousPath: null, additions: 1, deletions: 0, binary: false, diff: null, diffTruncated: true },
|
||||
],
|
||||
totals: { files: 40, additions: 120, deletions: 3 },
|
||||
patchArtifactRef: null,
|
||||
});
|
||||
expect(container.textContent).toContain("40 files changed");
|
||||
expect(container.querySelector('[data-testid="task-chat-workspace-change-omitted"]')?.textContent).toContain("39 more files changed.");
|
||||
});
|
||||
|
||||
it("lists the turn's changed files and opens each diff in place", async () => {
|
||||
renderCard(root, {
|
||||
id: "workspace",
|
||||
kind: "protocol",
|
||||
@@ -439,24 +458,22 @@ describe("TaskChatProtocolCard", () => {
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
binary: false,
|
||||
diff: "-old\n+new",
|
||||
diff: "@@ -1 +1 @@\n-old\n+new\n",
|
||||
},
|
||||
],
|
||||
totals: { files: 1, additions: 1, deletions: 1 },
|
||||
patchArtifactRef: null,
|
||||
});
|
||||
const button = Array.from(container.querySelectorAll("button")).find(
|
||||
(candidate) => candidate.textContent === "Review diff",
|
||||
);
|
||||
expect(button).not.toBeUndefined();
|
||||
expect(container.textContent).toContain("1 file changed · +1 −1");
|
||||
await act(async () => button?.click());
|
||||
expect(document.querySelector('[role="dialog"]')?.textContent).toContain(
|
||||
"ui/src/App.tsx",
|
||||
);
|
||||
expect(document.querySelector('[role="dialog"]')?.textContent).toContain(
|
||||
"old",
|
||||
);
|
||||
expect(container.textContent).toContain("Changed in this turn");
|
||||
expect(container.textContent).toContain("1 file changed");
|
||||
expect(container.querySelector('[data-testid="task-chat-diff-view"]')).toBeNull();
|
||||
const row = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-workspace-change-file"] button');
|
||||
expect(row?.textContent).toContain("ui/src/App.tsx");
|
||||
await act(async () => row?.click());
|
||||
const diff = container.querySelector('[data-testid="task-chat-diff-view"]');
|
||||
expect(diff?.textContent).toContain("Line 1");
|
||||
expect(diff?.textContent).toContain("old");
|
||||
expect(diff?.textContent).toContain("new");
|
||||
});
|
||||
|
||||
it("opens a bounded file preview", async () => {
|
||||
|
||||
@@ -48,6 +48,8 @@ import type {
|
||||
import { QuestionForm, QuestionResponseSummary } from "./QuestionForm";
|
||||
import { TaskChatComposerTakeoverHeader, TaskChatRequestHistoryContext } from "./TaskChatComposerTakeoverContext";
|
||||
import { RichWorkProductCard } from "./RichWorkProductCard";
|
||||
import { TaskChatDiffCounts, TaskChatDiffView } from "./TaskChatDiffView";
|
||||
import { parseUnifiedDiffLines } from "./diff-lines";
|
||||
import { useArtifactsV1Enabled } from "@/hooks/useArtifactsV1Enabled";
|
||||
import { ArtifactFileRow, ArtifactRow, fileRowSourceFromAttachment, fileRowSourceFromWorkProduct, subjectFromAttachment, subjectFromDocumentSummary, subjectFromWorkProduct, workProductStateBadge } from "@/components/artifacts/v1";
|
||||
|
||||
@@ -379,134 +381,143 @@ function ProviderActivityCard({
|
||||
);
|
||||
}
|
||||
|
||||
function diffStats(file: TaskChatWorkspaceChangeItem["files"][number]) {
|
||||
function WorkspaceChangeFileRow({
|
||||
file,
|
||||
open,
|
||||
onToggle,
|
||||
}: {
|
||||
file: TaskChatWorkspaceChangeItem["files"][number];
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const parsed = useMemo(
|
||||
() => (file.diff ? parseUnifiedDiffLines(file.diff) : null),
|
||||
[file.diff],
|
||||
);
|
||||
const expandable = file.binary || Boolean(parsed?.lines.length) || file.diffTruncated === true;
|
||||
return (
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||
{file.additions == null ? "" : `+${file.additions}`}
|
||||
{file.additions != null && file.deletions != null ? " " : ""}
|
||||
{file.deletions == null ? "" : `−${file.deletions}`}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-col" data-testid="task-chat-workspace-change-file">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expandable ? open : undefined}
|
||||
disabled={!expandable}
|
||||
className={cn(
|
||||
"group/file flex min-w-0 items-center gap-2 rounded-sm px-2 py-1 text-left text-xs",
|
||||
expandable ? "hover:bg-muted/60" : "cursor-default",
|
||||
)}
|
||||
onClick={expandable ? onToggle : undefined}
|
||||
>
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"h-3 w-3 shrink-0 text-muted-foreground transition-transform",
|
||||
open && "rotate-90",
|
||||
!expandable && "invisible",
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-foreground">
|
||||
{file.previousPath ? `${file.previousPath} → ${file.path}` : file.path}
|
||||
</span>
|
||||
{file.operation !== "modify" ? (
|
||||
<span className="shrink-0 text-(length:--text-micro) capitalize text-muted-foreground">
|
||||
{file.binary ? "binary · " : ""}
|
||||
{OPERATION_LABEL[file.operation] ?? file.operation.replaceAll("_", " ")}
|
||||
</span>
|
||||
) : file.binary ? (
|
||||
<span className="shrink-0 text-(length:--text-micro) text-muted-foreground">binary</span>
|
||||
) : null}
|
||||
<TaskChatDiffCounts added={file.additions} removed={file.deletions} />
|
||||
</button>
|
||||
{open ? (
|
||||
file.binary ? (
|
||||
<p className="ml-7 mt-1 text-xs text-muted-foreground">Binary file; text diff is unavailable.</p>
|
||||
) : parsed?.lines.length ? (
|
||||
<TaskChatDiffView
|
||||
className="ml-7 mt-1"
|
||||
lines={parsed.lines}
|
||||
truncated={parsed.truncated || file.diffTruncated === true}
|
||||
/>
|
||||
) : (
|
||||
<p className="ml-7 mt-1 text-xs text-muted-foreground">This patch is too large to show here.</p>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const OPERATION_LABEL: Record<string, string> = {
|
||||
create: "new",
|
||||
delete: "deleted",
|
||||
rename: "renamed",
|
||||
mode_change: "mode change",
|
||||
};
|
||||
|
||||
function WorkspaceChangeCard({ item }: { item: TaskChatWorkspaceChangeItem }) {
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [openPaths, setOpenPaths] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const selected =
|
||||
item.files.find((file) => file.path === selectedPath) ?? null;
|
||||
const visibleFiles = expanded ? item.files : item.files.slice(0, 3);
|
||||
const visibleFiles = expanded ? item.files : item.files.slice(0, 5);
|
||||
const fileCount = item.totals.files || item.files.length;
|
||||
const fileLabel = `${fileCount} ${fileCount === 1 ? "file" : "files"}`;
|
||||
const stats = [
|
||||
item.totals.additions == null ? null : `+${item.totals.additions}`,
|
||||
item.totals.deletions == null ? null : `−${item.totals.deletions}`,
|
||||
].filter(Boolean).join(" ");
|
||||
const summary = item.complete
|
||||
? `${fileLabel} changed${stats ? ` · ${stats}` : ""}`
|
||||
: "Workspace changes in progress";
|
||||
const summary = item.complete ? `${fileLabel} changed` : `${fileLabel} so far`;
|
||||
const toggle = (path: string) =>
|
||||
setOpenPaths((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(path)) next.delete(path);
|
||||
else next.add(path);
|
||||
return next;
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<CardShell
|
||||
icon={GitBranch}
|
||||
title="Workspace changes"
|
||||
status={item.complete ? "completed" : "running"}
|
||||
summary={summary}
|
||||
testId="task-chat-workspace-change"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
{visibleFiles.map((file) => (
|
||||
<button
|
||||
key={`${file.operation}:${file.path}`}
|
||||
type="button"
|
||||
className="flex min-w-0 items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-muted/60"
|
||||
onClick={() => setSelectedPath(file.path)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-mono">
|
||||
{file.previousPath
|
||||
? `${file.previousPath} → ${file.path}`
|
||||
: file.path}
|
||||
</span>
|
||||
<span className="text-xs capitalize text-muted-foreground">
|
||||
{file.binary ? "binary · " : ""}
|
||||
{file.operation.replaceAll("_", " ")}
|
||||
</span>
|
||||
</span>
|
||||
{diffStats(file)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{item.files.length > 3 ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
>
|
||||
{expanded
|
||||
? "Show fewer files"
|
||||
: `Show ${item.files.length - 3} more files`}
|
||||
</Button>
|
||||
) : null}
|
||||
{item.files.length > 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setSelectedPath(item.files[0].path)}
|
||||
>
|
||||
Review diff
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</CardShell>
|
||||
<Dialog
|
||||
open={selected !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedPath(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="w-full max-w-(--pct-90) overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-mono text-sm">
|
||||
{selected?.path ?? "Workspace diff"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{item.files.length > 1 ? (
|
||||
<div
|
||||
className="flex max-w-full gap-1 overflow-x-auto pb-1"
|
||||
aria-label="Changed files"
|
||||
>
|
||||
{item.files.map((file) => (
|
||||
<Button
|
||||
key={file.path}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={file.path === selected?.path ? "secondary" : "ghost"}
|
||||
onClick={() => setSelectedPath(file.path)}
|
||||
>
|
||||
{file.path}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{selected?.binary ? (
|
||||
<p className="rounded-sm bg-muted/50 p-3 text-sm text-muted-foreground">
|
||||
Binary file; text diff is unavailable.
|
||||
</p>
|
||||
) : selected?.diff ? (
|
||||
<pre className="max-h-(--sz-70vh) overflow-auto whitespace-pre rounded-sm bg-muted/50 p-3 font-mono text-xs">
|
||||
{selected.diff}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="rounded-sm bg-muted/50 p-3 text-sm text-muted-foreground">
|
||||
No inline patch was recorded for this file.
|
||||
</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
<CardShell
|
||||
icon={GitBranch}
|
||||
title={item.complete ? "Changed in this turn" : "Changing files"}
|
||||
status={item.complete ? null : "running"}
|
||||
testId="task-chat-workspace-change"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2 px-2 pb-1 text-xs text-muted-foreground">
|
||||
<span>{summary}</span>
|
||||
<TaskChatDiffCounts added={item.totals.additions} removed={item.totals.deletions} />
|
||||
{item.files.length > 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto text-foreground underline-offset-2 hover:underline"
|
||||
onClick={() =>
|
||||
setOpenPaths((current) =>
|
||||
current.size > 0 ? new Set() : new Set(item.files.map((file) => file.path)),
|
||||
)
|
||||
}
|
||||
>
|
||||
{openPaths.size > 0 ? "Collapse all" : "Expand all"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
{visibleFiles.map((file) => (
|
||||
<WorkspaceChangeFileRow
|
||||
key={`${file.operation}:${file.path}`}
|
||||
file={file}
|
||||
open={openPaths.has(file.path)}
|
||||
onToggle={() => toggle(file.path)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{item.files.length > 5 ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="mt-1"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
>
|
||||
{expanded ? "Show fewer files" : `Show ${item.files.length - 5} more files`}
|
||||
</Button>
|
||||
) : null}
|
||||
{fileCount > item.files.length ? (
|
||||
<p className="mt-1 px-2 text-xs text-muted-foreground" data-testid="task-chat-workspace-change-omitted">
|
||||
{fileCount - item.files.length} more {fileCount - item.files.length === 1 ? "file" : "files"} changed.
|
||||
This change set is too large to list them here.
|
||||
</p>
|
||||
) : null}
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import { TaskChatReasoningSummary } from "./TaskChatReasoningSummary";
|
||||
import { latestReasoningHeadline } from "./reasoning-summary";
|
||||
import { toolActivityPresentation, type ToolIcon } from "./tool-taxonomy";
|
||||
import { useTaskChatShimmer } from "./TaskChatShimmer";
|
||||
import { TaskChatDiffCounts, TaskChatDiffView } from "./TaskChatDiffView";
|
||||
|
||||
type Activity = TaskChatActivityPhaseItem["items"][number];
|
||||
|
||||
@@ -194,6 +195,13 @@ function ActivityContent({
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{item.kind === "tool" && item.diff && (item.diff.added > 0 || item.diff.removed > 0) ? (
|
||||
<TaskChatDiffCounts
|
||||
added={item.diff.added}
|
||||
removed={item.diff.removed}
|
||||
className="text-xs"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -262,6 +270,10 @@ function ActivityDetails({ item }: { item: Activity }) {
|
||||
{item.detail ?? item.label}
|
||||
</p>
|
||||
);
|
||||
if (item.diff?.lines?.length)
|
||||
return (
|
||||
<TaskChatDiffView lines={item.diff.lines} truncated={item.diff.truncated} />
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{item.target ? (
|
||||
@@ -370,6 +382,13 @@ export function TaskChatRunnerActivityGroup({
|
||||
const summary = completedActivitySummary(activities);
|
||||
const SummaryIcon = summary.icon;
|
||||
const countLabel = `${activities.length} ${activities.length === 1 ? "activity" : "activities"}`;
|
||||
const editTotals = activities.reduce(
|
||||
(sum, activity) =>
|
||||
activity.kind === "tool" && activity.diff
|
||||
? { added: sum.added + activity.diff.added, removed: sum.removed + activity.diff.removed }
|
||||
: sum,
|
||||
{ added: 0, removed: 0 },
|
||||
);
|
||||
return (
|
||||
<section
|
||||
className="flex min-w-0 flex-col gap-2"
|
||||
@@ -422,6 +441,13 @@ export function TaskChatRunnerActivityGroup({
|
||||
<RollingActivity item={latest} active={item.active} />
|
||||
)}
|
||||
<span className="flex shrink-0 items-center gap-1 text-xs">
|
||||
{!item.active && (editTotals.added > 0 || editTotals.removed > 0) ? (
|
||||
<TaskChatDiffCounts
|
||||
added={editTotals.added}
|
||||
removed={editTotals.removed}
|
||||
className="mr-1"
|
||||
/>
|
||||
) : null}
|
||||
{expanded ? countLabel : item.active ? activities.length : null}
|
||||
<ChevronRight
|
||||
className={cn("size-3.5", expanded && "rotate-90")}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import type { TaskChatToolItem } from "./task-chat-model";
|
||||
import { toolTaxonomy } from "./tool-taxonomy";
|
||||
import { useTaskChatShimmer } from "./TaskChatShimmer";
|
||||
import { TaskChatDiffCounts, TaskChatDiffView } from "./TaskChatDiffView";
|
||||
|
||||
const STATUS_ICON = {
|
||||
pending: { Icon: Loader2, spin: false, tone: "text-muted-foreground" },
|
||||
@@ -28,7 +29,8 @@ const STATUS_ICON = {
|
||||
* Tool invocation as a flat activity row (v7): [glyph] [name] [mono target] …
|
||||
* [hover chevron] [status]. No card chrome — activity is metadata, not
|
||||
* content. Clicking toggles a compact result/change summary (left-rail
|
||||
* inset). Full diff bodies stay out of the activity feed.
|
||||
* inset). An edit row carries its `+N −M` counts and opens to a compact,
|
||||
* height-capped diff of that one edit.
|
||||
*/
|
||||
export function TaskChatToolCard({ item }: { item: TaskChatToolItem }) {
|
||||
const { Icon, spin, tone } = STATUS_ICON[item.status];
|
||||
@@ -66,6 +68,13 @@ export function TaskChatToolCard({ item }: { item: TaskChatToolItem }) {
|
||||
) : null}
|
||||
</span>
|
||||
<span className="ml-auto flex shrink-0 items-center gap-2">
|
||||
{item.diff && (item.diff.added > 0 || item.diff.removed > 0) ? (
|
||||
<TaskChatDiffCounts
|
||||
added={item.diff.added}
|
||||
removed={item.diff.removed}
|
||||
className="text-(length:--text-micro)"
|
||||
/>
|
||||
) : null}
|
||||
{item.decision ? (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -103,7 +112,7 @@ export function TaskChatToolCard({ item }: { item: TaskChatToolItem }) {
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
{item.target && showDetail ? (
|
||||
{item.target && showDetail && !item.diff?.lines?.length ? (
|
||||
<div
|
||||
className="task-chat-expanded-line-wrap ml-1.5 mt-0.5 min-w-0 max-w-full overflow-hidden border-l-2 border-border py-1 pl-2.5 font-mono text-(length:--text-micro) leading-relaxed text-muted-foreground"
|
||||
data-testid="task-chat-tool-target-detail"
|
||||
@@ -117,22 +126,26 @@ export function TaskChatToolCard({ item }: { item: TaskChatToolItem }) {
|
||||
</pre>
|
||||
) : null}
|
||||
{item.diff && showDetail ? (
|
||||
<div
|
||||
className="ml-1.5 mt-0.5 border-l-2 border-border py-1 pl-2.5"
|
||||
data-testid="task-chat-tool-change-summary"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2 text-(length:--text-micro) text-muted-foreground">
|
||||
<span className="shrink-0">Changed</span>
|
||||
{item.diff.path ? (
|
||||
<span className="min-w-0 truncate font-mono text-foreground">
|
||||
{item.diff.path}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="ml-auto shrink-0 font-mono">
|
||||
+{item.diff.added} −{item.diff.removed}
|
||||
</span>
|
||||
item.diff.lines && item.diff.lines.length > 0 ? (
|
||||
<div className="ml-1.5 mt-1 min-w-0 max-w-full" data-testid="task-chat-tool-diff">
|
||||
<TaskChatDiffView lines={item.diff.lines} truncated={item.diff.truncated} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="ml-1.5 mt-0.5 border-l-2 border-border py-1 pl-2.5"
|
||||
data-testid="task-chat-tool-change-summary"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2 text-(length:--text-micro) text-muted-foreground">
|
||||
<span className="shrink-0">Changed</span>
|
||||
{item.diff.path ? (
|
||||
<span className="min-w-0 truncate font-mono text-foreground">
|
||||
{item.diff.path}
|
||||
</span>
|
||||
) : null}
|
||||
<TaskChatDiffCounts added={item.diff.added} removed={item.diff.removed} className="ml-auto" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseUnifiedDiffLines } from "./diff-lines";
|
||||
|
||||
describe("parseUnifiedDiffLines", () => {
|
||||
it("skips file headers but keeps header-like changed lines inside a hunk", () => {
|
||||
const parsed = parseUnifiedDiffLines(
|
||||
"diff --git a/q.sql b/q.sql\n--- a/q.sql\n+++ b/q.sql\n@@ -1,2 +1,2 @@\n--- old comment\n+++ new comment\n keep\n",
|
||||
);
|
||||
expect(parsed).toMatchObject({ added: 1, removed: 1 });
|
||||
expect(parsed.lines).toEqual([
|
||||
{ kind: "hunk", text: "@@ -1,2 +1,2 @@" },
|
||||
{ kind: "remove", text: "-- old comment" },
|
||||
{ kind: "add", text: "++ new comment" },
|
||||
{ kind: "context", text: "keep" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { TaskChatDiffLine } from "./task-chat-model";
|
||||
|
||||
/** Display cap for one diff in the task chat. */
|
||||
export const TASK_CHAT_DIFF_LINE_LIMIT = 400;
|
||||
|
||||
export type UnifiedDiffLineKind = TaskChatDiffLine["kind"] | "skip";
|
||||
|
||||
/**
|
||||
* Classify unified diff lines. File headers (`diff --git`, `index`, `---`,
|
||||
* `+++`, mode and rename lines) are skipped only before a file's first hunk;
|
||||
* inside a hunk, `--- x` is a removed `-- x` line and `+++ x` an added `++ x`.
|
||||
*/
|
||||
export function createUnifiedDiffClassifier(): (raw: string) => { kind: UnifiedDiffLineKind; text: string } {
|
||||
let inHunk = false;
|
||||
return (raw) => {
|
||||
if (raw.startsWith("diff --git ")) {
|
||||
inHunk = false;
|
||||
return { kind: "skip", text: raw };
|
||||
}
|
||||
if (raw.startsWith("@@")) {
|
||||
inHunk = true;
|
||||
return { kind: "hunk", text: raw };
|
||||
}
|
||||
if (raw === "\\ No newline at end of file") return { kind: "skip", text: raw };
|
||||
if (!inHunk && (
|
||||
raw.startsWith("+++ ") || raw.startsWith("--- ") || raw.startsWith("index ") ||
|
||||
raw.startsWith("new file mode") || raw.startsWith("deleted file mode") ||
|
||||
raw.startsWith("similarity index") || raw.startsWith("rename from") || raw.startsWith("rename to")
|
||||
)) return { kind: "skip", text: raw };
|
||||
if (raw.startsWith("+")) return { kind: "add", text: raw.slice(1) };
|
||||
if (raw.startsWith("-")) return { kind: "remove", text: raw.slice(1) };
|
||||
return { kind: "context", text: raw.startsWith(" ") ? raw.slice(1) : raw };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a unified diff body into display lines. File headers (`---`, `+++`,
|
||||
* `diff --git`, `index`) are dropped because the row or card already names the
|
||||
* file. Lines past the display cap are omitted and reported as truncated.
|
||||
*/
|
||||
export function parseUnifiedDiffLines(
|
||||
patch: string,
|
||||
limit = TASK_CHAT_DIFF_LINE_LIMIT,
|
||||
): { lines: TaskChatDiffLine[]; added: number; removed: number; truncated: boolean } {
|
||||
const lines: TaskChatDiffLine[] = [];
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
let truncated = false;
|
||||
const classify = createUnifiedDiffClassifier();
|
||||
const source = patch.endsWith("\n") ? patch.slice(0, -1) : patch;
|
||||
for (const raw of source.split("\n")) {
|
||||
const { kind, text } = classify(raw);
|
||||
if (kind === "skip") continue;
|
||||
if (kind === "add") added += 1;
|
||||
if (kind === "remove") removed += 1;
|
||||
if (lines.length < limit) lines.push({ kind, text });
|
||||
else truncated = true;
|
||||
}
|
||||
return { lines, added, removed, truncated };
|
||||
}
|
||||
|
||||
/** Readable hunk label: `@@ -41,9 +41,18 @@ fn()` becomes `Line 41 · fn()`. */
|
||||
export function hunkLabel(text: string): string {
|
||||
const match = text.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@ ?(.*)$/);
|
||||
if (!match) return text;
|
||||
const context = match[2]?.trim();
|
||||
return context ? `Line ${match[1]} · ${context}` : `Line ${match[1]}`;
|
||||
}
|
||||
@@ -60,6 +60,8 @@ export interface TaskChatWorkspaceChangeFile {
|
||||
deletions: number | null;
|
||||
binary: boolean;
|
||||
diff: string | null;
|
||||
/** The patch was shortened by its producer; counts still cover all of it. */
|
||||
diffTruncated?: boolean;
|
||||
}
|
||||
import type { IssueThreadInteraction } from "@/lib/issue-thread-interactions";
|
||||
|
||||
@@ -91,7 +93,20 @@ export interface TaskChatDiff {
|
||||
added: number;
|
||||
removed: number;
|
||||
/** Optional unified-diff-ish lines for display only. */
|
||||
lines?: Array<{ kind: "add" | "remove" | "context"; text: string }>;
|
||||
lines?: TaskChatDiffLine[];
|
||||
/** True when the producer bounded the patch and lines were omitted. */
|
||||
truncated?: boolean;
|
||||
/**
|
||||
* Per-file counts reported by the producer for the whole patch. When present
|
||||
* they are authoritative; `added`/`removed` are their sums, not line counts.
|
||||
*/
|
||||
files?: Array<{ path: string; additions: number; deletions: number; truncated: boolean }>;
|
||||
}
|
||||
|
||||
export interface TaskChatDiffLine {
|
||||
/** `file` starts the next file when one edit call changed several files. */
|
||||
kind: "add" | "remove" | "context" | "hunk" | "file";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface TaskChatTokenUsage {
|
||||
|
||||
@@ -720,13 +720,14 @@ describe("transcriptToTaskChatItems protocol surfaces", () => {
|
||||
opts,
|
||||
);
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]).toMatchObject({
|
||||
// The turn's changed files close the turn, after its other activity.
|
||||
expect(items[1]).toMatchObject({
|
||||
kind: "protocol",
|
||||
surface: "workspace_change",
|
||||
revision: 2,
|
||||
complete: true,
|
||||
});
|
||||
expect(items[1]).toMatchObject({
|
||||
expect(items[0]).toMatchObject({
|
||||
kind: "protocol",
|
||||
surface: "runtime_request",
|
||||
runId: "run-protocol",
|
||||
@@ -2865,3 +2866,76 @@ describe("prependIssueBrief (PAP-375)", () => {
|
||||
expect(prependIssueBrief(items, false)).toBe(items);
|
||||
});
|
||||
});
|
||||
|
||||
describe("turn changes assembled from edit rows", () => {
|
||||
it("lists each edited file when the harness reports only per-edit patches", () => {
|
||||
const items = transcriptToTaskChatItems(
|
||||
[
|
||||
{ kind: "tool_call", ts: TS, name: "Write", toolUseId: "w1", input: { path: "price.js" } },
|
||||
{ kind: "diff", ts: TS, changeType: "file_header", text: "price.js", toolUseId: "w1" },
|
||||
{ kind: "diff", ts: TS, changeType: "hunk", text: "@@ -0,0 +1,2 @@", toolUseId: "w1" },
|
||||
{ kind: "diff", ts: TS, changeType: "add", text: "a", toolUseId: "w1" },
|
||||
{ kind: "diff", ts: TS, changeType: "add", text: "b", toolUseId: "w1" },
|
||||
{ kind: "tool_call", ts: TS, name: "Edit", toolUseId: "e1", input: { path: "price.js" } },
|
||||
{ kind: "tool_call", ts: TS, name: "Edit", toolUseId: "e2", input: { path: "other.js" } },
|
||||
{ kind: "diff", ts: TS, changeType: "file_header", text: "other.js", toolUseId: "e2" },
|
||||
{ kind: "diff", ts: TS, changeType: "hunk", text: "@@ -4,2 +4,2 @@", toolUseId: "e2" },
|
||||
{ kind: "diff", ts: TS, changeType: "context", text: "keep", toolUseId: "e2" },
|
||||
{ kind: "diff", ts: TS, changeType: "remove", text: "old", toolUseId: "e2" },
|
||||
{ kind: "diff", ts: TS, changeType: "add", text: "new", toolUseId: "e2" },
|
||||
// An addressed line reaches its own row even after another call started.
|
||||
{ kind: "diff", ts: TS, changeType: "file_header", text: "price.js", toolUseId: "e1" },
|
||||
{ kind: "diff", ts: TS, changeType: "hunk", text: "@@ -1,2 +1,2 @@", toolUseId: "e1" },
|
||||
{ kind: "diff", ts: TS, changeType: "remove", text: "b", toolUseId: "e1" },
|
||||
{ kind: "diff", ts: TS, changeType: "add", text: "c", toolUseId: "e1" },
|
||||
{ kind: "diff", ts: TS, changeType: "truncation", text: "Diff shortened for display.", toolUseId: "e1" },
|
||||
{ kind: "tool_result", ts: TS, toolUseId: "w1", content: "", isError: false },
|
||||
{ kind: "tool_result", ts: TS, toolUseId: "e1", content: "", isError: false },
|
||||
{ kind: "tool_result", ts: TS, toolUseId: "e2", content: "", isError: false },
|
||||
],
|
||||
{ runId: "run-edits", running: false },
|
||||
);
|
||||
const edit = items.find((item) => item.kind === "tool" && item.id.endsWith("e1"));
|
||||
expect(edit).toMatchObject({ diff: { path: "price.js", added: 1, removed: 1, truncated: true } });
|
||||
const changes = items.at(-1);
|
||||
expect(changes).toMatchObject({
|
||||
kind: "protocol",
|
||||
surface: "workspace_change",
|
||||
complete: true,
|
||||
totals: { files: 2, additions: 3, deletions: 1 },
|
||||
});
|
||||
if (changes?.kind !== "protocol" || changes.surface !== "workspace_change") throw new Error("expected changes");
|
||||
// A file created this turn reports its net size.
|
||||
expect(changes.files[0]).toMatchObject({ path: "price.js", operation: "create", additions: 2, deletions: 0 });
|
||||
expect(changes.files[1]).toMatchObject({ path: "other.js", operation: "modify", additions: 1, deletions: 1 });
|
||||
expect(changes.files[1].diff).toBe("@@ -4,2 +4,2 @@\n keep\n-old\n+new\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edit diff review fixes", () => {
|
||||
it("leaves failed edits out of the turn card and keeps producer counts for shortened patches", () => {
|
||||
const items = transcriptToTaskChatItems(
|
||||
[
|
||||
{ kind: "tool_call", ts: TS, name: "Edit", toolUseId: "ok", input: { path: "big.js" } },
|
||||
{ kind: "diff", ts: TS, changeType: "file_header", text: "big.js", toolUseId: "ok", additions: 900, deletions: 40 },
|
||||
{ kind: "diff", ts: TS, changeType: "hunk", text: "@@ -1,1 +1,1 @@", toolUseId: "ok" },
|
||||
{ kind: "diff", ts: TS, changeType: "remove", text: "a", toolUseId: "ok" },
|
||||
{ kind: "diff", ts: TS, changeType: "add", text: "b", toolUseId: "ok" },
|
||||
{ kind: "diff", ts: TS, changeType: "truncation", text: "Diff shortened for display.", toolUseId: "ok" },
|
||||
{ kind: "tool_result", ts: TS, toolUseId: "ok", content: "", isError: false },
|
||||
{ kind: "tool_call", ts: TS, name: "Edit", toolUseId: "bad", input: { path: "never.js" } },
|
||||
{ kind: "diff", ts: TS, changeType: "file_header", text: "never.js", toolUseId: "bad", additions: 3, deletions: 0 },
|
||||
{ kind: "diff", ts: TS, changeType: "add", text: "x", toolUseId: "bad" },
|
||||
{ kind: "tool_result", ts: TS, toolUseId: "bad", content: "rejected", isError: true },
|
||||
],
|
||||
{ runId: "run-review", running: false },
|
||||
);
|
||||
expect(items.find((item) => item.kind === "tool" && item.id.endsWith(":ok"))).toMatchObject({
|
||||
diff: { added: 900, removed: 40, truncated: true },
|
||||
});
|
||||
const card = items.at(-1);
|
||||
if (card?.kind !== "protocol" || card.surface !== "workspace_change") throw new Error("expected changes");
|
||||
expect(card.files.map((file) => file.path)).toEqual(["big.js"]);
|
||||
expect(card.files[0]).toMatchObject({ additions: 900, deletions: 40, diffTruncated: true });
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
* only showing a response after the turn settles.
|
||||
*/
|
||||
import type { TranscriptEntry } from "@/adapters";
|
||||
import { turnChangesFromEditRows } from "./turn-file-changes";
|
||||
import type {
|
||||
TaskChatDiff,
|
||||
TaskChatActivityPhaseItem,
|
||||
@@ -128,9 +129,10 @@ export function flattenSelfTalk(text: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function diffKind(changeType: string): "add" | "remove" | "context" {
|
||||
function diffKind(changeType: string): "add" | "remove" | "context" | "hunk" {
|
||||
if (changeType === "add") return "add";
|
||||
if (changeType === "remove") return "remove";
|
||||
if (changeType === "hunk") return "hunk";
|
||||
return "context";
|
||||
}
|
||||
|
||||
@@ -832,16 +834,39 @@ export function transcriptToTaskChatItems(
|
||||
break;
|
||||
}
|
||||
case "diff": {
|
||||
// Diff lines addressed to a tool call attach to that row; legacy
|
||||
// unaddressed lines attach to the most recent tool.
|
||||
const addressedIndex = entry.toolUseId ? toolIndexById.get(entry.toolUseId) : undefined;
|
||||
const targetIndex = addressedIndex ?? lastToolIndex;
|
||||
const target =
|
||||
targetIndex >= 0 && items[targetIndex]?.kind === "tool"
|
||||
? (items[targetIndex] as TaskChatToolItem)
|
||||
: null;
|
||||
if (entry.changeType === "file_header") {
|
||||
if (lastToolIndex >= 0 && items[lastToolIndex].kind === "tool") {
|
||||
const tool = items[lastToolIndex] as TaskChatToolItem;
|
||||
tool.diff = tool.diff ?? {
|
||||
path: entry.text,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
lines: [],
|
||||
};
|
||||
tool.diff.path = entry.text;
|
||||
if (target) {
|
||||
if (target.diff?.lines?.length || target.diff?.files?.length) {
|
||||
target.diff.lines = target.diff.lines ?? [];
|
||||
target.diff.lines.push({ kind: "file", text: entry.text });
|
||||
} else {
|
||||
target.diff = target.diff ?? { added: 0, removed: 0, lines: [] };
|
||||
target.diff.path = entry.text;
|
||||
}
|
||||
if (entry.additions != null && entry.deletions != null) {
|
||||
const diff = target.diff!;
|
||||
diff.files = diff.files ?? [];
|
||||
diff.files.push({ path: entry.text, additions: entry.additions, deletions: entry.deletions, truncated: false });
|
||||
diff.added = diff.files.reduce((sum, file) => sum + file.additions, 0);
|
||||
diff.removed = diff.files.reduce((sum, file) => sum + file.deletions, 0);
|
||||
}
|
||||
}
|
||||
resetInline();
|
||||
break;
|
||||
}
|
||||
if (entry.changeType === "truncation") {
|
||||
if (target?.diff) {
|
||||
target.diff.truncated = true;
|
||||
const file = target.diff.files?.at(-1);
|
||||
if (file) file.truncated = true;
|
||||
}
|
||||
resetInline();
|
||||
break;
|
||||
@@ -850,18 +875,20 @@ export function transcriptToTaskChatItems(
|
||||
kind: diffKind(entry.changeType),
|
||||
text: entry.text ?? "",
|
||||
};
|
||||
if (lastToolIndex >= 0 && items[lastToolIndex].kind === "tool") {
|
||||
const tool = items[lastToolIndex] as TaskChatToolItem;
|
||||
const diff: TaskChatDiff = tool.diff ?? {
|
||||
if (target) {
|
||||
const diff: TaskChatDiff = target.diff ?? {
|
||||
added: 0,
|
||||
removed: 0,
|
||||
lines: [],
|
||||
};
|
||||
diff.lines = diff.lines ?? [];
|
||||
diff.lines.push(line);
|
||||
if (line.kind === "add") diff.added += 1;
|
||||
if (line.kind === "remove") diff.removed += 1;
|
||||
tool.diff = diff;
|
||||
// Producer counts cover the whole patch; count lines only without them.
|
||||
if (!diff.files?.length) {
|
||||
if (line.kind === "add") diff.added += 1;
|
||||
if (line.kind === "remove") diff.removed += 1;
|
||||
}
|
||||
target.diff = diff;
|
||||
} else {
|
||||
items.push({
|
||||
id: `${runId}:diff:${i}`,
|
||||
@@ -913,13 +940,12 @@ export function transcriptToTaskChatItems(
|
||||
revision: entry.revision,
|
||||
source: entry.source,
|
||||
complete: entry.complete,
|
||||
files: entry.files.map((file) => ({
|
||||
...file,
|
||||
diff:
|
||||
file.diff == null
|
||||
? null
|
||||
: file.diff.slice(0, PROTOCOL_OUTPUT_MAX),
|
||||
})),
|
||||
files: entry.files.map((file) => {
|
||||
if (file.diff == null || file.diff.length <= PROTOCOL_OUTPUT_MAX) return { ...file };
|
||||
// Keep whole lines so the preview still parses as a patch.
|
||||
const cut = file.diff.lastIndexOf("\n", PROTOCOL_OUTPUT_MAX);
|
||||
return { ...file, diff: file.diff.slice(0, cut > 0 ? cut + 1 : PROTOCOL_OUTPUT_MAX), diffTruncated: true };
|
||||
}),
|
||||
totals: entry.totals,
|
||||
patchArtifactRef: entry.patchArtifactRef,
|
||||
};
|
||||
@@ -1107,10 +1133,26 @@ export function transcriptToTaskChatItems(
|
||||
item.status === "pending"
|
||||
) {
|
||||
item.status = "cancelled";
|
||||
} else if (item.kind === "protocol" && item.surface === "workspace_change") {
|
||||
// A harness-reported change set is final once its run has ended.
|
||||
item.complete = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every turn that edited files ends with its changed files, just before its
|
||||
// final answer. A harness that reports only per-edit patches gets the card
|
||||
// assembled from its edit rows.
|
||||
const changeIndex = items.findIndex((item) => item.kind === "protocol" && item.surface === "workspace_change");
|
||||
const changes = changeIndex >= 0
|
||||
? items.splice(changeIndex, 1)[0]
|
||||
: turnChangesFromEditRows(items, runId ?? "run", !running);
|
||||
if (changes) {
|
||||
const last = items.at(-1);
|
||||
const beforeFinal = last?.kind === "message" && last.channel === "final";
|
||||
items.splice(beforeFinal ? items.length - 1 : items.length, 0, changes);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import type {
|
||||
TaskChatDiffLine,
|
||||
TaskChatItem,
|
||||
TaskChatToolItem,
|
||||
TaskChatWorkspaceChangeItem,
|
||||
} from "./task-chat-model";
|
||||
|
||||
type ChangeFile = TaskChatWorkspaceChangeItem["files"][number];
|
||||
|
||||
function patchLine(line: TaskChatDiffLine): string {
|
||||
if (line.kind === "hunk") return line.text;
|
||||
if (line.kind === "add") return `+${line.text}`;
|
||||
if (line.kind === "remove") return `-${line.text}`;
|
||||
return ` ${line.text}`;
|
||||
}
|
||||
|
||||
interface EditFile {
|
||||
path: string;
|
||||
lines: TaskChatDiffLine[];
|
||||
additions: number;
|
||||
deletions: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split one edit row's diff into per-file patches (`file` lines start the next
|
||||
* file). Producer counts, when present, cover patches that were shortened.
|
||||
*/
|
||||
function editFiles(tool: TaskChatToolItem): EditFile[] {
|
||||
const diff = tool.diff;
|
||||
if (!diff?.lines?.length && !diff?.files?.length) return [];
|
||||
const chunks: Array<{ path: string; lines: TaskChatDiffLine[] }> = [];
|
||||
let current = { path: diff.path ?? tool.target ?? "", lines: [] as TaskChatDiffLine[] };
|
||||
for (const line of diff.lines ?? []) {
|
||||
if (line.kind === "file") {
|
||||
if (current.path) chunks.push(current);
|
||||
current = { path: line.text, lines: [] };
|
||||
} else {
|
||||
current.lines.push(line);
|
||||
}
|
||||
}
|
||||
if (current.path) chunks.push(current);
|
||||
return chunks.map((chunk, index) => {
|
||||
const reported = diff.files?.[index];
|
||||
const counted = {
|
||||
additions: chunk.lines.filter((line) => line.kind === "add").length,
|
||||
deletions: chunk.lines.filter((line) => line.kind === "remove").length,
|
||||
};
|
||||
return {
|
||||
...chunk,
|
||||
additions: reported?.additions ?? counted.additions,
|
||||
deletions: reported?.deletions ?? counted.deletions,
|
||||
truncated: reported?.truncated ?? (diff.truncated === true && index === chunks.length - 1),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The turn's changed files, assembled from its edit rows. Used when the
|
||||
* harness reports only per-edit patches (Claude) and no whole-turn diff, so
|
||||
* every turn that edited files ends with the same card.
|
||||
*/
|
||||
export function turnChangesFromEditRows(
|
||||
items: readonly TaskChatItem[],
|
||||
runId: string,
|
||||
complete: boolean,
|
||||
): TaskChatWorkspaceChangeItem | null {
|
||||
const byPath = new Map<string, { patches: string[]; additions: number; deletions: number; created: boolean; truncated: boolean }>();
|
||||
for (const item of items) {
|
||||
// A failed or interrupted call may carry the patch it proposed before it
|
||||
// ran; only edits that ran (or are still running) count as changes.
|
||||
if (item.kind !== "tool" || item.status === "failed" || item.status === "interrupted") continue;
|
||||
for (const file of editFiles(item)) {
|
||||
const entry = byPath.get(file.path) ?? { patches: [], additions: 0, deletions: 0, created: true, truncated: false };
|
||||
if (file.lines.length) entry.patches.push(file.lines.map(patchLine).join("\n"));
|
||||
entry.additions += file.additions;
|
||||
entry.deletions += file.deletions;
|
||||
entry.truncated ||= file.truncated;
|
||||
// A file is new only when its first edit added lines to nothing.
|
||||
if (byPath.has(file.path) === false) {
|
||||
const hunks = file.lines.filter((line) => line.kind === "hunk");
|
||||
entry.created = hunks.length > 0
|
||||
&& hunks.every((line) => /^@@ -0,0 /.test(line.text))
|
||||
&& file.lines.every((line) => line.kind === "add" || line.kind === "hunk");
|
||||
}
|
||||
byPath.set(file.path, entry);
|
||||
}
|
||||
}
|
||||
if (byPath.size === 0) return null;
|
||||
const files: ChangeFile[] = [...byPath.entries()].map(([path, entry]) => ({
|
||||
path,
|
||||
operation: entry.created ? "create" : "modify",
|
||||
previousPath: null,
|
||||
// Every line removed from a file this turn created was added earlier in
|
||||
// the turn, so its net size is exact; edits to existing files are summed.
|
||||
additions: entry.created ? Math.max(entry.additions - entry.deletions, 0) : entry.additions,
|
||||
deletions: entry.created ? 0 : entry.deletions,
|
||||
binary: false,
|
||||
diff: entry.patches.length ? `${entry.patches.join("\n")}\n` : null,
|
||||
diffTruncated: entry.truncated,
|
||||
}));
|
||||
return {
|
||||
id: `${runId}:workspace:edits`,
|
||||
kind: "protocol",
|
||||
surface: "workspace_change",
|
||||
changeSetId: `${runId}:edits`,
|
||||
revision: 1,
|
||||
source: "harness_reported",
|
||||
complete,
|
||||
files,
|
||||
totals: {
|
||||
files: files.length,
|
||||
additions: files.reduce((sum, file) => sum + (file.additions ?? 0), 0),
|
||||
deletions: files.reduce((sum, file) => sum + (file.deletions ?? 0), 0),
|
||||
},
|
||||
patchArtifactRef: null,
|
||||
};
|
||||
}
|
||||
@@ -1188,3 +1188,75 @@ describe("nativeRunEventsToTranscript", () => {
|
||||
])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edit diffs", () => {
|
||||
const execution = (status: string, fileChanges?: unknown[]) => ({
|
||||
schema: "paperclip.tool.execution.v1",
|
||||
executionId: "edit-1",
|
||||
transport: "builtin",
|
||||
operation: "edit",
|
||||
name: "Edit",
|
||||
target: null,
|
||||
status,
|
||||
output: "",
|
||||
outputBytes: 0,
|
||||
outputTruncated: false,
|
||||
outputDigest: null,
|
||||
...(fileChanges ? { fileChanges } : {}),
|
||||
});
|
||||
const change = (diff: string, additions: number, deletions: number) => ({
|
||||
path: "src/price.js",
|
||||
operation: "modify",
|
||||
previousPath: null,
|
||||
additions,
|
||||
deletions,
|
||||
diff,
|
||||
diffTruncated: false,
|
||||
});
|
||||
|
||||
it("attaches the latest patch of an edit call to its row", () => {
|
||||
const entries = nativeRunEventsToTranscript([
|
||||
event(1, "tool.execution.started", execution("running", [change("@@ -1 +1 @@\n-a\n+optimistic\n", 1, 1)])),
|
||||
event(2, "tool.execution.completed", execution("completed", [change("@@ -3,2 +3,2 @@\n ctx\n-a\n+b\n", 1, 1)])),
|
||||
]);
|
||||
expect(entries.find((entry) => entry.kind === "tool_call")).toMatchObject({
|
||||
toolUseId: "edit-1",
|
||||
input: { path: "src/price.js" },
|
||||
});
|
||||
expect(entries.filter((entry) => entry.kind === "diff")).toEqual([
|
||||
expect.objectContaining({ changeType: "file_header", text: "src/price.js", toolUseId: "edit-1" }),
|
||||
expect.objectContaining({ changeType: "hunk", text: "@@ -3,2 +3,2 @@" }),
|
||||
expect.objectContaining({ changeType: "context", text: "ctx" }),
|
||||
expect.objectContaining({ changeType: "remove", text: "a" }),
|
||||
expect.objectContaining({ changeType: "add", text: "b" }),
|
||||
]);
|
||||
|
||||
const [tool] = transcriptToTaskChatItems(entries, { runId: RUN_ID, running: false });
|
||||
expect(tool).toMatchObject({ kind: "tool", target: "src/price.js", diff: { path: "src/price.js", added: 1, removed: 1 } });
|
||||
});
|
||||
|
||||
it("projects the harness change set and ends the turn with it", () => {
|
||||
const entries = nativeRunEventsToTranscript([
|
||||
event(1, "workspace.change.updated", {
|
||||
schema: "paperclip.workspace.diff.v1",
|
||||
changeSetId: "turn-1:workspace",
|
||||
revision: 1,
|
||||
source: "harness_reported",
|
||||
complete: false,
|
||||
files: [
|
||||
{ path: "a.js", operation: "create", previousPath: null, additions: 2, deletions: 0, binary: false, diff: "@@ -0,0 +1,2 @@\n+x\n+y\n" },
|
||||
{ path: "huge.js", operation: "modify", previousPath: null, additions: 0, deletions: 0, binary: false, diff: null, diffTruncated: true },
|
||||
],
|
||||
totals: { files: 1, additions: 2, deletions: 0 },
|
||||
patchArtifactRef: null,
|
||||
}),
|
||||
event(2, "tool.execution.completed", execution("completed", [change("@@ -1 +1 @@\n-a\n+b\n", 1, 1)])),
|
||||
]);
|
||||
const items = transcriptToTaskChatItems(entries, { runId: RUN_ID, running: false });
|
||||
expect(items.at(-1)).toMatchObject({ surface: "workspace_change", complete: true, totals: { files: 2, additions: 2 } });
|
||||
const card = items.at(-1);
|
||||
if (card?.kind !== "protocol" || card.surface !== "workspace_change") throw new Error("expected changes");
|
||||
expect(card.files[1]).toMatchObject({ path: "huge.js", diff: null, diffTruncated: true });
|
||||
expect(items.filter((item) => item.kind === "protocol" && item.surface === "workspace_change")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { HeartbeatRunEvent } from "@paperclipai/shared";
|
||||
import type { TranscriptEntry } from "@/adapters";
|
||||
import { isRunLogOnlyProviderEvent } from "./run-log-only-events";
|
||||
import { workspaceChangeEntry } from "@/adapters/paperclip-runner";
|
||||
import { createUnifiedDiffClassifier } from "@/components/task-chat/diff-lines";
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
@@ -670,6 +672,38 @@ function toolPresentation(
|
||||
* model already consumed by the task thread. Provider-native envelopes never
|
||||
* reach this boundary and unknown event kinds remain safely invisible.
|
||||
*/
|
||||
/** Diff lines for one edit call's bounded `fileChanges`, attached to its row. */
|
||||
function fileChangeDiffEntries(
|
||||
value: readonly unknown[] | undefined,
|
||||
toolUseId: string,
|
||||
ts: string,
|
||||
): TranscriptEntry[] {
|
||||
if (!value) return [];
|
||||
const entries: TranscriptEntry[] = [];
|
||||
for (const raw of value.slice(0, 32)) {
|
||||
const change = record(raw);
|
||||
const path = text(change?.path);
|
||||
if (!change || !path) continue;
|
||||
// The producer counted the whole patch; the patch itself may be shortened.
|
||||
const count = (value: unknown) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
||||
entries.push({
|
||||
kind: "diff", ts, changeType: "file_header", text: path, toolUseId,
|
||||
additions: count(change.additions), deletions: count(change.deletions),
|
||||
});
|
||||
const diff = text(change.diff) ?? "";
|
||||
const classify = createUnifiedDiffClassifier();
|
||||
for (const line of diff.endsWith("\n") ? diff.slice(0, -1).split("\n") : diff ? diff.split("\n") : []) {
|
||||
const { kind, text: value } = classify(line);
|
||||
if (kind === "skip" || kind === "file") continue;
|
||||
entries.push({ kind: "diff", ts, changeType: kind, text: value, toolUseId });
|
||||
}
|
||||
if (change.diffTruncated === true) {
|
||||
entries.push({ kind: "diff", ts, changeType: "truncation", text: "Diff shortened for display.", toolUseId });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]): TranscriptEntry[] {
|
||||
const entries: TranscriptEntry[] = [];
|
||||
const startedToolIds = new Set<string>();
|
||||
@@ -747,6 +781,17 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
|
||||
const completedReasoningIds = new Set<string>();
|
||||
const completionItemIdentityById = new Map<string, ItemIdentity>();
|
||||
const nativeToolItemsById = new Map<string, NativeToolItemDetails>();
|
||||
// An edit call reports an optimistic patch when it starts and the exact one
|
||||
// when it lands. The row shows the latest one, wherever it first appears.
|
||||
const fileChangesByExecutionId = new Map<string, unknown[]>();
|
||||
for (const event of orderedEvents) {
|
||||
if (!event.eventType.startsWith("tool.execution.")) continue;
|
||||
const payload = record(record(event.payload?.prpEvent)?.payload);
|
||||
const executionId = text(payload?.executionId);
|
||||
if (executionId && Array.isArray(payload?.fileChanges)) {
|
||||
fileChangesByExecutionId.set(executionId, payload.fileChanges);
|
||||
}
|
||||
}
|
||||
for (const event of orderedEvents) {
|
||||
if (!isItemIdentityEvent(event.eventType)) continue;
|
||||
const envelope = record(event.payload?.prpEvent);
|
||||
@@ -987,6 +1032,14 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
(event.eventType === "workspace.change.updated" || event.eventType === "workspace.diff.recorded")
|
||||
&& payload.schema === "paperclip.workspace.diff.v1"
|
||||
) {
|
||||
entries.push(workspaceChangeEntry(payload, ts));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.eventType === "tool.execution.started" || event.eventType === "tool.execution.completed") {
|
||||
if (payload.schema !== TOOL_EXECUTION_SCHEMA) continue;
|
||||
const executionId = text(payload.executionId);
|
||||
@@ -995,13 +1048,19 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
|
||||
const presentation = toolPresentation(payload, nativeToolItem);
|
||||
if (!startedToolIds.has(executionId)) {
|
||||
startedToolIds.add(executionId);
|
||||
const fileChanges = fileChangesByExecutionId.get(executionId);
|
||||
const editedPath = text(record(fileChanges?.[0])?.path);
|
||||
entries.push({
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: presentation.name,
|
||||
input: presentation.input,
|
||||
// An edit's opening event can precede its path; the patch names the file.
|
||||
input: editedPath && typeof presentation.input === "object"
|
||||
? { ...presentation.input as Record<string, unknown>, path: editedPath }
|
||||
: presentation.input,
|
||||
toolUseId: executionId,
|
||||
});
|
||||
entries.push(...fileChangeDiffEntries(fileChanges, executionId, ts));
|
||||
}
|
||||
if (event.eventType === "tool.execution.completed" && !completedToolIds.has(executionId)) {
|
||||
completedToolIds.add(executionId);
|
||||
|
||||
@@ -375,6 +375,13 @@
|
||||
--status-task-icon-blocked: var(--status-task-blocked); /* #dc2626 both modes */
|
||||
--status-task-icon-cancelled: #52585d;
|
||||
--status-task-icon-in_queue: var(--status-task-icon-in_progress); /* blocked shape, same mode-aware blue */
|
||||
/* Inline edit diffs (task chat edit rows and the turn changes card). Line
|
||||
tints derive from the status icon hues so both modes stay in step. */
|
||||
--diff-add-fg: var(--status-task-icon-done);
|
||||
--diff-remove-fg: var(--status-task-icon-blocked);
|
||||
--diff-add-bg: color-mix(in oklab, var(--status-task-icon-done) 12%, transparent);
|
||||
--diff-remove-bg: color-mix(in oklab, var(--status-task-icon-blocked) 12%, transparent);
|
||||
--sz-edit-diff-max-h: calc(var(--spacing) * 100);
|
||||
|
||||
--folder-color-indigo: #6366f1;
|
||||
--folder-color-violet: #8b5cf6;
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { ReactNode } from "react";
|
||||
import { TaskChatRunnerTurn } from "@/components/task-chat/TaskChatRunnerTurn";
|
||||
import { parseUnifiedDiffLines } from "@/components/task-chat/diff-lines";
|
||||
import { TaskChatExpansionState } from "@/components/task-chat/expansion-state";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import type {
|
||||
TaskChatDiff,
|
||||
TaskChatItem,
|
||||
TaskChatWorkspaceChangeItem,
|
||||
} from "@/components/task-chat/task-chat-model";
|
||||
|
||||
function diff(path: string, patch: string): TaskChatDiff {
|
||||
const parsed = parseUnifiedDiffLines(patch);
|
||||
return { path, added: parsed.added, removed: parsed.removed, lines: parsed.lines };
|
||||
}
|
||||
|
||||
const WORKER_PATCH = `--- a/server/src/services/webhook-delivery.ts
|
||||
+++ b/server/src/services/webhook-delivery.ts
|
||||
@@ -41,9 +41,18 @@ export async function deliverWebhook(
|
||||
const attempt = await db.webhookAttempts.next(event.id);
|
||||
- const response = await fetch(endpoint.url, {
|
||||
- method: "POST",
|
||||
- body: JSON.stringify(event.payload),
|
||||
- });
|
||||
+ if (await db.webhookDeliveries.exists(event.id, endpoint.id)) {
|
||||
+ logger.info({ eventId: event.id }, "skip duplicate webhook delivery");
|
||||
+ return { status: "duplicate" };
|
||||
+ }
|
||||
+ const response = await fetch(endpoint.url, {
|
||||
+ method: "POST",
|
||||
+ headers: { "Idempotency-Key": event.id },
|
||||
+ body: JSON.stringify(event.payload),
|
||||
+ });
|
||||
+ await db.webhookDeliveries.record(event.id, endpoint.id, response.status);
|
||||
if (!response.ok) {
|
||||
throw new RetryableDeliveryError(response.status);
|
||||
}`;
|
||||
|
||||
const SCHEMA_PATCH = `--- a/packages/db/src/schema/webhook_deliveries.ts
|
||||
+++ b/packages/db/src/schema/webhook_deliveries.ts
|
||||
@@ -12,6 +12,9 @@ export const webhookDeliveries = pgTable(
|
||||
status: integer("status").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
},
|
||||
- (table) => [index("webhook_deliveries_event_idx").on(table.eventId)],
|
||||
+ (table) => [
|
||||
+ index("webhook_deliveries_event_idx").on(table.eventId),
|
||||
+ uniqueIndex("webhook_deliveries_event_endpoint_uq").on(table.eventId, table.endpointId),
|
||||
+ ],
|
||||
);`;
|
||||
|
||||
const TEST_PATCH = `--- /dev/null
|
||||
+++ b/server/src/services/webhook-delivery.test.ts
|
||||
@@ -0,0 +1,24 @@
|
||||
+import { describe, expect, it } from "vitest";
|
||||
+import { deliverWebhook } from "./webhook-delivery";
|
||||
+import { fakeDb, fakeEndpoint, fakeEvent } from "../test/webhook-fixtures";
|
||||
+
|
||||
+describe("deliverWebhook", () => {
|
||||
+ it("delivers a retried event once", async () => {
|
||||
+ const db = fakeDb();
|
||||
+ const sent: string[] = [];
|
||||
+ const fetch = async (url: string) => {
|
||||
+ sent.push(url);
|
||||
+ return new Response(null, { status: 200 });
|
||||
+ };
|
||||
+ await deliverWebhook(db, fakeEndpoint(), fakeEvent("evt_1"), { fetch });
|
||||
+ const second = await deliverWebhook(db, fakeEndpoint(), fakeEvent("evt_1"), { fetch });
|
||||
+ expect(sent).toHaveLength(1);
|
||||
+ expect(second.status).toBe("duplicate");
|
||||
+ });
|
||||
+
|
||||
+ it("sends the event id as the idempotency key", async () => {
|
||||
+ const headers = await captureHeaders(fakeEvent("evt_2"));
|
||||
+ expect(headers.get("Idempotency-Key")).toBe("evt_2");
|
||||
+ });
|
||||
+});
|
||||
+`;
|
||||
|
||||
const workerDiff = diff("server/src/services/webhook-delivery.ts", WORKER_PATCH);
|
||||
const schemaDiff = diff("packages/db/src/schema/webhook_deliveries.ts", SCHEMA_PATCH);
|
||||
const testDiff = diff("server/src/services/webhook-delivery.test.ts", TEST_PATCH);
|
||||
|
||||
function workspaceChange(complete: boolean): TaskChatWorkspaceChangeItem {
|
||||
const files = [
|
||||
{ path: workerDiff.path!, operation: "modify" as const, patch: WORKER_PATCH, d: workerDiff },
|
||||
{ path: schemaDiff.path!, operation: "modify" as const, patch: SCHEMA_PATCH, d: schemaDiff },
|
||||
{ path: testDiff.path!, operation: "create" as const, patch: TEST_PATCH, d: testDiff },
|
||||
].map(({ path, operation, patch, d }) => ({
|
||||
path,
|
||||
operation,
|
||||
previousPath: null,
|
||||
additions: d.added,
|
||||
deletions: d.removed,
|
||||
binary: false,
|
||||
diff: patch,
|
||||
}));
|
||||
return {
|
||||
id: "run-edit-diffs:workspace",
|
||||
kind: "protocol",
|
||||
surface: "workspace_change",
|
||||
changeSetId: "turn-1:workspace",
|
||||
revision: 4,
|
||||
source: "harness_reported",
|
||||
complete,
|
||||
files,
|
||||
totals: {
|
||||
files: files.length,
|
||||
additions: files.reduce((sum, file) => sum + (file.additions ?? 0), 0),
|
||||
deletions: files.reduce((sum, file) => sum + (file.deletions ?? 0), 0),
|
||||
},
|
||||
patchArtifactRef: null,
|
||||
};
|
||||
}
|
||||
|
||||
const completedItems: TaskChatItem[] = [
|
||||
{
|
||||
id: "thinking-1",
|
||||
kind: "thinking",
|
||||
lines: ["Tracing the retry path in the delivery worker."],
|
||||
channel: "summary",
|
||||
transcriptIndex: 1,
|
||||
},
|
||||
{ id: "read-1", kind: "tool", name: "Read", rawName: "read_file", target: "server/src/services/webhook-delivery.ts", status: "completed" },
|
||||
{ id: "edit-1", kind: "tool", name: "Edit", rawName: "edit", toolKind: "edit", target: workerDiff.path, status: "completed", diff: workerDiff },
|
||||
{ id: "edit-2", kind: "tool", name: "Edit", rawName: "edit", toolKind: "edit", target: schemaDiff.path, status: "completed", diff: schemaDiff },
|
||||
{ id: "write-1", kind: "tool", name: "Write", rawName: "write", toolKind: "edit", target: testDiff.path, status: "completed", diff: testDiff },
|
||||
{ id: "bash-1", kind: "tool", name: "Bash", rawName: "bash", target: "pnpm vitest run server/src/services/webhook-delivery.test.ts", status: "completed", detail: "✓ 2 passed (412ms)" },
|
||||
workspaceChange(true),
|
||||
{
|
||||
id: "final-1",
|
||||
kind: "message",
|
||||
author: "agent",
|
||||
authorName: "CodexRunner",
|
||||
channel: "final",
|
||||
text: "Deliveries are now idempotent on the event id. A unique index backs the check, and a regression test covers a retried event.",
|
||||
},
|
||||
];
|
||||
|
||||
const runningItems: TaskChatItem[] = [
|
||||
completedItems[0],
|
||||
completedItems[1],
|
||||
completedItems[2],
|
||||
{ ...completedItems[3], status: "in_progress", diff: undefined } as TaskChatItem,
|
||||
workspaceChange(false),
|
||||
];
|
||||
|
||||
/** Opens activity groups and the named rows, as if a reviewer had clicked them. */
|
||||
class OpenRows extends Map<string, boolean> {
|
||||
constructor(private readonly rows: readonly string[]) { super(); }
|
||||
override get(id: string) {
|
||||
if (super.has(id)) return super.get(id);
|
||||
if (!id.startsWith("runner-detail:")) return true;
|
||||
return this.rows.includes(id.slice("runner-detail:".length));
|
||||
}
|
||||
}
|
||||
|
||||
function Frame({ title, rule, children }: { title: string; rule: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
<p className="text-xs text-muted-foreground">{rule}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-background p-4">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AllStates() {
|
||||
const started = Date.now() - 94_000;
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-6 text-foreground">
|
||||
<div className="mx-auto flex w-full max-w-2xl flex-col gap-10">
|
||||
<header>
|
||||
<h2 className="text-lg font-semibold">Inline edit diffs</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Each file edit shows its line counts and opens to a compact diff. The turn ends with the files it changed
|
||||
and the whole-turn diff. Codex and Claude edits look the same.
|
||||
</p>
|
||||
</header>
|
||||
<Frame title="Finished turn" rule="Edit rows carry +/− counts. The changes card lists every file the turn touched.">
|
||||
<TaskChatExpansionState.Provider value={new OpenRows(["edit-1"])}>
|
||||
<TaskChatRunnerTurn
|
||||
runId="run-edit-diffs"
|
||||
agentName="CodexRunner"
|
||||
items={completedItems}
|
||||
status="succeeded"
|
||||
startedAtMs={started}
|
||||
finishedAtMs={started + 94_000}
|
||||
/>
|
||||
</TaskChatExpansionState.Provider>
|
||||
</Frame>
|
||||
<Frame title="Running" rule="A file still being written has no counts yet. The card says changes are in progress.">
|
||||
<TaskChatRunnerTurn
|
||||
runId="run-edit-diffs-live"
|
||||
agentName="ClaudeRunner"
|
||||
items={runningItems}
|
||||
status="running"
|
||||
startedAtMs={started}
|
||||
/>
|
||||
</Frame>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
id: "tasks-task-chat-edit-diffs",
|
||||
title: "Tasks and conversations/Task creation and lifecycle/Tasks/Edit diffs",
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
paperclip: {
|
||||
capabilities: ["tasks/discussion", "runs-adapters/runs"],
|
||||
implementation: "production",
|
||||
boundary: "fixtures",
|
||||
notes: "Production runner turn with fixture edit rows and a fixture workspace change set.",
|
||||
},
|
||||
},
|
||||
} satisfies Meta;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const EditDiffs: Story = {
|
||||
render: () => <AllStates />,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const [card] = await canvas.findAllByTestId("task-chat-workspace-change");
|
||||
const [firstFile] = within(card).getAllByTestId("task-chat-workspace-change-file");
|
||||
await userEvent.click(within(firstFile).getByRole("button"));
|
||||
await expect(within(firstFile).getByTestId("task-chat-diff-view")).toBeVisible();
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user