feat(files): count PTC nested calls, str_replace_editor, and real search targets

File Activity only folded top-level tool-result nodes, so PTC sessions
(run_code as the only tool) showed an empty card and the Minimal preset
went unseen. The fold now covers every surface that executes a file op:

- Nested Code-Mode calls: each settled sub-call in the conversation
  node's subCalls tree folds as one op, attributed to the nested tool
  and located on its parent run_code result (FileOp.parent; the op's
  own seq names the dispatch event, which has no surface row). The op
  log shows the program's description as the "why". Narrowing is fully
  defensive: malformed blocks drop out, and the walk is bounded by a
  depth cap plus a visited-set cycle guard.
- str_replace_editor (Minimal preset): view reads; create,
  str_replace, and insert write with their own line deltas.
- Searches resolve to real files when the result's presentation meta
  carries the complete matched-file list (FileOp.hits shows the count,
  include filters join the detail); a capped or malformed meta falls
  back to the searched path/pattern as before, and meta attribution
  survives an aged-out call head.

Closes #24
This commit is contained in:
bowenliang123 committed 2026-08-27 23:48:32 +08:00
1 parent e24dcdae87
commit cbbbfa5ff7
8 files changed
+630 -62

No files matched your search

+5 -2
View File
@@ -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.
+11 -3
View File
@@ -98,6 +98,11 @@ export function makeFileCard(kit: ViewKit, settings: ContextSettings): ReactNS.C
<span className="lc-fa-op-time">{op.time !== undefined ? fmtTime(op.time) : '—'}</span>
<span className="lc-fa-op-tool">{op.tool}</span>
{op.detail !== undefined ? <span className="lc-fa-op-detail">{op.detail}</span> : null}
{op.hits !== undefined ? <span className="lc-fa-op-detail">{t('files.hits', { n: fmt(op.hits) })}</span> : 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
? <span className="lc-fa-op-detail">{op.program}</span>
: null}
{op.added + op.removed > 0 ? <DeltaPair added={op.added} removed={op.removed} /> : null}
{op.err ? <span className="lc-br-err-dot" title={t('node.failed')} /> : null}
</>
@@ -197,12 +202,15 @@ export function makeFileCard(kit: ViewKit, settings: ContextSettings): ReactNS.C
</button>
{open ? (
<div className="lc-fa-ops">
{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
? (
<button
key={op.seq}
key={key}
type="button"
className="lc-fa-op lc-fa-op-link"
title={t('files.locate')}
@@ -211,7 +219,7 @@ export function makeFileCard(kit: ViewKit, settings: ContextSettings): ReactNS.C
{opLine(op)}
</button>
)
: <div key={op.seq} className="lc-fa-op">{opLine(op)}</div>
: <div key={key} className="lc-fa-op">{opLine(op)}</div>
})}
</div>
) : null}
+255 -55
View File
@@ -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<string, unknown> | 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<string, unknown>): { 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<string, unknown> | 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<string, unknown>
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<object>,
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<string, FileEntry>()
/** 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<object>(), 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) {
+2
View File
@@ -79,6 +79,7 @@ export const DICT_ZH: Record<string, string> = {
'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<string, string> = {
'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',
+9
View File
@@ -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
}
/**
@@ -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<HTMLSelectElement>(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', () => {
+27
View File
@@ -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])
+272 -2
View File
@@ -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, unknown> | string, over: Record<string, unknown> = {}): Record<string, unknown> {
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)
})
})