mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-05 09:30:05 +02:00
* fix(redact): scrub secrets nested inside tool input maps and slices
InputMap only passed top-level string values through Text and documented
non-string values as "preserved as-is". Any secret one level down reached
the database and the WebSocket broadcast untouched:
flat -> [REDACTED ...] (scrubbed)
nested -> [map[diff:token=ghp_... path:a.go]] (leaked verbatim)
This is a prerequisite for recording structured file-edit payloads. Codex
reports an edit as changes[]{path, diff, content}, and the legacy protocol
reports a deletion as the whole outgoing file — so without this, deleting a
.env would persist its full contents in cleartext.
redactValue now walks the composite shapes json.Unmarshal produces, plus
[]string and map[string]string for argv-style inputs. Composites are copied
rather than scrubbed in place, because the caller keeps using the map it
passed in.
Nesting depth comes from daemon-supplied JSON, so the walk is bounded at 32
levels; a pathologically nested payload would otherwise recurse until the
stack blows. Hitting the bound yields a placeholder rather than the raw
value, keeping the fail-safe direction.
Verified: the five new tests each fail against the previous top-level-only
implementation and pass now; full ./pkg/redact suite green.
Co-authored-by: multica-agent <github@multica.ai>
* fix(codex): record file edit payload for patch_apply events
Both Codex protocol paths recorded a file edit as a bare call ID and set no
payload, so a run that edited six files left six blank, unexpandable rows in
the transcript. The same task on Claude or Grok showed a readable diff, and
the branch Codex pushed was the only surviving record of what it changed
(GH #6157).
The omission was specific to this one tool, not to the adapter: the
exec_command handlers directly above already captured command and output.
Both paths are fixed, since the protocol is sniffed at runtime. Their wire
shapes differ more than they appear, and the normalizer reconciles that:
- legacy patch_apply_begin/end carry map[path]FileChange, internally tagged
on `type`, where add/delete hold whole-file `content` and only update
holds a `unified_diff` plus `move_path`. There is no diff for every case,
so the normalized form keeps diff and content as alternatives.
- v2 fileChange items carry an ordered array of {path, kind, diff} where
`kind` is an object, not a string — reading it as a string silently
yields "" and loses the add/delete/update distinction.
- status spellings differ too: legacy is snake_case, v2 is camelCase and
adds inProgress. Both normalize onto one vocabulary, and a legacy event
predating `status` falls back to its `success` bool.
Legacy map iteration is sorted by path so a replayed event does not reshuffle
the file list.
Completion events now also produce a non-empty output (status, file count,
and any apply_patch stdout/stderr), because an empty output renders as an
unexpandable blank row just like a missing input.
Anything unrecognised — absent, wrongly typed, or malformed changes — returns
no payload, preserving exactly the previous degradation rather than risking
the transcript.
Total diff/content bytes are bounded at 64 KiB with UTF-8-safe truncation,
recording `truncated` and `original_bytes`; paths and kinds always survive,
since they are what a reviewer needs when the body is gone. The bound is
deliberately scoped to this new payload: other providers stream tool inputs
through unbounded, and clamping them here would silently truncate
transcripts that render correctly today. Unifying the limit at the
persistence boundary is left as a follow-up.
Verified: the new tests reproduce the reported symptom (Input:map[],
Output:"") against the previous call sites and pass now; ./pkg/agent and
./pkg/redact green, go vet and gofmt clean.
Co-authored-by: multica-agent <github@multica.ai>
* feat(transcript): render Codex multi-file patch payloads as diffs
The presenter identified an edit by input shape — a top-level file_path plus
old_string/new_string or content — which is Claude's and Grok's shape. Codex
records one patch_apply covering several files as changes[], so even with the
payload now populated it fell through to pretty JSON instead of a diff.
A new `patch` detail kind carries one entry per file, since collapsing them
into a single body would lose which change belongs where. Each file reuses the
existing single-file surfaces, so all bodies behave alike inside the
virtualized list.
Codex hands over a ready-made unified diff, so parseUnifiedDiff maps it onto
diff rows rather than recomputing one — there is no before/after pair to
compare, and reconstructing both sides from the diff just to diff them again
would be circular. Hunk headers become `gap` rows, which is what they denote:
skipped unchanged content.
A deletion renders as all-removals rather than as a whole-file write, because
the legacy protocol reports it as the outgoing file's content and a green
"+N" gutter would state the opposite of what happened.
The collapsed row needed its own fix: with no single path field, the summary
fell through the preference chain and came back empty. It now reads as the
first path plus "+N more".
Anything that is not this shape still falls back to pretty JSON, so a payload
this presenter does not understand stays readable.
Verified: 17 new tests (43 in the presenter suite) pass; repo typecheck and
lint clean. The one failing views test, layout/sidebar-resize, fails
identically on an untouched checkout.
Co-authored-by: multica-agent <github@multica.ai>
* fix(codex): route v2 add/delete payloads as content, not diff
Addresses review on #6158.
Upstream's format_file_change_diff only produces a unified diff for `update`.
For `add` and `delete` it returns the whole file's contents under the same
`diff` field, and for a moved `update` it appends a trailing
"\n\nMoved to: <path>" line:
FileChange::Add { content } => content.clone(),
FileChange::Delete { content } => content.clone(),
FileChange::Update { unified_diff, move_path } => ...
(codex-rs/app-server-protocol/src/protocol/item_builders.rs, rust-v0.145.0)
Recording that as a diff mislabels every line of an added or deleted file as
context, and actively inverts any line whose content begins with '+' or '-' —
so an added file containing "-minus lead" rendered as a deletion. The payload
is now routed by `kind` rather than by field name, and the "Moved to:"
sentence is stripped since move_path already carries the destination.
The previous v2 tests hid this by using a fixture the real protocol never
emits (an `add` carrying "@@ ... +package main"). They now use upstream's
shape, plus cases for delete, an empty add, and an add whose contents look
like diff headers.
Empty bodies are also kept on both paths: presence of the field, not its
non-emptiness, decides whether a body was reported, so an empty added file
renders as an empty body instead of "no content reported".
Verified: the new assertions fail against the previous normalizer — where an
`add` came through as {"diff": "package main\n"} — and pass now.
Co-authored-by: multica-agent <github@multica.ai>
* fix(transcript): stop treating header-like content lines as file headers
Addresses review on #6158.
parseUnifiedDiff matched "---" / "+++" / "diff --git" / "index " at any
position, so a changed line whose *content* starts with a dash or plus was
silently discarded:
parseUnifiedDiff("@@ -1 +1 @@\n--- old markdown\n+++ new markdown\n")
// before: [{ kind: "gap", ... }] — both changed lines gone
A removal of "-- old markdown" is spelled "--- old markdown" on the wire, so
this hit Markdown rules, embedded patches, and comment banners.
File headers only exist ahead of the first hunk, so they are only recognised
there; once inside a hunk every line is parsed strictly by its first
character.
Also localizes the multi-file summary count, which was hardcoded English and
so leaked into the zh-Hans / ja / ko transcript rows. The presenter owns no
React and no i18n by design, so the phrasing is injected by the caller rather
than imported here, keeping the module unit-testable in isolation; the English
form remains the fallback. The three Chinese/Japanese/Korean truncation
strings now use "..." to match the English source they translate.
Verified: both new parser assertions fail against the previous
strip-anywhere behaviour and pass now; 47 presenter tests green, repo
typecheck and lint clean.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): redact nested tool input before it leaves the daemon
Addresses review on #6158.
Recursive redaction ran only in the server's ingest handler. The daemon built
the new nested edit payload and sent msg.Input verbatim, so a daemon that
self-updated ahead of the server — or one talking to a server mid-rollout —
would ship whole-file edit contents to a peer that does not scrub nested
values yet. The legacy protocol reports a deletion as the whole outgoing file,
so that window covered a deleted .env in cleartext.
Ordering three commits inside one PR is not a deployment barrier, and daemon
and server upgrade independently. Deployment order is not a control we have,
so the sending side is now safe on its own; the server keeps redacting on
ingest as the second line of defence.
Scoped to Input, which is the field this PR newly fills with file contents.
Content and Output are plain strings already redacted server-side, and
changing their daemon-side handling would be unrelated to this fix.
Verified: the new daemon test asserts the nested token is masked in the
reported batch while the change metadata survives. It fails without this
change, reporting the full GITHUB_TOKEN= line on the wire, and passes with
it; ./internal/daemon green.
Co-authored-by: multica-agent <github@multica.ai>
* fix(transcript): correct the Chinese multi-file patch count semantics
Addresses review on #6158.
The summary is handed the number of files *beyond* the named one, but the
Chinese phrasing stated a total: "a.go 等 2 个文件" reads as two files including
a.go, so a three-file patch under-reported by one. English hides the
distinction ("+2 more"), which is why it survived the first pass.
Rewords zh-Hans to "另有 N 个文件". Japanese (他) and Korean (외) already read
as "besides", so their wording is unchanged.
Also renames the interpolation variable from `count` to `extra`, for two
reasons. i18next treats `count` as the plural selector — this very namespace
relies on that for events_one/events_other — so a plain number had no business
borrowing it. And the name is what a translator reads: `extra` cannot be
mistaken for a total the way `count` was.
Guards the whole bug class rather than just this string: a locale test asserts
every locale interpolates {{path}} and {{extra}} and never the reserved
{{count}}, and a presenter test pins that the injected number is the count of
additional files, not the total.
Verified: both new locale assertions fail against the reverted string and pass
now; rendering the real locale strings for a three-file patch yields "+2 more",
"另有 2 个文件", "他 2 件", "외 2개". 53 target tests pass, repo typecheck clean,
views lint back to its pre-existing 16 warnings and 0 errors.
Co-authored-by: multica-agent <github@multica.ai>
* fix(transcript): put the patch surface on the type scale
Addresses review on #6158.
The patch surface wrote text-[10px] / text-[11px] / text-[10px], copied from
the sibling transcript surfaces as they looked when this branch started. Since
then MUL-5451 (#6136) introduced a role-named type scale and migrated those
same siblings to text-micro, so these three call sites were the only remaining
arbitrary sizes — and the type-scale guard reports them precisely.
All three become text-micro. That matches the analogues they were copied from
now that those have moved: the FileWriteSurface line-count row, the
DiffDetailSurface header row, and the ToolDetailSurface body. It is also the
only correct target, since micro (11px) is the smallest step the scale defines
— there is nothing at 10px to map to.
Merges origin/main so the guard runs here rather than only in CI.
Verified: apps/web app/type-scale.test.ts 13/13 (it listed exactly these three
lines before), no `text-[` left in the file, repo typecheck clean, views lint
unchanged at 16 pre-existing warnings and 0 errors.
Co-authored-by: multica-agent <github@multica.ai>
* fix(codex): redact patch bodies before applying the size budget
Addresses review on #6158.
The adapter sized and truncated the normalized changes, and redaction only ran
later — in the daemon before sending, then again in the server on ingest. That
order loses secrets that straddle the budget.
The PEM rule needs both markers to match:
-----BEGIN[A-Z\s]*PRIVATE KEY-----.*?-----END[A-Z\s]*PRIVATE KEY-----
So a 70 KB private key whose BEGIN sits inside the first 64 KiB and whose END
falls past the cut stops matching once truncated. Neither later pass can
recognise what truncation already broke, so the marker and 64 KiB of key
material reach the database and the WebSocket broadcast. Measured on the
previous code:
stored bytes 65536 | BEGIN marker present | key body present | placeholder absent
Redaction now runs first, and the budget measures the redacted bodies — which
is also the honest measurement, since those are what actually gets stored and
redaction usually shrinks them (that key collapses to 23 bytes, so no trimming
is needed at all). `original_bytes` still reports the pre-redaction size so the
reader sees how large the real patch was. The daemon and server passes stay as
defence in depth; redaction is idempotent, so running three times is safe and
that is now asserted.
Note for callers: codexPatchInput no longer trims its argument in place, because
redaction copies first. Two existing tests were asserting on the caller's
original slice and had silently become vacuous; they now read the returned
payload, and one pins the no-mutation contract. The delete fixture in the
diff-vs-content routing test was also a credential-shaped string, which now
redacts — it is plain text so that test keeps testing routing.
Verified: the new boundary test fails on the previous order, reporting the
surviving BEGIN marker and key material, and passes now. go test ./pkg/agent
./pkg/redact ./internal/daemon green; execenv ByteIdentical green; go vet and
gofmt clean.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
584 lines
21 KiB
TypeScript
584 lines
21 KiB
TypeScript
// Trace Event Presenter — the pure readability layer for the execution
|
|
// transcript. Given one timeline event it decides visual kind, label, one-line
|
|
// summary, and default expansion, encoding the reading hierarchy:
|
|
//
|
|
// 1. Agent text is the primary layer and reads without a click.
|
|
// 2. Errors stand out and also read without a click.
|
|
// 3. Tool calls are compact — provider-native name + most-informative arg.
|
|
// 4. Tool results and thinking are de-emphasized and collapsed by default.
|
|
// 5. Unknown event types are retained as a generic event, never dropped.
|
|
//
|
|
// This module owns no React and no fetching, so it is unit-testable in
|
|
// isolation and independent of whichever list shell renders the events.
|
|
|
|
import type { TranscriptDetailDensity } from "@multica/core/agents/stores";
|
|
|
|
export type { TranscriptDetailDensity };
|
|
|
|
export interface TraceEvent {
|
|
seq?: number;
|
|
type: string;
|
|
tool?: string;
|
|
content?: string;
|
|
input?: Record<string, unknown>;
|
|
output?: string;
|
|
created_at?: string;
|
|
}
|
|
|
|
/** Visual kind driving color/emphasis. `generic` covers any unknown `type`. */
|
|
export type TraceEventKind =
|
|
| "agent"
|
|
| "thinking"
|
|
| "tool_use"
|
|
| "tool_result"
|
|
| "error"
|
|
| "generic";
|
|
|
|
export function traceEventKind(event: TraceEvent): TraceEventKind {
|
|
switch (event.type) {
|
|
case "text":
|
|
return "agent";
|
|
case "thinking":
|
|
return "thinking";
|
|
case "tool_use":
|
|
return "tool_use";
|
|
case "tool_result":
|
|
return "tool_result";
|
|
case "error":
|
|
return "error";
|
|
default:
|
|
return "generic";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Human label. Tool events show the provider-native tool name verbatim
|
|
* (exec_command, patch_apply — never renamed); an unknown type shows its own
|
|
* raw type string so evidence is never mislabeled.
|
|
*/
|
|
export function traceEventLabel(event: TraceEvent): string {
|
|
switch (event.type) {
|
|
case "text":
|
|
return "Agent";
|
|
case "thinking":
|
|
return "Thinking";
|
|
case "tool_use":
|
|
return event.tool && event.tool.length > 0 ? event.tool : "Tool";
|
|
case "tool_result":
|
|
return event.tool && event.tool.length > 0 ? event.tool : "Result";
|
|
case "error":
|
|
return "Error";
|
|
default:
|
|
return event.type && event.type.length > 0 ? event.type : "Event";
|
|
}
|
|
}
|
|
|
|
/** Shorten a long path to ".../parent/leaf" so a tool summary stays one line. */
|
|
export function shortenTracePath(p: string): string {
|
|
const parts = p.split("/");
|
|
if (parts.length <= 3) return p;
|
|
return ".../" + parts.slice(-2).join("/");
|
|
}
|
|
|
|
// Providers commonly wrap the real command in a login-shell invocation; the
|
|
// wrapper is pure noise in a one-line summary (the full original stays in the
|
|
// expanded params). Matches `<shell> -lc '<cmd>'` / `-c "<cmd>"` forms.
|
|
const SHELL_WRAPPER_PATTERN =
|
|
/^(?:\/[\w./-]*\/)?(?:zsh|bash|sh|fish)\s+(?:-[a-z]+\s+)*(['"])([\s\S]+)\1$/;
|
|
|
|
export function stripShellWrapper(command: string): string {
|
|
const match = SHELL_WRAPPER_PATTERN.exec(command.trim());
|
|
return match?.[2] ?? command;
|
|
}
|
|
|
|
function clip(value: string, max: number): string {
|
|
return value.length > max ? value.slice(0, max) + "..." : value;
|
|
}
|
|
|
|
/**
|
|
* Localizable phrasing the presenter cannot produce on its own. This module
|
|
* stays free of React and i18n so it remains unit-testable in isolation, so the
|
|
* caller injects the wording instead. Omitting it falls back to English, which
|
|
* keeps the fallback safe rather than blank.
|
|
*/
|
|
export interface TraceSummaryLabels {
|
|
/**
|
|
* Phrase a multi-file patch, e.g. `src/a.go +2 more`.
|
|
*
|
|
* `extraCount` is the number of files *beyond* the named one, not the total.
|
|
* Translations must say "and N more", not "N files in total" — the two read
|
|
* almost the same in English and diverge in other languages.
|
|
*/
|
|
morePaths?: (path: string, extraCount: number) => string;
|
|
}
|
|
|
|
/**
|
|
* The single most informative argument of a tool call, as one line. Preference
|
|
* order matches what a reviewer scans for first, falling back to the first
|
|
* short string value.
|
|
*/
|
|
export function traceToolArgSummary(
|
|
input: Record<string, unknown> | undefined,
|
|
labels?: TraceSummaryLabels,
|
|
): string {
|
|
if (!input) return "";
|
|
const str = (v: unknown): string => (typeof v === "string" ? v : "");
|
|
if (str(input.query)) return str(input.query);
|
|
// A multi-file patch has no single path field; without this the row's
|
|
// summary would fall through to the generic scan and come back empty.
|
|
const patch = readPatchSummary(input, labels);
|
|
if (patch) return patch;
|
|
if (str(input.file_path)) return shortenTracePath(str(input.file_path));
|
|
if (str(input.path)) return shortenTracePath(str(input.path));
|
|
if (str(input.pattern)) return str(input.pattern);
|
|
if (str(input.description)) return str(input.description);
|
|
if (str(input.command)) return clip(stripShellWrapper(str(input.command)), 120);
|
|
if (str(input.prompt)) return clip(str(input.prompt), 120);
|
|
if (str(input.skill)) return str(input.skill);
|
|
for (const v of Object.values(input)) {
|
|
if (typeof v === "string" && v.length > 0 && v.length < 120) return v;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function firstLine(value: string | undefined): string {
|
|
return value?.split("\n").find((l) => l.trim().length > 0) ?? "";
|
|
}
|
|
|
|
/**
|
|
* Collapse all whitespace runs to single spaces. Unlike firstLine this keeps
|
|
* content that spans lines, so a pretty-printed JSON result previews as
|
|
* `[ { "id": ... } ]` instead of a lone opening bracket.
|
|
*/
|
|
function collapseWhitespace(value: string | undefined): string {
|
|
return (value ?? "").replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
/** One-line summary for the collapsed row — never contains a newline. */
|
|
export function traceEventSummary(event: TraceEvent, labels?: TraceSummaryLabels): string {
|
|
switch (traceEventKind(event)) {
|
|
case "thinking":
|
|
return clip(firstLine(event.content), 200);
|
|
case "tool_use":
|
|
return traceToolArgSummary(event.input, labels);
|
|
case "tool_result":
|
|
// Unwrap first: the collapsed row is the one people read without
|
|
// clicking, so it must not show transport escaping.
|
|
return clip(collapseWhitespace(unwrapToolOutput(event.output ?? "")), 200);
|
|
default:
|
|
return firstLine(event.content ?? event.output);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Full, untruncated text for "copy all" — the complete body, not the one-line
|
|
* summary. Tool calls copy their full input JSON; results and prose copy their
|
|
* whole content. An RFC 3339 timestamp prefixes the line when the event has a
|
|
* valid `created_at` (#5873). Callers apply secret redaction on the result.
|
|
*/
|
|
export function traceEventCopyText(event: TraceEvent): string {
|
|
const label = traceEventLabel(event);
|
|
let body: string;
|
|
switch (traceEventKind(event)) {
|
|
case "tool_use":
|
|
body = event.input ? JSON.stringify(event.input, null, 2) : "";
|
|
break;
|
|
case "tool_result":
|
|
// Match what the row displays, so copied evidence reads like the
|
|
// terminal output rather than its transport encoding.
|
|
body = unwrapToolOutput(event.output ?? "");
|
|
break;
|
|
default:
|
|
body = event.content ?? "";
|
|
}
|
|
const date = event.created_at ? new Date(event.created_at) : null;
|
|
const timestamp = date && !Number.isNaN(date.getTime()) ? `[${date.toISOString()}] ` : "";
|
|
return body ? `${timestamp}[${label}] ${body}` : `${timestamp}[${label}]`;
|
|
}
|
|
|
|
/**
|
|
* Tool output is persisted JSON-encoded, so a result arrives as a quoted string
|
|
* whose newlines are escaped. Decode exactly one layer so it reads as the
|
|
* terminal output it was. Anything that is not a wrapped string — a bare JSON
|
|
* document, plain prose, a truncated body — is returned untouched.
|
|
*/
|
|
export function unwrapToolOutput(raw: string): string {
|
|
const trimmed = raw.trim();
|
|
if (trimmed.length < 2 || !trimmed.startsWith('"') || !trimmed.endsWith('"')) return raw;
|
|
try {
|
|
const decoded: unknown = JSON.parse(trimmed);
|
|
return typeof decoded === "string" ? decoded : raw;
|
|
} catch {
|
|
return raw;
|
|
}
|
|
}
|
|
|
|
export type TraceDiffLineKind = "add" | "remove" | "context" | "gap";
|
|
|
|
export interface TraceDiffLine {
|
|
kind: TraceDiffLineKind;
|
|
text: string;
|
|
/** Number of context lines a `gap` stands in for. Absent on other kinds. */
|
|
hidden?: number;
|
|
}
|
|
|
|
/**
|
|
* Expanded-row body. A replacement reads as a diff; a whole-file write reads as
|
|
* plain content, because nothing was compared — marking all of it `+` adds
|
|
* noise, not information. A patch carries one entry per file, since a single
|
|
* Codex `patch_apply` routinely touches several. Everything else is text.
|
|
*/
|
|
export type TraceEventDetail =
|
|
| { kind: "diff"; path: string; lines: TraceDiffLine[] }
|
|
| { kind: "file"; path: string; text: string; lineCount: number }
|
|
| { kind: "patch"; files: TracePatchFile[]; truncated: boolean }
|
|
| { kind: "text"; text: string };
|
|
|
|
/** Body of one file inside a multi-file patch. */
|
|
export type TracePatchBody =
|
|
| { kind: "diff"; lines: TraceDiffLine[] }
|
|
| { kind: "file"; text: string; lineCount: number }
|
|
/** Path and kind are known but the body was dropped by the size budget. */
|
|
| { kind: "none" };
|
|
|
|
export interface TracePatchFile {
|
|
path: string;
|
|
/** `add` | `delete` | `update`, verbatim from the provider when reported. */
|
|
changeKind?: string;
|
|
/** Rename destination, when the change moved the file. */
|
|
movePath?: string;
|
|
/** True when this file's body was trimmed to fit the payload budget. */
|
|
truncated?: boolean;
|
|
body: TracePatchBody;
|
|
}
|
|
|
|
/** An empty body is zero lines, not one blank line — a pure deletion has no `+`. */
|
|
function toLines(value: string): string[] {
|
|
return value.length === 0 ? [] : value.split("\n");
|
|
}
|
|
|
|
// Above this product the LCS table costs more than the readability is worth, so
|
|
// the change degrades to a plain replacement block instead of a minimal diff.
|
|
const MAX_DIFF_CELLS = 250_000;
|
|
|
|
/** Minimal line diff. Removals precede additions inside a change block. */
|
|
export function diffTraceLines(before: string[], after: string[]): TraceDiffLine[] {
|
|
const n = before.length;
|
|
const m = after.length;
|
|
const out: TraceDiffLine[] = [];
|
|
|
|
if (n * m > MAX_DIFF_CELLS) {
|
|
for (const text of before) out.push({ kind: "remove", text });
|
|
for (const text of after) out.push({ kind: "add", text });
|
|
return out;
|
|
}
|
|
|
|
// Flat (n+1) x (m+1) table: lcs[i][j] is the longest common subsequence of
|
|
// before[i:] and after[j:]. Typed-array cells stay `number` under
|
|
// noUncheckedIndexedAccess, and one allocation beats n+1 of them.
|
|
const width = m + 1;
|
|
const lcs = new Int32Array((n + 1) * width);
|
|
const at = (i: number, j: number): number => lcs[i * width + j] ?? 0;
|
|
for (let i = n - 1; i >= 0; i--) {
|
|
for (let j = m - 1; j >= 0; j--) {
|
|
lcs[i * width + j] =
|
|
before[i] === after[j] ? at(i + 1, j + 1) + 1 : Math.max(at(i + 1, j), at(i, j + 1));
|
|
}
|
|
}
|
|
|
|
let i = 0;
|
|
let j = 0;
|
|
while (i < n && j < m) {
|
|
const beforeLine = before[i] ?? "";
|
|
const afterLine = after[j] ?? "";
|
|
if (beforeLine === afterLine) {
|
|
out.push({ kind: "context", text: beforeLine });
|
|
i++;
|
|
j++;
|
|
} else if (at(i + 1, j) >= at(i, j + 1)) {
|
|
out.push({ kind: "remove", text: beforeLine });
|
|
i++;
|
|
} else {
|
|
out.push({ kind: "add", text: afterLine });
|
|
j++;
|
|
}
|
|
}
|
|
while (i < n) out.push({ kind: "remove", text: before[i++] ?? "" });
|
|
while (j < m) out.push({ kind: "add", text: after[j++] ?? "" });
|
|
return out;
|
|
}
|
|
|
|
/** Context lines kept either side of a change before a run is collapsed. */
|
|
const DIFF_CONTEXT_LINES = 3;
|
|
|
|
/**
|
|
* Collapse long unchanged stretches into a single `gap` row. A replacement can
|
|
* carry a large `old_string` for a one-line change; without this the change is
|
|
* buried in context that never moved. Runs short enough that collapsing would
|
|
* not save a line are left alone.
|
|
*/
|
|
export function collapseDiffContext(
|
|
lines: readonly TraceDiffLine[],
|
|
contextLines: number = DIFF_CONTEXT_LINES,
|
|
): TraceDiffLine[] {
|
|
const out: TraceDiffLine[] = [];
|
|
let index = 0;
|
|
while (index < lines.length) {
|
|
const line = lines[index];
|
|
if (line === undefined) break;
|
|
if (line.kind !== "context") {
|
|
out.push(line);
|
|
index++;
|
|
continue;
|
|
}
|
|
|
|
let end = index;
|
|
while (end < lines.length && lines[end]?.kind === "context") end++;
|
|
const run = lines.slice(index, end);
|
|
// A leading/trailing run only needs context on the side facing a change.
|
|
const head = index === 0 ? 0 : contextLines;
|
|
const tail = end === lines.length ? 0 : contextLines;
|
|
|
|
if (run.length <= head + tail + 1) {
|
|
out.push(...run);
|
|
} else {
|
|
out.push(...run.slice(0, head));
|
|
out.push({ kind: "gap", text: "", hidden: run.length - head - tail });
|
|
out.push(...run.slice(run.length - tail));
|
|
}
|
|
index = end;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* A file mutation is identified by the *shape* of its input, never by tool name:
|
|
* providers call this Edit, patch_apply, str_replace, write_file… and the
|
|
* presenter's contract is to keep provider-native names verbatim.
|
|
*/
|
|
type FileMutation =
|
|
| { mode: "replace"; path: string; before: string[]; after: string[] }
|
|
| { mode: "write"; path: string; content: string };
|
|
|
|
/**
|
|
* Parse a ready-made unified diff into diff rows.
|
|
*
|
|
* Codex reports an updated file as a unified diff rather than a before/after
|
|
* pair, so there is nothing to compare — recomputing a diff would mean first
|
|
* reconstructing both sides from the diff itself. Hunk headers become `gap`
|
|
* rows, which is exactly what they denote: skipped, unchanged content.
|
|
*/
|
|
export function parseUnifiedDiff(diff: string): TraceDiffLine[] {
|
|
const raw = diff.split("\n");
|
|
// `split` on a trailing newline yields a phantom final element; a genuinely
|
|
// empty trailing context line would have been " ", not "".
|
|
if (raw.length > 0 && raw[raw.length - 1] === "") raw.pop();
|
|
|
|
const out: TraceDiffLine[] = [];
|
|
// File headers only exist ahead of the first hunk. Past that point every
|
|
// line belongs to the file, and "---"/"+++" are ordinary changed lines whose
|
|
// content happens to start with a dash or plus — a Markdown rule, a nested
|
|
// patch, a comment banner. Treating them as headers anywhere deleted real
|
|
// content from the diff.
|
|
let inHunk = false;
|
|
for (const line of raw) {
|
|
if (line.startsWith("@@")) {
|
|
inHunk = true;
|
|
out.push({ kind: "gap", text: line });
|
|
continue;
|
|
}
|
|
if (!inHunk) {
|
|
if (
|
|
line.startsWith("diff --git") ||
|
|
line.startsWith("index ") ||
|
|
line.startsWith("--- ") ||
|
|
line.startsWith("+++ ") ||
|
|
line === "---" ||
|
|
line === "+++"
|
|
) {
|
|
continue;
|
|
}
|
|
}
|
|
// "\ No newline at end of file" is metadata, not a line of the file: a
|
|
// real line starting with a backslash carries a +/-/space prefix first.
|
|
if (line.startsWith("\\")) continue;
|
|
if (line.startsWith("+")) {
|
|
out.push({ kind: "add", text: line.slice(1) });
|
|
continue;
|
|
}
|
|
if (line.startsWith("-")) {
|
|
out.push({ kind: "remove", text: line.slice(1) });
|
|
continue;
|
|
}
|
|
if (line.startsWith(" ")) {
|
|
out.push({ kind: "context", text: line.slice(1) });
|
|
continue;
|
|
}
|
|
// Tolerate a context line that lost its leading space rather than dropping
|
|
// content on the floor.
|
|
out.push({ kind: "context", text: line });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Read the normalized multi-file patch payload that the Codex adapter records:
|
|
* `{ changes: [{ path, kind, diff?, content?, move_path? }], truncated? }`.
|
|
*
|
|
* Returns null for anything that is not this shape, so an unrecognised payload
|
|
* falls back to pretty JSON instead of rendering as an empty patch.
|
|
*/
|
|
function readPatchChanges(input: Record<string, unknown>): TracePatchFile[] | null {
|
|
if (!Array.isArray(input.changes)) return null;
|
|
|
|
const files: TracePatchFile[] = [];
|
|
for (const entry of input.changes) {
|
|
if (typeof entry !== "object" || entry === null) continue;
|
|
const rec = entry as Record<string, unknown>;
|
|
const path = typeof rec.path === "string" ? rec.path : "";
|
|
if (path.length === 0) continue;
|
|
|
|
const file: TracePatchFile = { path, body: { kind: "none" } };
|
|
if (typeof rec.kind === "string" && rec.kind.length > 0) file.changeKind = rec.kind;
|
|
if (typeof rec.move_path === "string" && rec.move_path.length > 0) {
|
|
file.movePath = rec.move_path;
|
|
}
|
|
if (rec.truncated === true) file.truncated = true;
|
|
|
|
if (typeof rec.diff === "string" && rec.diff.length > 0) {
|
|
file.body = { kind: "diff", lines: parseUnifiedDiff(rec.diff) };
|
|
} else if (typeof rec.content === "string") {
|
|
// An added file can legitimately be empty, so this keys on the field
|
|
// being present rather than on the content being non-empty.
|
|
if (file.changeKind === "delete") {
|
|
// The legacy protocol reports a deletion as the whole outgoing file.
|
|
// All-removals states that; a green "+N" would say the opposite.
|
|
file.body = {
|
|
kind: "diff",
|
|
lines: toLines(rec.content).map((text) => ({ kind: "remove" as const, text })),
|
|
};
|
|
} else {
|
|
file.body = {
|
|
kind: "file",
|
|
text: rec.content,
|
|
lineCount: toLines(rec.content).length,
|
|
};
|
|
}
|
|
}
|
|
files.push(file);
|
|
}
|
|
|
|
return files.length > 0 ? files : null;
|
|
}
|
|
|
|
/** First path plus a count, for the collapsed one-line summary. */
|
|
function readPatchSummary(
|
|
input: Record<string, unknown> | undefined,
|
|
labels?: TraceSummaryLabels,
|
|
): string {
|
|
if (!input) return "";
|
|
const files = readPatchChanges(input);
|
|
if (files === null) return "";
|
|
const first = files[0];
|
|
if (first === undefined) return "";
|
|
const head = shortenTracePath(first.path);
|
|
if (files.length === 1) return head;
|
|
const extraCount = files.length - 1;
|
|
return labels?.morePaths?.(head, extraCount) ?? `${head} +${extraCount} more`;
|
|
}
|
|
|
|
function readFileMutation(input: Record<string, unknown>): FileMutation | null {
|
|
const str = (v: unknown): string | null => (typeof v === "string" ? v : null);
|
|
const path = str(input.file_path) ?? str(input.path);
|
|
if (path === null) return null;
|
|
|
|
const oldString = str(input.old_string);
|
|
const newString = str(input.new_string);
|
|
if (oldString !== null && newString !== null) {
|
|
return { mode: "replace", path, before: toLines(oldString), after: toLines(newString) };
|
|
}
|
|
|
|
// Keyed on `content`, not on "the before side is empty": an edit whose
|
|
// old_string is empty is an insertion into an existing file, which still
|
|
// reads best as a diff.
|
|
const content = str(input.content);
|
|
if (content !== null) return { mode: "write", path, content };
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Structured body for the expanded row. Edits become a diff so a reviewer sees
|
|
* what changed rather than two escaped string literals; results are unwrapped;
|
|
* every other tool call falls back to pretty JSON.
|
|
*/
|
|
export function traceEventDetail(event: TraceEvent): TraceEventDetail {
|
|
switch (traceEventKind(event)) {
|
|
case "tool_use": {
|
|
if (!event.input) return { kind: "text", text: "" };
|
|
const patch = readPatchChanges(event.input);
|
|
if (patch !== null) {
|
|
return { kind: "patch", files: patch, truncated: event.input.truncated === true };
|
|
}
|
|
const mutation = readFileMutation(event.input);
|
|
if (mutation?.mode === "replace") {
|
|
return {
|
|
kind: "diff",
|
|
path: mutation.path,
|
|
lines: collapseDiffContext(diffTraceLines(mutation.before, mutation.after)),
|
|
};
|
|
}
|
|
if (mutation?.mode === "write") {
|
|
return {
|
|
kind: "file",
|
|
path: mutation.path,
|
|
text: mutation.content,
|
|
lineCount: toLines(mutation.content).length,
|
|
};
|
|
}
|
|
return { kind: "text", text: JSON.stringify(event.input, null, 2) };
|
|
}
|
|
case "tool_result":
|
|
return { kind: "text", text: unwrapToolOutput(event.output ?? "") };
|
|
default:
|
|
return { kind: "text", text: event.content ?? "" };
|
|
}
|
|
}
|
|
|
|
export function traceEventHasDetail(event: TraceEvent): boolean {
|
|
switch (traceEventKind(event)) {
|
|
case "tool_use":
|
|
return !!event.input && Object.keys(event.input).length > 0;
|
|
case "tool_result":
|
|
return !!event.output && event.output.length > 0;
|
|
default:
|
|
return !!event.content && event.content.length > 0;
|
|
}
|
|
}
|
|
|
|
/** Whether a monospace face fits the collapsed summary (commands/output). */
|
|
export function traceEventSummaryIsMono(kind: TraceEventKind): boolean {
|
|
return kind === "tool_use" || kind === "tool_result";
|
|
}
|
|
|
|
/**
|
|
* Default expansion under the `smart` density: the reading hierarchy itself.
|
|
* Agent text and errors read without a click; process noise stays folded.
|
|
*/
|
|
export function traceEventDefaultExpanded(
|
|
event: TraceEvent,
|
|
density: TranscriptDetailDensity,
|
|
): boolean {
|
|
if (!traceEventHasDetail(event)) return false;
|
|
switch (density) {
|
|
case "expanded":
|
|
return true;
|
|
case "collapsed":
|
|
return false;
|
|
case "smart": {
|
|
const kind = traceEventKind(event);
|
|
return kind === "agent" || kind === "error";
|
|
}
|
|
}
|
|
}
|