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:
1 parent
e24dcdae87
commit
cbbbfa5ff7
8 files changed
+630
-62
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user