diff --git a/src/client/components/contextView.tsx b/src/client/components/contextView.tsx index 273dce2..50f98af 100644 --- a/src/client/components/contextView.tsx +++ b/src/client/components/contextView.tsx @@ -220,9 +220,12 @@ export function makeContextView( [briefList, convOf, filesBefore], ) const locateFileOp = React.useCallback((op: FileOp): void => { - const step = locateStepOf(requests, op.seq, op.gone) + // A nested Code-Mode op has no surface row of its own — it reveals on + // its parent run_code result (whose removal stamp the op carries). + const seq = op.parent ?? op.seq + const step = locateStepOf(requests, seq, op.gone) if (step === null) return - setNodeFocus({ step, seq: op.seq, cat: 'tool' }) + setNodeFocus({ step, seq, cat: 'tool' }) }, [requests]) // A brief row's reveal target: inputs/opener live in the picked step's OWN assembled surface; the response node (seq === the // request's) first appears in the NEXT step's surface — or the live surface when the last bar is picked. diff --git a/src/client/components/fileCard.tsx b/src/client/components/fileCard.tsx index 4624e3e..a885ac5 100644 --- a/src/client/components/fileCard.tsx +++ b/src/client/components/fileCard.tsx @@ -98,6 +98,11 @@ export function makeFileCard(kit: ViewKit, settings: ContextSettings): ReactNS.C {op.time !== undefined ? fmtTime(op.time) : '—'} {op.tool} {op.detail !== undefined ? {op.detail} : null} + {op.hits !== undefined ? {t('files.hits', { n: fmt(op.hits) })} : null} + {/* A nested PTC op with nothing else to say still tells why: its program's description. */} + {op.detail === undefined && op.hits === undefined && op.program !== undefined + ? {op.program} + : null} {op.added + op.removed > 0 ? : null} {op.err ? : null} @@ -197,12 +202,15 @@ export function makeFileCard(kit: ViewKit, settings: ContextSettings): ReactNS.C {open ? (
- {e.ops.map((op) => { + {e.ops.map((op, i) => { const onLocate = props.onLocate + // A meta-attributed search rows one op per matched file off ONE + // result — the ops share that result's seq, so the key joins the index. + const key = `${op.seq}:${i}` return onLocate !== undefined ? (
) : null} diff --git a/src/client/fileActivity.ts b/src/client/fileActivity.ts index da4fc82..67a0b48 100644 --- a/src/client/fileActivity.ts +++ b/src/client/fileActivity.ts @@ -3,11 +3,19 @@ * timeline's served tool-result nodes (live tail + archive) joined with the * conversation snapshot for call arguments; no host or wire additions. * - * One tool-result surface node equals one executed call; a call whose + * One top-level tool-result surface node equals one executed call. Code Mode + * (PTC) runs nested calls instead — those ride the conversation node's + * `subCalls` tree, and each settled sub-dispatch folds as one op attributed + * to the nested tool and located on its parent run_code result. A call whose * arguments have aged out of the conversation join names no target and is * skipped — every op the card counts, it can also row. Line deltas are - * estimates read off the call ARGUMENTS (an - * edit's old/new strings, a write's content), never off result payloads. + * estimates read off the call ARGUMENTS (an edit's old/new strings, a write's + * content), never off result payloads. + * + * A search resolves further when its result's bounded presentation meta names + * the matched files: the ops then row per real file (with the match count), + * and only a capped or malformed meta falls back to the call's own target — + * the narrowing path, else the searched pattern. * * Scope: `before` is the EXCLUSIVE upper seq bound (the next request's seq), * so the picked step's own calls — whose results land before the next @@ -34,6 +42,16 @@ export interface FileOp { removed: number /** What was searched for, when a search named both a path and a pattern. */ detail?: string + /** + * Nested Code-Mode op only: the run_code result node the op ran under — the + * locate target, because the op's own seq names the dispatch event, which + * has no surface row. `gone` is the parent's stamp. + */ + parent?: number + /** Nested Code-Mode op only: the run_code program's model-authored description — why the file was touched. */ + program?: string + /** Meta-attributed search op only: matched lines the result reported for this file. */ + hits?: number } /** One file's aggregated activity; `ops` newest first. */ @@ -72,6 +90,18 @@ export function kindOfTool(tool: string | undefined): FileOpKind | null { return KIND_BY_TOOL[tool] ?? null } +/** + * The file purpose of one executed call. Like {@link kindOfTool} except for + * the one file tool whose purpose follows its arguments: `str_replace_editor` + * reads on `view` and writes on every other command (create / str_replace / + * insert — an unknown command writes too; the call failed and the row keeps + * its error flag). + */ +export function kindOfCall(tool: string, args: Record | null): FileOpKind | null { + if (tool === 'str_replace_editor') return args !== null && args.command === 'view' ? 'read' : 'write' + return kindOfTool(tool) +} + /** * The operation's target path: the path-ish argument of read/write tools; * for searches the narrowing `path`, else the pattern itself (a pathless @@ -100,24 +130,28 @@ export function linesOf(s: string): number { return s.endsWith('\n') ? n : n + 1 } +/** The added/removed pair of one content-bearing argument set, or zeros. */ +function pairOf(added: unknown, removed: unknown): { added: number; removed: number } { + return { + added: typeof added === 'string' ? linesOf(added) : 0, + removed: typeof removed === 'string' ? linesOf(removed) : 0, + } +} + /** * The signed line footprint of one call: an edit removes its old string and * adds its new one; a write adds its content (the pre-existing body, if any, - * is unknowable from the arguments — the estimate stays honest about that). - * Callers reach here only with parsed args (a null parse yields no path). + * is unknowable from the arguments — the estimate stays honest about that); + * `str_replace_editor` splits the same shapes across its commands. Callers + * reach here only with parsed args (a null parse yields no path). */ function deltaOf(tool: string, args: Record): { added: number; removed: number } { - if (tool === 'edit') { - const oldS = args.old_string - const newS = args.new_string - return { - added: typeof newS === 'string' ? linesOf(newS) : 0, - removed: typeof oldS === 'string' ? linesOf(oldS) : 0, - } - } - if (tool === 'write') { - const content = args.content - return { added: typeof content === 'string' ? linesOf(content) : 0, removed: 0 } + if (tool === 'edit') return pairOf(args.new_string, args.old_string) + if (tool === 'write') return pairOf(args.content, undefined) + if (tool === 'str_replace_editor') { + if (args.command === 'str_replace') return pairOf(args.new_str, args.old_str) + if (args.command === 'insert') return pairOf(args.new_str, undefined) + if (args.command === 'create') return pairOf(args.file_text, undefined) } return { added: 0, removed: 0 } } @@ -131,6 +165,138 @@ export function formOf(tool: string, path: string): FileForm { return 'text' } +/** + * A search op's detail: the pattern, with the include filter appended when + * one narrowed the call. A patternless (malformed) search has no detail. + */ +function searchDetailOf(args: Record | null): string | undefined { + const pattern = args?.pattern + if (typeof pattern !== 'string' || pattern === '') return undefined + const include = args?.include + return typeof include === 'string' && include !== '' ? `${pattern} (${include})` : pattern +} + +/** + * The files a search demonstrably reached, read off the result's bounded + * presentation meta (grep groups matched lines by file; glob lists paths). + * Only the COMPLETE list attributes: a capped search (`truncated`) names a + * partial file set, and a malformed meta names none — both fall back to the + * call's own target. Each entry carries the reported match count. + */ +function searchFilesOf(meta: unknown): { path: string; hits: number }[] | null { + if (meta === null || typeof meta !== 'object') return null + const m = meta as { shape?: unknown; truncated?: unknown; files?: unknown; paths?: unknown } + if (m.truncated !== false) return null + const files: { path: string; hits: number }[] = [] + if (m.shape === 'matches' && Array.isArray(m.files)) { + for (const f of m.files) { + if (f === null || typeof f !== 'object') continue + const group = f as { path?: unknown; matches?: unknown } + if (typeof group.path === 'string' && group.path !== '' && Array.isArray(group.matches)) { + files.push({ path: group.path, hits: group.matches.length }) + } + } + } else if (m.shape === 'paths' && Array.isArray(m.paths)) { + for (const p of m.paths) { + if (typeof p === 'string' && p !== '') files.push({ path: p, hits: 0 }) + } + } + return files.length > 0 ? files : null +} + +/** One settled nested call of a Code-Mode tree, as far as the fold consumes it. */ +interface SubCall { + name: string + argsRaw: string + seq: number + time?: number + err: boolean + subCalls?: readonly unknown[] +} + +/** + * Narrow one block of a conversation node's `subCalls` tree to a settled + * nested call, or null. The join is defensive — a running call has no result + * kind yet, and any malformed block is dropped, never thrown. (Null and + * non-object blocks never reach here: the folding loop pre-filters them.) + */ +function subCallOf(block: unknown): SubCall | null { + const b = block as Record + if (b.kind !== 'tool-result') return null + if (b.call === null || typeof b.call !== 'object') return null + const call = b.call as { name?: unknown; argsRaw?: unknown } + if (typeof call.name !== 'string' || typeof call.argsRaw !== 'string') return null + if (typeof b.seq !== 'number' || !Number.isFinite(b.seq)) return null + return { + name: call.name, + argsRaw: call.argsRaw, + seq: b.seq, + ...(typeof b.time === 'number' ? { time: b.time } : {}), + ...(b.isError === true ? { err: true } : { err: false }), + ...(Array.isArray(b.subCalls) ? { subCalls: b.subCalls } : {}), + } +} + +/** The run_code call's model-authored description — the nested ops' "why". */ +function programOf(conv: ConversationNodeLike | undefined): string | undefined { + const description = parseCallArgs(conv?.call?.argsRaw)?.description + return typeof description === 'string' && description !== '' ? description : undefined +} + +/** + * Depth guard for nested Code-Mode trees. The SDK bindings exclude `run_code` + * itself, so a real tree is one level deep; the cap only bounds defensive + * re-entry over a malformed join. + */ +const SUBCALL_MAX_DEPTH = 8 + +/** + * Fold one nested Code-Mode tree: every settled sub-dispatch whose arguments + * resolve to a file target books one op, attributed to the nested tool and + * located on the parent run_code result. `seen` holds the already-visited + * blocks, so a malformed (cyclic) join cannot loop. + */ +function foldSubCalls( + blocks: readonly unknown[], + parent: SurfaceNode, + program: string | undefined, + add: (op: FileOp, path: string) => void, + seen: Set, + depth: number, +): void { + if (depth > SUBCALL_MAX_DEPTH) return + for (const block of blocks) { + if (block === null || typeof block !== 'object' || seen.has(block)) continue + seen.add(block) + const sub = subCallOf(block) + if (sub !== null) { + const args = parseCallArgs(sub.argsRaw) + const kind = args !== null ? kindOfCall(sub.name, args) : null + const path = kind !== null ? pathOfArgs(sub.name, args) : null + if (args !== null && kind !== null && path !== null) { + const { added, removed } = deltaOf(sub.name, args) + const detail = kind === 'search' && typeof args.path === 'string' && args.path !== '' + ? searchDetailOf(args) + : undefined + add({ + seq: sub.seq, + ...(parent.gone !== undefined ? { gone: parent.gone } : {}), + kind, + tool: sub.name, + ...(sub.time !== undefined ? { time: sub.time } : {}), + err: sub.err, + added, + removed, + ...(detail !== undefined ? { detail } : {}), + parent: parent.seq, + ...(program !== undefined ? { program } : {}), + }, path) + } + if (sub.subCalls !== undefined) foldSubCalls(sub.subCalls, parent, program, add, seen, depth + 1) + } + } +} + /** Fold every served tool-result node into per-file activity. */ export function activityOf( nodes: SurfaceNode[], @@ -146,51 +312,85 @@ export function activityOf( removed: 0, } const byPath = new Map() + /** Book one executed op under its file; the totals and the entry move together. */ + const add = (op: FileOp, path: string): void => { + totals[op.kind].ops++ + let entry = byPath.get(path) + if (entry === undefined) { + entry = { path, form: formOf(op.tool, path), reads: 0, writes: 0, searches: 0, added: 0, removed: 0, errs: 0, ops: [] } + byPath.set(path, entry) + } + if (op.kind === 'read') entry.reads++ + else if (op.kind === 'write') entry.writes++ + else entry.searches++ + entry.added += op.added + entry.removed += op.removed + if (op.err) entry.errs++ + entry.ops.push(op) + } for (const n of nodes) { if (n.cat !== 'tool') continue if (before !== null && n.seq >= before) continue - const kind = kindOfTool(n.tool) - /* kind non-null ⟹ n.tool defined (kindOfTool's only other exit). */ - if (kind === null) continue - const tool = n.tool as string + const tool = n.tool + if (tool === undefined) continue const conv = convOf(n.seq) - const args = parseCallArgs(conv?.call?.argsRaw) - const path = pathOfArgs(tool, args) - // A file-kind call whose arguments aged out of the retained window (or - // name no target) has nothing to row — skipped, so counted means shown. - // The args check narrows `args` for the delta/detail reads below. - if (path === null || args === null) continue - totals[kind].ops++ - const { added, removed } = deltaOf(tool, args) - const detail = kind === 'search' - && typeof args.path === 'string' && args.path !== '' - && typeof args.pattern === 'string' && args.pattern !== '' - ? args.pattern - : undefined - const op: FileOp = { - seq: n.seq, - ...(n.gone !== undefined ? { gone: n.gone } : {}), - kind, - tool, - ...(n.time !== undefined ? { time: n.time } : {}), - // Parsed args ⟹ the conversation join carried this call; the assertion just restates it. - err: n.err === true || (conv as ConversationNodeLike).isError === true, - added, - removed, - ...(detail !== undefined ? { detail } : {}), + const staticKind = kindOfTool(tool) + // Args are read for the file-kind tools only — any other call (bash, web + // search…) contributes nothing at the top level and walks only its + // nested tree, if any. + const args = staticKind !== null || tool === 'str_replace_editor' + ? parseCallArgs(conv?.call?.argsRaw) + : null + const kind = staticKind ?? kindOfCall(tool, args) + if (kind !== null) { + const err = n.err === true || (conv as ConversationNodeLike).isError === true + // A search whose result meta carries the COMPLETE matched-file list + // rows its ops per real file; a capped or malformed meta falls back to + // the call's own target (the narrowing path, else the pattern). + const files = kind === 'search' ? searchFilesOf(conv?.meta) : null + if (files !== null) { + const detail = searchDetailOf(args) + for (const f of files) { + add({ + seq: n.seq, + ...(n.gone !== undefined ? { gone: n.gone } : {}), + kind, + tool, + ...(n.time !== undefined ? { time: n.time } : {}), + err, + added: 0, + removed: 0, + ...(detail !== undefined ? { detail } : {}), + ...(f.hits > 0 ? { hits: f.hits } : {}), + }, f.path) + } + } else if (args !== null) { + const path = pathOfArgs(tool, args) + if (path !== null) { + const { added, removed } = deltaOf(tool, args) + const detail = kind === 'search' + && typeof args.path === 'string' && args.path !== '' + ? searchDetailOf(args) + : undefined + add({ + seq: n.seq, + ...(n.gone !== undefined ? { gone: n.gone } : {}), + kind, + tool, + ...(n.time !== undefined ? { time: n.time } : {}), + err, + added, + removed, + ...(detail !== undefined ? { detail } : {}), + }, path) + } + } } - let entry = byPath.get(path) - if (entry === undefined) { - entry = { path, form: formOf(tool, path), reads: 0, writes: 0, searches: 0, added: 0, removed: 0, errs: 0, ops: [] } - byPath.set(path, entry) + // Nested Code-Mode calls (PTC): one settled sub-dispatch is one op. + const subCalls = conv?.subCalls + if (subCalls !== undefined && subCalls.length > 0) { + foldSubCalls(subCalls, n, programOf(conv), add, new Set(), 1) } - if (kind === 'read') entry.reads++ - else if (kind === 'write') entry.writes++ - else entry.searches++ - entry.added += added - entry.removed += removed - if (op.err) entry.errs++ - entry.ops.push(op) } const entries = [...byPath.values()] for (const e of entries) { diff --git a/src/client/i18n.ts b/src/client/i18n.ts index d91ac34..d11da0d 100644 --- a/src/client/i18n.ts +++ b/src/client/i18n.ts @@ -79,6 +79,7 @@ export const DICT_ZH: Record = { 'files.empty': '该范围内没有文件读取、写入或搜索操作', 'files.noMatch': '没有匹配当前过滤条件的文件', 'files.locate': '在上下文浏览器中查看此次操作', + 'files.hits': '{n} 处命中', 'files.errs': '{n} 次失败', 'files.form.image': '图片', 'files.form.dir': '目录', 'files.form.text': '文件', 'agents.title': 'Agent 网络', @@ -239,6 +240,7 @@ export const DICT_EN: Record = { 'files.empty': 'No file reads, writes, or searches in this range', 'files.noMatch': 'No files match the current filters', 'files.locate': 'Reveal this operation in the Context Browser', + 'files.hits': '{n} hits', 'files.errs': '{n} failed', 'files.form.image': 'Image', 'files.form.dir': 'Directory', 'files.form.text': 'File', 'agents.title': 'Agent Network', diff --git a/src/client/services.ts b/src/client/services.ts index cbc784d..a8fbe2e 100644 --- a/src/client/services.ts +++ b/src/client/services.ts @@ -55,6 +55,15 @@ export interface ConversationNodeLike { call?: { name: string; argsRaw: string } | null isError?: boolean summary?: string | null + /** + * Nested Code-Mode call tree (dsh's recursive ToolCallBlock[]) on a tool + * result whose call ran sub-dispatches — a PTC `run_code` program. Consumed + * structurally only (fileActivity): every block is re-proved at runtime and + * malformed shapes drop out instead of throwing. + */ + subCalls?: readonly unknown[] + /** The tool result's bounded presentation meta (a search's matched files), as the join delivers it. */ + meta?: unknown } /** diff --git a/tests/client/components/contextView.spec.ts b/tests/client/components/contextView.spec.ts index 410ff6e..480d066 100644 --- a/tests/client/components/contextView.spec.ts +++ b/tests/client/components/contextView.spec.ts @@ -427,6 +427,55 @@ describe('ContextView — file activity card', () => { assert.ok(text(query(m.container, '.lc-br-elem-on')).includes('/src/a.ts')) await m.unmount() }) + + test('a nested PTC op reveals on its parent run_code result, not on its dispatch seq', async () => { + const ptcTimeline = timeline({ + requests: [ + { seq: 2, turn: 1, step: 1, time: T0 + 1000, system: 10, tools: 20, user: 10, inject: 0, assistant: 20, tool: 10, total: 70 }, + { seq: 4, turn: 1, step: 2, time: T0 + 3000, system: 10, tools: 20, user: 10, inject: 0, assistant: 20, tool: 10, total: 70 }, + ], + nodes: [ + { seq: 2, cat: 'assistant', tokens: 20, text: 'r1', time: T0 + 1000 }, + { seq: 3, cat: 'tool', tokens: 30, tool: 'run_code', time: T0 + 2000 }, + { seq: 4, cat: 'assistant', tokens: 60, text: 'r2', time: T0 + 3000 }, + ], + }) + const ptcConv = [ + { + kind: 'tool-result', + seq: 3, + call: { name: 'run_code', argsRaw: JSON.stringify({ code: 'await tools.edit(…)', description: 'Fix the failing test' }) }, + subCalls: [ + { + kind: 'tool-result', + seq: 2, + time: T0 + 1500, + call: { name: 'edit', argsRaw: JSON.stringify({ file_path: '/src/a.ts', old_string: 'a\nb', new_string: 'a' }) }, + isError: false, + subCalls: [], + }, + ], + }, + ] + const View = makeView(new TestClientCtx()) + const m = await mount(h(View, { + sessionId: 'sv-ptc-locate', + useProjection: projectionsFor(ptcTimeline), + useSession: (sel => sel({ nodes: ptcConv })) as UseSessionLike, + })) + const card = queryAll(m.container, '.lc-card').find(c => text(c).includes(DICT_EN['files.title'])) + assert.ok(card !== undefined) + await click(query(card, '.lc-fa-row')) + // The op is attributed to the nested tool, and says why (its program). + assert.ok(text(card).includes('edit')) + assert.ok(text(card).includes('Fix the failing test')) + // The click lands on the parent run_code node (seq 3 → step 4's surface). + await click(query(card, '.lc-fa-op')) + const pick = query(m.container, 'select.lc-br-pick') + assert.equal(pick.value, '4') + assert.ok(text(query(m.container, '.lc-br-elem-on')).includes('Fix the failing test')) + await m.unmount() + }) }) describe('ContextView — targeted content fetch and image loading', () => { diff --git a/tests/client/components/fileCard.spec.ts b/tests/client/components/fileCard.spec.ts index 441465c..508ac46 100644 --- a/tests/client/components/fileCard.spec.ts +++ b/tests/client/components/fileCard.spec.ts @@ -318,6 +318,33 @@ describe('FileCard — expansion and locate', () => { await m.unmount() }) + test('a nested PTC op tells why (its program); a meta search op shows its hit count', async () => { + const m = await mount(h(FileCard, { + activity: richActivity({ + entries: [ + entry('/src/a.ts', { + reads: 2, + ops: [ + fileOp(9, 'read', 'read', { parent: 30, program: 'Read failing test and its fixture' }), + fileOp(7, 'read', 'read'), + ], + }), + entry('/src/b.ts', { searches: 1, ops: [fileOp(10, 'search', 'grep', { detail: 'needle', hits: 3 })] }), + ], + totals: { read: { files: 1, ops: 2 }, write: { files: 0, ops: 0 }, search: { files: 1, ops: 1 }, image: { files: 0, ops: 0 }, added: 0, removed: 0 }, + }), + scope: 'live', + })) + await click(rowByTitle(m.container, '/src/a.ts')) + assert.ok(text(query(m.container, '.lc-fa-ops')).includes('Read failing test and its fixture')) + await click(rowByTitle(m.container, '/src/a.ts')) // collapse + await click(rowByTitle(m.container, '/src/b.ts')) + const ops = text(query(m.container, '.lc-fa-ops')) + assert.ok(ops.includes('needle')) + assert.ok(ops.includes('3 hits')) + await m.unmount() + }) + test('without a locate hook the op lines render inert', async () => { const m = await mount(h(FileCard, { activity: richActivity(), scope: 'live' })) await click(queryAll(m.container, '.lc-fa-row')[0]) diff --git a/tests/client/fileActivity.spec.ts b/tests/client/fileActivity.spec.ts index 06348d5..e5cd1f4 100644 --- a/tests/client/fileActivity.spec.ts +++ b/tests/client/fileActivity.spec.ts @@ -1,10 +1,11 @@ // File activity derivation (src/client/fileActivity.ts) — the pure fold // behind the File Activity card: tool→kind classification, path extraction, -// line-delta estimates, per-file aggregation, and the locate-target lookup. +// line-delta estimates, per-file aggregation, nested Code-Mode (PTC) call +// folding, search-meta attribution, and the locate-target lookup. import assert from 'node:assert/strict' import { describe, test } from 'vitest' -import { activityOf, formOf, kindOfTool, linesOf, locateStepOf, pathOfArgs } from '../../src/client/fileActivity' +import { activityOf, formOf, kindOfCall, kindOfTool, linesOf, locateStepOf, pathOfArgs } from '../../src/client/fileActivity' import type { FileActivity } from '../../src/client/fileActivity' import type { ConversationNodeLike } from '../../src/client/services' import type { RequestRecord, SurfaceNode } from '../../src/shared/types' @@ -249,3 +250,272 @@ describe('locateStepOf', () => { assert.equal(locateStepOf([], 1, undefined), 'live') }) }) + +/** One settled nested sub-call block, as the conversation join delivers it. */ +function sub(seq: number, name: string, args: Record | string, over: Record = {}): Record { + return { + kind: 'tool-result', + seq, + time: T0 + seq, + call: { name, argsRaw: typeof args === 'string' ? args : JSON.stringify(args) }, + isError: false, + subCalls: [], + ...over, + } +} + +describe('kindOfCall', () => { + test('str_replace_editor reads on view and writes otherwise; every other tool keeps its static kind', () => { + assert.equal(kindOfCall('str_replace_editor', { command: 'view' }), 'read') + assert.equal(kindOfCall('str_replace_editor', { command: 'create' }), 'write') + assert.equal(kindOfCall('str_replace_editor', { command: 'str_replace' }), 'write') + assert.equal(kindOfCall('str_replace_editor', { command: 'insert' }), 'write') + assert.equal(kindOfCall('str_replace_editor', { command: 'explode' }), 'write') + assert.equal(kindOfCall('str_replace_editor', null), 'write') + assert.equal(kindOfCall('read', null), 'read') + assert.equal(kindOfCall('bash', { command: 'ls' }), null) + }) +}) + +describe('activityOf — str_replace_editor', () => { + test('view reads; create, str_replace, and insert write with their own line deltas', () => { + const a = run([ + op(3, 'str_replace_editor', { command: 'view', path: '/a.py' }), + op(5, 'str_replace_editor', { command: 'create', path: '/b.py', file_text: 'one\ntwo\n' }), + op(7, 'str_replace_editor', { command: 'str_replace', path: '/b.py', old_str: 'one\ntwo', new_str: 'one' }), + op(9, 'str_replace_editor', { command: 'insert', path: '/c.py', insert_line: 1, new_str: 'x\ny' }), + ]) + assert.deepEqual(a.entries.map(e => e.path), ['/c.py', '/b.py', '/a.py']) + const b = a.entries[1] + assert.equal(b.reads, 0) + assert.equal(b.writes, 2) + assert.equal(b.added, 3) // create 'one\ntwo\n' + str_replace 'one' + assert.equal(b.removed, 2) // str_replace 'one\ntwo' + assert.equal(a.entries[2].reads, 1) + assert.equal(a.entries[0].added, 2) // insert 'x\ny' + assert.deepEqual(a.totals.read, { files: 1, ops: 1 }) + // Writes: /b.py (create + str_replace) and /c.py (insert). + assert.deepEqual(a.totals.write, { files: 2, ops: 3 }) + }) + + test('a stringless call rows as a failed-looking but zero-delta write', () => { + const a = run([ + op(3, 'str_replace_editor', { command: 'str_replace', path: '/a.py' }), + op(5, 'str_replace_editor', { path: '/b.py' }), + ]) + assert.ok(a.entries.every(e => e.writes === 1 && e.added === 0 && e.removed === 0)) + }) +}) + +describe('activityOf — nested Code-Mode (PTC) calls', () => { + test('a run_code result folds its settled sub-dispatches into ops attributed to the nested tools', () => { + const a = run([ + op(30, 'run_code', { code: 'await tools.read(…)', description: 'Read failing test and its fixture' }, {}, { + subCalls: [ + sub(20, 'read', { file_path: '/src/a.ts' }), + sub(22, 'edit', { file_path: '/src/a.ts', old_string: 'x\ny', new_string: 'z' }), + sub(24, 'write', { file_path: '/src/b.ts', content: 'one\ntwo\n' }), + sub(26, 'grep', { pattern: 'needle', path: '/src' }), + sub(28, 'bash', { command: 'ls' }), + // A minimal settled block: no time, no error flag, no nested tree. + { kind: 'tool-result', seq: 27, call: { name: 'read', argsRaw: JSON.stringify({ file_path: '/src/a.ts' }) } }, + ], + }), + ]) + // /src/a.ts leads: its newest op is the minimal read at seq 27. + assert.deepEqual(a.entries.map(e => e.path), ['/src/a.ts', '/src', '/src/b.ts']) + const file = a.entries[0] + assert.equal(file.reads, 2) + assert.equal(file.writes, 1) + const read = file.ops.find(o => o.seq === 20) + assert.equal(read?.parent, 30) + assert.equal(read?.program, 'Read failing test and its fixture') + assert.equal(read?.time, T0 + 20) + // The minimal block carries no time and no error. + const minimal = file.ops.find(o => o.seq === 27) + assert.equal(minimal?.time, undefined) + assert.equal(minimal?.err, false) + assert.deepEqual(a.totals, { + read: { files: 1, ops: 2 }, + write: { files: 2, ops: 2 }, + search: { files: 1, ops: 1 }, + image: { files: 0, ops: 0 }, + added: 3, + removed: 2, + }) + }) + + test('a program without a description rows its nested ops without one', () => { + const a = run([op(30, 'run_code', { code: 'x' }, {}, { subCalls: [sub(20, 'read', { file_path: '/a.ts' })] })]) + assert.equal(a.entries[0].ops[0].program, undefined) + }) + + test('the sub-call error flag and the parent removal stamp ride the op', () => { + const a = run([op(30, 'run_code', { code: 'x' }, { gone: 40 }, { + subCalls: [ + sub(20, 'read', { file_path: '/a.ts' }, { isError: true }), + sub(22, 'read', { file_path: '/b.ts' }), + ], + })]) + assert.deepEqual(a.entries.map(e => e.path), ['/b.ts', '/a.ts']) + assert.equal(a.entries[1].ops[0].err, true) + // The parent's stamp locates the op; a failed program does not fail its + // earlier successful reads. + const ok = a.entries[0].ops[0] + assert.equal(ok.err, false) + assert.equal(ok.gone, 40) + const parentOnly = run([op(30, 'run_code', { code: 'x' }, { err: true }, { isError: true, subCalls: [sub(20, 'read', { file_path: '/c.ts' })] })]) + assert.equal(parentOnly.entries[0].ops[0].err, false) + }) + + test('the parent seq gates nested ops at the before bound', () => { + const runCode = op(30, 'run_code', { code: 'x' }, {}, { subCalls: [sub(20, 'read', { file_path: '/a.ts' })] }) + assert.equal(run([runCode], 30).entries.length, 0) + assert.equal(run([runCode], 31).entries.length, 1) + }) + + test('nested searches keep the pattern detail only when the path narrows', () => { + const a = run([op(30, 'run_code', null, {}, { subCalls: [ + sub(20, 'grep', { pattern: 'needle', path: '/src' }), + sub(21, 'glob', { pattern: '**/*.md' }), + ] })]) + assert.equal(a.entries[0].ops[0].detail, undefined) // the pattern IS the row path + assert.equal(a.entries[1].ops[0].detail, 'needle') + }) + + test('a nested image read keeps the image form and totals', () => { + const a = run([op(30, 'run_code', null, {}, { subCalls: [sub(20, 'read_image', { file_path: '/ui.png' })] })]) + assert.equal(a.entries[0].form, 'image') + assert.deepEqual(a.totals.image, { files: 1, ops: 1 }) + }) + + test('running and malformed sub-call blocks drop out instead of throwing', () => { + const a = run([op(30, 'run_code', null, {}, { + subCalls: [ + 'nope', + null, + 42, + { call: { name: 'read', argsRaw: '{}' }, seq: 21 }, // no kind — still running + { kind: 'tool-result', seq: 22, call: null }, // unpaired result + { kind: 'tool-result', seq: 23, call: 'x' }, // call not an object + { kind: 'tool-result', seq: 24, call: { name: 'read' } }, // argsRaw missing + { kind: 'tool-result', seq: 25, call: { name: 9, argsRaw: '{}' } }, // name not a string + { kind: 'tool-result', call: { name: 'read', argsRaw: '{}' } }, // seq missing + { kind: 'tool-result', seq: Number.NaN, call: { name: 'read', argsRaw: '{}' } }, // non-finite seq + sub(26, 'read', 'not-json'), // unparseable args + sub(27, 'read', {}), // parsed but target-less + sub(28, 'read', { file_path: '/ok.ts' }), + ], + })]) + assert.deepEqual(a.entries.map(e => e.path), ['/ok.ts']) + assert.equal(a.totals.read.ops, 1) + }) + + test('the depth cap and the cycle guard bound defensive trees', () => { + // A 10-deep chain books only the first eight levels. + let chain = sub(99, 'read', { file_path: '/deep.ts' }) + for (let s = 98; s >= 90; s--) chain = { ...sub(s, 'read', { file_path: '/deep.ts' }), subCalls: [chain] } + const deep = run([op(100, 'run_code', null, {}, { subCalls: [chain] })]) + assert.equal(deep.entries[0].reads, 8) + + // A cyclic join terminates and books each block once. + const x = sub(80, 'read', { file_path: '/cyc.ts' }) + const y = sub(81, 'read', { file_path: '/cyc.ts' }) + y.subCalls = [x] + x.subCalls = [y] + const cyclic = run([op(82, 'run_code', null, {}, { subCalls: [x] })]) + assert.equal(cyclic.entries[0].reads, 2) + }) + + test('a join without subCalls — aged out, or an older client — folds nothing extra', () => { + const a = run([op(30, 'run_code', { code: 'x' })]) + assert.deepEqual(a.entries, []) + }) +}) + +describe('activityOf — search meta attribution', () => { + test('a complete grep meta rows per matched file with hit counts and the pattern detail', () => { + const a = run([ + op(10, 'grep', { pattern: 'needle', path: '/src' }, {}, { + meta: { shape: 'matches', truncated: false, total: 3, files: [ + { path: '/src/a.ts', matches: [{ lineNumber: 1, line: 'needle' }, { lineNumber: 2, line: 'needle!' }] }, + { path: '/src/b.ts', matches: [{ lineNumber: 5, line: 'needle' }] }, + ] }, + }), + // A capped meta names a partial file set: the call falls back to its own target. + op(12, 'grep', { pattern: 'broad' }, {}, { + meta: { shape: 'matches', truncated: true, total: 500, files: [{ path: '/src/a.ts', matches: [{ lineNumber: 1, line: 'x' }] }] }, + }), + // An archived, timeless search still stamps its meta rows with the parent's removal. + op(14, 'grep', { pattern: 'old' }, { gone: 20, time: undefined }, { + meta: { shape: 'paths', truncated: false, total: 1, paths: ['/old.ts'] }, + }), + ]) + assert.deepEqual(a.entries.map(e => e.path), ['/old.ts', 'broad', '/src/a.ts', '/src/b.ts']) + const first = a.entries[2] + assert.equal(first.searches, 1) + assert.equal(first.ops[0].hits, 2) + assert.equal(first.ops[0].detail, 'needle') + assert.equal(a.entries[3].ops[0].hits, 1) + // The archived, timeless meta row carries the node's stamp (and no time). + assert.equal(a.entries[0].ops[0].gone, 20) + assert.equal(a.entries[0].ops[0].time, undefined) + assert.deepEqual(a.totals.search, { files: 4, ops: 4 }) + }) + + test('an include filter joins the detail; glob metas row without hit counts', () => { + const a = run([ + op(10, 'grep', { pattern: 'needle', include: '*.ts' }, {}, { + meta: { shape: 'matches', truncated: false, total: 1, files: [{ path: '/a.ts', matches: [{ lineNumber: 1, line: 'x' }] }] }, + }), + op(12, 'glob', { pattern: '**/*.md' }, {}, { + meta: { shape: 'paths', truncated: false, total: 2, paths: ['/r.md', '/o.md'] }, + }), + ]) + assert.deepEqual(a.entries.map(e => e.path), ['/r.md', '/o.md', '/a.ts']) + assert.equal(a.entries[2].ops[0].detail, 'needle (*.ts)') + assert.equal(a.entries[1].ops[0].hits, undefined) // a listed path matched no lines + assert.equal(a.entries[1].ops[0].detail, '**/*.md') + }) + + test('malformed or empty metas fall back to the pattern row', () => { + const metas: unknown[] = [ + 'junk', + 42, + { truncated: false, paths: ['/a.ts'] }, // shape missing + { shape: 'other', truncated: false, paths: ['/a.ts'] }, // unknown shape + { shape: 'matches', truncated: false }, // files missing + { shape: 'matches', truncated: false, files: 'x' }, // files not a list + { shape: 'matches', truncated: false, files: [] }, // nothing retained + { shape: 'matches', truncated: false, files: [null, 7, { path: '' }, { path: '/a.ts' }, { path: '/a.ts', matches: 'x' }] }, // all groups malformed + { shape: 'paths', truncated: false }, // paths missing + { shape: 'paths', truncated: false, paths: [] }, // nothing listed + ] + for (const [i, meta] of metas.entries()) { + const a = run([op(10, 'grep', { pattern: 'needle', path: '/src' }, {}, { meta })]) + assert.deepEqual(a.entries.map(e => e.path), ['/src'], `meta case ${i} must fall back`) + } + }) + + test('one valid path among malformed siblings still attributes', () => { + const a = run([op(10, 'grep', { pattern: 'needle', path: '/src' }, {}, { + meta: { shape: 'paths', truncated: false, total: 2, paths: ['/a.ts', ''] }, + })]) + assert.deepEqual(a.entries.map(e => e.path), ['/a.ts']) + }) + + test('a truncated flag is required — a missing one falls back', () => { + const a = run([op(10, 'grep', { pattern: 'n', path: '/src' }, {}, { + meta: { shape: 'paths', paths: ['/a.ts'] }, + })]) + assert.deepEqual(a.entries.map(e => e.path), ['/src']) + }) + + test('meta attribution survives an aged-out call head — no args, no pattern detail', () => { + const a = run([op(10, 'grep', null, {}, { + meta: { shape: 'paths', truncated: false, total: 1, paths: ['/only.md'] }, + })]) + assert.deepEqual(a.entries.map(e => e.path), ['/only.md']) + assert.equal(a.entries[0].ops[0].detail, undefined) + }) +})