mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Keep tool results with the right command and reveal all results on mobile (#26968)
* Pair native chat tool results in linear time with provider identity * Preserve orphan tool output and independent disclosure identity * Keep mobile tool detail attached to its command after row changes * Keep named mobile results open across copied history refreshes
This commit is contained in:
@@ -16,6 +16,8 @@ on:
|
||||
# without these a rename lands green and only breaks the next nightly.
|
||||
- 'src/main/sqlite/sync-database.test.ts'
|
||||
- 'src/main/runtime/orchestration/db/row-column-lists.test.ts'
|
||||
- 'src/shared/native-chat-tool-attribution-allocation.test.ts'
|
||||
- 'src/shared/native-chat-tool-pairing.performance.test.ts'
|
||||
- 'src/main/codex-usage/codex-rollout-file-lifetime.test.ts'
|
||||
- 'src/main/codex-usage/scanner-incremental-append.test.ts'
|
||||
- 'src/main/usage/jsonl-file-reader.test.ts'
|
||||
|
||||
@@ -17,8 +17,9 @@ manual review. A warning identifies repeated setup, not proof of visible lag.
|
||||
`pnpm test:perf:contracts` runs the explicit selection in
|
||||
`vitest.performance.config.ts`: SQLite statement reuse and schema parity, relay
|
||||
filesystem concurrency, incremental Codex scans and reader lifetimes, pinned JSONL
|
||||
readers and snapshots, tokenizer rejection, highlighting cache, queued cancellation,
|
||||
terminal backing-memory retention and detector fixtures. Missing
|
||||
readers and snapshots, linear tool-result pairing and attribution allocation,
|
||||
tokenizer rejection, highlighting cache, queued cancellation, terminal backing-memory
|
||||
retention and detector fixtures. Missing
|
||||
listed files fail configuration loading. Tests run serially, without retries,
|
||||
and inherit the full suite's setup and forced-GC support. This makes existing
|
||||
regression coverage easy to run and attribute; it does not create new workload
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
function block(type, callId, reads) {
|
||||
const value =
|
||||
type === 'tool-call'
|
||||
? { type, name: 'Bash', input: { command: `echo ${callId}` } }
|
||||
: { type, output: `stdout ${callId}\n${'x'.repeat(1024)}` }
|
||||
if (reads) {
|
||||
Object.defineProperty(value, 'callId', {
|
||||
enumerable: true,
|
||||
get() {
|
||||
reads.count += 1
|
||||
return callId
|
||||
}
|
||||
})
|
||||
} else {
|
||||
value.callId = callId
|
||||
}
|
||||
return Object.freeze(value)
|
||||
}
|
||||
|
||||
export function createFoldWorkloads(count, reads) {
|
||||
const call = (id) => block('tool-call', id, reads)
|
||||
const result = (id) => block('tool-result', id, reads)
|
||||
const named = Array.from({ length: count }, (_, index) => call(`call-${index}`))
|
||||
const answers = Array.from({ length: count }, (_, index) => result(`call-${index}`))
|
||||
const orphans = Array.from({ length: count }, (_, index) => result(`unknown-${index}`))
|
||||
const rows = (entries) =>
|
||||
Object.freeze(
|
||||
entries.map(([role, blocks], index) =>
|
||||
Object.freeze({
|
||||
id: `row-${index}`,
|
||||
role,
|
||||
blocks: Object.freeze(blocks),
|
||||
timestamp: index,
|
||||
source: 'transcript',
|
||||
journalPosition: Object.freeze({ sequence: index + 1, index: 0 })
|
||||
})
|
||||
)
|
||||
)
|
||||
return [
|
||||
{
|
||||
name: 'immediate-pairs',
|
||||
messages: rows([
|
||||
['assistant', [{ type: 'text', text: 'Running commands' }]],
|
||||
...named.flatMap((entry, index) => [
|
||||
['tool', [entry]],
|
||||
['tool', [answers[index]]]
|
||||
])
|
||||
])
|
||||
},
|
||||
{
|
||||
name: 'reverse-results',
|
||||
messages: rows([
|
||||
['assistant', named],
|
||||
...answers.toReversed().map((entry) => ['tool', [entry]])
|
||||
])
|
||||
},
|
||||
{
|
||||
name: 'silent-call-backlog',
|
||||
messages: rows([
|
||||
['assistant', named],
|
||||
...named.flatMap((_, index) => [
|
||||
['tool', [call(`later-${index}`)]],
|
||||
['tool', [result(`later-${index}`)]]
|
||||
])
|
||||
])
|
||||
},
|
||||
{ name: 'named-orphans-batch', messages: rows([['tool', orphans]]) },
|
||||
{
|
||||
name: 'named-orphans-singletons',
|
||||
messages: rows([['assistant', named], ...orphans.map((entry) => ['tool', [entry]])])
|
||||
},
|
||||
{
|
||||
name: 'leading-results-before-calls',
|
||||
messages: rows([
|
||||
['user', [{ type: 'text', text: 'Continue' }]],
|
||||
...answers.map((entry) => ['tool', [entry]]),
|
||||
['assistant', named]
|
||||
])
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export function summarizeFoldOutput(messages) {
|
||||
const blocks = messages.flatMap((message) => message.blocks)
|
||||
return {
|
||||
rows: messages.length,
|
||||
calls: blocks.filter((entry) => entry.type === 'tool-call').length,
|
||||
results: blocks.filter((entry) => entry.type === 'tool-result').length,
|
||||
standaloneRows: messages.filter((entry) => entry.unpairedToolResults === true).length
|
||||
}
|
||||
}
|
||||
|
||||
export function assertFoldConservesEvidence(input, output) {
|
||||
const before = input.flatMap((message) => message.blocks)
|
||||
const after = output.flatMap((message) => message.blocks)
|
||||
for (const type of ['tool-call', 'tool-result']) {
|
||||
assert.deepEqual(
|
||||
new Set(after.filter((entry) => entry.type === type)),
|
||||
new Set(before.filter((entry) => entry.type === type))
|
||||
)
|
||||
}
|
||||
assert.equal(new Set(output.map((message) => message.id)).size, output.length)
|
||||
}
|
||||
|
||||
export function countFoldOperations(run, messages, reads) {
|
||||
const known = new Set(messages.flatMap((message) => message.blocks))
|
||||
const push = Array.prototype.push
|
||||
let pairWrappers = 0
|
||||
const startReads = reads.count
|
||||
Array.prototype.push = function (...items) {
|
||||
for (const item of items) {
|
||||
if (
|
||||
item !== null &&
|
||||
typeof item === 'object' &&
|
||||
(('call' in item && known.has(item.call)) || ('result' in item && known.has(item.result)))
|
||||
) {
|
||||
pairWrappers += 1
|
||||
}
|
||||
}
|
||||
return push.apply(this, items)
|
||||
}
|
||||
let output
|
||||
try {
|
||||
output = run(messages)
|
||||
} finally {
|
||||
Array.prototype.push = push
|
||||
}
|
||||
return { pairWrappers, callIdReads: reads.count - startReads, output }
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { build } from 'esbuild'
|
||||
import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs'
|
||||
import { summarizeBenchmarkSamples } from './benchmark-sample-summary.mjs'
|
||||
import { runProcessSync } from '@orca/process-host'
|
||||
import { describeProcessFailure } from './process-failure-message.mjs'
|
||||
import {
|
||||
assertFoldConservesEvidence,
|
||||
countFoldOperations,
|
||||
createFoldWorkloads,
|
||||
summarizeFoldOutput
|
||||
} from './native-chat-fold-benchmark-workloads.mjs'
|
||||
|
||||
// ORCA_BACKGROUND_LAUNCH=1 node --expose-gc config/scripts/native-chat-fold-benchmark.mjs > full-fold.json
|
||||
const root = path.resolve(import.meta.dirname, '../..')
|
||||
const refs = {
|
||||
original: '51e7181850b022bae2d41091fe1229accef4bd50',
|
||||
published: 'b1e44269d655608447dc535f0a4d7d05c37c80b7',
|
||||
current: null
|
||||
}
|
||||
const entry = `
|
||||
export { foldToolMessages as fold } from './src/shared/native-chat-tool-fold'
|
||||
import { createNativeChatMessageListProjection } from './src/renderer/src/components/native-chat/native-chat-message-list-projection'
|
||||
import { deriveNativeChatRowContent } from './src/shared/native-chat-row-content'
|
||||
import { pairNativeChatToolResults } from './src/shared/native-chat-tool-pairing'
|
||||
export function createPipeline() {
|
||||
const project = createNativeChatMessageListProjection()
|
||||
return (messages) => {
|
||||
const projection = project(messages)
|
||||
const rows = [...projection.conversation, ...Array.from(projection.subagentRows.values()).flat().map(row => row.message)]
|
||||
const paired = rows.flatMap(row => {
|
||||
const tools = deriveNativeChatRowContent(row.blocks).tools
|
||||
return tools.length > 0 ? [pairNativeChatToolResults(tools)] : []
|
||||
})
|
||||
return { rows, projection, paired }
|
||||
}
|
||||
}
|
||||
`
|
||||
const hash = (source) => createHash('sha256').update(source).digest('hex')
|
||||
|
||||
function measureOutputMemory(run, messages, check) {
|
||||
global.gc()
|
||||
const before = process.memoryUsage().heapUsed
|
||||
const output = run(messages)
|
||||
const afterRun = process.memoryUsage().heapUsed
|
||||
global.gc()
|
||||
const retained = process.memoryUsage().heapUsed
|
||||
check(output)
|
||||
return {
|
||||
transientHeapDeltaBytes: afterRun - before,
|
||||
retainedHeapDeltaBytes: retained - before
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv.includes('--memory-control')) {
|
||||
const samples = Array.from({ length: 5 }, () =>
|
||||
measureOutputMemory(
|
||||
() => Array.from({ length: 1_000_000 }, () => 0),
|
||||
undefined,
|
||||
(output) => assert.equal(output.length, 1_000_000)
|
||||
)
|
||||
)
|
||||
assert.ok(samples.every((sample) => sample.retainedHeapDeltaBytes > 2_000_000))
|
||||
console.log(JSON.stringify({ arraySlots: 1_000_000, samples }, null, 2))
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
async function load(ref) {
|
||||
const modules = new Map()
|
||||
const built = await build({
|
||||
stdin: {
|
||||
contents: entry,
|
||||
resolveDir: root,
|
||||
sourcefile: 'fold-benchmark-entry.ts',
|
||||
loader: 'ts'
|
||||
},
|
||||
bundle: true,
|
||||
write: false,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
plugins: [
|
||||
{
|
||||
name: 'exact-chat-source-snapshot',
|
||||
setup(builder) {
|
||||
builder.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, ({ path: file }) => {
|
||||
const relative = path.relative(root, file).split(path.sep).join('/')
|
||||
if (!relative.startsWith('src/')) {
|
||||
return undefined
|
||||
}
|
||||
let source
|
||||
if (ref) {
|
||||
const read = runProcessSync({
|
||||
program: 'git',
|
||||
args: ['show', `${ref}:${relative}`],
|
||||
cwd: root,
|
||||
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' },
|
||||
timeoutMs: 30_000,
|
||||
maxOutputBytes: 2 * 1024 * 1024
|
||||
})
|
||||
if (read.code !== 0 || read.timedOut || read.outputTruncated) {
|
||||
throw new Error(describeProcessFailure(read))
|
||||
}
|
||||
source = read.stdout
|
||||
} else {
|
||||
source = readFileSync(file, 'utf8')
|
||||
}
|
||||
modules.set(relative, hash(source))
|
||||
return { contents: source, loader: file.endsWith('tsx') ? 'tsx' : 'ts' }
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
const code = built.outputFiles[0].text
|
||||
return {
|
||||
...(await import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`)),
|
||||
bundleSha256: hash(code),
|
||||
moduleSha256: Object.fromEntries(
|
||||
[...modules].sort(([left], [right]) => left.localeCompare(right))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!global.gc) {
|
||||
throw new Error('Memory measurements require node --expose-gc')
|
||||
}
|
||||
const loaded = Object.fromEntries(
|
||||
await Promise.all(Object.entries(refs).map(async ([arm, ref]) => [arm, await load(ref)]))
|
||||
)
|
||||
const bundles = Object.fromEntries(
|
||||
Object.entries(loaded).map(([arm, source]) => [
|
||||
arm,
|
||||
{
|
||||
bundleSha256: source.bundleSha256,
|
||||
moduleSha256: source.moduleSha256
|
||||
}
|
||||
])
|
||||
)
|
||||
if (process.argv.includes('--prepare-only')) {
|
||||
console.log(JSON.stringify({ refs, bundles }, null, 2))
|
||||
process.exit(0)
|
||||
}
|
||||
const stages = ['fold', 'projection-and-pairing', 'refold']
|
||||
const results = []
|
||||
|
||||
function operation(arm, stage, messages) {
|
||||
if (stage === 'projection-and-pairing') {
|
||||
return loaded[arm].createPipeline()
|
||||
}
|
||||
if (stage === 'refold') {
|
||||
const folded = loaded[arm].fold(messages)
|
||||
return () => loaded[arm].fold(folded)
|
||||
}
|
||||
return loaded[arm].fold
|
||||
}
|
||||
|
||||
const rowsOf = (stage, output) => (stage === 'projection-and-pairing' ? output.rows : output)
|
||||
for (const count of [32, 512, 8192]) {
|
||||
for (const workload of createFoldWorkloads(count)) {
|
||||
assert.deepEqual(
|
||||
loaded.current.fold(workload.messages),
|
||||
loaded.published.fold(workload.messages)
|
||||
)
|
||||
const summaries = Object.fromEntries(
|
||||
Object.keys(loaded).map((arm) => {
|
||||
const folded = loaded[arm].fold(workload.messages)
|
||||
if (arm !== 'original') {
|
||||
assertFoldConservesEvidence(workload.messages, folded)
|
||||
assert.deepEqual(loaded[arm].fold(folded), folded)
|
||||
}
|
||||
return [arm, summarizeFoldOutput(folded)]
|
||||
})
|
||||
)
|
||||
for (const stage of stages) {
|
||||
const operations = Object.fromEntries(
|
||||
Object.keys(loaded).map((arm) => [arm, operation(arm, stage, workload.messages)])
|
||||
)
|
||||
const expected = Object.fromEntries(
|
||||
Object.keys(loaded).map((arm) => [arm, operations[arm](workload.messages)])
|
||||
)
|
||||
for (let warmup = 0; warmup < 3; warmup += 1) {
|
||||
for (const arm of Object.keys(loaded)) {
|
||||
operations[arm](workload.messages)
|
||||
}
|
||||
}
|
||||
const iterations = count === 32 ? 16 : count === 512 ? 2 : 1
|
||||
const comparisons = []
|
||||
for (const baseline of ['original', 'published']) {
|
||||
const samples = { [baseline]: [], current: [] }
|
||||
for (const pair of buildCounterbalancedSchedule(6, baseline, 'current')) {
|
||||
for (const arm of pair) {
|
||||
let output
|
||||
const started = performance.now()
|
||||
for (let repeat = 0; repeat < iterations; repeat += 1) {
|
||||
output = operations[arm](workload.messages)
|
||||
}
|
||||
samples[arm].push((performance.now() - started) / iterations)
|
||||
assert.deepEqual(rowsOf(stage, output), rowsOf(stage, expected[arm]))
|
||||
}
|
||||
}
|
||||
comparisons.push({
|
||||
baseline,
|
||||
iterationsPerSample: iterations,
|
||||
samplesMs: samples,
|
||||
summary: Object.fromEntries(
|
||||
Object.entries(samples).map(([arm, values]) => [arm, summarizeBenchmarkSamples(values)])
|
||||
)
|
||||
})
|
||||
}
|
||||
const operationsCount = {}
|
||||
const memory = {}
|
||||
for (const arm of Object.keys(loaded)) {
|
||||
const reads = { count: 0 }
|
||||
const counted = createFoldWorkloads(count, reads).find(
|
||||
(entry) => entry.name === workload.name
|
||||
)
|
||||
const countedRun = operation(arm, stage, counted.messages)
|
||||
const measured = countFoldOperations(countedRun, counted.messages, reads)
|
||||
operationsCount[arm] = {
|
||||
pairWrappers: measured.pairWrappers,
|
||||
callIdReads: measured.callIdReads
|
||||
}
|
||||
const samples = []
|
||||
for (let sample = 0; sample < 5; sample += 1) {
|
||||
const run = operation(arm, stage, workload.messages)
|
||||
samples.push(
|
||||
measureOutputMemory(run, workload.messages, (output) =>
|
||||
assert.deepEqual(rowsOf(stage, output), rowsOf(stage, expected[arm]))
|
||||
)
|
||||
)
|
||||
}
|
||||
memory[arm] = samples
|
||||
}
|
||||
results.push({
|
||||
count,
|
||||
inputRows: workload.messages.length,
|
||||
workload: workload.name,
|
||||
stage,
|
||||
summaries,
|
||||
operationsCount,
|
||||
memorySamples: memory,
|
||||
comparisons
|
||||
})
|
||||
}
|
||||
process.stderr.write(`Measured ${workload.name} at ${count}\n`)
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
node: process.version,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
refs,
|
||||
bundles,
|
||||
scope:
|
||||
'Production full tool fold; settled desktop transcript projection and all-row render-data derivation/pairing; repeated fold. The consumer stage models every tool run opened, while real desktop windowing normally mounts only viewport rows. No React commits, DOM layout, Electron frames, mobile runtime or network latency.',
|
||||
memoryMethod:
|
||||
'Each sample runs in an isolated function so its output dies before the next before-GC. Projection memory uses a fresh projector/cache, while timing reuses a settled projector. Forced-GC heapUsed before/after with output and current run cache held alive; transient deltas are allocation-pressure samples, not peak heap or total allocated bytes. Shared source inputs are excluded. Negative retained deltas are measurement noise, not savings or per-update growth.',
|
||||
correctness:
|
||||
'Published/current fold outputs deeply equal for every workload; both conserve all call/result block references, have unique row IDs, and remain deeply identical on refold. Timing samples match each version own output outside the timed interval.',
|
||||
originalCorrectness:
|
||||
'Original may drop named orphans or preserve them under unrelated calls; output counts report differences. Timing comparisons do not imply equivalent visible output.',
|
||||
results
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,126 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { build } from 'esbuild'
|
||||
import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs'
|
||||
import { summarizeBenchmarkSamples } from './benchmark-sample-summary.mjs'
|
||||
|
||||
// git show <baseline>:src/shared/native-chat-tool-fold.ts | node config/scripts/native-chat-tool-pairing-benchmark.mjs
|
||||
const root = path.resolve(import.meta.dirname, '../..')
|
||||
async function load(source) {
|
||||
const built = await build({
|
||||
stdin: {
|
||||
contents: source,
|
||||
sourcefile: 'native-chat-tool-fold.ts',
|
||||
resolveDir: path.join(root, 'src/shared'),
|
||||
loader: 'ts'
|
||||
},
|
||||
bundle: true,
|
||||
write: false,
|
||||
platform: 'node',
|
||||
format: 'esm'
|
||||
})
|
||||
const code = built.outputFiles[0].text
|
||||
return {
|
||||
pair: (await import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`))
|
||||
.pairToolBlocks,
|
||||
bundleSha256: createHash('sha256').update(code).digest('hex')
|
||||
}
|
||||
}
|
||||
|
||||
const loaded = {
|
||||
before: await load(readFileSync(0, 'utf8')),
|
||||
after: await load(readFileSync(path.join(root, 'src/shared/native-chat-tool-fold.ts'), 'utf8'))
|
||||
}
|
||||
const call = (callId) => ({ type: 'tool-call', name: 'Bash', input: {}, callId })
|
||||
const result = (callId) => ({ type: 'tool-result', output: 'done', callId })
|
||||
const results = []
|
||||
for (const count of [1, 8, 32, 128, 512, 2048, 8192]) {
|
||||
const named = Array.from({ length: count }, (_, index) => call(`call-${index}`))
|
||||
const answers = named.map((block) => result(block.callId))
|
||||
const positional = Array.from({ length: count }, () => call(undefined))
|
||||
const repeated = Array.from({ length: count }, () => call('same'))
|
||||
const workloads = [
|
||||
{ name: 'immediate-named', blocks: named.flatMap((block, index) => [block, answers[index]]) },
|
||||
{ name: 'reverse-named', blocks: [...named, ...answers.toReversed()] },
|
||||
{
|
||||
name: 'fifo-positional',
|
||||
blocks: [...positional, ...positional.map(() => result(undefined))]
|
||||
},
|
||||
{ name: 'duplicate-id', blocks: [...repeated, ...repeated.map(() => result('same'))] },
|
||||
{
|
||||
name: 'silent-call-backlog',
|
||||
blocks: [
|
||||
...named,
|
||||
...named.flatMap((_, index) => [call(`later-${index}`), result(`later-${index}`)])
|
||||
]
|
||||
},
|
||||
{ name: 'unmatched-named', blocks: [...named, ...named.map(() => result('missing'))] },
|
||||
{ name: 'limited-reverse', blocks: [...named, ...answers.toReversed()], limit: 8 }
|
||||
]
|
||||
for (const workload of workloads) {
|
||||
for (const block of workload.blocks) {
|
||||
Object.freeze(block)
|
||||
}
|
||||
Object.freeze(workload.blocks)
|
||||
const expected = loaded.before.pair(workload.blocks, workload.limit)
|
||||
assert.deepEqual(loaded.after.pair(workload.blocks, workload.limit), expected)
|
||||
const iterations = Math.max(4, Math.floor(16_384 / count))
|
||||
for (let warmup = 0; warmup < 16; warmup += 1) {
|
||||
loaded.before.pair(workload.blocks, workload.limit)
|
||||
loaded.after.pair(workload.blocks, workload.limit)
|
||||
}
|
||||
/** @type {{ before: number[], after: number[] }} */
|
||||
const samples = { before: [], after: [] }
|
||||
for (const pair of buildCounterbalancedSchedule(8, 'before', 'after')) {
|
||||
for (const arm of pair) {
|
||||
let actual
|
||||
const started = performance.now()
|
||||
for (let repeat = 0; repeat < iterations; repeat += 1) {
|
||||
actual = loaded[arm].pair(workload.blocks, workload.limit)
|
||||
}
|
||||
samples[arm].push(performance.now() - started)
|
||||
assert.deepEqual(actual, expected)
|
||||
actual.forEach((paired, index) => {
|
||||
assert.equal(paired.call, expected[index].call)
|
||||
assert.equal(paired.result, expected[index].result)
|
||||
})
|
||||
}
|
||||
}
|
||||
results.push({
|
||||
count,
|
||||
blocks: workload.blocks.length,
|
||||
workload: workload.name,
|
||||
limit: workload.limit ?? 'unlimited',
|
||||
iterations,
|
||||
meanMicrosecondsPerPairing: Object.fromEntries(
|
||||
Object.entries(samples).map(([arm, values]) => [
|
||||
arm,
|
||||
(values.reduce((sum, ms) => sum + ms, 0) * 1000) / values.length / iterations
|
||||
])
|
||||
),
|
||||
before: summarizeBenchmarkSamples(samples.before),
|
||||
after: summarizeBenchmarkSamples(samples.after),
|
||||
samplesMs: samples
|
||||
})
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
node: process.version,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
bundleSha256: Object.fromEntries(
|
||||
Object.entries(loaded).map(([arm, value]) => [arm, value.bundleSha256])
|
||||
),
|
||||
scope:
|
||||
'Production tool pairing only; excludes React, DOM, transcript projection and transport.',
|
||||
results
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
@@ -7,6 +7,8 @@ import { nodeRuntimePool } from './scripts/vitest-node-runtime-pool'
|
||||
const contracts = [
|
||||
'src/main/sqlite/sync-database.test.ts',
|
||||
'src/main/runtime/orchestration/db/row-column-lists.test.ts',
|
||||
'src/shared/native-chat-tool-attribution-allocation.test.ts',
|
||||
'src/shared/native-chat-tool-pairing.performance.test.ts',
|
||||
'src/main/codex-usage/codex-rollout-file-lifetime.test.ts',
|
||||
'src/main/codex-usage/scanner-incremental-append.test.ts',
|
||||
'src/main/usage/jsonl-file-reader.test.ts',
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
# Native chat tool pairing
|
||||
|
||||
Latest follow-up: [mobile row identity](./mobile-row-identity.md) keeps an opened detail with its command after earlier rows disappear or move.
|
||||
|
||||
Long tool runs repeatedly scanned all unanswered calls to find each named result.
|
||||
Calls that emitted no output stayed at the front of that scan. Positional results
|
||||
also shifted the remaining pending-call array after every answer.
|
||||
|
||||
The shared matcher now uses a linked FIFO and builds an ID index only when a
|
||||
named answer needs to match beyond its head. That index stays valid until the
|
||||
pending queue empties. Named and unnamed answers remove a pending call without
|
||||
array movement; each call enters the index at most once. Duplicate IDs retain
|
||||
FIFO order. Existing pair limits and output references are preserved.
|
||||
The implementation remains shared by desktop, mobile, and host readers.
|
||||
|
||||
The rendered check exposed related correctness defects: Claude and Codex
|
||||
transcript decoders discarded result IDs, and folding used only call counts to
|
||||
assign results. Decoders now retain the optional provider ID; folding uses
|
||||
the canonical matcher and occurrence indices, so an unrelated result cannot
|
||||
consume another call's answer. Unmatched named results stay visible separately
|
||||
and cannot acquire a guessed owner during later folds. Results without IDs
|
||||
remain positional; already imported calls missing IDs cannot own named results.
|
||||
|
||||
Legacy journal import also preserves IDs on singleton calls and keeps singleton
|
||||
results as result messages. The focused importer/projector regression test
|
||||
covers reverse batched results, mixed record shapes, duplicate IDs, anonymous
|
||||
FIFO, and old persisted calls whose IDs were already lost. That integration
|
||||
coverage is separate from the rendered transcript fixture below.
|
||||
|
||||
## Reproduction and measurements
|
||||
|
||||
Baseline: `51e7181850b`. At 2,048 calls, the regression tests measured 2,098,176
|
||||
ID reads for reverse completions, 4,194,304 reads for silent-call backlogs and
|
||||
unmatched results, and 2,096,128 moved array slots for FIFO completions. The
|
||||
fixed implementation passes linear operation budgets at 64, 512, and 2,048
|
||||
calls. Semantic differential tests preserve call/result references across
|
||||
mixed IDs, interleavings, immutable inputs, and unusual limits.
|
||||
|
||||
Run the production benchmark with the original module on stdin:
|
||||
|
||||
```sh
|
||||
git show 51e7181850b:src/shared/native-chat-tool-fold.ts |
|
||||
ORCA_BACKGROUND_LAUNCH=1 node config/scripts/native-chat-tool-pairing-benchmark.mjs
|
||||
```
|
||||
|
||||
[benchmark.json](./benchmark.json) records eight counterbalanced sample pairs
|
||||
per workload, source bundle hashes, raw timings, and correctness checks. It
|
||||
includes immediate pairs, reverse completion, positional FIFO, duplicate IDs,
|
||||
silent-call backlogs, unmatched results, and finite limits at seven sizes.
|
||||
At 2,048 calls, reverse completion improved from 7.46 ms to 0.163 ms, a
|
||||
silent-call backlog from 13.02 ms to 0.244 ms, and immediate ordered pairs from
|
||||
92.0 microseconds to 47.3 microseconds. Single-call backlog/missing workloads
|
||||
remain within 11 ns of the baseline. These timings cover the shared pairing
|
||||
function, not React, DOM, transport, or the full transcript projection. These
|
||||
measurements identify the earlier matcher revision in the artifact. Later orphan
|
||||
projection, journal import, and discarded-wrapper allocation changes are outside
|
||||
this benchmark's scope.
|
||||
|
||||
The deterministic contract runs in `pnpm test:perf:contracts`. Broader coverage
|
||||
uses the shared/native-chat and renderer/native-chat suites, provider transcript
|
||||
reader suites, and `tests/e2e/native-chat-tool-pairing.spec.ts` for hidden
|
||||
Electron rendering and output ownership.
|
||||
|
||||
## Full folding, allocation, and mobile disclosure
|
||||
|
||||
The later allocation fix reuses the same matcher for folding attribution without
|
||||
collecting pair wrappers that folding immediately discarded. Consumer pairing
|
||||
still returns fresh wrappers with the original call/result references. No cache,
|
||||
parallel matcher, or ownership fallback was added.
|
||||
|
||||
Run the three-version production-module benchmark and its retained-allocation
|
||||
control with:
|
||||
|
||||
```sh
|
||||
ORCA_BACKGROUND_LAUNCH=1 node --expose-gc config/scripts/native-chat-fold-benchmark.mjs --memory-control
|
||||
ORCA_BACKGROUND_LAUNCH=1 node --expose-gc config/scripts/native-chat-fold-benchmark.mjs
|
||||
```
|
||||
|
||||
[full-fold-benchmark.json](./full-fold-benchmark.json) compares original
|
||||
`51e7181850b`, published `b1e44269d655`, and the measured worktree. It records
|
||||
54 cases across three sizes, six workloads, and full folding, settled desktop
|
||||
projection plus all-row render-data pairing, and repeated folding. Each
|
||||
comparison uses six counterbalanced sample pairs; the table uses raw-sample
|
||||
medians because the artifact's rounded summary loses microsecond precision.
|
||||
The two comparisons have separate current samples.
|
||||
|
||||
| All-row projection and pairing | Original → current | Published → current |
|
||||
| -------------------------------------------------- | -------------------------------- | ------------------- |
|
||||
| 8,192 reverse completions | 320.4727 → 3.7358 ms, about 86× | 3.5954 → 3.6126 ms |
|
||||
| 8,192 silent calls plus 8,192 later answered calls | 558.9641 → 5.5075 ms, about 101× | 5.5551 → 5.2629 ms |
|
||||
| 8,192 immediate ordered pairs | 2.4124 → 2.5636 ms | 2.6577 → 2.3606 ms |
|
||||
|
||||
These are large adversarial-run gains, not a general frame-time improvement.
|
||||
At 32 immediate pairs, the original/current pipeline medians were 11.38 and
|
||||
16.74 microseconds: roughly 5.4 microseconds of extra work. The separate
|
||||
published/current comparison measured 15.60 and 13.10 microseconds. Original
|
||||
folding and refolding alone were faster than the ownership-preserving fold:
|
||||
for the large silent backlog, folding rose from 1.1191 to 2.6317 ms and refolding
|
||||
from 0.3171 to 1.4090 ms. The expensive original consumer scan dominates the
|
||||
large named-result cases. Full folding still visits the collection on updates;
|
||||
settled row reuse does not make the fold incremental.
|
||||
|
||||
Against published C, the large fold medians fell approximately 3–9%; whole
|
||||
pipeline changes varied, including the small reverse-case increase above.
|
||||
The deterministic allocation gain is clearer: large silent-backlog folding
|
||||
collects zero discarded pair wrappers instead of 16,384, while consumer pairing
|
||||
retains the required 16,384. Its median pre-GC heap delta fell from 8.23 to
|
||||
6.84 MB; retained output stayed near 0.23 MB. Named orphan conservation,
|
||||
original block references, unique row IDs, and repeated-fold identity are
|
||||
checked for published/current outputs, which are deeply equal for every
|
||||
workload. Original output counts expose historical drops or guessed ownership;
|
||||
those cases do not provide equivalent visible-output timing comparisons.
|
||||
|
||||
Preserving separately sourced orphan evidence still costs rows and memory.
|
||||
With 8,192 singleton orphan outputs, current folding retains roughly 2.61 MB
|
||||
versus the original's 0.15 MB; fresh all-open projection and pairing retain
|
||||
about 9.23 MB versus 0.33 MB. Published/current retention is essentially the
|
||||
same. The original grouped these outputs with unrelated calls. Desktop
|
||||
windowing normally mounts only viewport rows, while this consumer benchmark
|
||||
processes every run as opened. It excludes React commits, DOM layout, mobile
|
||||
runtime, and transport latency.
|
||||
|
||||
[full-fold-memory-control.json](./full-fold-memory-control.json) confirms that
|
||||
the sampling method retains a live million-slot array at about 8 MB. Each
|
||||
memory sample releases its preceding output, holds the current output through
|
||||
forced GC, and excludes shared source inputs. Projection memory uses a fresh
|
||||
projector/cache; timing reuses a settled projector. Pre-GC deltas indicate
|
||||
allocation pressure, not peak memory or total allocated bytes. Negative
|
||||
retained deltas are noise, not savings or per-update growth.
|
||||
[full-fold-benchmark-receipt.json](./full-fold-benchmark-receipt.json) records
|
||||
commands, source/tool hashes, and the post-main-merge graph review. Only three
|
||||
account-startup enum/map modules changed after measurement; those branches are
|
||||
absent from the benchmark fixtures, so no new timing claim is made for them.
|
||||
|
||||
Mobile previously exposed only six tool rows with no way to reach later
|
||||
outputs. The existing disclosure now reveals six more rows per request and can
|
||||
collapse back to six. A closed run performs no pairing; an opened run collects
|
||||
only the requested prefix plus one sentinel to decide whether Show more is
|
||||
available. This limits wrapper and mounted-row retention, while matching may
|
||||
still scan a pending suffix to find the requested owners. Long runs require
|
||||
multiple reveals, and expanding them mounts more rows at the user's request.
|
||||
Result-only summaries use the existing Result label without inventing a call
|
||||
count. Local mobile stream and SSH-authoritative handler tests cover late named
|
||||
completion, orphan reachability, cursor paging, and older-host no-cursor
|
||||
replacement. They establish ownership and paging behavior, not network latency.
|
||||
|
||||
## Rendered verification
|
||||
|
||||
The rebuilt Electron app passed the dedicated E2E test through the real Claude
|
||||
JSONL reader: three reverse completions behind 32 silent calls, an unrelated
|
||||
result, and successful/failed task updates. The [inspected screenshot](https://github.com/user-attachments/assets/79f41dc6-8ec8-4f12-8e89-e4d7ec4b5daa)
|
||||
shows all three outputs attached to their commands, the accepted pending task,
|
||||
and the unrelated output under its own Result summary and row.
|
||||
[rendered-validation.json](./rendered-validation.json) records the
|
||||
commands, test source hash, exact worktree identity, hidden/unfocused native
|
||||
window assertions, and process cleanup. The fixture uses synthetic Claude
|
||||
JSONL through the production reader and real UI clicks. This is desktop
|
||||
correctness evidence; it does not exercise an actual provider CLI or adoption
|
||||
launch, measure mobile rendering, or measure SSH transport latency.
|
||||
|
||||
The mobile component check uses the actual React Native Web primitives and
|
||||
production message/fold modules under the shipped browser build options and
|
||||
shell content-security policy. The [before capture](https://github.com/user-attachments/assets/6b6b30f1-58f0-4bc7-8149-6f2169a368b2)
|
||||
shows six orphan results with no way to reveal later rows. The [after capture](https://github.com/user-attachments/assets/81241691-0dfe-4a88-b3d1-bdf7e8d02adc)
|
||||
shows all eight Result rows and the seventh output's full body. Real clicks on
|
||||
Show more and Show less reveal and hide those rows. Further captures show
|
||||
[reversed named outputs and a separate orphan](https://github.com/user-attachments/assets/26e0559f-3f0c-482e-9ef2-f32903466c82)
|
||||
and [a named completion after seventeen silent calls](https://github.com/user-attachments/assets/69b1e49b-0627-40b3-977d-052b885beef6).
|
||||
[mobile-rendered-validation.json](./mobile-rendered-validation.json) records
|
||||
row-specific full-body assertions, source and bundle hashes, the one-component
|
||||
historical overlay, and exact headless-browser cleanup. This proves mobile web
|
||||
component disclosure and ownership; it does not exercise a native phone, the
|
||||
full session route, provider decoding, adoption, or transport.
|
||||
|
||||
The [before screenshot](https://github.com/user-attachments/assets/b9f84d6e-a786-42d5-b895-8a4319f972cd) shows the unrelated result and named outputs attached to silent calls, and the failed task replacing the accepted task. The [before validation record](./before-validation.json) identifies the three modules restored from `51e7181850b`: `native-chat-tool-fold.ts`, `transcript-record-blocks.ts`, and `transcript-line-decoders-codex.ts`. Other app code, including the hidden launch policy, stayed current. This proves the original folding/decoder defect, rather than a full historical app build. Each of the three overlaid source files was restored byte for byte and the owned app process exited. Images are uploaded attachments, not committed binaries.
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"recordedAt": "2026-10-09T19:59:42.766Z",
|
||||
"appPath": "/Users/nwparker/orca/workspaces/orca/perf-2",
|
||||
"baselineCommit": "51e7181850b022bae2d41091fe1229accef4bd50",
|
||||
"headAtStart": "51e7181850b022bae2d41091fe1229accef4bd50",
|
||||
"overlayFiles": [
|
||||
"src/shared/native-chat-tool-fold.ts",
|
||||
"src/main/native-chat/transcript-record-blocks.ts",
|
||||
"src/main/native-chat/transcript-line-decoders-codex.ts"
|
||||
],
|
||||
"currentSourceSha256": {
|
||||
"src/shared/native-chat-tool-fold.ts": "3d75ee82c5c09bc796caef4d5127d55b3d291c03d3090f10a204622188aaba4a",
|
||||
"src/main/native-chat/transcript-record-blocks.ts": "110990b4d8ebd6f28ea8702de92568fff5af398bffa5dafdb81d93b65f7da594",
|
||||
"src/main/native-chat/transcript-line-decoders-codex.ts": "cbd4133da04988a9b86015349411a679a5b1b513738a7c10bf165869c49e05e4"
|
||||
},
|
||||
"baselineSourceSha256": {
|
||||
"src/shared/native-chat-tool-fold.ts": "9390ea63c8948a705da09e25366e72dc0087a331aa2a04d6571c61478d1d2543",
|
||||
"src/main/native-chat/transcript-record-blocks.ts": "9b82a474dabc49b82281a53fd4132055ccd4c369cb28c45876d618870e41d4f8",
|
||||
"src/main/native-chat/transcript-line-decoders-codex.ts": "511b7083a211cdab0c68df2495469724957c278b159a3fc5123e00ea002a074a"
|
||||
},
|
||||
"specSha256": "708ebb635900d9a5ef0fd15a05a8ec08169afd998751eb34a93b22241dcaecb6",
|
||||
"commands": {
|
||||
"ensureElectron": "ORCA_BACKGROUND_LAUNCH=1 pnpm run ensure:electron-runtime",
|
||||
"build": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec electron-vite build --mode e2e",
|
||||
"test": "ORCA_BACKGROUND_LAUNCH=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/native-chat-tool-pairing-baseline-proof.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --retries=0 --output=/tmp/orca-perf-2-pr-proof/baseline-test-results"
|
||||
},
|
||||
"scope": "Only three chat implementation files overlaid; current hidden launch policy, real Claude JSONL fixture, real UI clicks, no DOM mutation",
|
||||
"afterReference": {
|
||||
"screenshot": "after.png",
|
||||
"sha256": "0caedb0986fbd15c7cd8b8d6f8d8064e5e4043bfd01568050c4b14ccfd0e8b1c",
|
||||
"validation": "after-provenance.json"
|
||||
},
|
||||
"ensure-electron": {
|
||||
"exitCode": 0,
|
||||
"signal": null,
|
||||
"seconds": 0.58,
|
||||
"logfile": "/tmp/orca-perf-2-pr-proof/ensure-electron.log"
|
||||
},
|
||||
"overlayStartedAt": "2026-10-09T19:59:43.364Z",
|
||||
"baseline-build": {
|
||||
"exitCode": 0,
|
||||
"signal": null,
|
||||
"seconds": 8.124,
|
||||
"logfile": "/tmp/orca-perf-2-pr-proof/baseline-build.log"
|
||||
},
|
||||
"baselineBuildOutputSha256": {
|
||||
"out/main/index.js": "62d8b312d8fa6d6f25b93ecdfb4f3b272d4ced03e50d946344d0a5e770277572",
|
||||
"out/renderer/index.html": "cb36b8d736ba557cbc986026c4bd2cd4f88f20d87f86a54c386cf225c7365eed"
|
||||
},
|
||||
"baseline-test": {
|
||||
"exitCode": 0,
|
||||
"signal": null,
|
||||
"seconds": 7.175,
|
||||
"logfile": "/tmp/orca-perf-2-pr-proof/baseline-test.log"
|
||||
},
|
||||
"launch": {
|
||||
"appPath": "/Users/nwparker/orca/workspaces/orca/perf-2",
|
||||
"windows": [
|
||||
{
|
||||
"visible": false,
|
||||
"focused": false
|
||||
}
|
||||
],
|
||||
"processId": 15523
|
||||
},
|
||||
"cleanup": {
|
||||
"ownedProcessCheckCommand": "ps -p 15523 -o pid=,command=",
|
||||
"exitCode": 1,
|
||||
"stdout": "",
|
||||
"allNativeWindowsHiddenAndUnfocusedAtEnd": true,
|
||||
"fixtureShutdownCompleted": true
|
||||
},
|
||||
"beforeScreenshot": {
|
||||
"file": "before.png",
|
||||
"sha256": "05b92196f89016925d338febd452510f23b902ee475dffaa715fc18d97bad0dd"
|
||||
},
|
||||
"observedRegression": {
|
||||
"outsideRunOutputOn": "printf silent_0",
|
||||
"reverseNamedOutputsOn": ["printf silent_1", "printf silent_2", "printf silent_3"],
|
||||
"namedCallRowsHaveNoOutputs": true,
|
||||
"taskList": "Reject this failed replacement",
|
||||
"taskState": "1 of 1 tasks completed"
|
||||
},
|
||||
"baselineTestPassed": true,
|
||||
"restoredSourceSha256": {
|
||||
"src/shared/native-chat-tool-fold.ts": "3d75ee82c5c09bc796caef4d5127d55b3d291c03d3090f10a204622188aaba4a",
|
||||
"src/main/native-chat/transcript-record-blocks.ts": "110990b4d8ebd6f28ea8702de92568fff5af398bffa5dafdb81d93b65f7da594",
|
||||
"src/main/native-chat/transcript-line-decoders-codex.ts": "cbd4133da04988a9b86015349411a679a5b1b513738a7c10bf165869c49e05e4"
|
||||
},
|
||||
"restoredByteForByte": true,
|
||||
"temporarySpecRemoved": true,
|
||||
"overlayEndedAt": "2026-10-09T19:59:58.674Z",
|
||||
"restored-current-build": {
|
||||
"exitCode": 0,
|
||||
"signal": null,
|
||||
"seconds": 6.993,
|
||||
"logfile": "/tmp/orca-perf-2-pr-proof/restored-current-build.log"
|
||||
},
|
||||
"restoredBuildOutputSha256": {
|
||||
"out/main/index.js": "4b5611df4675a9d9fcbb9ff555ff725b3b89121cd70ccf44f0e8a337b0fad80f",
|
||||
"out/renderer/index.html": "4feff6c6cf322240ab4d4d5cb0555c1f9f4d55cb680fc111f87b050d0243a36d"
|
||||
},
|
||||
"currentAppRebuilt": true,
|
||||
"renderedInspection": {
|
||||
"inspected": true,
|
||||
"before": "UNATTRIBUTED_OUTPUT on silent_0; PAIR_OWNER_THIRD/SECOND/FIRST on silent_1/2/3; composer task list1/1 shows rejected replacement",
|
||||
"after": "PAIR_OWNER_FIRST/SECOND/THIRD on pairing_first/second/third; accepted pending task remains0/1",
|
||||
"noDOMMutation": true
|
||||
},
|
||||
"currentChatSourcesMatchCopiedAfterProof": true,
|
||||
"finalVerifiedAt": "2026-10-09T20:00:43.689Z",
|
||||
"screenshot": "https://github.com/user-attachments/assets/b9f84d6e-a786-42d5-b895-8a4319f972cd",
|
||||
"afterScreenshot": "https://github.com/user-attachments/assets/ae57901b-2e8d-4a35-b6c9-fe63129d9fea"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"commands": [
|
||||
"ORCA_BACKGROUND_LAUNCH=1 node --expose-gc config/scripts/native-chat-fold-benchmark.mjs --memory-control > /tmp/orca-chat-full-fold-memory-control.json",
|
||||
"ORCA_BACKGROUND_LAUNCH=1 node --expose-gc config/scripts/native-chat-fold-benchmark.mjs > /tmp/orca-chat-full-fold-final.json"
|
||||
],
|
||||
"artifactPromotion": "Final JSON copied only after successful process exit, all 54 cases completed, and memory-control validation passed.",
|
||||
"artifactFormatting": {
|
||||
"rawSha256": "ce89f6c858439fd3eaf7335ee4fa500414d102e5590a2b5516d97c0775a96c66",
|
||||
"formattedSha256": "c101e09bd1841aa7637bc0c37b5779f7c05336413d7a365075d7806c55ae89f4",
|
||||
"parsedJsonIdentical": true
|
||||
},
|
||||
"sha256": {
|
||||
"config/scripts/native-chat-fold-benchmark.mjs": "8211f3f7159cb7559de622e35f5216e04000d40913109636adda7cf116aa1b16",
|
||||
"config/scripts/native-chat-fold-benchmark-workloads.mjs": "2e6996889d678c71513e08c820c2b3dc2ae0731764b7133e5f2a88a72fe5f977",
|
||||
"config/scripts/counterbalanced-benchmark-schedule.mjs": "bc35b40e954fa7ddec0225ed375bd3b113c2e05b8554bc50734d14f89987294d",
|
||||
"config/scripts/benchmark-sample-summary.mjs": "feecbd5ec627e7d0ad568f8521c108c1a1cfd478b56f2239d72cd04cb079db1c",
|
||||
"config/scripts/script-child-process.mjs": "a7441428bf7b5c9a897b6a3c057c50f46acca24c4f218a4b849de5aec53c4173",
|
||||
"src/shared/child-process/run-process.ts": "f4d9683c0a0d2ea3ceb9c99b5b713b502496a2f3f982e2c63728063d5f1b239d",
|
||||
"src/shared/native-chat-tool-pairs.ts": "73e0da592a69f0962cba46d7fedfeb1feab01cc39d1d938d7460fabd17a09a33",
|
||||
"mobile/src/session/MobileNativeChatToolRun.tsx": "a6690578f1fbd89ff2874bc80a069deed5807dd445a21fdaa7f285eee31f59cd",
|
||||
"docs/bug-reproductions/native-chat-tool-pairing/full-fold-benchmark.json": "c101e09bd1841aa7637bc0c37b5779f7c05336413d7a365075d7806c55ae89f4",
|
||||
"docs/bug-reproductions/native-chat-tool-pairing/full-fold-memory-control.json": "01feb2876d2c43302bd5c503469168c2077054787003f4b210ab8a4bc7940159"
|
||||
},
|
||||
"postMainMergeReview": {
|
||||
"head": "bcfbb8cb6d543aca516fca346c32c47ec880019f",
|
||||
"measuredCurrentModules": 55,
|
||||
"unchangedModules": 52,
|
||||
"changes": [
|
||||
{
|
||||
"path": "src/shared/agent-session-failure.ts",
|
||||
"measuredSha256": "a857f8657cd3a24eb7f81c169a37cfcfb9981e6fec2c568f2994c4267962e8bf",
|
||||
"postMergeSha256": "9a0e10463b338c695c35dfbff3561bab71b862d7588465b12a74f58f13e540af",
|
||||
"delta": "AGENT_SESSION_FAILURE_KINDS adds claudeAccountFolderMissing and claudeAccountSetupFailed."
|
||||
},
|
||||
{
|
||||
"path": "src/shared/agent-session-refusal-details.ts",
|
||||
"measuredSha256": "b4715dac4535cfdcb7db4c1c2e422330d3360e7c74f8c89e694b408bc7d9e897",
|
||||
"postMergeSha256": "370368c32fd91271b3a2f2d8721031ce1ebcf49424b3861e8c028ae2431d3b0f",
|
||||
"delta": "The startup refusal-reason union adds claudeAccountFolderMissing and claudeAccountSetupFailed, plus comments."
|
||||
},
|
||||
{
|
||||
"path": "src/shared/structured-agent-session-dispatch-rejection.ts",
|
||||
"measuredSha256": "24131349753a9e69ea8c2d24c97408b1c79bfebcccf651c1e85acc6f67d99234",
|
||||
"postMergeSha256": "1fb152d779eb890ddcd711da543b4f7501e635baa9969eb0fa24350f94770c57",
|
||||
"delta": "KIND_CATEGORY maps both added reasons to startFailed; KIND_VERDICT maps both to failure."
|
||||
}
|
||||
],
|
||||
"executedPathAssessment": "Only static account-startup enum/map additions changed. Fixtures contain no failure/refusal events or subagent journal envelopes; the root-conversation projection returns before turn-membership work when byAgent is empty. No function logic used by these fixtures changed. Module initialization occurs outside timed operations and before memory-sample GC baselines.",
|
||||
"decision": "No timing rerun: measured matching/folding/projection paths and benchmark tools remain byte-identical. Artifact module and bundle hashes continue to identify the measured pre-merge graph; post-merge enum/map hashes are recorded separately, without claiming a newly timed bundle."
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"arraySlots": 1000000,
|
||||
"samples": [
|
||||
{
|
||||
"transientHeapDeltaBytes": 8003528,
|
||||
"retainedHeapDeltaBytes": 7995080
|
||||
},
|
||||
{
|
||||
"transientHeapDeltaBytes": 8004232,
|
||||
"retainedHeapDeltaBytes": 8000144
|
||||
},
|
||||
{
|
||||
"transientHeapDeltaBytes": 8004232,
|
||||
"retainedHeapDeltaBytes": 8000016
|
||||
},
|
||||
{
|
||||
"transientHeapDeltaBytes": 8001976,
|
||||
"retainedHeapDeltaBytes": 8000680
|
||||
},
|
||||
{
|
||||
"transientHeapDeltaBytes": 8003912,
|
||||
"retainedHeapDeltaBytes": 8000224
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
{
|
||||
"recordedAt": "2026-10-10T00:42:45.888Z",
|
||||
"sourceCommit": "bcfbb8cb6d543aca516fca346c32c47ec880019f",
|
||||
"primaryWorktree": "/Users/nwparker/orca/workspaces/orca/perf-2",
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 node /tmp/orca-perf-2-pr-proof/mobile-tool-availability-proof.mjs",
|
||||
"harness": {
|
||||
"file": "/tmp/orca-perf-2-pr-proof/mobile-tool-availability-proof.mjs",
|
||||
"sha256": "fe66e12c27a628aaf01b5b0074b8986f440f0b05c08ce10b23e16789717688d4",
|
||||
"entrySha256": "89e8740ec312d4ad8158f382eda0a39887923c41573f07ea75331adb0b74f174",
|
||||
"reuse": [
|
||||
"config/scripts/build-mobile-web-app-bundle.mjs: mobileWebAppBuildOptions, production root reset and parity styles",
|
||||
"config/scripts/mobile-web-app-render-harness.mjs: createBundleServer and readShellCsp"
|
||||
],
|
||||
"sourceSha256": {
|
||||
"config/scripts/build-mobile-web-app-bundle.mjs": "d5538dc0f33a2270e96515a2b9f155a02ae32e83711b15dfa3882c280c7ea699",
|
||||
"config/scripts/mobile-web-app-render-harness.mjs": "795218671c3d01a6c982197fa950fbb4b8b7ae079ccc68fc83d4394f1c36f58c",
|
||||
"mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellCsp.kt": "9936abe45e1eb2717154fa7bc2c6daf99ac0bb928ed46208916e90e1209bf479"
|
||||
},
|
||||
"productionComponents": [
|
||||
"mobile/src/session/MobileNativeChatMessage.tsx",
|
||||
"mobile/src/session/MobileNativeChatToolRun.tsx",
|
||||
"mobile/src/session/mobile-native-chat-render-data.ts"
|
||||
],
|
||||
"viewport": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"browser": "Headless Chromium",
|
||||
"reactNativePrimitives": "Actual react-native-web, no renderer mocks or DOM mutations"
|
||||
},
|
||||
"before": {
|
||||
"ref": "b1e44269d655608447dc535f0a4d7d05c37c80b7",
|
||||
"overlaidFile": "mobile/src/session/MobileNativeChatToolRun.tsx",
|
||||
"sourceSha256": "4342c807795d9fc7c49d3ce1330845cde265eb4af7bcba8b6e47a98efd01e9bc",
|
||||
"scope": "Only MobileNativeChatToolRun.tsx overlaid inside the temporary bundle; other production modules and shared matcher stay current. No worktree mutation.",
|
||||
"scenarios": [
|
||||
{
|
||||
"scenario": "named",
|
||||
"initialRows": 6,
|
||||
"laterRowsUnavailable": true,
|
||||
"noDisclosure": true
|
||||
},
|
||||
{
|
||||
"scenario": "orphans",
|
||||
"initialRows": 6,
|
||||
"laterRowsUnavailable": true,
|
||||
"noDisclosure": true
|
||||
},
|
||||
{
|
||||
"scenario": "late",
|
||||
"initialRows": 6,
|
||||
"laterRowsUnavailable": true,
|
||||
"noDisclosure": true
|
||||
}
|
||||
],
|
||||
"otherSourceHashesMatchAfter": true,
|
||||
"compiledOutputSha256": {
|
||||
"before/mobile-tool-availability.js": "0077697ff5e9f3173dc5ef64004d210f031033923eaeef08ec09b0f8dabc51f2",
|
||||
"before/index.html": "f1f04bc442384e86c6bd7687fd5a2df146b9b0d45fb1539a6e79483cd2975fac"
|
||||
}
|
||||
},
|
||||
"after": {
|
||||
"sourceSha256": {
|
||||
"mobile/src/theme/mobile-theme.ts": "0b622538ae68934110fb9c8c283c05d125a97358d7a715ff172f3bf2591a3fdd",
|
||||
"mobile/src/session/MobileNativeChatMessage.tsx": "b21f1da6f3101661416b4e5b694e739395d372caff8b604aca5a8ef9bbfdd17d",
|
||||
"mobile/src/session/mobile-native-chat-render-data.ts": "0888dcfda84e196d599da009daf4dc3d6384acba46e21f2e20548842b8a1725f",
|
||||
"mobile/src/session/mobile-native-chat-image-transcript-markers.ts": "ddb44478e79f4addf6207e049ec749efdfa9898810517029fc8c35e42b5c368a",
|
||||
"src/shared/native-chat-empty-state.ts": "30fb51da467432f19ee668faee56b6690378f8a46229dcf6d0582c3fc02a0692",
|
||||
"src/shared/agent-type-label.ts": "8caa6b29fcc80aa6675fa9dcc84691d47ba92ec1146f11c29f07a198f5035930",
|
||||
"src/shared/agent-session-journal-producer.ts": "0f5983d862e36513ef49a5b58901b41f7fffc25fb8ed0191377996759aebfc4e",
|
||||
"src/shared/native-chat-types.ts": "449b3ed60a5e75de48584d9d4e33ad431342834d6c7a8951571304a21f7f5ded",
|
||||
"src/shared/native-chat-noise.ts": "052e9ee8883fcac5b38e032670e325a5797d89a5edb0dd747612eb84b6bc680e",
|
||||
"src/shared/native-chat-provider-frame-summary.ts": "594241490db5a6bd39983280969ce5129eaca2b28c101afe9627906de5669a80",
|
||||
"src/shared/native-chat-tool-fold.ts": "97b6a7e7bad809827178fd72a3343be9e6d9956c4eb30631de081f35d8126253",
|
||||
"mobile/src/components/MobileSelectableText.tsx": "6c53f714a5470178f0f3eb3542b57033553a1e99069bef32a7ba1a15afca9eb3",
|
||||
"mobile/src/session/MobileNativeChatLongPressContent.tsx": "7d6d33f72d1e354fc9c6539d85eaf12c1ee85d57be4de17f99e61adfcb1b3e32",
|
||||
"mobile/src/components/inline-text-selection.ts": "89fd19e240148b2756cf3b145cfce0a823723068fe598945cd88b83a0e7734ce",
|
||||
"mobile/src/session/MobileNativeChatMessageActionsSheet.tsx": "d2c53ed525bb27a93f8f2c9675e650f34a8325fd56cafda5db80eb487795494a",
|
||||
"src/shared/agent-session-host-status-rows.ts": "02c293f1c4e6e3b666ec394c7be85a8edf587e38b00cfb20785ca0b413106d89",
|
||||
"src/shared/native-chat-subagent-summary.ts": "ff7d5b09a6c241909b11b49681dc987b93c3b3d7b47fa067c8bea403aee2f538",
|
||||
"src/shared/native-chat-tool-activity.ts": "54102040afefeb4437b36965fe3ef1190eef3e8c4bdf857cad4b6dbbac4ae975",
|
||||
"mobile/src/components/MobileMarkdown.tsx": "de6faa09a9a0a40c4b761e1fe184d5f2f719af3c7efc1e1538a0b48199c830d5",
|
||||
"mobile/src/session/MobileNativeChatReasoningRow.tsx": "b4bdc8ec1c8910b8ffad8b7fb8522cfabc12a5ef53c3cf2696cb2a8ee4157f06",
|
||||
"src/shared/native-chat-row-content.ts": "c777ef1fc78bc5db708321e7751e59713606bfcc39b7aecd81004f8495413ee0",
|
||||
"mobile/src/session/MobileNativeChatTurnStatus.tsx": "29551eae63e81afe5637b715348f86de143d11b0c4e3e3253f175a05c4561b1d",
|
||||
"src/shared/agent-session-provider-context.ts": "f3131bee71f7d39255838a0f39f84b4a393eaa5db700948f3aba8dc07474f3f6",
|
||||
"mobile/src/session/MobileNativeChatSubagentGroup.tsx": "7aaf08e5dedd4a8d761802c5282ae5c6f0322dcd0e3f8e6c5bb46a7e9a0427ea",
|
||||
"mobile/src/session/MobileNativeChatToolRun.tsx": "a6690578f1fbd89ff2874bc80a069deed5807dd445a21fdaa7f285eee31f59cd",
|
||||
"mobile/src/session/mobile-agent-message-attribution.ts": "1e5b77808f3b9214310ab07a199e52612238097c145f095df47c01fe04caaedb",
|
||||
"mobile/src/session/mobile-native-chat-image-preview.ts": "8d99dd562af46fb13d1e3df08628c67332a2d544a018f7f857807e3f12bea141",
|
||||
"mobile/src/session/mobile-native-chat-message-styles.ts": "689369f350d20813a98f2acaec289e17831f3b15d7b851d15da32b0dc5277faf",
|
||||
"mobile/src/session/mobile-native-chat-visual-context.ts": "a1e450a69ce996d9f58fd6e88effe556a689c1f5986f696a579529f2f4455a08",
|
||||
"src/shared/native-chat-visual-directive.ts": "c08a797d572c628839fe8006e6bb6ee2c8b4cc16a9ee79c76cac38de2aef8890",
|
||||
"src/shared/agent-session-message-source.ts": "3d4461d5f2c3227d8a52433db2a2d0643a5003e347b5d243c9f07142d825f38d",
|
||||
"src/shared/native-chat-image-transcript-markers.ts": "4c387c3273798706a4b9326e9e17368382012eac3de43e11e70e649fe019fd5b",
|
||||
"src/shared/zod-salvage.ts": "0c752ffb1d80f98cd1e6d7687adb619d1354ad9b454c40f71144f3a6ec3c640c",
|
||||
"src/shared/orca-session-address.ts": "cf821ba18a36194293efac045bd826a250c6d000e8442c7c66b92a12ec7d9d0d",
|
||||
"src/shared/agent-session-conversation-name.ts": "f732ba58dbb855a4df0cc243c9112c06667611f7e790cb3ef610d71c39f439b9",
|
||||
"src/shared/orca-session-address-prefix.ts": "d44a4bf817c56f7e83c2e4d48991bacb088e6114eefce43c34e6baa949b7c6ee",
|
||||
"src/shared/structured-worker-handle.ts": "c05edb3002a912b602808c3a6911a789548a3993e5a8d8defd5817a00882a20f",
|
||||
"src/shared/agent-session-record.ts": "391bd83b0b47c8bde56ab6412337c90cb6fc7f958ba0ae9e5e26bcbef4e8a2b6",
|
||||
"src/shared/harness-injected-user-turns.ts": "c4ddb96ca6fc15fb9ff8012ef883ca8fdd152ab0bff18dac70e12beec89e0c5b",
|
||||
"src/shared/native-chat-tool-summary.ts": "501f748758c37eefce9b28b5d65db87111d768a98addbbef652757beb12317bf",
|
||||
"src/shared/surrogate-safe-text-slice.ts": "c93077a966a142e8c4e4c4145801e219cee54c7616607a3cb989b6dabe7dadfd",
|
||||
"mobile/src/session/mobile-native-chat-message-plain-text.ts": "69a9d1216f13253754da684e987123a5f343969021993cfc5dd9b5dc073991a6",
|
||||
"mobile/src/components/BottomDrawer.tsx": "8c5182c9d42dc18ad3a2a0e28cad258428e2dd1205969d22550448c605f449d0",
|
||||
"mobile/src/components/ActionSheetModal.tsx": "ceddb89c9b178bb82c7d6498c1ac3a760e99aada3338afba73dea4e3b790ebc0",
|
||||
"mobile/src/platform/clipboard.web.ts": "dea3f5957973553e232ab334e17b7f00e6130c90f47b5510a2d3b51428faae78",
|
||||
"mobile/src/hooks/use-now.ts": "b9339df6c969927dc67c3be8baf51692ffd4bd1083d978aa93e04767e0110f52",
|
||||
"src/shared/native-chat-subagent-group-header.ts": "cb235f59f17642f5ac5505a2bed7b7374889a5e6d7a8d0797da9d2360cf4e826",
|
||||
"src/shared/native-chat-turn-status.ts": "227270f57d7916bb2eaf8bd474ced57de8d7b8aa9ca2c32582c3c0794f577778",
|
||||
"src/shared/native-chat-prose.ts": "f6800ec105efc72efeb76f69bd91a0d3968d32e3a587baa31b73ffec83c48c06",
|
||||
"src/shared/native-chat-background-task-row.ts": "de1d0c95b4f8096799caa0a4e563406dbc8a3c3cb4be211a937c0942891f861a",
|
||||
"mobile/src/components/keyed-bottom-drawer.tsx": "549c8304dbdeea48efd2d63d67590a2f38ef916193887e970808b5fbb3348e01",
|
||||
"src/shared/native-chat-tool-preview-prefix.ts": "42c2d527b81f8868b22c991da27fc326e2a219c92167d9bcb4850e3b32a63fda",
|
||||
"src/shared/ansi-escape-sequences.ts": "75c79a88fb91c8b7a128a1ccc133051cde9e3e09f553d4d4fbb8887f89b6d217",
|
||||
"src/shared/claude-pasted-content.ts": "f155ecaa82274a49f55ed0a4039a3a6b6d377b8c48182ad95234c20da190f308",
|
||||
"mobile/src/components/mounted-bottom-drawer.tsx": "bc35d578c3f10e1ec5fd8e2fd316adc882678a68861572ef32ad45cdebe612f2",
|
||||
"src/shared/native-chat-reasoning-row.ts": "8496fd11ea90e4f9e6b3d037ae0642011e323f44857ac7fdb35f172d8faf5932",
|
||||
"src/shared/native-chat-tool-pairs.ts": "73e0da592a69f0962cba46d7fedfeb1feab01cc39d1d938d7460fabd17a09a33",
|
||||
"src/shared/native-chat-folded-tool-results.ts": "e563aad787ef7b96fe91077006347121ffb5e4accca44e56a8282a9d278e2881",
|
||||
"mobile/src/mobile-web-shell/bridge/staged-media-bytes.ts": "c7173bbdb3960f246aab942dadf342ea90f0a06fd7653b91332a8d2fbd845f0a",
|
||||
"mobile/src/mobile-web-shell/bridge/use-native-verbs.ts": "88de410114191fc8a12f7c7331d49734bb3b338de862fd826d81b9938314ff2b",
|
||||
"mobile/src/mobile-web-shell/bridge/bridge-media-verbs.ts": "10e10e8298d6afbf87824ac505e762e704147139ad7e6391c56f666970950ebe",
|
||||
"mobile/src/session/mobile-image-base64-accumulator.ts": "4c61305175000d75aed8fd55926459c503a82d9abf17b5649118b858ca31c305",
|
||||
"src/shared/native-chat-tool-icon.ts": "ca408c2a609fbfcfa866da8a1933a17817d583b6f527858ba5b6d597397d95c8",
|
||||
"src/shared/agent-session-account-home.ts": "d4d7fe74f62c7b3075f1fc4256cb4d13f03e56825436d1c9481663fe61e09f52",
|
||||
"src/shared/agent-session-launch-args.ts": "4001086933c5e189cf9c824df053abb9a139fe812c725f5e7823d5629e5b851f",
|
||||
"src/shared/agent-session-rewind.ts": "d2568da9ba18b55b4c95aa8a9a36cd38a24f1d7a8fc1dbfd09ae63db9dae94ca",
|
||||
"src/shared/agent-session-provider-handle-encoding.ts": "d3404ef90f8533fbf37720ae7eac43cd0a1ebdbce335bce20eb05adeabea5d8f",
|
||||
"src/shared/agent-session-legacy-handoff-lease.ts": "5894948104c7abb90851e5fdb8de2ffcffdaf1da185ae18b9afdd59ee63eab68",
|
||||
"src/shared/agent-session-conversation-command.ts": "c3c5bac0d9569aadc2f7fde32e453ae317c818cdbcccd357b4fb5eacfedacb1d",
|
||||
"src/shared/agent-session-provider-handle.ts": "67b1fb7025f84d31fa507340e4757af846ec0be8e341e8b094a43d6b1fe1ce52",
|
||||
"mobile/src/components/markdown-inline-matcher.ts": "fcb9483e872574c0a75f802c628819f3c985e5bbefd13833f6b11cd5401b8e0d",
|
||||
"mobile/src/components/pr-sidebar/MermaidDiagram.web.tsx": "98ed187989c5c9d752d1ff5ca0d7f9cd55b36693d2c5ac17dd8e158aa336e4d7",
|
||||
"mobile/src/platform/external-link.web.ts": "0215a4c4551dd44600595000ee0ea87ebcb5a9ead40bbc1090d4ab497b851705",
|
||||
"mobile/src/components/markdown-inline-token-rules.ts": "0675e2c59b51c5e329ec2e7f7ce855bb147cd6cb28558840559323703af3832c",
|
||||
"mobile/src/components/mobile-markdown-styles.ts": "d6050ecf8cee73064ac467349a040865c48ed7cea34cc1ae0307b015c399e114",
|
||||
"mobile/src/components/mobile-mermaid-language.ts": "372906856bd040c16736cc94569237ca6055751527dc1c0d33f49f249314d963",
|
||||
"mobile/src/components/markdown-file-path-detection.ts": "e2c3640be6059a508ddfbc7270a84729a8958431698d067a6d3ae34cff44624a",
|
||||
"src/shared/native-chat-diff.ts": "a78c7a4cd31c3293e288021aabfa86a0871b571f460a9912489d8a83bf4cb227",
|
||||
"mobile/src/components/markdown-href-routing.ts": "726a20c58b3fdeb97432c1b5ad551d4f35bbe5a9e7c4e45a1c80eb151ca948f7",
|
||||
"mobile/src/components/use-mobile-markdown-blocks.ts": "48afe73963d31b7308c51f65513b82f6d3cb71db4cd9966a8199c8532cd3259c",
|
||||
"src/shared/file-link-location.ts": "0caa3537d7b23b5442f82d3738522159011b21a75889273279fd21181b4ab560",
|
||||
"mobile/src/components/pr-sidebar/mermaid-page-engine.ts": "c2fee924f8922a6a9604528545f73ee013177aaa95673658ca18c35e4d3f585d",
|
||||
"mobile/src/components/pr-sidebar/mermaid-diagram-config.ts": "09a6b58d7d4966130486c390f004d27ac925d30f478c641a34c313a6ef7285db",
|
||||
"src/shared/agent-session-journal-schemas.ts": "0962dc85972886bbff8cb8138d0cf2de8dbbe12d2eedbbe2255d6bbdb7d50afc",
|
||||
"src/shared/agent-session-journal-item-key.ts": "09ebe4758e3f38b5b7591f07f7b4dbfd04c7cc01713c9b0afe03b07b656d1fe8",
|
||||
"src/shared/agent-session-provider-handle-replacement.ts": "9e236b2c2c082c9c3ec4aca3827ce5d8f75926dd6b139c7053d9264e9b301cf7",
|
||||
"mobile/src/mobile-web-shell/bridge/bridge-caps.ts": "93f47fc3a6d9819e91d3e071c911c8f8fec783019badb4c13e07fa22b68f3101",
|
||||
"mobile/src/transport/base64-byte-codec.ts": "bdb75c23300144d42b224f72327ef22a7515f79fce66c5d2817086a6d032c8f4",
|
||||
"src/shared/native-chat-tool-identity.ts": "8c98eec1a53b26a67ed48a0859c5863f5accbe67e013c4eb5a0f8b32441ee925",
|
||||
"mobile/src/components/mobile-markdown-visual-lines.ts": "5a2ddf16ba9fa6fd3cbe9c5e93d6bc1f4cc333aed60c59d85ba14049690f9b3a",
|
||||
"mobile/src/components/mobile-markdown-parser.ts": "6545ec307734bcbff15af08015e78d66d49e867c196277c0fc0f8a74c2571b56",
|
||||
"mobile/src/components/mobile-markdown-preview-html.ts": "31f376a4c23f520a74d1adfc299fd04d63fa8f46d455ed6113ee487dc3939e73",
|
||||
"src/shared/agent-session-context-usage-schema.ts": "57c0afc26dd85849b9ca832346c802a1906784a059759ae77e949bcc2678fb77",
|
||||
"src/shared/agent-session-failure-fact-schema.ts": "b1674dbd7c66c2b9cde0d604217a5e0835eb5d6d747fbf10889d900058ce183b",
|
||||
"src/shared/agent-session-journal-thread-goal-schema.ts": "f0eb37f8a2bd42688fb8592053fe06a858c35d291666d00576e6471721c3ba08",
|
||||
"src/shared/agent-session-journal-open-union.ts": "2205366bd9f2c93bd36966d2833d33b5ae3c4d0ad7548d1ee0e0189585e36954",
|
||||
"mobile/src/components/rich-markdown/markdown-table-rows.ts": "807b3c1fb705c1519d1a9cccef1029a02ae7627c667e652aaebf2d7d81941229",
|
||||
"mobile/src/components/mobile-markdown-preview-tag-stripper.ts": "a12d4356235063a353a05fbc77333bbbd46836de95193d9a1a65e72a62c7a77b",
|
||||
"mobile/src/components/bottom-drawer-fill-height.ts": "3db079086cc8d745201d140d1945eff8df2a0bc0ab4805b31bfa8fa4f6cd1c5f",
|
||||
"mobile/src/components/bottom-drawer-constants.ts": "bae54257c25078dbf1aed42467c516b670c0cbbeaf47dd5c7852e8ce9c476a81",
|
||||
"mobile/src/components/bottom-drawer-keyboard-inset.ts": "b69bcb461aaf7ffefc539fba9dfd86a75c9adf62ec22fb9b31ecd865e5810ab9",
|
||||
"mobile/src/components/bottom-drawer-modal-host.tsx": "f81e4360566cb684994024cc10b2fb9829dfc031f9fd838c5558c220f652ef09",
|
||||
"mobile/src/layout/responsive-layout.ts": "513e4556668d9a15dbae3085a1b7343248454157b7e41ff325da0efdb4c60754",
|
||||
"mobile/src/navigation/use-back-claim.web.ts": "d22645a431734847d3d03f96e518dba8b4a343c50c1b2886e381b67f3bd89e7d",
|
||||
"mobile/src/components/bottom-drawer-styles.ts": "4693c6c004beb5a495a9dcbb8b70e285e77c138d870b795424a2d582a6de807b",
|
||||
"mobile/src/platform/keyboard-occlusion.web.ts": "f41fcea963aa7f41e3177849e886d21a4fa27db1ba24c54087bfd8f27a31e3d0",
|
||||
"mobile/src/platform/host-os.web.ts": "c9f3f3926a1464c89bc55e28ce9fbc8a70de40e7f5f81c363c8f0f86470a7ce8",
|
||||
"mobile/src/mobile-web-shell/bridge/bridge-native-verbs.ts": "6b97a355523f25d4b92b0d4495a075644c1bba03a2048bd942212dbbbb8ff714",
|
||||
"mobile/src/transport/client-context.web.tsx": "41fd1b3504ce82edd294aa962a245fff64f9d33839d2a3d834faa676ef41b03e",
|
||||
"mobile/src/mobile-web-shell/bridge/bridge-audio-verbs.ts": "eb8d86d8524a836be0ae97afc13b00863fe0d77b9905a40d81bf3e7cab9f05ca",
|
||||
"src/shared/native-chat-href-routing.ts": "8df6855303d40a03fd9fd10065991e40860199eb95cd85d6b5120d44f5371014",
|
||||
"src/shared/clipboard-image.ts": "03fc1db2b00c37c5595f7e731aef082bce9730489ff6a166e37379cf41f0aac5",
|
||||
"mobile/src/session/mobile-clipboard-image-upload-chunk.ts": "3fa39da0385f1d3817d9e7b10f7ff94d44bb6a95219eac7c1320e30fe43ee88b",
|
||||
"src/shared/agent-session-context-usage.ts": "cf8ce6a5f9b813f0965a784e91f8b5c7d23e20d7858e79ddb7c8e89072469355",
|
||||
"mobile/src/transport/host-client-hooks.ts": "58aa9aaaa20bfacdbe282567a5e0e8c33f7fbfde6689e4baeab762207a3b9ad5",
|
||||
"mobile/src/mobile-web-shell/bridge/bridge-page-client-identity.ts": "9e204e7168d88f27d5fabb18f2aef9cc1a0efc3cd63857cd7133e9a7bfb8d655",
|
||||
"src/shared/cross-platform-path.ts": "d72a91065f535824b17f2d3285f45f6d2f40ec2a2ac7ffb4ae17edaa4f9a97f3",
|
||||
"mobile/src/layout/responsive-layout-metrics.ts": "65a22518940a77d877ccca8faf565a77701f4e99ba2f641e53dcd4a241211975",
|
||||
"src/shared/file-uri-path.ts": "e584a0db4780a5742960d0592c05500a98ce137c09ee6fbe0d8f96fe29065571",
|
||||
"mobile/src/hooks/mobile-dictation-pending-audio-budget.ts": "7c13a7d0ef267c287429849658bd98f4e7b0ee0ac90091c5a9d76ade17eff292",
|
||||
"mobile/src/components/pr-sidebar/mermaid-page-engine.generated.ts": "e54cc675b81d7eafe1b22a35ac9fa69f7761b79c1de62ffa6886a286a74a93f4",
|
||||
"src/shared/wsl-paths.ts": "8271d75fb65b85b6bfd279f2a12636f79ddeb3ffe4d9d409dd77f5cce2a46219",
|
||||
"mobile/src/mobile-web-shell/page-storage-keys.ts": "ef55d07341d5346779bbce693ebc03a0667604a324060102d18042a8f76dbd4f"
|
||||
},
|
||||
"compiledOutputSha256": {
|
||||
"after/mobile-tool-availability.js": "845abfbe2a0b4b1940b89a5511ff9c034e6487bf2e267b61e2e92d367f8b6e47",
|
||||
"after/index.html": "f1f04bc442384e86c6bd7687fd5a2df146b9b0d45fb1539a6e79483cd2975fac"
|
||||
},
|
||||
"scenarios": [
|
||||
{
|
||||
"scenario": "named",
|
||||
"initialRows": 6,
|
||||
"disclosedRows": 8,
|
||||
"rowLocalFullBodyCorrect": true,
|
||||
"showLessHidesLaterRows": true
|
||||
},
|
||||
{
|
||||
"scenario": "orphans",
|
||||
"initialRows": 6,
|
||||
"disclosedRows": 8,
|
||||
"rowLocalFullBodyCorrect": true,
|
||||
"showLessHidesLaterRows": true
|
||||
},
|
||||
{
|
||||
"scenario": "late",
|
||||
"initialRows": 6,
|
||||
"disclosedRows": 18,
|
||||
"rowLocalFullBodyCorrect": true,
|
||||
"showLessHidesLaterRows": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"sourceHashesStableThroughoutBuildAndCapture": true,
|
||||
"screenshots": [
|
||||
{
|
||||
"arm": "before",
|
||||
"file": "before-orphans.png",
|
||||
"sha256": "4d26e83ba1de890656b81f98269e8c647546f07ad41c7962f901d60bd10ab3b2",
|
||||
"scenario": "orphans",
|
||||
"stage": "six-row ceiling",
|
||||
"inspected": true,
|
||||
"url": "https://github.com/user-attachments/assets/6b6b30f1-58f0-4bc7-8149-6f2169a368b2"
|
||||
},
|
||||
{
|
||||
"arm": "after",
|
||||
"file": "after-named.png",
|
||||
"sha256": "fe3bbca39c3e69bb11a616f291b76931655ad4d70d20c77bc328d745a899eb7c",
|
||||
"scenario": "named",
|
||||
"stage": "later rows disclosed and correct body opened",
|
||||
"inspected": true,
|
||||
"url": "https://github.com/user-attachments/assets/26e0559f-3f0c-482e-9ef2-f32903466c82"
|
||||
},
|
||||
{
|
||||
"arm": "after",
|
||||
"file": "after-orphans.png",
|
||||
"sha256": "2f445379315f932da2a73e56e534db7780b76e49279f6fceb10197534de45c0c",
|
||||
"scenario": "orphans",
|
||||
"stage": "later rows disclosed and correct body opened",
|
||||
"inspected": true,
|
||||
"url": "https://github.com/user-attachments/assets/81241691-0dfe-4a88-b3d1-bdf7e8d02adc"
|
||||
},
|
||||
{
|
||||
"arm": "after",
|
||||
"file": "after-late.png",
|
||||
"sha256": "4808278ddf1c4e9b9107e6d0e9f9d186ddfc668cb6e1d84925cd42eba7383ba0",
|
||||
"scenario": "late",
|
||||
"stage": "later rows disclosed and correct body opened",
|
||||
"inspected": true,
|
||||
"url": "https://github.com/user-attachments/assets/69b1e49b-0627-40b3-977d-052b885beef6"
|
||||
}
|
||||
],
|
||||
"cleanup": {
|
||||
"headless": true,
|
||||
"ownedBrowserPid": 12062,
|
||||
"browserAndExactLaunchServerHandlesClosed": true,
|
||||
"ownedBrowserAbsent": true,
|
||||
"processCheck": "process.kill(12062, 0) raised ESRCH after close",
|
||||
"bothLocalServersClosed": true,
|
||||
"noElectronLaunchOrNativeRuntimeSwitch": true
|
||||
},
|
||||
"renderedInspection": "Before: six orphan rows, count 6 and no disclosure; after: eight Result rows with the seventh full body revealed. Reversed seventh/eighth named outputs stay on their respective commands, a named orphan is separate, and a named output behind 17 silent calls belongs only to call 17. Show less hides later rows.",
|
||||
"scope": "Production mobile Message and fold through shipped RN-web build options and shell CSP, with real DOM clicks. Component disclosure and ownership only; no native phone, full session route, provider decoder, adoption or transport validation.",
|
||||
"measurementLimits": "Rendered correctness only. Screenshots and disclosure assertions do not measure allocation savings, frame latency or full-fold speed. Before uses one historical component module with current dependencies and matcher, not a full historical mobile app."
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"recordedAt": "2026-10-10T03:38:12.628Z",
|
||||
"root": "/Users/nwparker/orca/workspaces/orca/perf-2",
|
||||
"baselineRef": "224cae59a671b67ecffdda2a4fa2795b4086ca11",
|
||||
"overlaidFile": "mobile/src/session/MobileNativeChatToolRun.tsx",
|
||||
"harnessSha256": "cebf78e4d1730b87a8586edfddfabd63d993d875401bd7408535324bf0f3f7f8",
|
||||
"entrySha256": "4b69a2f3afc942789b0728dd00b759cbf683dbce0444934adac5a20deb29105b",
|
||||
"arms": {
|
||||
"before": {
|
||||
"sourceSha256": {
|
||||
"mobile/src/theme/mobile-theme.ts": "0b622538ae68934110fb9c8c283c05d125a97358d7a715ff172f3bf2591a3fdd",
|
||||
"mobile/src/session/MobileNativeChatToolRun.tsx": "a6690578f1fbd89ff2874bc80a069deed5807dd445a21fdaa7f285eee31f59cd",
|
||||
"src/shared/native-chat-diff.ts": "a78c7a4cd31c3293e288021aabfa86a0871b571f460a9912489d8a83bf4cb227",
|
||||
"src/shared/native-chat-tool-activity.ts": "54102040afefeb4437b36965fe3ef1190eef3e8c4bdf857cad4b6dbbac4ae975",
|
||||
"src/shared/native-chat-tool-fold.ts": "97b6a7e7bad809827178fd72a3343be9e6d9956c4eb30631de081f35d8126253",
|
||||
"src/shared/native-chat-tool-summary.ts": "501f748758c37eefce9b28b5d65db87111d768a98addbbef652757beb12317bf",
|
||||
"src/shared/native-chat-tool-icon.ts": "ca408c2a609fbfcfa866da8a1933a17817d583b6f527858ba5b6d597397d95c8",
|
||||
"mobile/src/session/mobile-native-chat-message-styles.ts": "689369f350d20813a98f2acaec289e17831f3b15d7b851d15da32b0dc5277faf",
|
||||
"src/shared/native-chat-types.ts": "449b3ed60a5e75de48584d9d4e33ad431342834d6c7a8951571304a21f7f5ded",
|
||||
"src/shared/native-chat-tool-preview-prefix.ts": "42c2d527b81f8868b22c991da27fc326e2a219c92167d9bcb4850e3b32a63fda",
|
||||
"src/shared/native-chat-tool-identity.ts": "8c98eec1a53b26a67ed48a0859c5863f5accbe67e013c4eb5a0f8b32441ee925",
|
||||
"src/shared/native-chat-turn-status.ts": "227270f57d7916bb2eaf8bd474ced57de8d7b8aa9ca2c32582c3c0794f577778",
|
||||
"src/shared/harness-injected-user-turns.ts": "c4ddb96ca6fc15fb9ff8012ef883ca8fdd152ab0bff18dac70e12beec89e0c5b",
|
||||
"src/shared/native-chat-provider-frame-summary.ts": "594241490db5a6bd39983280969ce5129eaca2b28c101afe9627906de5669a80",
|
||||
"src/shared/native-chat-tool-pairs.ts": "73e0da592a69f0962cba46d7fedfeb1feab01cc39d1d938d7460fabd17a09a33",
|
||||
"src/shared/native-chat-folded-tool-results.ts": "e563aad787ef7b96fe91077006347121ffb5e4accca44e56a8282a9d278e2881",
|
||||
"src/shared/native-chat-noise.ts": "052e9ee8883fcac5b38e032670e325a5797d89a5edb0dd747612eb84b6bc680e"
|
||||
},
|
||||
"compiledOutputSha256": {
|
||||
"before/mobile-tool-availability.js": "c108f292cae09b5736886aa5f2b59a5d139049e0c775be7e66d26db88ee4e67d",
|
||||
"before/index.html": "f1f04bc442384e86c6bd7687fd5a2df146b9b0d45fb1539a6e79483cd2975fac"
|
||||
}
|
||||
},
|
||||
"after": {
|
||||
"sourceSha256": {
|
||||
"mobile/src/theme/mobile-theme.ts": "0b622538ae68934110fb9c8c283c05d125a97358d7a715ff172f3bf2591a3fdd",
|
||||
"mobile/src/session/MobileNativeChatToolRun.tsx": "b1f9f5871d4dce1880a94bd709a46840672da3460b5be6dccf19fc401a51e425",
|
||||
"src/shared/native-chat-diff.ts": "a78c7a4cd31c3293e288021aabfa86a0871b571f460a9912489d8a83bf4cb227",
|
||||
"src/shared/native-chat-tool-summary.ts": "501f748758c37eefce9b28b5d65db87111d768a98addbbef652757beb12317bf",
|
||||
"src/shared/native-chat-tool-line-identity.ts": "cfc8e3e592a5691699219d385d2123543c901ea254500ac8d1afca82cf1dcb2c",
|
||||
"src/shared/native-chat-tool-activity.ts": "54102040afefeb4437b36965fe3ef1190eef3e8c4bdf857cad4b6dbbac4ae975",
|
||||
"mobile/src/session/mobile-native-chat-message-styles.ts": "689369f350d20813a98f2acaec289e17831f3b15d7b851d15da32b0dc5277faf",
|
||||
"src/shared/native-chat-tool-fold.ts": "97b6a7e7bad809827178fd72a3343be9e6d9956c4eb30631de081f35d8126253",
|
||||
"src/shared/native-chat-tool-icon.ts": "ca408c2a609fbfcfa866da8a1933a17817d583b6f527858ba5b6d597397d95c8",
|
||||
"src/shared/native-chat-types.ts": "449b3ed60a5e75de48584d9d4e33ad431342834d6c7a8951571304a21f7f5ded",
|
||||
"src/shared/native-chat-tool-preview-prefix.ts": "42c2d527b81f8868b22c991da27fc326e2a219c92167d9bcb4850e3b32a63fda",
|
||||
"src/shared/native-chat-tool-identity.ts": "8c98eec1a53b26a67ed48a0859c5863f5accbe67e013c4eb5a0f8b32441ee925",
|
||||
"src/shared/native-chat-turn-status.ts": "227270f57d7916bb2eaf8bd474ced57de8d7b8aa9ca2c32582c3c0794f577778",
|
||||
"src/shared/native-chat-tool-pairs.ts": "73e0da592a69f0962cba46d7fedfeb1feab01cc39d1d938d7460fabd17a09a33",
|
||||
"src/shared/native-chat-noise.ts": "052e9ee8883fcac5b38e032670e325a5797d89a5edb0dd747612eb84b6bc680e",
|
||||
"src/shared/harness-injected-user-turns.ts": "c4ddb96ca6fc15fb9ff8012ef883ca8fdd152ab0bff18dac70e12beec89e0c5b",
|
||||
"src/shared/native-chat-provider-frame-summary.ts": "594241490db5a6bd39983280969ce5129eaca2b28c101afe9627906de5669a80",
|
||||
"src/shared/native-chat-folded-tool-results.ts": "e563aad787ef7b96fe91077006347121ffb5e4accca44e56a8282a9d278e2881"
|
||||
},
|
||||
"compiledOutputSha256": {
|
||||
"after/mobile-tool-availability.js": "485e166ac3ac68d567067b1eedd7b728b5a0567471b5b94704e555f0959ab597",
|
||||
"after/index.html": "f1f04bc442384e86c6bd7687fd5a2df146b9b0d45fb1539a6e79483cd2975fac"
|
||||
}
|
||||
}
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"arm": "before",
|
||||
"scenario": "unique",
|
||||
"expectedBehaviorVerified": true,
|
||||
"screenshot": "/tmp/orca-perf-2-pr-proof/mobile-tool-identity/before-unique.png",
|
||||
"sha256": "2df2e972df1ab85efc95f4401fe72f8884b3c9fe96e9c02397eb561142bec839"
|
||||
},
|
||||
{
|
||||
"arm": "before",
|
||||
"scenario": "duplicates",
|
||||
"expectedBehaviorVerified": true,
|
||||
"screenshot": "/tmp/orca-perf-2-pr-proof/mobile-tool-identity/before-duplicates.png",
|
||||
"sha256": "2df2e972df1ab85efc95f4401fe72f8884b3c9fe96e9c02397eb561142bec839"
|
||||
},
|
||||
{
|
||||
"arm": "after",
|
||||
"scenario": "unique",
|
||||
"expectedBehaviorVerified": true,
|
||||
"screenshot": "/tmp/orca-perf-2-pr-proof/mobile-tool-identity/after-unique.png",
|
||||
"sha256": "38835af78fbece545bf396d2cb42bc4cfa0ea577720c1c6bef4fc00d8a7a07d2"
|
||||
},
|
||||
{
|
||||
"arm": "after",
|
||||
"scenario": "duplicates",
|
||||
"expectedBehaviorVerified": true,
|
||||
"screenshot": "/tmp/orca-perf-2-pr-proof/mobile-tool-identity/after-duplicates.png",
|
||||
"sha256": "38835af78fbece545bf396d2cb42bc4cfa0ea577720c1c6bef4fc00d8a7a07d2"
|
||||
}
|
||||
],
|
||||
"ownedBrowserPid": 84492,
|
||||
"ownedBrowserAbsent": true,
|
||||
"scope": "Production mobile ToolRun under shipped React Native Web build and shell CSP. No native-phone or SSH latency claim.",
|
||||
"screenshots": {
|
||||
"before": "https://github.com/user-attachments/assets/966c9d20-650e-4651-96da-1e51f121b95c",
|
||||
"after": "https://github.com/user-attachments/assets/358669cd-7a49-4750-a76b-9897d3eb6323"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# Keep mobile tool details with their row
|
||||
|
||||
Opening a tool result, then removing an earlier command, could move the expanded detail to a different command. Mobile used the row position as its React identity.
|
||||
|
||||
Mobile now shares desktop's provider-ID and duplicate-occurrence identity. Named standalone results use a separate result-ID namespace, preserving their detail across copied history refreshes without transferring it to a command. Anonymous rows use a weak object identity, avoiding large input serialization and resetting detail when a replacement cannot be identified safely. Desktop identities and matching rules are preserved.
|
||||
|
||||
Four mobile component regressions cover prefix removal, reordering, changed input/results, duplicate IDs, orphan replacement and copied named-result refreshes. Two shared identity tests cover named duplicates and avoiding serialization of anonymous inputs. The current-main integration check also passed all 13 tests across the identity, result-availability and paging suites, including both host-scope variants of copied journal refreshes. The earlier rendered receipt below captures the command-row fix before this additional result-row guard.
|
||||
|
||||
The rendered browser check uses production React Native Web components, shipped bundle options and the shell CSP. Both unique-ID and duplicate-ID scenarios reproduce the incorrect expansion before the fix and retain the correct detail after removal and reordering. It records source and output hashes in [the receipt](./mobile-row-identity-validation.json). This is mobile component proof, not a native-phone or SSH latency test.
|
||||
|
||||
| Before | After |
|
||||
| --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| [Different command becomes expanded](https://github.com/user-attachments/assets/966c9d20-650e-4651-96da-1e51f121b95c) | [Selected result remains expanded](https://github.com/user-attachments/assets/358669cd-7a49-4750-a76b-9897d3eb6323) |
|
||||
|
||||
Missing historical IDs cannot be recovered. Removing or reordering earlier occurrences with the same provider ID remains ambiguous. Replacement anonymous rows close their detail; results stay visible. Weak identities do not retain removed blocks. No public wire fields or styling changed.
|
||||
@@ -0,0 +1,260 @@
|
||||
{
|
||||
"recordedAt": "2026-10-10T01:03:28.131138+00:00",
|
||||
"baselineCommit": "51e7181850b022bae2d41091fe1229accef4bd50",
|
||||
"sourceCommit": "bcfbb8cb6d543aca516fca346c32c47ec880019f",
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/native-chat-tool-pairing.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --retries=0 --output=/tmp/orca-perf-2-pr-proof/round2-desktop-test-results",
|
||||
"buildCommand": "ORCA_BACKGROUND_LAUNCH=1 pnpm run build:electron-vite --mode e2e",
|
||||
"build": {
|
||||
"sourceSnapshotAt": "2026-10-10T01:01:51.984377+00:00",
|
||||
"headCommit": "bcfbb8cb6d543aca516fca346c32c47ec880019f",
|
||||
"sourceSha256": {
|
||||
"config/scripts/ensure-native-runtime.mjs": "ba33bac7159cc8cd9375f20617d662a7ab5ef85120f94ea3b4f0877569f97365",
|
||||
"config/scripts/run-electron-vite-build.mjs": "457e6a2cc6ce3cbe3dd460909a95862ba6f4ef4ef081f451317ca73843bb3840",
|
||||
"config/tsconfig.e2e.json": "a380344aa7318a7dedf7e9d17575b6d8a98e8b4487e04698888ba13a49c06a24",
|
||||
"electron.vite.config.ts": "bfb7b82d1d479c3fe5036f61cf739df5b9dd2d8436e8b63291214cc6c6ea644d",
|
||||
"mobile/src/session/MobileNativeChatToolRun.tsx": "a6690578f1fbd89ff2874bc80a069deed5807dd445a21fdaa7f285eee31f59cd",
|
||||
"package.json": "e2bfb1f33cde3d4e9d255ea74a8c6f79a4e0eb55e8dee964aa23a79f1a76ca7f",
|
||||
"pnpm-lock.yaml": "6b0da6b6375a40be7ba9ab98798618b3185ca73e5f01e654967469494ec73829",
|
||||
"src/main/claude-usage/persisted-projection-validation.ts": "f3d4da4410ff51519660d234380b1a4fb7f31d2cc17ae9c703fccf874b329160",
|
||||
"src/main/claude-usage/persisted-source-cache.ts": "c51b94ed2edc6dd19a53a940e1a5ab3ed0b5de5b033c97d679f6419e92b5a535",
|
||||
"src/main/claude-usage/persisted-token-columns.ts": "87c7892cd887bbfaf0ebcf2d85f460773a2070594de896eef94955c40b823855",
|
||||
"src/main/claude-usage/persisted-usage-report.ts": "13737060c3fde1287f194ad833e149f596842534e35eb91e8826b289f9686a04",
|
||||
"src/main/claude-usage/persisted-usage-test-fixture.ts": "ab45375b06b8d3916fb90e3c31fc7ce6de5e3b2eb7410fa574b9095d0c29d6fa",
|
||||
"src/main/claude-usage/scanner.ts": "8174e231f388f5fbc73cdcc2f1216d0cdb5d7213eaa97edd59c304bf76dd219e",
|
||||
"src/main/claude-usage/store.ts": "5beb17f973fbd12b70408e7c8526fda9486ca3fdba2acc89dccb3b0f6d3e8b39",
|
||||
"src/main/claude-usage/transcript-file-discovery.ts": "54c84d0b99b45c0fa13e589a1bcec2b479cb01e8b17bcbddbb65a4bc85a03401",
|
||||
"src/main/claude-usage/transcript-projection-integrity.ts": "a9447fd3916484b03e60993f56bf6052c122a3774a3d91b2541aaeb51bd06aef",
|
||||
"src/main/claude-usage/transcript-record-parser.ts": "bfd4608941761444fdbc53a3d86f9e401d8d088a77f5d906cc36910786ac937b",
|
||||
"src/main/claude-usage/transcript-resume-projection.ts": "d9d5fd504489a32371bcbffe0f77b5d00a8f4dd7a8aa2dea7eaeb8b63d1866db",
|
||||
"src/main/claude-usage/transcript-token-checkpoint.ts": "6e3ebb95532118730eb7c91d486e6885d429c6cc0c47f4b1078fc71afd910b86",
|
||||
"src/main/claude-usage/transcript-usage-projection.ts": "5711193900bcab341c110529c91c973f38dd625d045cbaaab91ac6b0903e118c",
|
||||
"src/main/claude-usage/types.ts": "900f26e7aa69707b9141cd8ef0d27c72ec7c6aab187140d0ec08cd770afe27bf",
|
||||
"src/main/codex-usage/codex-rollout-file-parse.ts": "f39a10c1dce47a71d46c0948d05df30aa6dcc044c81b777b6a013be6f23e9c7a",
|
||||
"src/main/codex-usage/codex-rollout-resume-state.ts": "aa028d2155b7744c0d961c5649537faa20c39bfe404dea8d9c4c1464d11a0e39",
|
||||
"src/main/codex-usage/codex-session-file-discovery.ts": "6c36d6c7da6f5f7ff526b2b67290d072f710f0018b13d604854410ae07bdfe24",
|
||||
"src/main/codex-usage/scanner.ts": "8221f7420ddaae20a94a6c14421bc688d659f82daf85eb47bbe26760418c41a5",
|
||||
"src/main/codex-usage/types.ts": "84bfd3976613b6a775d215fa54f00917cabb981be780d4837c92d43f7cb0a036",
|
||||
"src/main/native-chat/agent-session-journal/journal-legacy-import-tool-pairing.test.ts": "db13d19c0e1036d2b5e7298482ef3811d2dc29d130d896a849f791beb72b4266",
|
||||
"src/main/native-chat/agent-session-journal/journal-legacy-import.ts": "73ec3704b9558b0468446a5a597a56d80decb5398239a2697f3244a9141714ec",
|
||||
"src/main/native-chat/transcript-line-decoders-codex.ts": "cbd4133da04988a9b86015349411a679a5b1b513738a7c10bf165869c49e05e4",
|
||||
"src/main/native-chat/transcript-record-blocks.ts": "110990b4d8ebd6f28ea8702de92568fff5af398bffa5dafdb81d93b65f7da594",
|
||||
"src/main/usage/jsonl-file-checkpoint.ts": "b7f307564989dabaf1dfda095a26c7bd9f0683e0efcabe79a010c7b58fa1a2d4",
|
||||
"src/main/usage/jsonl-file-snapshot.ts": "b1b8869db92c08c762160acba7570aabff25879649cb6ffa3826912ee35deffe",
|
||||
"src/main/usage/jsonl-line-offsets.ts": "f3574adfad4549c6330e2ac52760c91be5a8cbb9bf8e1beb440e3226087074db",
|
||||
"src/main/usage/usage-cache-json-integrity.ts": "7fbe3f92f3e59d7f6e55a4edf50899f142a313d8662587c85fdc9489976231d7",
|
||||
"src/main/usage/usage-event-aggregation.ts": "31adda2e8e0a32756416fb49b2c3e02c707703bfe76434aabc95c61a7efd180b",
|
||||
"src/main/usage/usage-inline-cache-preservation.ts": "4167fe146fb6c87e2cbed190618fcb9e79166ec60fcc495106428b9fd0eecacf",
|
||||
"src/main/usage/usage-provider-store-lifecycle.ts": "7dfa6cfdd487149c39c07a491ce4d327ebc366fa4809c695f7eca056e7584901",
|
||||
"src/main/usage/usage-scan-worker-client.ts": "fe18350d29970d9100af6518c3d7982308f8e5e232bd956c6d2e07849e8a0386",
|
||||
"src/main/usage/usage-scan-worker-entry.ts": "480ccdb83674f85fbd8f33c27d4af3b8faad9d7e5075da4b99c8ff400542425a",
|
||||
"src/main/usage/usage-source-cache-file.ts": "ee53cd3c823ce5bd49892bf96486f48d285c0b4e536a295adfe5e94f147712f5",
|
||||
"src/main/window/foreground-activation-policy.ts": "239ed120ac1e6ede9918dc960685faf487d48d875c0f65dc5521776f79230740",
|
||||
"src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx": "8801f413a061d42d1986bc6abd28caa30184dbe56c6be9c59175a688105b579b",
|
||||
"src/renderer/src/components/native-chat/NativeChatToolRun.tsx": "3b1d3378f18ddc3a2bcb9a1253797cae430933a6195b68fa4f456a0e5dd64fb1",
|
||||
"src/renderer/src/components/native-chat/NativeChatToolRunMemberList.tsx": "e98c7d150acd4a4f63da0f5f70e02c033a347335be06a0e3c61ff5a5d87891f2",
|
||||
"src/renderer/src/components/native-chat/native-chat-task-list-history.ts": "9b1068f2e10c60ad78e0042de429384880aaf24726cf6b26dfa7a54ca0845cc1",
|
||||
"src/renderer/src/components/native-chat/native-chat-task-list-state.ts": "99897f87fb2df731e1a991eb82a1d863fe8c36115192fbcfa6508cf745ad9e26",
|
||||
"src/renderer/src/env.d.ts": "cea39433a66e2f28fbacf8152f5587a8355eaa0e2868b9f496349238d652f42c",
|
||||
"src/shared/native-chat-folded-tool-results.ts": "e563aad787ef7b96fe91077006347121ffb5e4accca44e56a8282a9d278e2881",
|
||||
"src/shared/native-chat-tool-fold.ts": "97b6a7e7bad809827178fd72a3343be9e6d9956c4eb30631de081f35d8126253",
|
||||
"src/shared/native-chat-tool-pairing.ts": "3ccb8d4ef673f4f6bc00d7f63fbc112cfd688585f8054ee3f53f2e745f712fdb",
|
||||
"src/shared/native-chat-tool-pairs.ts": "73e0da592a69f0962cba46d7fedfeb1feab01cc39d1d938d7460fabd17a09a33",
|
||||
"src/shared/native-chat-transcript-projection.ts": "9813324df27e4e8192347c38f7602f8f850a9611b2ee8f7527c40888795ebbe2",
|
||||
"src/shared/native-chat-turn-membership.ts": "6bb5d619ae4d6335345b855e07aca84fc79721d1ec32d6c5a2201453b78430af",
|
||||
"src/shared/native-chat-turn-row-attribution.ts": "a441a6dccfa304c95e18f6886fbc7a6c6bdc98c6fec65a637d0b7ad320028386",
|
||||
"src/shared/native-chat-types.ts": "449b3ed60a5e75de48584d9d4e33ad431342834d6c7a8951571304a21f7f5ded",
|
||||
"src/shared/structured-agent-session-projection.ts": "484deef1674afa52a3c16e304c0a1640e635d96eeabaddfe31d24e52cc295238",
|
||||
"src/shared/structured-agent-session-tool-call-block.ts": "2bfcf1bba56b94154ca7f5b3fc44a517754e61352512c5e486457e5ea4744e7e",
|
||||
"tests/e2e/helpers/electron-process-shutdown.ts": "94e6be0f7348309561186d529410621caace80966b5d6fc3e3e68182c0253c60",
|
||||
"tests/e2e/helpers/native-chat-transcript-fixture.ts": "e769ca91c2c2d117f817cd36544e1be99583139306444a411f699aa83053b60b",
|
||||
"tests/e2e/helpers/orca-app.ts": "f514cb2215434c57519e63c01a9ca49c129203f46ea470232fcb2bf9bb0cc573",
|
||||
"tests/e2e/helpers/store.ts": "82807c85d54f68bf4347ac37dee04a6112eae84521f3f597f883728df4627dea",
|
||||
"tests/e2e/native-chat-tool-pairing.spec.ts": "4a76af0989ae2e47df0e2f01792ff995d8b5b18546c3505e41ca42b9f7973608"
|
||||
},
|
||||
"sourceSnapshotSelection": "Prior desktop surface, fixture and launch policy plus recorded A/B/C production paths",
|
||||
"buildCommand": "ORCA_BACKGROUND_LAUNCH=1 pnpm run build:electron-vite --mode e2e",
|
||||
"compilerOnly": true,
|
||||
"buildOutputSha256": {
|
||||
"out/main/index.js": "4244ad9f7c023bbf4c9e305b3e7cd3784ffd5ade622485ecc03fa594c45bed67",
|
||||
"out/preload/index.js": "cd7ce30025711f399219fd3434ac7d7ea7eddc37c2dc9c4b833be1ef213b5395",
|
||||
"out/renderer/index.html": "ab10055bb8138465b737161af99c5a02d3050ac42585ddcbeaf26498aa58ca85",
|
||||
"out/renderer/assets/index-BZXVgZo3.js": "9e77badc5442eda7e35b8697f957daca98de54b9503a02860ab38acff703d969"
|
||||
},
|
||||
"rendererChatChunkSha256": {
|
||||
"out/renderer/assets/native-chat-layout-actions-p5EtP_LF.js": "13c94c32f25c5045ccd0e2bc527d42e3c5d7c376f92213840ad54dfdd323d765",
|
||||
"out/renderer/assets/native-chat-resume-run-view-BYp7PJSh.js": "6f8f69dede17abf9980a570d20cf6ce75c4123d76e91778f02ca9b51648033fa",
|
||||
"out/renderer/assets/native-chat-runtime-contract-DzrwGKiV.js": "abceb8886edfde4b2d4386cea8e11257ed520625049acbbb7cf26f4c9ac79942",
|
||||
"out/renderer/assets/native-chat-tool-identity-0GsWAP69.js": "717a8839df0551723beea9371a2e2faffdc5ca622955715a2992e825ed0ac0e0",
|
||||
"out/renderer/assets/native-chat-turn-status-D1KZFoU0.js": "33b27cba4f58a7c9971b470fe6ee23ed72fa7f6fa26435a37b75624739b625f4",
|
||||
"out/renderer/assets/native-chat-upgrade-tip-audience-CuyR88Mj.js": "7e956bbaec8304e930012cd908f2e83c8d9e4894c9f301b4354cb2caed580b5d",
|
||||
"out/renderer/assets/store-BuxSCsDN.js": "ead8c38c16619275a7c51df36338ff9bee2d167f9e12642ef4a22f3ee619764f",
|
||||
"out/renderer/assets/store-uwnXpoN1.js": "4fac6fd409f37ad74c7dfdaeef4fc714f6256b96eca81c03237983bcdf1c1e0a"
|
||||
},
|
||||
"rendererJavascriptManifest": {
|
||||
"files": 1235,
|
||||
"sha256": "6202398589f33d0e2289c8d111822f68fa0c77d7efffda76981b1d3bd27952e8"
|
||||
},
|
||||
"sourceStableThroughoutBuild": true,
|
||||
"buildCompletedAt": "2026-10-10T01:02:51.504006+00:00",
|
||||
"ensureElectronCommand": "ORCA_BACKGROUND_LAUNCH=1 pnpm run ensure:electron-runtime",
|
||||
"ensureElectronSeconds": 1.888,
|
||||
"nativeRuntimeEnsuredAfterCompilerOnlyBuild": true,
|
||||
"sourceAndBuildStableThroughoutCapture": true,
|
||||
"captureCompletedAt": "2026-10-10T01:03:28.129689+00:00",
|
||||
"chatProductionSourceSha256": {
|
||||
"mobile/src/session/MobileNativeChatToolRun.tsx": "a6690578f1fbd89ff2874bc80a069deed5807dd445a21fdaa7f285eee31f59cd",
|
||||
"src/main/native-chat/agent-session-journal/journal-legacy-import.ts": "73ec3704b9558b0468446a5a597a56d80decb5398239a2697f3244a9141714ec",
|
||||
"src/main/native-chat/transcript-line-decoders-codex.ts": "cbd4133da04988a9b86015349411a679a5b1b513738a7c10bf165869c49e05e4",
|
||||
"src/main/native-chat/transcript-record-blocks.ts": "110990b4d8ebd6f28ea8702de92568fff5af398bffa5dafdb81d93b65f7da594",
|
||||
"src/renderer/src/components/native-chat/NativeChatToolRun.tsx": "3b1d3378f18ddc3a2bcb9a1253797cae430933a6195b68fa4f456a0e5dd64fb1",
|
||||
"src/renderer/src/components/native-chat/NativeChatToolRunMemberList.tsx": "e98c7d150acd4a4f63da0f5f70e02c033a347335be06a0e3c61ff5a5d87891f2",
|
||||
"src/renderer/src/components/native-chat/native-chat-task-list-history.ts": "9b1068f2e10c60ad78e0042de429384880aaf24726cf6b26dfa7a54ca0845cc1",
|
||||
"src/renderer/src/components/native-chat/native-chat-task-list-state.ts": "99897f87fb2df731e1a991eb82a1d863fe8c36115192fbcfa6508cf745ad9e26",
|
||||
"src/shared/native-chat-folded-tool-results.ts": "e563aad787ef7b96fe91077006347121ffb5e4accca44e56a8282a9d278e2881",
|
||||
"src/shared/native-chat-tool-fold.ts": "97b6a7e7bad809827178fd72a3343be9e6d9956c4eb30631de081f35d8126253",
|
||||
"src/shared/native-chat-tool-pairing.ts": "3ccb8d4ef673f4f6bc00d7f63fbc112cfd688585f8054ee3f53f2e745f712fdb",
|
||||
"src/shared/native-chat-tool-pairs.ts": "73e0da592a69f0962cba46d7fedfeb1feab01cc39d1d938d7460fabd17a09a33",
|
||||
"src/shared/native-chat-transcript-projection.ts": "9813324df27e4e8192347c38f7602f8f850a9611b2ee8f7527c40888795ebbe2",
|
||||
"src/shared/native-chat-turn-membership.ts": "6bb5d619ae4d6335345b855e07aca84fc79721d1ec32d6c5a2201453b78430af",
|
||||
"src/shared/native-chat-turn-row-attribution.ts": "a441a6dccfa304c95e18f6886fbc7a6c6bdc98c6fec65a637d0b7ad320028386",
|
||||
"src/shared/native-chat-types.ts": "449b3ed60a5e75de48584d9d4e33ad431342834d6c7a8951571304a21f7f5ded",
|
||||
"src/shared/structured-agent-session-projection.ts": "484deef1674afa52a3c16e304c0a1640e635d96eeabaddfe31d24e52cc295238",
|
||||
"src/shared/structured-agent-session-tool-call-block.ts": "2bfcf1bba56b94154ca7f5b3fc44a517754e61352512c5e486457e5ea4744e7e"
|
||||
},
|
||||
"chatSourcesStillMatchAtDocumentationRefresh": true,
|
||||
"documentationRefreshedAt": "2026-10-10T01:04:47.586946+00:00",
|
||||
"combinedBuildScope": "Final C chat sources on the then-frozen combined worktree; later B-only optimizations reopened after this capture and are not included in this build."
|
||||
},
|
||||
"test": {
|
||||
"file": "tests/e2e/native-chat-tool-pairing.spec.ts",
|
||||
"sourceSha256": "4a76af0989ae2e47df0e2f01792ff995d8b5b18546c3505e41ca42b9f7973608",
|
||||
"passed": 1,
|
||||
"failed": 0,
|
||||
"testSeconds": 6.3,
|
||||
"totalSeconds": 7.5
|
||||
},
|
||||
"differential": {
|
||||
"source": "src/shared/native-chat-tool-pairs.ts",
|
||||
"sourceSha256": "6be4c6ac899c275362658ac69478cd3b71dec97b0cd59520f71eb5e1dfc093b4",
|
||||
"pairComparisons": 81920,
|
||||
"unpairedOccurrenceComparisons": 8192,
|
||||
"oracle": "independent original pending array scan",
|
||||
"status": "passed",
|
||||
"scope": "Historical earlier matcher revision measured before orphan/import hardening and optional-sink allocation changes; not a differential check of the current source hash"
|
||||
},
|
||||
"lint": {
|
||||
"basic": "passed --deny-warnings",
|
||||
"nativePlugin": "passed --deny-warnings",
|
||||
"typeAware": "passed --deny-warnings",
|
||||
"files": ["tests/e2e/native-chat-tool-pairing.spec.ts"]
|
||||
},
|
||||
"typecheck": {
|
||||
"status": "passed",
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 node node_modules/typescript/bin/tsc --noEmit -p config/tsconfig.native-chat-tool-pairing.verify.json",
|
||||
"temporaryConfiguration": {
|
||||
"extends": "./tsconfig.e2e.json",
|
||||
"include": [
|
||||
"../tests/e2e/native-chat-tool-pairing.spec.ts",
|
||||
"../src/renderer/src/env.d.ts",
|
||||
"../src/types/**/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"incremental": false,
|
||||
"composite": false
|
||||
}
|
||||
},
|
||||
"temporaryConfigurationRemoved": true,
|
||||
"scope": "This E2E spec and its imports with renderer ambient declarations"
|
||||
},
|
||||
"unitCoverage": {
|
||||
"journalImport": {
|
||||
"file": "src/main/native-chat/agent-session-journal/journal-legacy-import-tool-pairing.test.ts",
|
||||
"coverage": [
|
||||
"Separately imported calls and reverse batched results behind a silent call",
|
||||
"Singleton and multi-call records retain matching IDs",
|
||||
"Duplicate provider IDs retain FIFO order",
|
||||
"Calls and results without IDs remain positional",
|
||||
"Old persisted call rows missing IDs keep named outputs visible separately",
|
||||
"Codex function, custom-tool and local-shell call IDs survive singleton import"
|
||||
],
|
||||
"scope": "Actual importer and projector regression coverage; provider adoption launch not exercised in this Electron run"
|
||||
},
|
||||
"resultSummary": {
|
||||
"reportedByRendererOwner": true,
|
||||
"passedTests": 99,
|
||||
"files": 8,
|
||||
"scope": "Historical focused Result-only summary and adjacent renderer correctness suites from preceding integration"
|
||||
},
|
||||
"historicalAffectedDomainRun": {
|
||||
"files": 851,
|
||||
"passedTests": 8398,
|
||||
"todoTests": 5,
|
||||
"scope": "Historical affected-domain Node unit coverage from the preceding integration; not the current revision or whole repository unit suite",
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts --maxWorkers=4 src/main/claude-usage src/main/codex-usage src/main/usage src/shared/native-chat src/main/native-chat src/renderer/src/components/native-chat src/main/runtime/orchestration/structured-worker-journal-archive.test.ts src/main/runtime/rpc/methods/native-chat-ssh-transcript.test.ts",
|
||||
"log": "/tmp/orca-perf-2-hardening-domain-tests-node.log",
|
||||
"sourceCommit": "38598a51c5069a116a17eda3a35365761181be6e"
|
||||
},
|
||||
"historicalPerformanceContracts": {
|
||||
"files": 17,
|
||||
"passedTests": 155,
|
||||
"command": "pnpm test:perf:contracts",
|
||||
"scope": "Historical155-test performance contract run from the preceding integration"
|
||||
},
|
||||
"round2ReportedByCoordinator": {
|
||||
"fullTypecheck": "passed",
|
||||
"performanceContractTests": 163,
|
||||
"qualityGates": "passed",
|
||||
"independentCounsels": "passed",
|
||||
"scope": "Coordinator-reported combined contract/typecheck/quality validation before later B-only source optimizations; no whole-unit-suite claim"
|
||||
}
|
||||
},
|
||||
"launch": {
|
||||
"appPath": "/Users/nwparker/orca/workspaces/orca/perf-2",
|
||||
"windows": [
|
||||
{
|
||||
"visible": false,
|
||||
"focused": false
|
||||
}
|
||||
],
|
||||
"processId": 29225
|
||||
},
|
||||
"completion": {
|
||||
"allNativeWindowsHiddenAndUnfocused": true,
|
||||
"ownedElectronProcessAbsent": true,
|
||||
"processCheckCommand": "ps -p 29225 -o pid=,command=",
|
||||
"processCheckExitCode": 1,
|
||||
"processCheckStdout": "",
|
||||
"fixtureShutdown": "closeElectronAppForE2E, cleanupE2EDaemons and temporary transcript/profile cleanup completed"
|
||||
},
|
||||
"scenario": {
|
||||
"loader": "Synthetic Claude JSONL through production main-process native chat reader and real UI clicks",
|
||||
"silentCalls": 32,
|
||||
"namedOutputsCompletedInReverseOrder": 3,
|
||||
"unmatchedNamedOutputRetainedStandalone": true,
|
||||
"unmatchedNamedOutputAbsentFromCallRun": true,
|
||||
"standaloneOrphanOwnsNoNamedCallOutputs": true,
|
||||
"failedTodoWritePreservesAcceptedPendingTask": true,
|
||||
"standaloneSummaryUsesResultLabel": true,
|
||||
"standaloneSummaryHasNoFabricatedToolCallCount": true
|
||||
},
|
||||
"screenshot": "https://github.com/user-attachments/assets/79f41dc6-8ec8-4f12-8e89-e4d7ec4b5daa",
|
||||
"screenshotSha256": "67783ff3a4a7f4294fd3619d600ab61a32a5b9c6f3b40b1afb632e0b183c442f",
|
||||
"renderedInspection": "All three reverse completions belong to their named commands; unrelated output appears under separate Result summary and row without a fabricated tool-call count; accepted task remains pending 0/1 after matched failure",
|
||||
"beforeProof": {
|
||||
"screenshot": "https://github.com/user-attachments/assets/b9f84d6e-a786-42d5-b895-8a4319f972cd",
|
||||
"record": "before-validation.json",
|
||||
"baselineCommit": "51e7181850b022bae2d41091fe1229accef4bd50",
|
||||
"overlayFiles": [
|
||||
"src/shared/native-chat-tool-fold.ts",
|
||||
"src/main/native-chat/transcript-record-blocks.ts",
|
||||
"src/main/native-chat/transcript-line-decoders-codex.ts"
|
||||
],
|
||||
"scope": "Only these three modules were restored from the baseline in the then-current app; other code and hidden launch policy remained current. It demonstrates the original ownership defect, not a full historical app build or adopted-journal UI flow."
|
||||
},
|
||||
"scope": "Hidden desktop Electron ownership, standalone output retention and task correctness. No provider CLI run, provider adoption launch, mobile rendering, SSH transport or full-render timing measurement.",
|
||||
"sourceSnapshotScope": "Exact combined app build at the recorded source snapshot with final C sources. B-only optimizations reopened after capture and are not included. C production hashes remained unchanged at documentation refresh.",
|
||||
"mobileProof": {
|
||||
"record": "mobile-rendered-validation.json",
|
||||
"scope": "Actual production mobile Message/fold with React Native Web, browser build options and shell CSP; component disclosure and ownership, no native phone or transport"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import { createElement, Fragment, type ReactNode } from 'react'
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalRenderItem
|
||||
} from '../../../src/shared/agent-session-journal-types'
|
||||
import * as toolFold from '../../../src/shared/native-chat-tool-fold'
|
||||
import { projectStructuredItemsToNativeChat } from '../../../src/shared/structured-agent-session-projection'
|
||||
import { foldMobileNativeChatMessages } from './mobile-native-chat-render-data'
|
||||
import { styles } from './mobile-native-chat-message-styles'
|
||||
import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure'
|
||||
|
||||
vi.mock('react-native', async () => {
|
||||
const React = await import('react')
|
||||
const Text = ({ children, ...props }: { children?: ReactNode }): ReactNode =>
|
||||
React.createElement('Text', props, children)
|
||||
return {
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
Animated: {
|
||||
Text,
|
||||
Value: class {
|
||||
setValue(): void {}
|
||||
},
|
||||
loop: (animation: unknown) => animation,
|
||||
sequence: () => ({ start: vi.fn(), stop: vi.fn() }),
|
||||
timing: () => ({ start: vi.fn(), stop: vi.fn() })
|
||||
},
|
||||
Image: 'Image',
|
||||
Platform: { OS: 'ios' },
|
||||
Pressable: 'Pressable',
|
||||
ScrollView: ({ children, ...props }: { children?: ReactNode }) =>
|
||||
React.createElement('ScrollView', props, children),
|
||||
Text,
|
||||
View: ({ children, ...props }: { children?: ReactNode }) =>
|
||||
React.createElement('View', props, children),
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }
|
||||
}
|
||||
})
|
||||
vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() }))
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
ArrowUp: 'ArrowUp',
|
||||
Brain: 'Brain',
|
||||
ChevronDown: 'ChevronDown',
|
||||
Copy: 'Copy',
|
||||
SquareChevronRight: 'SquareChevronRight',
|
||||
SquareTerminal: 'SquareTerminal',
|
||||
Wrench: 'Wrench',
|
||||
ChevronRight: 'ChevronRight'
|
||||
}))
|
||||
vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' }))
|
||||
vi.mock('./MobileNativeChatMessageActionsSheet', () => ({
|
||||
MobileNativeChatMessageActionsSheet: 'MessageActionsSheet'
|
||||
}))
|
||||
|
||||
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
|
||||
|
||||
function message(blocks: NativeChatMessage['blocks']): NativeChatMessage {
|
||||
return { id: 'source', role: 'assistant', blocks, timestamp: 1, source: 'transcript' }
|
||||
}
|
||||
|
||||
function textIn(node: ReactTestInstance): string[] {
|
||||
return node
|
||||
.findAll((child) => String(child.type) === 'Text')
|
||||
.map((child) =>
|
||||
child.children
|
||||
.flatMap((part) =>
|
||||
typeof part === 'string' || typeof part === 'number' ? [String(part)] : []
|
||||
)
|
||||
.join('')
|
||||
)
|
||||
}
|
||||
|
||||
function button(root: ReactTestInstance, label: string): ReactTestInstance {
|
||||
return root.find((node) => String(node.type) === 'Pressable' && textIn(node).includes(label))
|
||||
}
|
||||
|
||||
function toolLines(root: ReactTestInstance): ReactTestInstance[] {
|
||||
return root.findAll(
|
||||
(node) =>
|
||||
String(node.type) === 'Pressable' &&
|
||||
node.findAll(
|
||||
(child) => String(child.type) === 'Text' && child.props.style === styles.toolName
|
||||
).length > 0
|
||||
)
|
||||
}
|
||||
|
||||
function press(node: ReactTestInstance): void {
|
||||
act(() => {
|
||||
node.props.onPress()
|
||||
})
|
||||
}
|
||||
|
||||
describe('mobile tool result availability', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function render(row: NativeChatMessage, turnExpanded = true): ReactTestRenderer {
|
||||
act(() => {
|
||||
renderer = create(createElement(MobileNativeChatMessage, { message: row, turnExpanded }))
|
||||
})
|
||||
return renderer!
|
||||
}
|
||||
|
||||
it('counts every orphan and lets the reader reveal the seventh output and hide it again', () => {
|
||||
const raw = message(
|
||||
Array.from({ length: 8 }, (_, index) => ({
|
||||
type: 'tool-result' as const,
|
||||
callId: `missing-${index}`,
|
||||
output: `preview ${index}\ncomplete output ${index}`
|
||||
}))
|
||||
)
|
||||
const [row] = foldMobileNativeChatMessages([raw])
|
||||
expect(row?.role).toBe('tool')
|
||||
expect(foldMobileNativeChatMessages([row!])).toEqual([row])
|
||||
const tree = render(row!)
|
||||
expect(textIn(tree.root)).toContain('8×')
|
||||
expect(
|
||||
tree.root.find(
|
||||
(node) => String(node.type) === 'Text' && node.props.style === styles.toolRunLabel
|
||||
).props.children
|
||||
).toBe('Result')
|
||||
expect(toolLines(tree.root)).toHaveLength(6)
|
||||
expect(textIn(tree.root)).not.toContain('preview 6')
|
||||
|
||||
press(button(tree.root, 'Show more'))
|
||||
expect(toolLines(tree.root)).toHaveLength(8)
|
||||
press(button(tree.root, 'preview 6'))
|
||||
expect(textIn(tree.root)).toContain('preview 6\ncomplete output 6')
|
||||
expect(textIn(tree.root)).not.toContain('preview 7\ncomplete output 7')
|
||||
|
||||
press(button(tree.root, 'Show less'))
|
||||
expect(toolLines(tree.root)).toHaveLength(6)
|
||||
expect(textIn(tree.root)).not.toContain('preview 6\ncomplete output 6')
|
||||
})
|
||||
|
||||
it('reveals a named completion beyond silent calls without attaching it to the prefix', () => {
|
||||
const calls: NativeChatMessage['blocks'] = Array.from({ length: 18 }, (_, index) => ({
|
||||
type: 'tool-call',
|
||||
callId: `call-${index}`,
|
||||
name: 'Bash',
|
||||
input: { command: `command ${index}` }
|
||||
}))
|
||||
const raw = message([
|
||||
...calls,
|
||||
{ type: 'tool-result', callId: 'call-17', output: 'late named output' }
|
||||
])
|
||||
const [row] = foldMobileNativeChatMessages([raw])
|
||||
const pairs = toolFold.pairToolBlocks(row!.blocks)
|
||||
expect(pairs.slice(0, 17).every((pair) => pair.result === undefined)).toBe(true)
|
||||
expect(pairs[17]?.result?.output).toBe('late named output')
|
||||
const tree = render(row!)
|
||||
expect(toolLines(tree.root)).toHaveLength(6)
|
||||
expect(textIn(tree.root)).not.toContain('late named output')
|
||||
|
||||
press(button(tree.root, 'Show more'))
|
||||
expect(toolLines(tree.root)).toHaveLength(12)
|
||||
press(button(tree.root, 'Show more'))
|
||||
expect(toolLines(tree.root)).toHaveLength(18)
|
||||
press(button(tree.root, 'command 17'))
|
||||
expect(textIn(tree.root)).toContain('late named output')
|
||||
expect(textIn(tree.root)).not.toContain('command 0\nlate named output')
|
||||
})
|
||||
|
||||
it.each(['calls', 'results'] as const)(
|
||||
'retains only the requested prefix and one reveal sentinel for 2,049 %s',
|
||||
(kind) => {
|
||||
const blocks: NativeChatMessage['blocks'] = Array.from({ length: 2049 }, (_, index) =>
|
||||
kind === 'calls'
|
||||
? { type: 'tool-call', callId: `call-${index}`, name: 'Bash', input: `input ${index}` }
|
||||
: { type: 'tool-result', callId: `missing-${index}`, output: `output ${index}` }
|
||||
)
|
||||
const pairing = vi.spyOn(toolFold, 'pairToolBlocks')
|
||||
const tree = render(message(blocks), false)
|
||||
expect(toolLines(tree.root)).toHaveLength(0)
|
||||
expect(pairing).not.toHaveBeenCalled()
|
||||
press(button(tree.root, '2049×'))
|
||||
expect(toolLines(tree.root)).toHaveLength(6)
|
||||
expect(pairing).toHaveBeenCalledTimes(1)
|
||||
expect(pairing).toHaveBeenLastCalledWith(blocks, 7)
|
||||
expect(pairing.mock.results[0]?.value).toHaveLength(7)
|
||||
expect(textIn(tree.root)).toContain('2049×')
|
||||
expect(button(tree.root, 'Show more').props.accessibilityRole).toBe('button')
|
||||
press(button(tree.root, 'Show more'))
|
||||
expect(pairing).toHaveBeenLastCalledWith(blocks, 13)
|
||||
expect(pairing.mock.results[1]?.value).toHaveLength(13)
|
||||
expect(toolLines(tree.root)).toHaveLength(12)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([true, false])(
|
||||
'draws derived outputs ahead of a prompt queued while stopping (host scopes: %s)',
|
||||
(hasScopes) => {
|
||||
function item(
|
||||
itemId: string,
|
||||
sequence: number,
|
||||
body: AgentJournalItemBody
|
||||
): AgentJournalRenderItem {
|
||||
return {
|
||||
itemId,
|
||||
sequence,
|
||||
body,
|
||||
revision: 1,
|
||||
observedAt: sequence,
|
||||
...(hasScopes
|
||||
? {
|
||||
turnScope:
|
||||
sequence <= 2
|
||||
? { kind: 'thread' as const }
|
||||
: { kind: 'turn' as const, turnItemId: 'turn' }
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
const items = [
|
||||
item('prompt', 1, {
|
||||
kind: 'message',
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text: 'question' }]
|
||||
}),
|
||||
item('turn', 2, { kind: 'turn', turnId: 'turn', userItemId: 'prompt', state: 'running' }),
|
||||
item('working-call', 3, {
|
||||
kind: 'tool-call',
|
||||
callId: 'working',
|
||||
name: 'Bash',
|
||||
input: 'still working',
|
||||
state: 'running'
|
||||
}),
|
||||
item('queued', 4, {
|
||||
kind: 'message',
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text: 'queued question' }]
|
||||
}),
|
||||
item('source', 5, {
|
||||
kind: 'message',
|
||||
role: 'assistant',
|
||||
blocks: [
|
||||
{ type: 'text', text: 'source context' },
|
||||
{ type: 'tool-result', callId: 'unavailable-a', output: 'output one\nfull one' },
|
||||
{ type: 'tool-result', callId: 'unavailable-b', output: 'output two\nfull two' }
|
||||
]
|
||||
})
|
||||
]
|
||||
let drawn: NativeChatMessage[] = []
|
||||
let waiting: string[] = []
|
||||
let live: string[] = []
|
||||
function Phone({ journal }: { journal: AgentJournalRenderItem[] }): React.JSX.Element {
|
||||
const raw = projectStructuredItemsToNativeChat(journal).map((row) =>
|
||||
row.id === 'queued' ? { ...row, queued: true as const } : row
|
||||
)
|
||||
const rows = foldMobileNativeChatMessages(raw)
|
||||
const disclosure = useMobileNativeChatTurnDisclosure({
|
||||
messages: rows,
|
||||
enabled: true,
|
||||
isWorking: true,
|
||||
stopping: true,
|
||||
turnJournal: { items: journal, submissions: [] },
|
||||
scopeKey: 'remote-host\0folder\0tab'
|
||||
})
|
||||
drawn = Array.from(disclosure.listMessages)
|
||||
waiting = disclosure.waitingRows.map((row) => row.item.id)
|
||||
live = drawn.flatMap((row, index) =>
|
||||
disclosure.resolveRow(index, row).activeTurnIsWorking ? [row.id] : []
|
||||
)
|
||||
return createElement(
|
||||
Fragment,
|
||||
null,
|
||||
...drawn.map((row, index) =>
|
||||
createElement(MobileNativeChatMessage, {
|
||||
key: row.id,
|
||||
message: row,
|
||||
...disclosure.resolveRow(index, row),
|
||||
structuredActivityUi: true,
|
||||
toolsExpanded: true
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
act(() => {
|
||||
renderer = create(createElement(Phone, { journal: items }))
|
||||
})
|
||||
const tree = renderer!
|
||||
const orphan = drawn.find((row) => row.role === 'tool')!
|
||||
expect(orphan.id).not.toBe('source')
|
||||
expect(orphan.journalPosition).toEqual({ sequence: 5, index: 0 })
|
||||
expect(drawn.map((row) => row.id)).toEqual(['prompt', 'working-call', 'source', orphan.id])
|
||||
expect(waiting).toEqual(['queued'])
|
||||
expect(live).toContain(orphan.id)
|
||||
expect(toolLines(tree.root)).toHaveLength(3)
|
||||
expect(textIn(tree.root)).toContain('output one\nfull one')
|
||||
expect(textIn(tree.root)).toContain('output two\nfull two')
|
||||
const firstOutput = button(tree.root, 'output one')
|
||||
act(() => tree.update(createElement(Phone, { journal: structuredClone(items) })))
|
||||
expect(drawn.find((row) => row.role === 'tool')?.id).toBe(orphan.id)
|
||||
expect(waiting).toEqual(['queued'])
|
||||
expect(button(tree.root, 'output one')).toBe(firstOutput)
|
||||
expect(foldMobileNativeChatMessages(drawn)).toEqual(drawn)
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps duplicate-ID rows independently open as results and later rows arrive', () => {
|
||||
const initial = message([
|
||||
{ type: 'tool-call', callId: 'duplicate', name: 'Bash', input: 'first input' },
|
||||
{ type: 'tool-call', callId: 'duplicate', name: 'Bash', input: 'second input' },
|
||||
{ type: 'tool-result', callId: 'duplicate', output: 'first output' }
|
||||
])
|
||||
const tree = render(initial)
|
||||
const first = button(tree.root, 'first input')
|
||||
const second = button(tree.root, 'second input')
|
||||
press(first)
|
||||
expect(textIn(tree.root)).toContain('first output')
|
||||
const updated = message([
|
||||
{ type: 'tool-call', callId: 'duplicate', name: 'Bash', input: 'changed first input' },
|
||||
{ type: 'tool-call', callId: 'duplicate', name: 'Bash', input: 'changed second input' },
|
||||
{ type: 'tool-result', callId: 'duplicate', output: 'first output' },
|
||||
{ type: 'tool-result', callId: 'duplicate', output: 'second output' },
|
||||
{ type: 'tool-call', callId: 'later', name: 'Bash', input: 'later input' }
|
||||
])
|
||||
act(() =>
|
||||
tree.update(createElement(MobileNativeChatMessage, { message: updated, turnExpanded: true }))
|
||||
)
|
||||
expect(button(tree.root, 'changed first input')).toBe(first)
|
||||
expect(button(tree.root, 'changed second input')).toBe(second)
|
||||
expect(textIn(tree.root)).toContain('first output')
|
||||
expect(textIn(tree.root)).not.toContain('second output')
|
||||
press(second)
|
||||
expect(textIn(tree.root)).toContain('second output')
|
||||
press(first)
|
||||
expect(textIn(tree.root)).not.toContain('first output')
|
||||
expect(textIn(tree.root)).toContain('second output')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatBlock } from '../../../src/shared/native-chat-types'
|
||||
import { ToolRun } from './MobileNativeChatToolRun'
|
||||
|
||||
vi.mock('react-native', () => ({ Text: 'span', View: 'div', Pressable: 'button' }))
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
ChevronDown: () => null,
|
||||
SquareChevronRight: () => null,
|
||||
SquareTerminal: () => null,
|
||||
Wrench: () => null
|
||||
}))
|
||||
vi.mock('./mobile-native-chat-message-styles', () => ({ styles: {} }))
|
||||
|
||||
let tree: ReactTestRenderer | undefined
|
||||
afterEach(async () => {
|
||||
await act(async () => tree?.unmount())
|
||||
tree = undefined
|
||||
})
|
||||
|
||||
function blocks(id: string, command: string, output: string): NativeChatBlock[] {
|
||||
return [
|
||||
{ type: 'tool-call', name: 'Bash', callId: id, input: { command } },
|
||||
{ type: 'tool-result', callId: id, output: `preview-${id}\n${output}` }
|
||||
]
|
||||
}
|
||||
|
||||
async function update(next: NativeChatBlock[]): Promise<ReactTestRenderer> {
|
||||
await act(async () => {
|
||||
const element = createElement(ToolRun, {
|
||||
blocks: next,
|
||||
defaultExpanded: true,
|
||||
expandChildren: false,
|
||||
activeCall: null
|
||||
})
|
||||
if (tree) {
|
||||
tree.update(element)
|
||||
} else {
|
||||
tree = create(element)
|
||||
}
|
||||
})
|
||||
if (!tree) {
|
||||
throw new Error('Tool run did not mount.')
|
||||
}
|
||||
return tree
|
||||
}
|
||||
|
||||
async function open(rendered: ReactTestRenderer, label: string, occurrence = 0): Promise<void> {
|
||||
const node = rendered.root.findAllByType('span').filter((item) => item.children.includes(label))[
|
||||
occurrence
|
||||
]
|
||||
if (!node?.parent) {
|
||||
throw new Error(`Tool row is missing: ${label}`)
|
||||
}
|
||||
await act(async () => node.parent?.props.onPress())
|
||||
}
|
||||
|
||||
function details(rendered: ReactTestRenderer): string {
|
||||
return JSON.stringify(rendered.toJSON())
|
||||
}
|
||||
|
||||
it('keeps expansion with the same command after prefix removal, reordering and input updates', async () => {
|
||||
const first = blocks('first', 'echo first', 'FIRST_DETAIL')
|
||||
const second = blocks('second', 'echo second', 'SECOND_DETAIL')
|
||||
const third = blocks('third', 'echo third', 'THIRD_DETAIL')
|
||||
const rendered = await update([...first, ...second, ...third])
|
||||
await open(rendered, 'echo second')
|
||||
expect(details(rendered)).toContain('SECOND_DETAIL')
|
||||
expect(details(rendered)).not.toContain('FIRST_DETAIL')
|
||||
await update([...third, ...second])
|
||||
expect(details(rendered)).toContain('SECOND_DETAIL')
|
||||
expect(details(rendered)).not.toContain('THIRD_DETAIL')
|
||||
await update([...blocks('second', 'echo updated', 'UPDATED_DETAIL'), ...third])
|
||||
expect(details(rendered)).toContain('UPDATED_DETAIL')
|
||||
expect(details(rendered)).not.toContain('THIRD_DETAIL')
|
||||
})
|
||||
|
||||
it('opens repeated provider IDs independently after an unrelated prefix disappears', async () => {
|
||||
const prefix = blocks('prefix', 'echo prefix', 'PREFIX_DETAIL')
|
||||
const duplicate = [
|
||||
...blocks('duplicate', 'echo repeated', 'FIRST_DETAIL'),
|
||||
...blocks('duplicate', 'echo repeated', 'SECOND_DETAIL')
|
||||
]
|
||||
const rendered = await update([...prefix, ...duplicate])
|
||||
await open(rendered, 'echo repeated', 1)
|
||||
expect(details(rendered)).toContain('SECOND_DETAIL')
|
||||
expect(details(rendered)).not.toContain('FIRST_DETAIL')
|
||||
await update(duplicate)
|
||||
expect(details(rendered)).toContain('SECOND_DETAIL')
|
||||
expect(details(rendered)).not.toContain('FIRST_DETAIL')
|
||||
})
|
||||
|
||||
it('does not transfer an opened orphan result to an unrelated command', async () => {
|
||||
const orphan: NativeChatBlock = {
|
||||
type: 'tool-result',
|
||||
callId: 'missing',
|
||||
output: 'ORPHAN\nDETAIL'
|
||||
}
|
||||
const rendered = await update([orphan, ...blocks('first', 'echo first', 'FIRST_DETAIL')])
|
||||
await open(rendered, 'ORPHAN')
|
||||
expect(details(rendered)).toContain('ORPHAN\\nDETAIL')
|
||||
await update(blocks('first', 'echo first', 'FIRST_DETAIL'))
|
||||
expect(details(rendered)).not.toContain('FIRST_DETAIL')
|
||||
})
|
||||
|
||||
it('keeps a named orphan result open after a copied history refresh and output update', async () => {
|
||||
const orphan: NativeChatBlock = {
|
||||
type: 'tool-result',
|
||||
callId: 'missing',
|
||||
output: 'ORPHAN\nORIGINAL_DETAIL'
|
||||
}
|
||||
const other: NativeChatBlock = {
|
||||
type: 'tool-result',
|
||||
callId: 'other',
|
||||
output: 'OTHER\nOTHER_DETAIL'
|
||||
}
|
||||
const rendered = await update([orphan, other])
|
||||
await open(rendered, 'ORPHAN')
|
||||
expect(details(rendered)).toContain('ORIGINAL_DETAIL')
|
||||
await update([structuredClone(other), { ...orphan, output: 'ORPHAN\nUPDATED_DETAIL' }])
|
||||
expect(details(rendered)).toContain('UPDATED_DETAIL')
|
||||
expect(details(rendered)).not.toContain('OTHER_DETAIL')
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Animated, Pressable, Text, View } from 'react-native'
|
||||
import { ChevronDown, SquareChevronRight, SquareTerminal, Wrench } from 'lucide-react-native'
|
||||
import { diffFromText, diffFromToolCall } from '../../../src/shared/native-chat-diff'
|
||||
@@ -18,10 +18,11 @@ import {
|
||||
} from '../../../src/shared/native-chat-tool-activity'
|
||||
import { isShellActivityToolCall } from '../../../src/shared/native-chat-tool-icon'
|
||||
import type { NativeChatBlock } from '../../../src/shared/native-chat-types'
|
||||
import { nativeChatToolLineIdentity } from '../../../src/shared/native-chat-tool-line-identity'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { styles } from './mobile-native-chat-message-styles'
|
||||
|
||||
const MAX_VISIBLE_TOOL_PAIRS = 6
|
||||
const TOOL_PAIR_REVEAL_PAGE = 6
|
||||
const MAX_TOOL_RUN_DIFF_ROWS = 240
|
||||
|
||||
function DiffView({ lines }: { lines: DiffLine[] }): React.JSX.Element {
|
||||
@@ -183,16 +184,28 @@ export function ToolRun({
|
||||
onOpenFile?: (relativePath: string) => void
|
||||
}): React.JSX.Element {
|
||||
const [open, setOpen] = useState(defaultExpanded)
|
||||
const pairs = pairToolBlocks(blocks, MAX_VISIBLE_TOOL_PAIRS)
|
||||
const diffLineLimit = Math.max(1, Math.floor(MAX_TOOL_RUN_DIFF_ROWS / (pairs.length * 2 || 1)))
|
||||
const [visibleCount, setVisibleCount] = useState(TOOL_PAIR_REVEAL_PAGE)
|
||||
const pairs = useMemo(
|
||||
() => (open ? pairToolBlocks(blocks, visibleCount + 1) : []),
|
||||
[blocks, open, visibleCount]
|
||||
)
|
||||
const visiblePairs = pairs.slice(0, visibleCount)
|
||||
const seen = new Map<string, number>()
|
||||
const diffLineLimit = Math.max(
|
||||
1,
|
||||
Math.floor(MAX_TOOL_RUN_DIFF_ROWS / (visiblePairs.length * 2 || 1))
|
||||
)
|
||||
let callCount = 0
|
||||
let resultCount = 0
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'tool-call') {
|
||||
callCount++
|
||||
} else if (block.type === 'tool-result') {
|
||||
resultCount++
|
||||
}
|
||||
}
|
||||
callCount ||= pairs.length
|
||||
const summary = summarizeToolRun(blocks)
|
||||
const count = callCount || resultCount
|
||||
const summary = callCount > 0 ? summarizeToolRun(blocks) : 'Result'
|
||||
// The call's input, not its word: Codex names a classified shell row
|
||||
// `read`/`search`/`list` and keeps the command it ran, while Claude's `Read`
|
||||
// shares that word and ran none.
|
||||
@@ -222,26 +235,45 @@ export function ToolRun({
|
||||
) : (
|
||||
<SquareChevronRight size={15} color={colors.textMuted} strokeWidth={2} />
|
||||
)}
|
||||
<Text style={styles.toolRunCount}>{callCount}×</Text>
|
||||
<Text style={styles.toolRunCount}>{count}×</Text>
|
||||
<Text style={styles.toolRunLabel} numberOfLines={1}>
|
||||
{summary || formatToolCallCount(callCount)}
|
||||
{summary || formatToolCallCount(count)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
{open ? (
|
||||
<View style={styles.toolRunBody}>
|
||||
{pairs.map((pair, i) => (
|
||||
{visiblePairs.map((pair) => (
|
||||
<ToolLine
|
||||
key={i}
|
||||
key={nativeChatToolLineIdentity(pair.call ?? pair.result!, seen, true)}
|
||||
pair={pair}
|
||||
defaultExpanded={expandChildren}
|
||||
diffLineLimit={diffLineLimit}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
))}
|
||||
{callCount > pairs.length ? (
|
||||
<Text style={styles.toolPreview}>… {callCount - pairs.length} more tool calls</Text>
|
||||
{pairs.length > visiblePairs.length ? (
|
||||
<Pressable
|
||||
style={styles.toolLine}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ expanded: visibleCount > TOOL_PAIR_REVEAL_PAGE }}
|
||||
onPress={() => setVisibleCount((count) => count + TOOL_PAIR_REVEAL_PAGE)}
|
||||
hitSlop={6}
|
||||
>
|
||||
<Text style={styles.toolPreview}>Show more</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{visiblePairs.length > TOOL_PAIR_REVEAL_PAGE ? (
|
||||
<Pressable
|
||||
style={styles.toolLine}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ expanded: true }}
|
||||
onPress={() => setVisibleCount(TOOL_PAIR_REVEAL_PAGE)}
|
||||
hitSlop={6}
|
||||
>
|
||||
<Text style={styles.toolPreview}>Show less</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { pairToolBlocks } from '../../../src/shared/native-chat-tool-fold'
|
||||
import { nativeChatTurnMembership } from '../../../src/shared/native-chat-turn-membership'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { foldMobileNativeChatMessages } from './mobile-native-chat-render-data'
|
||||
import {
|
||||
useMobileNativeChatSession,
|
||||
type MobileNativeChatSession
|
||||
} from './use-mobile-native-chat-session'
|
||||
|
||||
function message(
|
||||
id: string,
|
||||
blocks: NativeChatMessage['blocks'],
|
||||
role: NativeChatMessage['role'] = 'assistant'
|
||||
): NativeChatMessage {
|
||||
return { id, role, blocks, timestamp: 1, source: 'transcript' }
|
||||
}
|
||||
|
||||
function pairOutputs(rows: readonly NativeChatMessage[]): [string | undefined, string][] {
|
||||
return rows.flatMap((row) =>
|
||||
pairToolBlocks(row.blocks).flatMap((pair) => {
|
||||
if (!pair.result) {
|
||||
return []
|
||||
}
|
||||
const output: [string | undefined, string] = [pair.call?.callId, pair.result.output]
|
||||
return [output]
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
describe('mobile transcript tool result paging', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let session: MobileNativeChatSession | null = null
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
session = null
|
||||
})
|
||||
|
||||
function current(): MobileNativeChatSession {
|
||||
if (!session) {
|
||||
throw new Error('session hook did not render')
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
function Harness({ client }: { client: RpcClient }): null {
|
||||
session = useMobileNativeChatSession({
|
||||
client,
|
||||
sourceIdentity: 'ssh-host\0folder-workspace',
|
||||
agent: 'claude',
|
||||
sessionId: 'remote-session',
|
||||
transcriptPath: null
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
it.each([true, false])(
|
||||
'restores exact ownership after an earlier read and reconnect (host cursors: %s)',
|
||||
async (hasCursor) => {
|
||||
const tail = [
|
||||
{
|
||||
...message(
|
||||
'backlog',
|
||||
Array.from({ length: 2048 }, (_, index) => ({
|
||||
type: 'tool-call' as const,
|
||||
callId: `silent-${index}`,
|
||||
name: 'Bash',
|
||||
input: { command: `command ${index}` }
|
||||
}))
|
||||
),
|
||||
transcriptOffset: 700
|
||||
},
|
||||
{
|
||||
...message('source-results', [
|
||||
{ type: 'tool-result', callId: 'outside-a', output: 'output A' },
|
||||
{ type: 'tool-result', callId: 'silent-2047', output: 'late named output' },
|
||||
{ type: 'tool-result', callId: 'outside-b', output: 'output B', isError: true }
|
||||
]),
|
||||
transcriptOffset: 800
|
||||
}
|
||||
]
|
||||
const older = [
|
||||
message('prompt', [{ type: 'text', text: 'original question' }], 'user'),
|
||||
message('older-calls', [
|
||||
{ type: 'tool-call', callId: 'outside-a', name: 'Bash', input: 'A' },
|
||||
{ type: 'tool-call', callId: 'outside-b', name: 'Bash', input: 'B' }
|
||||
])
|
||||
]
|
||||
let emit: (frame: unknown) => void = () => {}
|
||||
const sendRequest = vi.fn<RpcClient['sendRequest']>(async () => ({
|
||||
id: 'older-page',
|
||||
ok: true,
|
||||
result: {
|
||||
messages: hasCursor ? older : [...older, ...tail],
|
||||
hasMore: false,
|
||||
...(hasCursor ? { beforeOffset: 0 } : {})
|
||||
}
|
||||
}))
|
||||
const client: RpcClient = {
|
||||
sendRequest,
|
||||
subscribe: (_method, _params, onData) => {
|
||||
emit = onData
|
||||
onData({
|
||||
type: 'snapshot',
|
||||
messages: structuredClone(tail),
|
||||
hasMore: true,
|
||||
...(hasCursor ? { beforeOffset: 700 } : {})
|
||||
})
|
||||
return () => {}
|
||||
},
|
||||
updateTerminalSubscriptionViewport: () => {},
|
||||
getState: () => 'connected',
|
||||
getReconnectAttempt: () => 0,
|
||||
getLastConnectedAt: () => 1,
|
||||
onStateChange: () => () => {},
|
||||
notifyForeground: () => {},
|
||||
close: () => {}
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { client }))
|
||||
})
|
||||
|
||||
const windowed = foldMobileNativeChatMessages(current().messages)
|
||||
expect(pairOutputs(windowed)).toEqual([
|
||||
['silent-2047', 'late named output'],
|
||||
[undefined, 'output A'],
|
||||
[undefined, 'output B']
|
||||
])
|
||||
expect(windowed[1]).toMatchObject({
|
||||
id: 'source-results',
|
||||
role: 'tool',
|
||||
transcriptOffset: 800,
|
||||
blocks: [{ output: 'output A' }, { output: 'output B', isError: true }]
|
||||
})
|
||||
expect(foldMobileNativeChatMessages(windowed)).toEqual(windowed)
|
||||
expect(current().messages).toEqual(tail)
|
||||
expect(current().messages.every((row) => !Object.hasOwn(row, 'unpairedToolResults'))).toBe(
|
||||
true
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
current().loadEarlier()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(sendRequest).toHaveBeenCalledWith('nativeChat.readSession', {
|
||||
agent: 'claude',
|
||||
sessionId: 'remote-session',
|
||||
limit: hasCursor ? 60 : 100,
|
||||
...(hasCursor ? { beforeOffset: 700 } : {})
|
||||
})
|
||||
expect(current().messages.map((row) => row.id)).toEqual([
|
||||
'prompt',
|
||||
'older-calls',
|
||||
'backlog',
|
||||
'source-results'
|
||||
])
|
||||
const whole = foldMobileNativeChatMessages(current().messages)
|
||||
expect(pairOutputs(whole)).toEqual([
|
||||
['outside-a', 'output A'],
|
||||
['outside-b', 'output B'],
|
||||
['silent-2047', 'late named output']
|
||||
])
|
||||
expect(whole.some((row) => row.role === 'tool')).toBe(false)
|
||||
expect(nativeChatTurnMembership(whole).turnKeys).toEqual(['prompt', 'prompt'])
|
||||
expect(foldMobileNativeChatMessages(whole)).toEqual(whole)
|
||||
|
||||
await act(async () => {
|
||||
emit({ type: 'snapshot', messages: structuredClone(tail), hasMore: true })
|
||||
})
|
||||
expect(foldMobileNativeChatMessages(current().messages)).toEqual(whole)
|
||||
expect(current().hasMore).toBe(false)
|
||||
expect(current().messages.every((row) => !Object.hasOwn(row, 'unpairedToolResults'))).toBe(
|
||||
true
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
+316
@@ -0,0 +1,316 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
|
||||
import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types'
|
||||
import { foldToolMessages, pairToolBlocks } from '../../../shared/native-chat-tool-fold'
|
||||
import { projectNativeChatTranscriptMessages } from '../../../shared/native-chat-transcript-projection'
|
||||
import { projectStructuredItemsToNativeChat } from '../../../shared/structured-agent-session-projection'
|
||||
import { prepareLegacyTranscriptImport } from './journal-legacy-import'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function importRows(records: unknown[], agent: 'claude' | 'codex' = 'claude') {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-import-tool-pairing-'))
|
||||
roots.push(root)
|
||||
const filePath = join(root, 'transcript.jsonl')
|
||||
await writeFile(filePath, `${records.map((record) => JSON.stringify(record)).join('\n')}\n`)
|
||||
const prepared = await prepareLegacyTranscriptImport({
|
||||
agent,
|
||||
sessionId: 'imported-session',
|
||||
options: { filePath }
|
||||
})
|
||||
if (!prepared.ok) {
|
||||
throw new Error(prepared.error)
|
||||
}
|
||||
const items: AgentJournalRenderItem[] = prepared.items.map((item, index) => ({
|
||||
itemId: agentJournalItemKey(item.identity),
|
||||
body: item.body,
|
||||
revision: 0,
|
||||
sequence: index + 1,
|
||||
observedAt: item.observedAt ?? index + 1
|
||||
}))
|
||||
const messages = projectStructuredItemsToNativeChat(items)
|
||||
const pairs = foldToolMessages(messages).flatMap((message) => pairToolBlocks(message.blocks))
|
||||
return { items, messages, pairs }
|
||||
}
|
||||
|
||||
function claudeRecord(uuid: string, role: 'assistant' | 'user', content: unknown) {
|
||||
return {
|
||||
type: role,
|
||||
uuid,
|
||||
sessionId: 'imported-session',
|
||||
timestamp: '2026-10-09T10:00:00.000Z',
|
||||
message: { role, content }
|
||||
}
|
||||
}
|
||||
|
||||
function claudeCall(id: string | undefined, command: string) {
|
||||
return {
|
||||
type: 'tool_use',
|
||||
...(id !== undefined ? { id } : {}),
|
||||
name: 'Bash',
|
||||
input: { command }
|
||||
}
|
||||
}
|
||||
|
||||
function claudeResult(toolUseId: string | undefined, content: string, isError = false) {
|
||||
return {
|
||||
type: 'tool_result',
|
||||
...(toolUseId !== undefined ? { tool_use_id: toolUseId } : {}),
|
||||
content,
|
||||
...(isError ? { is_error: true } : {})
|
||||
}
|
||||
}
|
||||
|
||||
describe('legacy import tool identity', () => {
|
||||
it('pairs batched named results with separately imported calls past a silent call', async () => {
|
||||
const { items, pairs } = await importRows([
|
||||
claudeRecord('silent-call', 'assistant', [claudeCall('silent', 'echo silent')]),
|
||||
claudeRecord('first-call', 'assistant', [claudeCall('first', 'echo first')]),
|
||||
claudeRecord('second-call', 'assistant', [claudeCall('second', 'echo second')]),
|
||||
claudeRecord('results', 'user', [
|
||||
claudeResult('second', 'SECOND', true),
|
||||
claudeResult('first', 'FIRST')
|
||||
])
|
||||
])
|
||||
expect(pairs).toMatchObject([
|
||||
{ call: { callId: 'silent', input: { command: 'echo silent' } } },
|
||||
{
|
||||
call: { callId: 'first', input: { command: 'echo first' } },
|
||||
result: { callId: 'first', output: 'FIRST' }
|
||||
},
|
||||
{
|
||||
call: { callId: 'second', input: { command: 'echo second' } },
|
||||
result: { callId: 'second', output: 'SECOND', isError: true }
|
||||
}
|
||||
])
|
||||
expect(pairs[0]?.result).toBeUndefined()
|
||||
expect(items.slice(0, 3).map((item) => item.body)).toMatchObject([
|
||||
{ kind: 'tool-call', callId: 'silent' },
|
||||
{ kind: 'tool-call', callId: 'first' },
|
||||
{ kind: 'tool-call', callId: 'second' }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps identity consistent across single-call and multi-call import records', async () => {
|
||||
const { pairs } = await importRows([
|
||||
claudeRecord('first-call', 'assistant', [claudeCall('first', 'echo first')]),
|
||||
claudeRecord('more-calls', 'assistant', [
|
||||
claudeCall('second', 'echo second'),
|
||||
claudeCall('third', 'echo third')
|
||||
]),
|
||||
claudeRecord('results', 'user', [
|
||||
claudeResult('third', 'THIRD'),
|
||||
claudeResult('second', 'SECOND'),
|
||||
claudeResult('first', 'FIRST')
|
||||
])
|
||||
])
|
||||
expect(pairs.map((pair) => [pair.call?.callId, pair.result?.output])).toEqual([
|
||||
['first', 'FIRST'],
|
||||
['second', 'SECOND'],
|
||||
['third', 'THIRD']
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves FIFO for duplicate provider IDs', async () => {
|
||||
const { pairs } = await importRows([
|
||||
claudeRecord('first-call', 'assistant', [claudeCall('same', 'echo first')]),
|
||||
claudeRecord('second-call', 'assistant', [claudeCall('same', 'echo second')]),
|
||||
claudeRecord('results', 'user', [
|
||||
claudeResult('same', 'FIRST'),
|
||||
claudeResult('same', 'SECOND')
|
||||
])
|
||||
])
|
||||
expect(pairs.map((pair) => [pair.call?.input, pair.result?.output])).toEqual([
|
||||
[{ command: 'echo first' }, 'FIRST'],
|
||||
[{ command: 'echo second' }, 'SECOND']
|
||||
])
|
||||
})
|
||||
|
||||
it('pairs singleton result records with real calls instead of inventing extra invocations', async () => {
|
||||
const { items, pairs } = await importRows([
|
||||
claudeRecord('silent-call', 'assistant', [claudeCall('silent', 'echo silent')]),
|
||||
claudeRecord('first-call', 'assistant', [claudeCall('first', 'echo first')]),
|
||||
claudeRecord('second-call', 'assistant', [claudeCall('second', 'echo second')]),
|
||||
{
|
||||
...claudeRecord('second-output', 'user', [claudeResult('second', 'SECOND', true)]),
|
||||
toolUseResult: {
|
||||
filePath: 'example.ts',
|
||||
structuredPatch: [
|
||||
{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-old', '+new'] }
|
||||
]
|
||||
}
|
||||
},
|
||||
claudeRecord('first-output', 'user', [claudeResult('first', 'FIRST')])
|
||||
])
|
||||
expect(
|
||||
pairs.map((pair) => [pair.call?.callId, pair.result?.output, pair.result?.isError])
|
||||
).toEqual([
|
||||
['silent', undefined, undefined],
|
||||
['first', 'FIRST', undefined],
|
||||
['second', 'SECOND', true]
|
||||
])
|
||||
expect(items.slice(3).map((item) => item.body)).toMatchObject([
|
||||
{ kind: 'message', blocks: [{ type: 'tool-result', callId: 'second', isError: true }] },
|
||||
{ kind: 'message', blocks: [{ type: 'tool-result', callId: 'first' }] }
|
||||
])
|
||||
expect(pairs[2]?.result?.editPatch).toEqual({
|
||||
filePath: 'example.ts',
|
||||
hunks: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-old', '+new'] }]
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves the exact TodoWrite call each singleton acceptance or rejection answers', async () => {
|
||||
const todo = (id: string, content: string) => ({
|
||||
type: 'tool_use',
|
||||
id,
|
||||
name: 'TodoWrite',
|
||||
input: { todos: [{ content, status: 'pending' }] }
|
||||
})
|
||||
const { pairs } = await importRows([
|
||||
claudeRecord('accepted-call', 'assistant', [todo('accepted', 'Keep accepted')]),
|
||||
claudeRecord('accepted-result', 'user', [claudeResult('accepted', 'ACCEPTED')]),
|
||||
claudeRecord('rejected-call', 'assistant', [todo('rejected', 'Discard rejected')]),
|
||||
claudeRecord('rejected-result', 'user', [claudeResult('rejected', 'REJECTED', true)])
|
||||
])
|
||||
expect(pairs).toHaveLength(2)
|
||||
expect(pairs).toMatchObject([
|
||||
{
|
||||
call: {
|
||||
callId: 'accepted',
|
||||
name: 'TodoWrite',
|
||||
input: { todos: [{ content: 'Keep accepted', status: 'pending' }] }
|
||||
},
|
||||
result: { callId: 'accepted', output: 'ACCEPTED' }
|
||||
},
|
||||
{
|
||||
call: {
|
||||
callId: 'rejected',
|
||||
name: 'TodoWrite',
|
||||
input: { todos: [{ content: 'Discard rejected', status: 'pending' }] }
|
||||
},
|
||||
result: { callId: 'rejected', output: 'REJECTED', isError: true }
|
||||
}
|
||||
])
|
||||
expect(pairs[0]?.result?.isError).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps legacy calls and results without provider IDs positional', async () => {
|
||||
const { items, pairs } = await importRows([
|
||||
claudeRecord('first-call', 'assistant', [claudeCall(undefined, 'echo first')]),
|
||||
claudeRecord('second-call', 'assistant', [claudeCall(undefined, 'echo second')]),
|
||||
claudeRecord('results', 'user', [
|
||||
claudeResult(undefined, 'FIRST'),
|
||||
claudeResult(undefined, 'SECOND')
|
||||
])
|
||||
])
|
||||
expect(pairs.map((pair) => [pair.call?.input, pair.result?.output])).toEqual([
|
||||
[{ command: 'echo first' }, 'FIRST'],
|
||||
[{ command: 'echo second' }, 'SECOND']
|
||||
])
|
||||
expect(Object.hasOwn(items[0]?.body ?? {}, 'callId')).toBe(false)
|
||||
expect(Object.hasOwn(items[1]?.body ?? {}, 'callId')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps outputs from already imported journals visible without guessing their call ownership', async () => {
|
||||
const { items } = await importRows([
|
||||
claudeRecord('first-call', 'assistant', [claudeCall('first', 'echo first')]),
|
||||
claudeRecord('second-call', 'assistant', [claudeCall('second', 'echo second')]),
|
||||
claudeRecord('results', 'user', [
|
||||
claudeResult('second', 'SECOND', true),
|
||||
claudeResult('first', 'FIRST')
|
||||
])
|
||||
])
|
||||
const oldItems = items.map((item) =>
|
||||
item.body.kind === 'tool-call'
|
||||
? {
|
||||
...item,
|
||||
body: {
|
||||
kind: 'tool-call' as const,
|
||||
name: item.body.name,
|
||||
input: item.body.input,
|
||||
state: item.body.state
|
||||
}
|
||||
}
|
||||
: item
|
||||
)
|
||||
const projected = projectNativeChatTranscriptMessages(
|
||||
projectStructuredItemsToNativeChat(oldItems)
|
||||
)
|
||||
const pairs = projected.flatMap((message) => pairToolBlocks(message.blocks))
|
||||
expect(pairs.map((pair) => [pair.call?.input, pair.result?.output])).toEqual([
|
||||
[{ command: 'echo first' }, undefined],
|
||||
[{ command: 'echo second' }, undefined],
|
||||
[undefined, 'SECOND'],
|
||||
[undefined, 'FIRST']
|
||||
])
|
||||
expect(projected[1]).toMatchObject({ role: 'tool', journalPosition: { sequence: 3, index: 0 } })
|
||||
expect(pairs[2]?.result?.isError).toBe(true)
|
||||
expect(projectNativeChatTranscriptMessages(projected)).toEqual(projected)
|
||||
})
|
||||
|
||||
it.each(['function_call', 'custom_tool_call', 'local_shell_call'] as const)(
|
||||
'preserves Codex %s identity through the single-call import',
|
||||
async (type) => {
|
||||
const { items, messages } = await importRows(
|
||||
[
|
||||
{
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type,
|
||||
id: 'call-record',
|
||||
call_id: 'provider-call',
|
||||
name: 'exec',
|
||||
input: 'pwd'
|
||||
}
|
||||
}
|
||||
],
|
||||
'codex'
|
||||
)
|
||||
expect(items[0]?.body).toMatchObject({ kind: 'tool-call', callId: 'provider-call' })
|
||||
expect(messages[0]?.blocks[0]).toMatchObject({ type: 'tool-call', callId: 'provider-call' })
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['function_call', 'function_call_output'],
|
||||
['custom_tool_call', 'custom_tool_call_output'],
|
||||
['local_shell_call', 'function_call_output']
|
||||
])('pairs Codex singleton %s and %s import records', async (callType, outputType) => {
|
||||
const { pairs } = await importRows(
|
||||
[
|
||||
{
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: callType,
|
||||
id: 'call-record',
|
||||
call_id: 'provider-call',
|
||||
name: 'exec',
|
||||
input: 'pwd'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: outputType,
|
||||
id: 'output-record',
|
||||
call_id: 'provider-call',
|
||||
output: 'PROVIDER OUTPUT'
|
||||
}
|
||||
}
|
||||
],
|
||||
'codex'
|
||||
)
|
||||
expect(pairs).toHaveLength(1)
|
||||
expect(pairs[0]).toMatchObject({
|
||||
call: { callId: 'provider-call' },
|
||||
result: { callId: 'provider-call', output: 'PROVIDER OUTPUT' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -427,14 +427,17 @@ describe('payload bounds on import', () => {
|
||||
})
|
||||
|
||||
const item = journal.snapshot().items[0]
|
||||
expect(item?.body).toMatchObject({ kind: 'tool-call', state: 'completed' })
|
||||
expect(item?.body).toMatchObject({ kind: 'message', role: 'tool' })
|
||||
const body = item?.body
|
||||
if (body?.kind !== 'tool-call' || !body.output) {
|
||||
throw new Error('expected a bounded tool-call output')
|
||||
const result = body?.kind === 'message' ? body.blocks[0] : undefined
|
||||
if (result?.type !== 'tool-result') {
|
||||
throw new Error('expected a bounded tool result')
|
||||
}
|
||||
expect(body.output.truncated).toBe(true)
|
||||
expect(body.output.byteLength).toBe(64 * 1024)
|
||||
expect(body.output.head).toHaveLength(1_024)
|
||||
expect(result.callId).toBe('toolu_9')
|
||||
expect(result.output.startsWith('y'.repeat(1_024))).toBe(true)
|
||||
expect(result.output).toContain('65536 bytes')
|
||||
expect(result.output).toContain('output truncated')
|
||||
expect(result.output.length).toBeLessThan(1_200)
|
||||
})
|
||||
|
||||
it('bounds an imported subagent roster by entry count, label and id', async () => {
|
||||
|
||||
@@ -33,7 +33,6 @@ import { createLegacyIdentityTracker } from './journal-legacy-identity'
|
||||
import type { JournalReplacementItem } from './journal-epoch-replacement'
|
||||
import {
|
||||
boundInlineText,
|
||||
boundPayload,
|
||||
boundToolInput,
|
||||
DEFAULT_JOURNAL_PAYLOAD_LIMITS,
|
||||
type JournalPayloadLimits
|
||||
@@ -218,18 +217,10 @@ function legacyItemBody(
|
||||
kind: 'tool-call',
|
||||
name: only.name,
|
||||
input: boundToolInput(only.input, limits),
|
||||
...(only.callId !== undefined ? { callId: only.callId } : {}),
|
||||
state: 'completed'
|
||||
}
|
||||
}
|
||||
if (only?.type === 'tool-result') {
|
||||
return {
|
||||
kind: 'tool-call',
|
||||
name: 'tool-result',
|
||||
input: null,
|
||||
state: only.isError ? 'failed' : 'completed',
|
||||
output: boundPayload(only.output, limits)
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: 'message',
|
||||
role: message.role,
|
||||
|
||||
@@ -107,7 +107,7 @@ function codexResponseItem(
|
||||
return {
|
||||
id,
|
||||
role: 'tool',
|
||||
blocks: [codexToolResult(payload.output)],
|
||||
blocks: [codexToolResult(payload.output, extractString(payload.call_id))],
|
||||
timestamp,
|
||||
source: 'transcript'
|
||||
}
|
||||
@@ -225,12 +225,13 @@ function codexCallInput(payload: Record<string, unknown>): unknown {
|
||||
return payload.input ?? payload.action ?? null
|
||||
}
|
||||
|
||||
function codexToolResult(output: unknown): NativeChatBlock {
|
||||
function codexToolResult(output: unknown, callId: string | null): NativeChatBlock {
|
||||
const record = asRecord(output)
|
||||
const isError = record?.success === false || record?.is_error === true
|
||||
return {
|
||||
type: 'tool-result',
|
||||
output: toolResultOutput(record?.content ?? record?.output ?? output),
|
||||
...(callId ? { callId } : {}),
|
||||
...(isError ? { isError: true } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,9 +94,11 @@ function claudeContentBlock(record: Record<string, unknown>): NativeChatBlock |
|
||||
}
|
||||
|
||||
function toolResultBlock(record: Record<string, unknown>): NativeChatToolResultBlock {
|
||||
const callId = extractString(record.tool_use_id)
|
||||
return {
|
||||
type: 'tool-result',
|
||||
output: toolResultOutput(record.content),
|
||||
...(callId ? { callId } : {}),
|
||||
...(record.is_error === true ? { isError: true } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { foldToolMessages, pairToolBlocks } from '../../shared/native-chat-tool-fold'
|
||||
import type { NativeChatMessage } from '../../shared/native-chat-types'
|
||||
import { decodeClaudeTranscriptLine } from './transcript-line-decoders-claude'
|
||||
import { decodeCodexTranscriptLine } from './transcript-line-decoders-codex'
|
||||
import { readNativeChatTranscriptTailFile } from './transcript-tail-reader'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function load(records: unknown[], agent: 'claude' | 'codex'): Promise<NativeChatMessage[]> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-transcript-result-identity-'))
|
||||
roots.push(root)
|
||||
const filePath = join(root, 'transcript.jsonl')
|
||||
await writeFile(filePath, `${records.map((record) => JSON.stringify(record)).join('\n')}\n`)
|
||||
const page = await readNativeChatTranscriptTailFile(
|
||||
filePath,
|
||||
100,
|
||||
agent === 'claude' ? decodeClaudeTranscriptLine : decodeCodexTranscriptLine
|
||||
)
|
||||
expect(page.hasMore).toBe(false)
|
||||
return page.messages
|
||||
}
|
||||
|
||||
function claudeRecord(id: string, role: 'assistant' | 'user', content: unknown): unknown {
|
||||
return {
|
||||
type: role,
|
||||
uuid: id,
|
||||
timestamp: '2026-10-09T10:00:00.000Z',
|
||||
message: { role, content }
|
||||
}
|
||||
}
|
||||
|
||||
function codexRecord(id: string, payload: Record<string, unknown>, wrapped = true): unknown {
|
||||
return {
|
||||
timestamp: '2026-10-09T10:00:00.000Z',
|
||||
...(wrapped ? { type: 'response_item', payload: { id, ...payload } } : { id, ...payload })
|
||||
}
|
||||
}
|
||||
|
||||
function claudeCalls(): unknown[] {
|
||||
return [
|
||||
claudeRecord('calls', 'assistant', [
|
||||
{ type: 'tool_use', id: 'spawn', name: 'spawn_agent', input: { task: 'Inspect' } },
|
||||
{ type: 'tool_use', id: 'first', name: 'Bash', input: { command: 'echo first' } },
|
||||
{ type: 'tool_use', id: 'second', name: 'Bash', input: { command: 'echo second' } }
|
||||
])
|
||||
]
|
||||
}
|
||||
|
||||
function codexCalls(type: 'function_call' | 'custom_tool_call', wrapped: boolean): unknown[] {
|
||||
return ['spawn', 'first', 'second'].map((callId) =>
|
||||
codexRecord(
|
||||
`call-${callId}`,
|
||||
{
|
||||
type,
|
||||
call_id: callId,
|
||||
name: callId === 'spawn' ? 'spawn_agent' : 'exec_command',
|
||||
arguments: JSON.stringify({ command: `echo ${callId}` })
|
||||
},
|
||||
wrapped
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function pairsFrom(messages: NativeChatMessage[]) {
|
||||
return foldToolMessages(messages).flatMap((message) => pairToolBlocks(message.blocks))
|
||||
}
|
||||
|
||||
describe('provider transcript result identity', () => {
|
||||
it('keeps Claude reverse completions attached to their calls past a silent spawn', async () => {
|
||||
const messages = await load(
|
||||
[
|
||||
...claudeCalls(),
|
||||
claudeRecord('results', 'user', [
|
||||
{ type: 'tool_result', tool_use_id: 'second', content: 'SECOND', is_error: true },
|
||||
{ type: 'tool_result', tool_use_id: 'first', content: ['FIRST', { text: 'detail' }] }
|
||||
])
|
||||
],
|
||||
'claude'
|
||||
)
|
||||
expect(messages.at(-1)?.blocks).toEqual([
|
||||
{ type: 'tool-result', callId: 'second', output: 'SECOND', isError: true },
|
||||
{ type: 'tool-result', callId: 'first', output: 'FIRST\ndetail' }
|
||||
])
|
||||
expect(pairsFrom(messages)).toMatchObject([
|
||||
{ call: { callId: 'spawn' } },
|
||||
{ call: { callId: 'first' }, result: { callId: 'first', output: 'FIRST\ndetail' } },
|
||||
{ call: { callId: 'second' }, result: { callId: 'second', output: 'SECOND', isError: true } }
|
||||
])
|
||||
expect(pairsFrom(messages)[0]?.result).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['function_call', 'function_call_output', true],
|
||||
['custom_tool_call', 'custom_tool_call_output', true],
|
||||
['function_call', 'function_call_output', false],
|
||||
['custom_tool_call', 'custom_tool_call_output', false]
|
||||
] as const)(
|
||||
'keeps Codex %s/%s results named (wrapped=%s)',
|
||||
async (callType, outputType, wrapped) => {
|
||||
const messages = await load(
|
||||
[
|
||||
...codexCalls(callType, wrapped),
|
||||
codexRecord(
|
||||
'result-second',
|
||||
{
|
||||
type: outputType,
|
||||
call_id: 'second',
|
||||
output: { content: ['SECOND'], success: false }
|
||||
},
|
||||
wrapped
|
||||
),
|
||||
codexRecord(
|
||||
'result-first',
|
||||
{ type: outputType, call_id: 'first', output: { output: 'FIRST' } },
|
||||
wrapped
|
||||
)
|
||||
],
|
||||
'codex'
|
||||
)
|
||||
expect(messages.slice(-2).map((message) => message.blocks[0])).toEqual([
|
||||
{ type: 'tool-result', callId: 'second', output: 'SECOND', isError: true },
|
||||
{ type: 'tool-result', callId: 'first', output: 'FIRST' }
|
||||
])
|
||||
expect(pairsFrom(messages)).toMatchObject([
|
||||
{ call: { callId: 'spawn' } },
|
||||
{ call: { callId: 'first' }, result: { callId: 'first', output: 'FIRST' } },
|
||||
{
|
||||
call: { callId: 'second' },
|
||||
result: { callId: 'second', output: 'SECOND', isError: true }
|
||||
}
|
||||
])
|
||||
expect(pairsFrom(messages)[0]?.result).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves the result identity together with Claude resolved edit ranges and record metadata', async () => {
|
||||
const messages = await load(
|
||||
[
|
||||
{
|
||||
type: 'user',
|
||||
uuid: 'edit-result',
|
||||
parentUuid: 'edit-call',
|
||||
timestamp: '2026-10-09T10:01:00.000Z',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'tool_result', tool_use_id: 'edit', content: 'edited', is_error: true }
|
||||
]
|
||||
},
|
||||
toolUseResult: {
|
||||
filePath: 'src/example.ts',
|
||||
structuredPatch: [
|
||||
{ oldStart: 10, oldLines: 1, newStart: 10, newLines: 1, lines: ['-old', '+new'] }
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
'claude'
|
||||
)
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
id: 'edit-result',
|
||||
parentId: 'edit-call',
|
||||
role: 'tool',
|
||||
timestamp: Date.parse('2026-10-09T10:01:00.000Z'),
|
||||
source: 'transcript',
|
||||
blocks: [
|
||||
{
|
||||
type: 'tool-result',
|
||||
callId: 'edit',
|
||||
output: 'edited',
|
||||
isError: true,
|
||||
editPatch: {
|
||||
filePath: 'src/example.ts',
|
||||
hunks: [
|
||||
{ oldStart: 10, oldLines: 1, newStart: 10, newLines: 1, lines: ['-old', '+new'] }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it.each(['claude', 'codex'] as const)(
|
||||
'keeps historical %s results without IDs positional',
|
||||
async (agent) => {
|
||||
const records =
|
||||
agent === 'claude'
|
||||
? [
|
||||
claudeRecord('calls', 'assistant', [
|
||||
{ type: 'tool_use', id: 'first', name: 'Bash', input: {} },
|
||||
{ type: 'tool_use', id: 'second', name: 'Bash', input: {} }
|
||||
]),
|
||||
claudeRecord('results', 'user', [
|
||||
{ type: 'tool_result', content: 'FIRST' },
|
||||
{ type: 'tool_result', content: 'SECOND' }
|
||||
])
|
||||
]
|
||||
: [
|
||||
...codexCalls('function_call', true).slice(1),
|
||||
codexRecord('first-result', { type: 'function_call_output', output: 'FIRST' }),
|
||||
codexRecord('second-result', { type: 'function_call_output', output: 'SECOND' })
|
||||
]
|
||||
const messages = await load(records, agent)
|
||||
expect(pairsFrom(messages)).toMatchObject([
|
||||
{ call: { callId: 'first' }, result: { output: 'FIRST' } },
|
||||
{ call: { callId: 'second' }, result: { output: 'SECOND' } }
|
||||
])
|
||||
for (const message of messages) {
|
||||
for (const block of message.blocks) {
|
||||
if (block.type === 'tool-result') {
|
||||
expect(Object.hasOwn(block, 'callId')).toBe(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each([null, '', ' ', 42, {}])('omits invalid provider result IDs (%s)', (callId) => {
|
||||
const claude = decodeClaudeTranscriptLine(
|
||||
JSON.stringify(
|
||||
claudeRecord('result', 'user', [
|
||||
{ type: 'tool_result', tool_use_id: callId, content: 'done' }
|
||||
])
|
||||
),
|
||||
'fallback'
|
||||
)
|
||||
const codex = decodeCodexTranscriptLine(
|
||||
JSON.stringify(
|
||||
codexRecord('result', { type: 'function_call_output', call_id: callId, output: 'done' })
|
||||
),
|
||||
'fallback'
|
||||
)
|
||||
expect(claude?.blocks).toEqual([{ type: 'tool-result', output: 'done' }])
|
||||
expect(codex?.blocks).toEqual([{ type: 'tool-result', output: 'done' }])
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { stripNoiseMessages } from '../../shared/native-chat-noise'
|
||||
import { mergeNativeChatMessages } from '../../shared/native-chat-merge'
|
||||
import { foldToolMessages, pairToolBlocks } from '../../shared/native-chat-tool-fold'
|
||||
import { isToolCallBlock, isToolResultBlock } from '../../shared/native-chat-types'
|
||||
import type { NativeChatMessage } from '../../shared/native-chat-types'
|
||||
@@ -34,7 +35,9 @@ function toolTurn(n: number): unknown[] {
|
||||
timestamp: `2026-08-20T10:00:${String(n).padStart(2, '0')}.000Z`,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool_use', name: 'Bash', input: { command: `echo ${n}` } }]
|
||||
content: [
|
||||
{ type: 'tool_use', id: `toolu_${n}`, name: 'Bash', input: { command: `echo ${n}` } }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -49,8 +52,7 @@ function toolTurn(n: number): unknown[] {
|
||||
]
|
||||
}
|
||||
|
||||
/** The rendered defect: a tool row with output but no call to attribute it to.
|
||||
* Mirrors what both chat views build from a folded message's tool blocks. */
|
||||
/** Both chat views expose a result-only pair when no loaded call owns it. */
|
||||
function unattributedResults(messages: readonly NativeChatMessage[]): string[] {
|
||||
const stray: string[] = []
|
||||
for (const message of messages) {
|
||||
@@ -76,24 +78,24 @@ async function renderedMessages(filePath: string, limit: number): Promise<Native
|
||||
decodeClaudeTranscriptLine,
|
||||
true
|
||||
)
|
||||
return stripNoiseMessages(foldToolMessages(page.messages))
|
||||
return stripNoiseMessages(foldToolMessages(mergeNativeChatMessages([], page.messages)))
|
||||
}
|
||||
|
||||
describe('windowed transcript tool attribution', () => {
|
||||
it('drops a tool result whose call falls outside the read window', async () => {
|
||||
it('shows a tool result whose call falls outside the read window without guessing its owner', async () => {
|
||||
const filePath = await writeTranscript([...toolTurn(1), ...toolTurn(2), ...toolTurn(3)])
|
||||
|
||||
// A 3-message window cuts turn 2 in half: its result is the oldest record in
|
||||
// the window, its `tool_use` is not.
|
||||
const windowed = await renderedMessages(filePath, 3)
|
||||
|
||||
expect(unattributedResults(windowed)).toEqual([])
|
||||
// The turn that survives whole still renders its output.
|
||||
expect(unattributedResults(windowed)).toEqual(['output 2'])
|
||||
expect(windowed[0]).toMatchObject({ id: 'result-2', role: 'tool' })
|
||||
expect(
|
||||
windowed.flatMap((message) =>
|
||||
message.blocks.filter(isToolResultBlock).map((block) => block.output)
|
||||
)
|
||||
).toEqual(['output 3'])
|
||||
).toEqual(['output 2', 'output 3'])
|
||||
})
|
||||
|
||||
it('keeps every result when the window contains its call', async () => {
|
||||
@@ -150,7 +152,46 @@ describe('windowed transcript tool attribution', () => {
|
||||
|
||||
const messages = await renderedMessages(filePath, 100)
|
||||
|
||||
expect(unattributedResults(messages)).toEqual([])
|
||||
expect(messages.map((message) => message.id)).toEqual(['call-1'])
|
||||
expect(unattributedResults(messages)).toEqual(['output 2'])
|
||||
expect(messages.map((message) => message.id)).toEqual(['call-1', 'result-2'])
|
||||
expect(pairToolBlocks(messages[0]!.blocks)[0]?.result).toBeUndefined()
|
||||
expect(messages[1]?.role).toBe('tool')
|
||||
})
|
||||
|
||||
it('uses provider paging cursors unchanged while an older page restores the exact call', async () => {
|
||||
const filePath = await writeTranscript([...toolTurn(1), ...toolTurn(2), ...toolTurn(3)])
|
||||
const page = await readNativeChatTranscriptTailFile(
|
||||
filePath,
|
||||
3,
|
||||
decodeClaudeTranscriptLine,
|
||||
true
|
||||
)
|
||||
const cursor = page.beforeOffset
|
||||
const consumed = page.consumedTo
|
||||
const windowed = foldToolMessages(page.messages)
|
||||
const older = await readNativeChatTranscriptTailFile(
|
||||
filePath,
|
||||
100,
|
||||
decodeClaudeTranscriptLine,
|
||||
true,
|
||||
cursor
|
||||
)
|
||||
expect(page.beforeOffset).toBe(cursor)
|
||||
expect(page.consumedTo).toBe(consumed)
|
||||
expect(page.hasMore).toBe(true)
|
||||
expect(windowed[0]).toMatchObject({ id: 'result-2', role: 'tool' })
|
||||
expect(older.messages.at(-1)?.id).toBe('call-2')
|
||||
expect(older.consumedTo).toBe(cursor)
|
||||
const whole = foldToolMessages(mergeNativeChatMessages(older.messages, page.messages))
|
||||
expect(unattributedResults(whole)).toEqual([])
|
||||
expect(
|
||||
whole
|
||||
.flatMap((message) => pairToolBlocks(message.blocks))
|
||||
.map((pair) => [pair.call?.callId, pair.result?.output])
|
||||
).toEqual([
|
||||
['toolu_1', 'output 1'],
|
||||
['toolu_2', 'output 2'],
|
||||
['toolu_3', 'output 3']
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types'
|
||||
import type { NativeChatMessage } from '../../../shared/native-chat-types'
|
||||
import { buildStructuredJournalArchive } from './structured-worker-journal-archive'
|
||||
import { foldToolMessages, pairToolBlocks } from '../../../shared/native-chat-tool-fold'
|
||||
|
||||
const STRUCTURED_ARCHIVE_MAX_BYTES = 262_144
|
||||
|
||||
@@ -70,6 +72,60 @@ describe('buildStructuredJournalArchive', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('archives raw result-before-call order without publishing client presentation markers', () => {
|
||||
const items: AgentJournalRenderItem[] = [
|
||||
{
|
||||
itemId: 'a',
|
||||
sequence: 1,
|
||||
revision: 0,
|
||||
observedAt: 1,
|
||||
body: {
|
||||
kind: 'tool-call',
|
||||
name: 'Bash',
|
||||
callId: 'a',
|
||||
input: { command: 'a' },
|
||||
state: 'completed'
|
||||
}
|
||||
},
|
||||
{
|
||||
itemId: 'old-x',
|
||||
sequence: 2,
|
||||
revision: 0,
|
||||
observedAt: 2,
|
||||
body: {
|
||||
kind: 'message',
|
||||
role: 'tool',
|
||||
blocks: [{ type: 'tool-result', callId: 'x', output: 'OLD X' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
itemId: 'x',
|
||||
sequence: 3,
|
||||
revision: 0,
|
||||
observedAt: 3,
|
||||
body: {
|
||||
kind: 'tool-call',
|
||||
name: 'Bash',
|
||||
callId: 'x',
|
||||
input: { command: 'x' },
|
||||
state: 'completed'
|
||||
}
|
||||
}
|
||||
]
|
||||
const built = archive(items)
|
||||
expect(built.messages.map((message) => message.id)).toEqual(['a', 'old-x', 'x'])
|
||||
expect(built.messages.every((message) => !Object.hasOwn(message, 'unpairedToolResults'))).toBe(
|
||||
true
|
||||
)
|
||||
const roundTrip: NativeChatMessage[] = JSON.parse(JSON.stringify(built.messages))
|
||||
const projected = foldToolMessages(roundTrip)
|
||||
expect(pairToolBlocks(projected[0]!.blocks).every((pair) => pair.result === undefined)).toBe(
|
||||
true
|
||||
)
|
||||
expect(projected[1]).toMatchObject({ id: 'old-x', role: 'tool', blocks: [{ output: 'OLD X' }] })
|
||||
expect(foldToolMessages(projected)).toEqual(projected)
|
||||
})
|
||||
|
||||
it('still reports omitted older items when the page itself was bounded', () => {
|
||||
const built = archive(longJournal(2), true)
|
||||
expect(built.limited).toBe(true)
|
||||
|
||||
@@ -4,6 +4,8 @@ import { dirname, join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import { mergeNativeChatMessages } from '../../../../shared/native-chat-merge'
|
||||
import { foldToolMessages, pairToolBlocks } from '../../../../shared/native-chat-tool-fold'
|
||||
import type { IFilesystemProvider } from '../../../providers/types'
|
||||
import {
|
||||
registerSshFilesystemProvider,
|
||||
@@ -152,6 +154,174 @@ describe('native chat for an agent whose transcript lives on an SSH host (#26057
|
||||
expect(page.texts).toEqual(['first remote turn', 'second remote turn'])
|
||||
})
|
||||
|
||||
it('publishes raw provider order to old peers without private folded-result markers', async () => {
|
||||
const records = [
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'a',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool_use', id: 'a', name: 'Bash', input: { command: 'a' } }]
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'user',
|
||||
uuid: 'old-x',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'tool_result', tool_use_id: 'x', content: 'OLD X' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'x',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool_use', id: 'x', name: 'Bash', input: { command: 'x' } }]
|
||||
}
|
||||
}
|
||||
]
|
||||
registerSshFilesystemProvider(
|
||||
CONNECTION_ID,
|
||||
sshHost(
|
||||
new Map([
|
||||
[transcriptPath, `${records.map((record) => JSON.stringify(record)).join('\n')}\n`]
|
||||
])
|
||||
)
|
||||
)
|
||||
const read = method('nativeChat.readSession')
|
||||
if ('stream' in read) {
|
||||
throw new Error('nativeChat.readSession is a stream')
|
||||
}
|
||||
const page = await read.handler(
|
||||
{ agent: 'claude', sessionId: SESSION_ID, transcriptPath },
|
||||
phoneContext({})
|
||||
)
|
||||
if (
|
||||
!page ||
|
||||
typeof page !== 'object' ||
|
||||
!('messages' in page) ||
|
||||
!Array.isArray(page.messages)
|
||||
) {
|
||||
throw new Error('nativeChat.readSession did not publish messages')
|
||||
}
|
||||
const published: NativeChatMessage[] = page.messages
|
||||
expect(published.map((message) => message.id)).toEqual(['a', 'old-x', 'x'])
|
||||
expect(published.every((message) => !Object.hasOwn(message, 'unpairedToolResults'))).toBe(true)
|
||||
const oldPeerRoundTrip: NativeChatMessage[] = JSON.parse(JSON.stringify(published))
|
||||
const projected = foldToolMessages(oldPeerRoundTrip)
|
||||
expect(pairToolBlocks(projected[0]!.blocks).every((pair) => pair.result === undefined)).toBe(
|
||||
true
|
||||
)
|
||||
expect(projected[1]).toMatchObject({ id: 'old-x', role: 'tool', blocks: [{ output: 'OLD X' }] })
|
||||
expect(foldToolMessages(projected)).toEqual(projected)
|
||||
})
|
||||
|
||||
it('keeps remote page cursors and reattaches several outputs to their earlier named calls', async () => {
|
||||
const records = [
|
||||
{
|
||||
type: 'user',
|
||||
uuid: 'prompt',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'remote question' }] }
|
||||
},
|
||||
...['a', 'b'].map((id) => ({
|
||||
type: 'assistant',
|
||||
uuid: `call-${id}`,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool_use', id, name: 'Bash', input: { command: id } }]
|
||||
}
|
||||
})),
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'backlog',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: Array.from({ length: 64 }, (_, index) => ({
|
||||
type: 'tool_use',
|
||||
id: `silent-${index}`,
|
||||
name: 'Bash',
|
||||
input: { command: `silent ${index}` }
|
||||
}))
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'user',
|
||||
uuid: 'outputs',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'tool_result', tool_use_id: 'a', content: 'output A' },
|
||||
{ type: 'tool_result', tool_use_id: 'b', content: 'output B', is_error: true }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
const lines = records.map((record) => `${JSON.stringify(record)}\n`)
|
||||
const cursor = Buffer.byteLength(lines.slice(0, 3).join(''))
|
||||
registerSshFilesystemProvider(
|
||||
CONNECTION_ID,
|
||||
sshHost(new Map([[transcriptPath, lines.join('')]]))
|
||||
)
|
||||
const read = method('nativeChat.readSession')
|
||||
if ('stream' in read) {
|
||||
throw new Error('nativeChat.readSession is a stream')
|
||||
}
|
||||
const readPage = read.handler
|
||||
async function page(limit: number, beforeOffset?: number) {
|
||||
const result = await readPage(
|
||||
{ agent: 'claude', sessionId: SESSION_ID, transcriptPath, limit, beforeOffset },
|
||||
phoneContext({})
|
||||
)
|
||||
if (
|
||||
!result ||
|
||||
typeof result !== 'object' ||
|
||||
!('messages' in result) ||
|
||||
!Array.isArray(result.messages) ||
|
||||
!('beforeOffset' in result) ||
|
||||
!('hasMore' in result)
|
||||
) {
|
||||
throw new Error('nativeChat.readSession did not publish a page')
|
||||
}
|
||||
const messages: NativeChatMessage[] = result.messages
|
||||
return { messages, beforeOffset: result.beforeOffset, hasMore: result.hasMore }
|
||||
}
|
||||
const tail = await page(2)
|
||||
expect(tail.beforeOffset).toBe(cursor)
|
||||
expect(tail.hasMore).toBe(true)
|
||||
expect(tail.messages.map((row) => row.id)).toEqual(['backlog', 'outputs'])
|
||||
expect(tail.messages.every((row) => !Object.hasOwn(row, 'unpairedToolResults'))).toBe(true)
|
||||
const partial = foldToolMessages(tail.messages)
|
||||
expect(partial[1]).toMatchObject({
|
||||
id: 'outputs',
|
||||
role: 'tool',
|
||||
blocks: [{ output: 'output A' }, { output: 'output B', isError: true }]
|
||||
})
|
||||
expect(pairToolBlocks(partial[0]!.blocks).every((pair) => pair.result === undefined)).toBe(true)
|
||||
expect(foldToolMessages(partial)).toEqual(partial)
|
||||
|
||||
const older = await page(100, cursor)
|
||||
expect(older.beforeOffset).toBe(0)
|
||||
expect(older.hasMore).toBe(false)
|
||||
expect(older.messages.map((row) => row.id)).toEqual(['prompt', 'call-a', 'call-b'])
|
||||
const raw = mergeNativeChatMessages(older.messages, tail.messages)
|
||||
expect(raw.every((row) => !Object.hasOwn(row, 'unpairedToolResults'))).toBe(true)
|
||||
const whole = foldToolMessages(raw)
|
||||
expect(whole.some((row) => row.role === 'tool')).toBe(false)
|
||||
expect(
|
||||
whole.flatMap((row) =>
|
||||
pairToolBlocks(row.blocks).flatMap((pair) =>
|
||||
pair.result ? [[pair.call?.callId, pair.result.output]] : []
|
||||
)
|
||||
)
|
||||
).toEqual([
|
||||
['a', 'output A'],
|
||||
['b', 'output B']
|
||||
])
|
||||
expect(foldToolMessages(whole)).toEqual(whole)
|
||||
expect(tail.beforeOffset).toBe(cursor)
|
||||
})
|
||||
|
||||
it('streams the SSH host history and its later turns to a subscribed phone', async () => {
|
||||
const files = new Map([[transcriptPath, claudeLines('remote history')]])
|
||||
registerSshFilesystemProvider(CONNECTION_ID, sshHost(files))
|
||||
|
||||
@@ -694,9 +694,10 @@ describe('NativeChatToolRun', () => {
|
||||
it('leaves an unpaired result row without a category glyph', () => {
|
||||
const blocks: NativeChatBlock[] = [{ type: 'tool-result', output: 'first line' }]
|
||||
|
||||
render(<NativeChatToolRun blocks={blocks} expandSignal />)
|
||||
const { container } = render(<NativeChatToolRun blocks={blocks} expandSignal />)
|
||||
|
||||
const resultRow = screen.getByText('Result').closest('button')
|
||||
expect(runHeader(container)).toHaveAccessibleName('Result')
|
||||
const resultRow = screen.getByRole('button', { name: 'Result first line' })
|
||||
// Keying a category off 'Result' would resolve a different glyph per locale.
|
||||
expect(
|
||||
[...(resultRow?.querySelectorAll('svg') ?? [])].map(
|
||||
@@ -804,16 +805,25 @@ describe('NativeChatToolRun', () => {
|
||||
expect(leadingGlyphs(container)).toEqual(['lucide-search', 'lucide-search'])
|
||||
})
|
||||
|
||||
it('leaves a run with no tool calls headed by no category glyph', () => {
|
||||
const blocks: NativeChatBlock[] = [{ type: 'tool-result', output: 'first line' }]
|
||||
it.each([1, 3])('labels a run of %s unpaired results without inventing tool calls', (count) => {
|
||||
const blocks: NativeChatBlock[] = Array.from({ length: count }, (_, index) => ({
|
||||
type: 'tool-result',
|
||||
output: `first line ${index}`
|
||||
}))
|
||||
|
||||
const { container } = render(
|
||||
<NativeChatToolRun blocks={blocks} expandSignal activeTurnIsWorking={false} />
|
||||
)
|
||||
|
||||
expect(runHeader(container)).toHaveAccessibleName('Result')
|
||||
expect(runHeader(container)).not.toHaveTextContent(/tool calls?/)
|
||||
|
||||
// Only the trailing check and the chevron; a wrench here would claim a
|
||||
// tool category for a run holding no tool call.
|
||||
expect(leadingGlyphs(container)).toEqual(['lucide-check', 'lucide-chevron-right'])
|
||||
expect(leadingGlyphs(container)).toEqual([
|
||||
'lucide-check',
|
||||
...blocks.map(() => 'lucide-chevron-right')
|
||||
])
|
||||
})
|
||||
|
||||
it('holds the run glyph across live and settled', () => {
|
||||
|
||||
@@ -153,8 +153,8 @@ export function NativeChatToolRun({
|
||||
} = useMemo(() => nativeChatAskRunBlocks(blocks), [blocks])
|
||||
const hasAskCall = asks.length > 0
|
||||
const askSubject = hasAskCall ? nativeChatAskRunSubject(asks) : null
|
||||
const showsHeader = !hasAskCall || countToolCalls(headerBlocks) > 0
|
||||
const callCount = countToolCalls(headerBlocks) || headerBlocks.length
|
||||
const callCount = countToolCalls(headerBlocks)
|
||||
const showsHeader = !hasAskCall || callCount > 0
|
||||
const askIsActive = selectActiveToolCall(unansweredAsks, { activeTurnIsWorking }) !== null
|
||||
// Live is the turn's state, not a call's. Deriving it from "some call is
|
||||
// running" flipped the header to settled and back around every call, and a
|
||||
@@ -194,11 +194,13 @@ export function NativeChatToolRun({
|
||||
// to a check would read as a change of identity.
|
||||
const settledHeaderIcon = nativeChatToolRunIconName(headerBlocks.filter(isToolCallBlock))
|
||||
const fallbackLabel =
|
||||
callCount === 1
|
||||
? translate('components.native-chat.tool.countOne', NATIVE_CHAT_TOOL_ACTIVITY_COPY.countOne)
|
||||
: translate('components.native-chat.tool.countN', NATIVE_CHAT_TOOL_ACTIVITY_COPY.countN, {
|
||||
value0: callCount
|
||||
})
|
||||
callCount === 0
|
||||
? translate('components.native-chat.tool.result', 'Result')
|
||||
: callCount === 1
|
||||
? translate('components.native-chat.tool.countOne', NATIVE_CHAT_TOOL_ACTIVITY_COPY.countOne)
|
||||
: translate('components.native-chat.tool.countN', NATIVE_CHAT_TOOL_ACTIVITY_COPY.countN, {
|
||||
value0: callCount
|
||||
})
|
||||
|
||||
// A roster with no tool calls beside it is the whole run: rendering the tool
|
||||
// header too would announce "1 tool call" for activity that has none.
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
NativeChatBlock,
|
||||
NativeChatToolCallBlock,
|
||||
NativeChatToolResultBlock
|
||||
} from '../../../../shared/native-chat-types'
|
||||
import { NativeChatToolRunMemberList } from './NativeChatToolRunMemberList'
|
||||
import {
|
||||
NativeChatDisclosureContext,
|
||||
useNativeChatDisclosures
|
||||
} from './native-chat-disclosure-store'
|
||||
|
||||
vi.mock('./NativeChatDiffCard', () => ({ NativeChatDiffCard: () => null }))
|
||||
vi.mock('./NativeChatDiffView', () => ({ NativeChatDiffView: () => null }))
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function shellCall(callId: string, command: string): NativeChatToolCallBlock {
|
||||
return { type: 'tool-call', name: 'shell', callId, input: { command }, state: 'completed' }
|
||||
}
|
||||
|
||||
function shellResult(callId: string, output: string): NativeChatToolResultBlock {
|
||||
return { type: 'tool-result', callId, output }
|
||||
}
|
||||
|
||||
function MemberDisclosureHarness({
|
||||
blocks,
|
||||
mounted = true
|
||||
}: {
|
||||
blocks: NativeChatBlock[]
|
||||
mounted?: boolean
|
||||
}): React.JSX.Element {
|
||||
const disclosures = useNativeChatDisclosures()
|
||||
return (
|
||||
<NativeChatDisclosureContext.Provider value={disclosures}>
|
||||
{mounted ? (
|
||||
<NativeChatToolRunMemberList
|
||||
blocks={blocks}
|
||||
headerBlocks={blocks}
|
||||
disclosureId="message-1"
|
||||
/>
|
||||
) : null}
|
||||
</NativeChatDisclosureContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function commandButton(command: string, occurrence = 0): HTMLElement {
|
||||
const button = screen.getAllByRole('button', { name: new RegExp(command) })[occurrence]
|
||||
if (!button) {
|
||||
throw new Error(`Missing command occurrence ${occurrence}: ${command}`)
|
||||
}
|
||||
return button
|
||||
}
|
||||
|
||||
function duplicateShellBlocks(command: string, firstOutput: string, secondOutput: string) {
|
||||
return [
|
||||
shellCall('duplicate', command),
|
||||
shellCall('duplicate', command),
|
||||
shellResult('duplicate', firstOutput),
|
||||
shellResult('duplicate', secondOutput)
|
||||
]
|
||||
}
|
||||
|
||||
describe('tool member provider identity', () => {
|
||||
it('opens duplicate-ID calls independently and retains their rows across updates and removals', () => {
|
||||
const prefix = [shellCall('prefix', 'echo prefix'), shellResult('prefix', 'PREFIX_OUTPUT')]
|
||||
const original = duplicateShellBlocks('echo repeated', 'FIRST_OUTPUT', 'SECOND_OUTPUT')
|
||||
const { rerender } = render(<MemberDisclosureHarness blocks={[...prefix, ...original]} />)
|
||||
const first = commandButton('echo repeated')
|
||||
const second = commandButton('echo repeated', 1)
|
||||
|
||||
fireEvent.click(second)
|
||||
expect(first.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(second.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(screen.queryByText('FIRST_OUTPUT')).toBeNull()
|
||||
expect(screen.getByText('SECOND_OUTPUT')).toBeTruthy()
|
||||
|
||||
fireEvent.click(first)
|
||||
expect(first.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(second.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(screen.getByText('FIRST_OUTPUT')).toBeTruthy()
|
||||
|
||||
fireEvent.click(first)
|
||||
const updated = duplicateShellBlocks('echo updated', 'FIRST_UPDATED', 'SECOND_UPDATED')
|
||||
rerender(<MemberDisclosureHarness blocks={[...prefix, ...updated]} />)
|
||||
expect(commandButton('echo updated')).toBe(first)
|
||||
expect(commandButton('echo updated', 1)).toBe(second)
|
||||
expect(first.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(second.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(screen.getByText('SECOND_UPDATED')).toBeTruthy()
|
||||
|
||||
rerender(<MemberDisclosureHarness blocks={updated} />)
|
||||
expect(commandButton('echo updated')).toBe(first)
|
||||
expect(commandButton('echo updated', 1)).toBe(second)
|
||||
expect(second.getAttribute('aria-expanded')).toBe('true')
|
||||
|
||||
const firstOnly = [
|
||||
shellCall('duplicate', 'echo updated'),
|
||||
shellResult('duplicate', 'FIRST_UPDATED')
|
||||
]
|
||||
rerender(<MemberDisclosureHarness blocks={firstOnly} />)
|
||||
expect(commandButton('echo updated')).toBe(first)
|
||||
expect(first.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.getAllByRole('button')).toHaveLength(1)
|
||||
|
||||
rerender(<MemberDisclosureHarness blocks={updated} />)
|
||||
expect(commandButton('echo updated')).toBe(first)
|
||||
expect(commandButton('echo updated', 1).getAttribute('aria-expanded')).toBe('true')
|
||||
|
||||
rerender(<MemberDisclosureHarness blocks={updated} mounted={false} />)
|
||||
rerender(<MemberDisclosureHarness blocks={updated} />)
|
||||
expect(commandButton('echo updated').getAttribute('aria-expanded')).toBe('false')
|
||||
expect(commandButton('echo updated', 1).getAttribute('aria-expanded')).toBe('true')
|
||||
})
|
||||
|
||||
it('keeps a unique call mounted and open as its input changes and its result arrives', () => {
|
||||
const command = `echo ${'long argument '.repeat(12)}`
|
||||
const call = { ...shellCall('unique', command), state: 'running' as const }
|
||||
const { rerender } = render(<MemberDisclosureHarness blocks={[call]} />)
|
||||
const button = commandButton('echo long argument')
|
||||
fireEvent.click(button)
|
||||
expect(button.getAttribute('aria-expanded')).toBe('true')
|
||||
|
||||
rerender(
|
||||
<MemberDisclosureHarness
|
||||
blocks={[shellCall('unique', 'echo completed'), shellResult('unique', 'COMPLETE_OUTPUT')]}
|
||||
/>
|
||||
)
|
||||
expect(commandButton('echo completed')).toBe(button)
|
||||
expect(button.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(screen.getByText('COMPLETE_OUTPUT')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps duplicate occurrences separate from a provider ID containing an occurrence suffix', () => {
|
||||
const blocks = [
|
||||
...duplicateShellBlocks('echo repeated', 'FIRST_OUTPUT', 'SECOND_OUTPUT'),
|
||||
shellCall('duplicate:1', 'echo distinct'),
|
||||
shellResult('duplicate:1', 'DISTINCT_OUTPUT')
|
||||
]
|
||||
render(<MemberDisclosureHarness blocks={blocks} />)
|
||||
fireEvent.click(commandButton('echo repeated', 1))
|
||||
expect(commandButton('echo repeated').getAttribute('aria-expanded')).toBe('false')
|
||||
expect(commandButton('echo distinct').getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.getByText('SECOND_OUTPUT')).toBeTruthy()
|
||||
expect(screen.queryByText('DISTINCT_OUTPUT')).toBeNull()
|
||||
|
||||
fireEvent.click(commandButton('echo distinct'))
|
||||
expect(screen.getByText('DISTINCT_OUTPUT')).toBeTruthy()
|
||||
expect(commandButton('echo repeated', 1).getAttribute('aria-expanded')).toBe('true')
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { useMemo } from 'react'
|
||||
import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown'
|
||||
import type { NativeChatBlock, NativeChatToolCallBlock } from '../../../../shared/native-chat-types'
|
||||
import { pairNativeChatToolResults } from '../../../../shared/native-chat-tool-pairing'
|
||||
import { nativeChatToolLineIdentity } from '../../../../shared/native-chat-tool-line-identity'
|
||||
import { NativeChatDiffCard } from './NativeChatDiffCard'
|
||||
import { NativeChatTaskList } from './NativeChatTaskList'
|
||||
import { NativeChatToolLine } from './NativeChatToolLine'
|
||||
@@ -10,19 +11,6 @@ import { buildNativeChatTaskListRows } from './native-chat-task-list-history'
|
||||
import type { NativeChatDiffReveal } from './native-chat-turn-diffs'
|
||||
import type { NativeChatToolRunAsides } from './NativeChatToolRun'
|
||||
|
||||
/** A row's identity when its provider gave none: what it says, and which repeat of that it is. */
|
||||
function contentIdentity(block: NativeChatBlock, seen: Map<string, number>): string {
|
||||
const signature =
|
||||
block.type === 'tool-call'
|
||||
? `${block.type}:${block.name}:${JSON.stringify(block.input)}`
|
||||
: block.type === 'tool-result'
|
||||
? `${block.type}:${block.output}`
|
||||
: `${block.type}`
|
||||
const occurrence = seen.get(signature) ?? 0
|
||||
seen.set(signature, occurrence + 1)
|
||||
return `${signature}:${occurrence}`
|
||||
}
|
||||
|
||||
/** An opened run's members in order, each one line until opened. Mounted only while the
|
||||
* run is open, so a collapsed run pays for none of the diffing and pairing done here. */
|
||||
export function NativeChatToolRunMemberList({
|
||||
@@ -100,12 +88,7 @@ export function NativeChatToolRunMemberList({
|
||||
if (consumedResults.has(block) || pairedResults.has(block)) {
|
||||
return null
|
||||
}
|
||||
const providerCallId =
|
||||
block.type === 'tool-call' && block.callId !== undefined && block.callId.trim().length > 0
|
||||
? block.callId
|
||||
: undefined
|
||||
const lineIdentity =
|
||||
providerCallId !== undefined ? `call:${providerCallId}` : contentIdentity(block, seen)
|
||||
const lineIdentity = nativeChatToolLineIdentity(block, seen)
|
||||
return (
|
||||
<NativeChatToolLine
|
||||
key={lineIdentity}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createNativeChatMessageListProjection } from './native-chat-message-lis
|
||||
import { orderNativeChatMessages } from './native-chat-message-grouping'
|
||||
import { stripNoiseMessages } from './native-chat-noise'
|
||||
import { foldToolMessages } from './native-chat-tool-fold'
|
||||
import { pairToolBlocks } from '../../../../shared/native-chat-tool-pairs'
|
||||
|
||||
function message(
|
||||
id: string,
|
||||
@@ -14,6 +15,40 @@ function message(
|
||||
return { id, timestamp, blocks, role, source: 'transcript' }
|
||||
}
|
||||
|
||||
it('retains detached evidence and its row identity when later work uses the same provider ID', () => {
|
||||
const project = createNativeChatMessageListProjection()
|
||||
const source = message('source', 1, [
|
||||
{ type: 'text', text: 'Working' },
|
||||
{ type: 'tool-call', name: 'Bash', callId: 'a', input: { command: 'echo a' } },
|
||||
{ type: 'tool-result', callId: 'x', output: 'earlier unowned output' }
|
||||
])
|
||||
const initial = project([source])
|
||||
const orphan = initial.conversation.find((row) => row.unpairedToolResults)
|
||||
expect(orphan).toBeDefined()
|
||||
expect(project([source])).toBe(initial)
|
||||
const laterCall = message(
|
||||
'later-call',
|
||||
2,
|
||||
[{ type: 'tool-call', name: 'Bash', callId: 'x', input: { command: 'echo x' } }],
|
||||
'tool'
|
||||
)
|
||||
const laterResult = message(
|
||||
'later-result',
|
||||
3,
|
||||
[{ type: 'tool-result', callId: 'x', output: 'actual later output' }],
|
||||
'tool'
|
||||
)
|
||||
const updated = project([source, laterCall, laterResult])
|
||||
expect(updated.conversation.find((row) => row.unpairedToolResults)).toBe(orphan)
|
||||
expect(updated.conversation[0]).not.toBe(initial.conversation[0])
|
||||
expect(pairToolBlocks(updated.conversation[0]!.blocks).at(-1)).toEqual({
|
||||
call: laterCall.blocks[0],
|
||||
result: laterResult.blocks[0]
|
||||
})
|
||||
expect(foldToolMessages(updated.conversation)).toEqual(updated.conversation)
|
||||
expect(source.blocks).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('retains settled folded runs while exposing changed tools, metadata, and attribution boundaries', () => {
|
||||
const projectList = createNativeChatMessageListProjection()
|
||||
const project = (messages: NativeChatMessage[]) => projectList(messages).conversation
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { NativeChatBlock, NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import { foldToolMessages } from './native-chat-tool-fold'
|
||||
import { nativeChatTaskListState } from './native-chat-task-list-state'
|
||||
|
||||
function message(id: string, blocks: NativeChatBlock[]): NativeChatMessage {
|
||||
@@ -70,4 +71,26 @@ describe('nativeChatTaskListState', () => {
|
||||
expect(result.list?.tasks[0].content).toBe('Read')
|
||||
expect(result.messages[0].blocks).toEqual([tasks, success, shell, error])
|
||||
})
|
||||
|
||||
it('preserves accepted tasks when named orphan failures remain visibly separate', () => {
|
||||
const tasks = { ...call('Accepted'), callId: 'accepted' }
|
||||
const rejected = { ...call('Rejected', 'completed'), callId: 'rejected' }
|
||||
const source = message('calls', [tasks, rejected])
|
||||
const outputs = {
|
||||
...message('outputs', [
|
||||
{ type: 'tool-result', callId: 'unknown', output: 'Unowned failure', isError: true },
|
||||
{ type: 'tool-result', callId: 'rejected', output: 'Rejected update', isError: true },
|
||||
{ type: 'tool-result', callId: 'accepted', output: 'Accepted update' }
|
||||
]),
|
||||
role: 'tool' as const
|
||||
}
|
||||
const folded = foldToolMessages([source, outputs])
|
||||
expect(nativeChatTaskListState(folded).list?.tasks).toEqual([
|
||||
{ content: 'Accepted', status: 'pending' }
|
||||
])
|
||||
expect(folded[1]).toMatchObject({ id: 'outputs', role: 'tool', blocks: [outputs.blocks[0]] })
|
||||
expect(nativeChatTaskListState(foldToolMessages(folded)).list?.tasks).toEqual([
|
||||
{ content: 'Accepted', status: 'pending' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// a Stop naming no turn. Against any other host, one that accepts sends first included, it stays
|
||||
// exactly what it was: a running turn only, named.
|
||||
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
AgentJournalRenderItem,
|
||||
@@ -134,6 +134,7 @@ function cancels(): unknown[] {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
setLocalRuntimeCapabilitiesForTests(null)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { unpairedToolResultIndices } from './native-chat-tool-pairs'
|
||||
import type { NativeChatBlock, NativeChatMessage } from './native-chat-types'
|
||||
|
||||
type ToolContribution = {
|
||||
source: NativeChatMessage
|
||||
blocks: readonly NativeChatBlock[]
|
||||
start: number
|
||||
insertAt: number
|
||||
sourceRow?: number
|
||||
}
|
||||
|
||||
/** Retains each result's source row while a run combines provider records. */
|
||||
export class FoldedToolResultSources {
|
||||
private readonly contributions = new Map<number, ToolContribution[]>()
|
||||
|
||||
constructor(private readonly sourceMessages: readonly NativeChatMessage[]) {}
|
||||
|
||||
append(
|
||||
row: number,
|
||||
target: NativeChatMessage,
|
||||
source: NativeChatMessage,
|
||||
blocks: readonly NativeChatBlock[],
|
||||
insertAt: number,
|
||||
sourceRow?: number
|
||||
): void {
|
||||
let contributors = this.contributions.get(row)
|
||||
if (!contributors) {
|
||||
contributors = [
|
||||
{ source: target, blocks: target.blocks, start: 0, insertAt: row + 1, sourceRow: row }
|
||||
]
|
||||
this.contributions.set(row, contributors)
|
||||
}
|
||||
contributors.push({ source, blocks, start: target.blocks.length, insertAt, sourceRow })
|
||||
}
|
||||
|
||||
project(rows: NativeChatMessage[]): NativeChatMessage[] {
|
||||
let retained: (NativeChatMessage | null)[] | undefined
|
||||
const unpairedByRow = new Map<number, ReadonlySet<number>>()
|
||||
for (const [rowIndex, message] of rows.entries()) {
|
||||
if (
|
||||
message.unpairedToolResults ||
|
||||
!message.blocks.some((block) => block.type === 'tool-result')
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const unpaired = unpairedToolResultIndices(message.blocks)
|
||||
if (unpaired.size === 0) {
|
||||
continue
|
||||
}
|
||||
const blocks = message.blocks.filter((_block, index) => !unpaired.has(index))
|
||||
retained ??= Array.from(rows)
|
||||
retained[rowIndex] = blocks.length > 0 ? { ...message, blocks } : null
|
||||
unpairedByRow.set(rowIndex, unpaired)
|
||||
}
|
||||
if (!retained) {
|
||||
return rows
|
||||
}
|
||||
const orphans = new Map<number, NativeChatMessage[]>()
|
||||
const allocatedIds = new Set(this.sourceMessages.map((message) => message.id))
|
||||
const occurrences = new Map<string, number>()
|
||||
for (const [rowIndex, unpaired] of unpairedByRow) {
|
||||
const message = rows[rowIndex]!
|
||||
const contributors = this.contributions.get(rowIndex) ?? [
|
||||
{
|
||||
source: message,
|
||||
blocks: message.blocks,
|
||||
start: 0,
|
||||
insertAt: rowIndex + 1,
|
||||
sourceRow: rowIndex
|
||||
}
|
||||
]
|
||||
for (const contribution of contributors) {
|
||||
const orphanBlocks = contribution.blocks.filter(
|
||||
(block, index) =>
|
||||
block.type === 'tool-result' &&
|
||||
block.callId !== undefined &&
|
||||
unpaired.has(contribution.start + index)
|
||||
)
|
||||
if (orphanBlocks.length === 0) {
|
||||
continue
|
||||
}
|
||||
const sourceStillDraws =
|
||||
contribution.sourceRow !== undefined && retained[contribution.sourceRow] !== null
|
||||
const id = sourceStillDraws
|
||||
? orphanRowId(contribution.source.id, allocatedIds, occurrences)
|
||||
: contribution.source.id
|
||||
allocatedIds.add(id)
|
||||
const orphan: NativeChatMessage = {
|
||||
...contribution.source,
|
||||
id,
|
||||
role: 'tool',
|
||||
blocks: orphanBlocks,
|
||||
unpairedToolResults: true
|
||||
}
|
||||
const at = orphans.get(contribution.insertAt)
|
||||
if (at) {
|
||||
at.push(orphan)
|
||||
} else {
|
||||
orphans.set(contribution.insertAt, [orphan])
|
||||
}
|
||||
}
|
||||
}
|
||||
const projected: NativeChatMessage[] = []
|
||||
for (let index = 0; index <= retained.length; index += 1) {
|
||||
projected.push(...(orphans.get(index) ?? []))
|
||||
const message = retained[index]
|
||||
if (message) {
|
||||
projected.push(message)
|
||||
}
|
||||
}
|
||||
return projected
|
||||
}
|
||||
}
|
||||
|
||||
function orphanRowId(
|
||||
sourceId: string,
|
||||
allocated: ReadonlySet<string>,
|
||||
occurrences: Map<string, number>
|
||||
): string {
|
||||
let occurrence = occurrences.get(sourceId) ?? 0
|
||||
let id = `unpaired-tool-results:${JSON.stringify([sourceId, occurrence])}`
|
||||
while (allocated.has(id)) {
|
||||
occurrence += 1
|
||||
id = `unpaired-tool-results:${JSON.stringify([sourceId, occurrence])}`
|
||||
}
|
||||
occurrences.set(sourceId, occurrence + 1)
|
||||
return id
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { foldToolMessages } from './native-chat-tool-fold'
|
||||
import { pairToolBlocks } from './native-chat-tool-pairs'
|
||||
import type {
|
||||
NativeChatBackgroundTaskBlock,
|
||||
NativeChatBlock,
|
||||
@@ -34,6 +35,58 @@ function backgroundTaskMessage(id: string): NativeChatMessage {
|
||||
}
|
||||
|
||||
describe('tool attribution allocation', () => {
|
||||
it('allocates pair wrappers only for the consumer, including a large pending backlog and orphans', () => {
|
||||
const calls: NativeChatBlock[] = Array.from({ length: 2048 }, (_, i) => ({
|
||||
type: 'tool-call',
|
||||
name: 'read',
|
||||
callId: `call-${i}`,
|
||||
input: { path: `file-${i}` }
|
||||
}))
|
||||
const answer: NativeChatBlock = {
|
||||
type: 'tool-result',
|
||||
callId: 'call-2047',
|
||||
output: 'last call answered'
|
||||
}
|
||||
const orphan: NativeChatBlock = {
|
||||
type: 'tool-result',
|
||||
callId: 'unknown',
|
||||
output: 'unattributed evidence'
|
||||
}
|
||||
const input = message('backlog', [...calls, answer, orphan])
|
||||
const blocks = new Set<unknown>(input.blocks)
|
||||
const push = Array.prototype.push
|
||||
let wrappers = 0
|
||||
Array.prototype.push = function (this: unknown[], ...items: unknown[]) {
|
||||
for (const item of items) {
|
||||
if (
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
(('call' in item && blocks.has(item.call)) ||
|
||||
('result' in item && blocks.has(item.result)))
|
||||
) {
|
||||
wrappers += 1
|
||||
}
|
||||
}
|
||||
return push.apply(this, items)
|
||||
}
|
||||
let folded: NativeChatMessage[]
|
||||
let pairs: ReturnType<typeof pairToolBlocks>[]
|
||||
let foldWrappers = 0
|
||||
try {
|
||||
folded = foldToolMessages([input])
|
||||
foldWrappers = wrappers
|
||||
pairs = folded.map((entry) => pairToolBlocks(entry.blocks))
|
||||
} finally {
|
||||
Array.prototype.push = push
|
||||
}
|
||||
expect(foldWrappers).toBe(0)
|
||||
expect(wrappers).toBe(calls.length + 1)
|
||||
expect(pairs[0]?.at(-1)).toEqual({ call: calls.at(-1), result: answer })
|
||||
expect(pairs[1]).toEqual([{ result: orphan }])
|
||||
expect(folded[1]).toMatchObject({ role: 'tool', unpairedToolResults: true })
|
||||
expect(input.blocks).toEqual([...calls, answer, orphan])
|
||||
})
|
||||
|
||||
it('does not append valid prose blocks to discarded attribution arrays', () => {
|
||||
const prose: NativeChatBlock = { type: 'text', text: 'Ordinary prose' }
|
||||
const messages = Array.from({ length: 1000 }, (_, i) => message(String(i), [prose]))
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { foldToolMessages, pairToolBlocks } from './native-chat-tool-fold'
|
||||
import type { NativeChatBlock, NativeChatMessage } from './native-chat-types'
|
||||
|
||||
function message(id: string, blocks: NativeChatBlock[]): NativeChatMessage {
|
||||
return { id, role: 'assistant', source: 'transcript', timestamp: 1, blocks }
|
||||
}
|
||||
|
||||
it('shows an unrelated named result separately without consuming the call a later result answers', () => {
|
||||
const call: NativeChatBlock = { type: 'tool-call', name: 'Bash', callId: 'shell', input: {} }
|
||||
const stray: NativeChatBlock = { type: 'tool-result', callId: 'missing', output: 'unrelated' }
|
||||
const result: NativeChatBlock = { type: 'tool-result', callId: 'shell', output: 'owned' }
|
||||
const input = message('run', [call, stray, result])
|
||||
const [folded, orphan] = foldToolMessages([input])
|
||||
expect(folded?.blocks).toEqual([call, result])
|
||||
expect(pairToolBlocks(folded?.blocks ?? [])).toEqual([{ call, result }])
|
||||
expect(orphan).toMatchObject({ role: 'tool', blocks: [stray] })
|
||||
expect(orphan?.id).not.toBe(folded?.id)
|
||||
expect(input.blocks).toEqual([call, stray, result])
|
||||
})
|
||||
|
||||
it('attributes repeated result object occurrences independently', () => {
|
||||
const result: NativeChatBlock = { type: 'tool-result', callId: 'shell', output: 'owned' }
|
||||
const call: NativeChatBlock = { type: 'tool-call', name: 'Bash', callId: 'shell', input: {} }
|
||||
const [folded, orphan] = foldToolMessages([message('run', [result, call, result, result])])
|
||||
expect(folded?.blocks).toEqual([call, result])
|
||||
expect(orphan?.blocks).toEqual([result, result])
|
||||
})
|
||||
|
||||
it('keeps named and positional answers after calls fold across provider rows', () => {
|
||||
const silent: NativeChatBlock = { type: 'tool-call', name: 'spawn_agent', callId: 's', input: {} }
|
||||
const shell: NativeChatBlock = { type: 'tool-call', name: 'Bash', callId: 'x', input: {} }
|
||||
const done: NativeChatBlock = { type: 'tool-result', callId: 'x', output: 'shell output' }
|
||||
const positional: NativeChatBlock = { type: 'tool-result', output: 'spawn output' }
|
||||
const stray: NativeChatBlock = { type: 'tool-result', callId: 'missing', output: 'unrelated' }
|
||||
const [folded, orphan] = foldToolMessages([
|
||||
message('calls', [silent, shell]),
|
||||
{ ...message('outputs', [stray, done, positional]), role: 'tool' }
|
||||
])
|
||||
expect(folded?.blocks).toEqual([silent, shell, done, positional])
|
||||
expect(pairToolBlocks(folded?.blocks ?? [])).toEqual([
|
||||
{ call: silent, result: positional },
|
||||
{ call: shell, result: done }
|
||||
])
|
||||
expect(orphan).toMatchObject({ id: 'outputs', role: 'tool', blocks: [stray] })
|
||||
})
|
||||
|
||||
it('reuses a message with fully attributable answers', () => {
|
||||
const input = message('run', [
|
||||
{ type: 'tool-call', name: 'Bash', callId: 'x', input: {} },
|
||||
{ type: 'tool-result', callId: 'x', output: 'owned' }
|
||||
])
|
||||
expect(foldToolMessages([input])[0]).toBe(input)
|
||||
})
|
||||
@@ -4,9 +4,7 @@ import {
|
||||
isToolCallBlock,
|
||||
isToolResultBlock,
|
||||
type NativeChatBlock,
|
||||
type NativeChatMessage,
|
||||
type NativeChatToolCallBlock,
|
||||
type NativeChatToolResultBlock
|
||||
type NativeChatMessage
|
||||
} from './native-chat-types'
|
||||
import { isKnownHarnessInjectedUserTurnText } from './harness-injected-user-turns'
|
||||
import { isNoiseMessage } from './native-chat-noise'
|
||||
@@ -14,6 +12,9 @@ import {
|
||||
CODEX_PLAN_UPDATED_FRAME_KIND,
|
||||
isWordlessProviderFrameMessage
|
||||
} from './native-chat-provider-frame-summary'
|
||||
import { FoldedToolResultSources } from './native-chat-folded-tool-results'
|
||||
|
||||
export { pairToolBlocks, type NativeChatToolPair } from './native-chat-tool-pairs'
|
||||
|
||||
function isToolOnlyMessage(message: NativeChatMessage): boolean {
|
||||
return (
|
||||
@@ -73,29 +74,6 @@ function isInterruptionBoundary(message: NativeChatMessage): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
/** Drop tool results the renderer cannot pair within their folded message. */
|
||||
function dropUnattributableToolResults(message: NativeChatMessage): NativeChatMessage | null {
|
||||
let blocks: NativeChatBlock[] | undefined
|
||||
let unansweredCalls = 0
|
||||
for (let index = 0; index < message.blocks.length; index++) {
|
||||
const block = message.blocks[index]
|
||||
if (isToolCallBlock(block)) {
|
||||
unansweredCalls += 1
|
||||
} else if (isToolResultBlock(block)) {
|
||||
if (unansweredCalls === 0) {
|
||||
blocks ??= message.blocks.slice(0, index)
|
||||
continue
|
||||
}
|
||||
unansweredCalls -= 1
|
||||
}
|
||||
blocks?.push(block)
|
||||
}
|
||||
if (!blocks) {
|
||||
return message
|
||||
}
|
||||
return blocks.length > 0 ? { ...message, blocks } : null
|
||||
}
|
||||
|
||||
/** A run drawn at its assistant row can hold calls newer than rows drawn below it. */
|
||||
function recordFoldedPosition(target: NativeChatMessage, folded: NativeChatMessage): void {
|
||||
if (folded.journalPosition) {
|
||||
@@ -106,18 +84,27 @@ function recordFoldedPosition(target: NativeChatMessage, folded: NativeChatMessa
|
||||
/** Fold consecutive tool-only messages into their preceding assistant turn. */
|
||||
export function foldToolMessages(messages: readonly NativeChatMessage[]): NativeChatMessage[] {
|
||||
const output: NativeChatMessage[] = []
|
||||
const sources = new FoldedToolResultSources(messages)
|
||||
let mutableAssistantIndex = -1
|
||||
let clonedAssistantIndex = -1
|
||||
for (const message of messages) {
|
||||
if (message.unpairedToolResults) {
|
||||
output.push(message)
|
||||
mutableAssistantIndex = -1
|
||||
clonedAssistantIndex = -1
|
||||
continue
|
||||
}
|
||||
if (isHarnessSidecarToolMessage(message) && mutableAssistantIndex >= 0) {
|
||||
const index = mutableAssistantIndex
|
||||
const assistant = output[index]
|
||||
if (assistant?.role === 'assistant') {
|
||||
const results = message.blocks.filter(isToolResultBlock)
|
||||
sources.append(index, assistant, message, results, output.length, output.length)
|
||||
if (clonedAssistantIndex !== index) {
|
||||
output[index] = { ...assistant, blocks: [...assistant.blocks] }
|
||||
clonedAssistantIndex = index
|
||||
}
|
||||
output[index].blocks.push(...message.blocks.filter(isToolResultBlock))
|
||||
output[index].blocks.push(...results)
|
||||
recordFoldedPosition(output[index], message)
|
||||
output.push({
|
||||
...message,
|
||||
@@ -134,6 +121,7 @@ export function foldToolMessages(messages: readonly NativeChatMessage[]): Native
|
||||
mutableAssistantIndex = -1
|
||||
continue
|
||||
}
|
||||
sources.append(index, assistant, message, message.blocks, output.length)
|
||||
if (clonedAssistantIndex !== index) {
|
||||
output[index] = { ...assistant, blocks: [...assistant.blocks] }
|
||||
clonedAssistantIndex = index
|
||||
@@ -156,67 +144,7 @@ export function foldToolMessages(messages: readonly NativeChatMessage[]): Native
|
||||
clonedAssistantIndex = -1
|
||||
}
|
||||
}
|
||||
const attributedOutput: NativeChatMessage[] = []
|
||||
for (const message of output) {
|
||||
const attributed = dropUnattributableToolResults(message)
|
||||
if (attributed) {
|
||||
attributedOutput.push(attributed)
|
||||
}
|
||||
}
|
||||
return attributedOutput
|
||||
}
|
||||
|
||||
export type NativeChatToolPair = {
|
||||
call?: NativeChatToolCallBlock
|
||||
result?: NativeChatToolResultBlock
|
||||
}
|
||||
|
||||
/** Where in `unanswered` (oldest first) the call `result` answers is, or -1 for none. */
|
||||
function answeredToolCallIndex<T>(
|
||||
unanswered: readonly T[],
|
||||
result: NativeChatToolResultBlock,
|
||||
callIdOf: (entry: T) => string | undefined
|
||||
): number {
|
||||
if (result.callId === undefined) {
|
||||
return unanswered.length > 0 ? 0 : -1
|
||||
}
|
||||
return unanswered.findIndex((entry) => callIdOf(entry) === result.callId)
|
||||
}
|
||||
|
||||
/** Pair results to calls by `answeredToolCallIndex`. Every reader of a run pairs through this
|
||||
* (`pairNativeChatToolResults` included), so they all agree on who owns an output. */
|
||||
export function pairToolBlocks(
|
||||
blocks: readonly NativeChatBlock[],
|
||||
limit = Infinity
|
||||
): NativeChatToolPair[] {
|
||||
const pairs: NativeChatToolPair[] = []
|
||||
/** Slots of retained calls not yet answered, oldest first. */
|
||||
const callSlots: number[] = []
|
||||
for (const block of blocks) {
|
||||
if (pairs.length >= limit && callSlots.length === 0) {
|
||||
break
|
||||
}
|
||||
if (block.type === 'tool-call') {
|
||||
if (pairs.length < limit) {
|
||||
callSlots.push(pairs.length)
|
||||
pairs.push({ call: block })
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (block.type !== 'tool-result') {
|
||||
continue
|
||||
}
|
||||
const answered = answeredToolCallIndex(callSlots, block, (slot) => pairs[slot]?.call?.callId)
|
||||
const [slot] = answered === -1 ? [] : callSlots.splice(answered, 1)
|
||||
if (slot === undefined) {
|
||||
if (pairs.length < limit) {
|
||||
pairs.push({ result: block })
|
||||
}
|
||||
} else {
|
||||
pairs[slot]!.result = block
|
||||
}
|
||||
}
|
||||
return pairs
|
||||
return sources.project(output)
|
||||
}
|
||||
|
||||
export function splitNativeChatBlocks(blocks: readonly NativeChatBlock[]): {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import type { NativeChatToolCallBlock } from './native-chat-types'
|
||||
import { nativeChatToolLineIdentity } from './native-chat-tool-line-identity'
|
||||
|
||||
it('uses object identity for anonymous mobile rows without serializing large input', () => {
|
||||
const input = {
|
||||
toJSON: () => {
|
||||
throw new Error('Input must stay unread.')
|
||||
}
|
||||
}
|
||||
const block: NativeChatToolCallBlock = { type: 'tool-call', name: 'Read', input }
|
||||
const original = nativeChatToolLineIdentity(block, new Map(), true)
|
||||
expect(nativeChatToolLineIdentity(block, new Map(), true)).toBe(original)
|
||||
expect(nativeChatToolLineIdentity({ ...block }, new Map(), true)).not.toBe(original)
|
||||
const seen = new Map<string, number>()
|
||||
expect(nativeChatToolLineIdentity(block, seen, true)).not.toBe(
|
||||
nativeChatToolLineIdentity(block, seen, true)
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves named desktop identity and independent duplicate occurrences', () => {
|
||||
const block: NativeChatToolCallBlock = {
|
||||
type: 'tool-call',
|
||||
callId: 'id',
|
||||
name: 'Read',
|
||||
input: {}
|
||||
}
|
||||
expect(nativeChatToolLineIdentity(block, new Map())).toBe('call:id')
|
||||
const seen = new Map<string, number>()
|
||||
expect(nativeChatToolLineIdentity(block, seen, true)).toBe('call:id')
|
||||
expect(nativeChatToolLineIdentity(block, seen, true)).toBe('call-occurrence:["id",1]')
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { NativeChatBlock } from './native-chat-types'
|
||||
|
||||
const anonymousRows = new WeakMap<NativeChatBlock, number>()
|
||||
let nextAnonymousRow = 0
|
||||
|
||||
/** Each repeated provider ID needs a separate row, while unrelated rows may move freely. */
|
||||
export function nativeChatToolLineIdentity(
|
||||
block: NativeChatBlock,
|
||||
seen: Map<string, number>,
|
||||
preserveAnonymousObject = false
|
||||
): string {
|
||||
const providerCallId =
|
||||
(block.type === 'tool-call' || (preserveAnonymousObject && block.type === 'tool-result')) &&
|
||||
block.callId !== undefined &&
|
||||
block.callId.trim().length > 0
|
||||
? block.callId
|
||||
: undefined
|
||||
if (providerCallId === undefined && preserveAnonymousObject) {
|
||||
const id = anonymousRows.get(block) ?? nextAnonymousRow++
|
||||
anonymousRows.set(block, id)
|
||||
const signature = `anonymous:${id}`
|
||||
const occurrence = seen.get(signature) ?? 0
|
||||
seen.set(signature, occurrence + 1)
|
||||
return `${signature}:${occurrence}`
|
||||
}
|
||||
const providerKind = block.type === 'tool-result' ? 'result' : 'call'
|
||||
const signature =
|
||||
providerCallId !== undefined
|
||||
? `${providerKind}:${providerCallId}`
|
||||
: block.type === 'tool-call'
|
||||
? `${block.type}:${block.name}:${JSON.stringify(block.input)}`
|
||||
: block.type === 'tool-result'
|
||||
? `${block.type}:${block.output}`
|
||||
: `${block.type}`
|
||||
const occurrence = seen.get(signature) ?? 0
|
||||
seen.set(signature, occurrence + 1)
|
||||
if (providerCallId === undefined) {
|
||||
return `${signature}:${occurrence}`
|
||||
}
|
||||
return occurrence === 0
|
||||
? signature
|
||||
: `${providerKind}-occurrence:${JSON.stringify([providerCallId, occurrence])}`
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { pairToolBlocks, type NativeChatToolPair } from './native-chat-tool-fold'
|
||||
import type {
|
||||
NativeChatBlock,
|
||||
NativeChatToolCallBlock,
|
||||
NativeChatToolResultBlock
|
||||
} from './native-chat-types'
|
||||
|
||||
function call(callId?: string): NativeChatToolCallBlock {
|
||||
return { type: 'tool-call', name: 'Bash', input: {}, ...(callId !== undefined ? { callId } : {}) }
|
||||
}
|
||||
|
||||
function result(callId?: string): NativeChatToolResultBlock {
|
||||
return { type: 'tool-result', output: 'done', ...(callId !== undefined ? { callId } : {}) }
|
||||
}
|
||||
|
||||
// The original scan is an independent oracle for named and positional matching.
|
||||
function scanPairs(blocks: readonly NativeChatBlock[], limit = Infinity): NativeChatToolPair[] {
|
||||
const pairs: NativeChatToolPair[] = []
|
||||
const pending: number[] = []
|
||||
for (const block of blocks) {
|
||||
if (pairs.length >= limit && pending.length === 0) {
|
||||
break
|
||||
}
|
||||
if (block.type === 'tool-call') {
|
||||
if (pairs.length < limit) {
|
||||
pending.push(pairs.length)
|
||||
pairs.push({ call: block })
|
||||
}
|
||||
} else if (block.type === 'tool-result') {
|
||||
const pendingIndex =
|
||||
block.callId === undefined
|
||||
? pending.length > 0
|
||||
? 0
|
||||
: -1
|
||||
: pending.findIndex((slot) => pairs[slot]?.call?.callId === block.callId)
|
||||
if (pendingIndex === -1) {
|
||||
if (pairs.length < limit) {
|
||||
pairs.push({ result: block })
|
||||
}
|
||||
} else {
|
||||
const [slot] = pending.splice(pendingIndex, 1)
|
||||
pairs[slot]!.result = block
|
||||
}
|
||||
}
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
function expectSameReferences(
|
||||
actual: readonly NativeChatToolPair[],
|
||||
expected: readonly NativeChatToolPair[]
|
||||
): void {
|
||||
expect(actual).toEqual(expected)
|
||||
actual.forEach((pair, index) => {
|
||||
expect(pair.call).toBe(expected[index]?.call)
|
||||
expect(pair.result).toBe(expected[index]?.result)
|
||||
})
|
||||
}
|
||||
|
||||
function countedCalls(count: number): {
|
||||
calls: NativeChatToolCallBlock[]
|
||||
reads: () => number
|
||||
} {
|
||||
let reads = 0
|
||||
const calls = Array.from({ length: count }, (_, index): NativeChatToolCallBlock => ({
|
||||
type: 'tool-call',
|
||||
name: 'Bash',
|
||||
input: {},
|
||||
get callId() {
|
||||
reads += 1
|
||||
return `call-${index}`
|
||||
}
|
||||
}))
|
||||
return { calls, reads: () => reads }
|
||||
}
|
||||
|
||||
function countQueueMovement(
|
||||
blocks: readonly NativeChatBlock[],
|
||||
pair = pairToolBlocks
|
||||
): {
|
||||
pairs: NativeChatToolPair[]
|
||||
movedSlots: number
|
||||
} {
|
||||
const originalSplice = Array.prototype.splice
|
||||
const originalShift = Array.prototype.shift
|
||||
let movedSlots = 0
|
||||
Array.prototype.splice = function (
|
||||
this: unknown[],
|
||||
requestedIndex: number,
|
||||
deleteCount?: number,
|
||||
...items: unknown[]
|
||||
) {
|
||||
if (arguments.length === 0) {
|
||||
return originalSplice.call(this, 0, 0)
|
||||
}
|
||||
const requestedStart = Math.trunc(requestedIndex) || 0
|
||||
const start =
|
||||
requestedStart < 0
|
||||
? Math.max(0, this.length + requestedStart)
|
||||
: Math.min(this.length, requestedStart)
|
||||
const removed =
|
||||
arguments.length < 2
|
||||
? this.length - start
|
||||
: Math.min(this.length - start, Math.max(0, Math.trunc(deleteCount ?? 0) || 0))
|
||||
if (removed !== items.length) {
|
||||
movedSlots += this.length - start - removed
|
||||
}
|
||||
return originalSplice.call(this, requestedIndex, removed, ...items)
|
||||
}
|
||||
Array.prototype.shift = function (this: unknown[]) {
|
||||
movedSlots += Math.max(0, this.length - 1)
|
||||
return originalShift.call(this)
|
||||
}
|
||||
try {
|
||||
return { pairs: pair(blocks), movedSlots }
|
||||
} finally {
|
||||
Array.prototype.shift = originalShift
|
||||
Array.prototype.splice = originalSplice
|
||||
}
|
||||
}
|
||||
|
||||
describe('tool pairing lookup budget', () => {
|
||||
it.each(['positional', 'named', 'duplicate'] as const)(
|
||||
'removes %s completions without quadratic queue movement',
|
||||
(kind) => {
|
||||
const count = 2048
|
||||
const idOf = (index: number): string | undefined =>
|
||||
kind === 'positional' ? undefined : kind === 'duplicate' ? 'same' : `call-${index}`
|
||||
const calls = Array.from({ length: count }, (_, index) => call(idOf(index)))
|
||||
const results = Array.from({ length: count }, (_, index) => result(idOf(index)))
|
||||
const blocks = [...calls, ...results]
|
||||
const measured = countQueueMovement(blocks)
|
||||
expect(countQueueMovement(blocks, scanPairs).movedSlots).toBe((count * (count - 1)) / 2)
|
||||
expectSameReferences(
|
||||
measured.pairs,
|
||||
calls.map((pending, index) => ({ call: pending, result: results[index] }))
|
||||
)
|
||||
expect(measured.movedSlots).toBeLessThanOrEqual(count * 3)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([64, 512, 2048])(
|
||||
'looks up named results in linear work for %i calls answered in reverse order',
|
||||
(count) => {
|
||||
const fixture = countedCalls(count)
|
||||
const results = Array.from({ length: count }, (_, index) => result(`call-${index}`))
|
||||
const pairs = pairToolBlocks([...fixture.calls, ...results.toReversed()])
|
||||
const lookupReads = fixture.reads()
|
||||
expect(pairs).toHaveLength(count)
|
||||
pairs.forEach((pair, index) => {
|
||||
expect(pair.call).toBe(fixture.calls[index])
|
||||
expect(pair.result).toBe(results[index])
|
||||
})
|
||||
expect(lookupReads).toBeLessThanOrEqual(count * 3)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([64, 512, 2048])(
|
||||
'keeps later named completions linear behind %i calls that emitted no result',
|
||||
(count) => {
|
||||
const fixture = countedCalls(count)
|
||||
const laterCalls = Array.from({ length: count }, (_, index) => call(`later-${index}`))
|
||||
const results = Array.from({ length: count }, (_, index) => result(`later-${index}`))
|
||||
const pairs = pairToolBlocks([
|
||||
...fixture.calls,
|
||||
...laterCalls.flatMap((laterCall, index) => [laterCall, results[index]!])
|
||||
])
|
||||
const lookupReads = fixture.reads()
|
||||
expect(pairs).toHaveLength(count * 2)
|
||||
fixture.calls.forEach((pendingCall, index) => {
|
||||
expect(pairs[index]?.call).toBe(pendingCall)
|
||||
expect(pairs[index]?.result).toBeUndefined()
|
||||
expect(pairs[count + index]?.call).toBe(laterCalls[index])
|
||||
expect(pairs[count + index]?.result).toBe(results[index])
|
||||
})
|
||||
expect(lookupReads).toBeLessThanOrEqual(count * 3)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([64, 512, 2048])(
|
||||
'keeps unmatched named results linear behind %i pending calls',
|
||||
(count) => {
|
||||
const fixture = countedCalls(count)
|
||||
const results = Array.from({ length: count }, (_, index) => result(`missing-${index}`))
|
||||
const pairs = pairToolBlocks([...fixture.calls, ...results])
|
||||
const lookupReads = fixture.reads()
|
||||
expect(pairs).toHaveLength(count * 2)
|
||||
results.forEach((unmatched, index) => {
|
||||
expect(pairs[count + index]?.call).toBeUndefined()
|
||||
expect(pairs[count + index]?.result).toBe(unmatched)
|
||||
})
|
||||
expect(lookupReads).toBeLessThanOrEqual(count * 3)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('tool pairing identity and ordering', () => {
|
||||
it('indexes missing named answers only once when every pending call has no ID', () => {
|
||||
let reads = 0
|
||||
const count = 2048
|
||||
const calls: NativeChatToolCallBlock[] = Array.from({ length: count }, () => ({
|
||||
type: 'tool-call',
|
||||
name: 'Bash',
|
||||
input: {},
|
||||
get callId() {
|
||||
reads += 1
|
||||
return undefined
|
||||
}
|
||||
}))
|
||||
const results = Array.from({ length: count }, () => result('missing'))
|
||||
expect(pairToolBlocks([...calls, ...results])).toHaveLength(count * 2)
|
||||
expect(reads).toBeLessThanOrEqual(count * 3)
|
||||
})
|
||||
|
||||
it('keeps duplicate and positional matching correct across drained and refilled queues', () => {
|
||||
const blocks: NativeChatBlock[] = []
|
||||
for (let epoch = 0; epoch < 512; epoch += 1) {
|
||||
blocks.push(
|
||||
call('same'),
|
||||
call('other'),
|
||||
result('other'),
|
||||
call('same'),
|
||||
result(),
|
||||
result('same'),
|
||||
result('missing')
|
||||
)
|
||||
}
|
||||
expectSameReferences(pairToolBlocks(blocks), scanPairs(blocks))
|
||||
})
|
||||
|
||||
it('matches repeated and empty IDs FIFO while positional results consume the oldest call', () => {
|
||||
const calls = [call('same'), call(''), call('same'), call(), call('same')]
|
||||
const results = [result('same'), result(), result('same'), result(), result('same')]
|
||||
const blocks = Object.freeze([
|
||||
...calls.map((block) => Object.freeze(block)),
|
||||
...results.map((block) => Object.freeze(block))
|
||||
])
|
||||
expectSameReferences(pairToolBlocks(blocks), [
|
||||
{ call: calls[0], result: results[0] },
|
||||
{ call: calls[1], result: results[1] },
|
||||
{ call: calls[2], result: results[2] },
|
||||
{ call: calls[3], result: results[3] },
|
||||
{ call: calls[4], result: results[4] }
|
||||
])
|
||||
})
|
||||
|
||||
it('does not retroactively answer future calls or consume unrelated calls for a named result', () => {
|
||||
const early = result('later')
|
||||
const pending = call('waiting')
|
||||
const unrelated = result('other')
|
||||
const later = call('later')
|
||||
const completed = result('later')
|
||||
const positional = result()
|
||||
const repeated = result('later')
|
||||
expectSameReferences(
|
||||
pairToolBlocks([early, pending, unrelated, later, completed, positional, repeated]),
|
||||
[
|
||||
{ result: early },
|
||||
{ call: pending, result: positional },
|
||||
{ result: unrelated },
|
||||
{ call: later, result: completed },
|
||||
{ result: repeated }
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
it('matches the original semantics for mixed identities, interleavings, and all limits', () => {
|
||||
const ids: readonly (string | undefined)[] = [
|
||||
undefined,
|
||||
'',
|
||||
'same',
|
||||
'same',
|
||||
'__proto__',
|
||||
'constructor',
|
||||
'東京',
|
||||
'i\u0307',
|
||||
'💡'
|
||||
]
|
||||
const limits = [0, -1, 0.5, 1, 2, 5, 30, Infinity, Number.NaN]
|
||||
for (let seed = 0; seed < 256; seed += 1) {
|
||||
let random = seed + 1
|
||||
const next = (): number => {
|
||||
random = (Math.imul(random, 1664525) + 1013904223) >>> 0
|
||||
return random
|
||||
}
|
||||
const blocks: NativeChatBlock[] = Array.from({ length: 48 }, () => {
|
||||
const kind = next() % 5
|
||||
const id = ids[next() % ids.length]
|
||||
return Object.freeze(
|
||||
kind < 2 ? call(id) : kind < 4 ? result(id) : { type: 'text', text: 'context' }
|
||||
)
|
||||
})
|
||||
Object.freeze(blocks)
|
||||
for (const limit of limits) {
|
||||
expectSameReferences(pairToolBlocks(blocks, limit), scanPairs(blocks, limit))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
import type {
|
||||
NativeChatBlock,
|
||||
NativeChatToolCallBlock,
|
||||
NativeChatToolResultBlock
|
||||
} from './native-chat-types'
|
||||
|
||||
export type NativeChatToolPair = {
|
||||
call?: NativeChatToolCallBlock
|
||||
result?: NativeChatToolResultBlock
|
||||
}
|
||||
|
||||
type PendingCall = {
|
||||
slot: number
|
||||
callId: string | undefined
|
||||
previous?: PendingCall
|
||||
next?: PendingCall
|
||||
nextWithId?: PendingCall
|
||||
}
|
||||
|
||||
type CallIdQueue = { first: PendingCall; last: PendingCall }
|
||||
|
||||
class PendingToolCalls {
|
||||
private first: PendingCall | undefined
|
||||
private last: PendingCall | undefined
|
||||
private byId: Map<string, CallIdQueue> | undefined
|
||||
|
||||
get empty(): boolean {
|
||||
return this.first === undefined
|
||||
}
|
||||
|
||||
add(slot: number, callId: string | undefined): void {
|
||||
const call: PendingCall = { slot, callId, previous: this.last }
|
||||
if (this.last) {
|
||||
this.last.next = call
|
||||
} else {
|
||||
this.first = call
|
||||
}
|
||||
this.last = call
|
||||
if (this.byId) {
|
||||
this.indexCall(call, this.byId)
|
||||
}
|
||||
}
|
||||
|
||||
private indexCall(call: PendingCall, byId: Map<string, CallIdQueue>): void {
|
||||
if (call.callId === undefined) {
|
||||
return
|
||||
}
|
||||
const queue = byId.get(call.callId)
|
||||
if (queue) {
|
||||
queue.last.nextWithId = call
|
||||
queue.last = call
|
||||
} else {
|
||||
byId.set(call.callId, { first: call, last: call })
|
||||
}
|
||||
}
|
||||
|
||||
take(callId: string | undefined): number | undefined {
|
||||
let call = this.first
|
||||
if (call && callId !== undefined && call.callId !== callId) {
|
||||
if (!this.byId) {
|
||||
const byId = new Map<string, CallIdQueue>()
|
||||
// Ordered results need only the FIFO; build the index when a named result misses its head.
|
||||
for (let pending: PendingCall | undefined = call; pending; pending = pending.next) {
|
||||
this.indexCall(pending, byId)
|
||||
}
|
||||
this.byId = byId
|
||||
}
|
||||
call = this.byId.get(callId)?.first
|
||||
}
|
||||
if (!call) {
|
||||
return undefined
|
||||
}
|
||||
if (call.previous) {
|
||||
call.previous.next = call.next
|
||||
} else {
|
||||
this.first = call.next
|
||||
}
|
||||
if (call.next) {
|
||||
call.next.previous = call.previous
|
||||
} else {
|
||||
this.last = call.previous
|
||||
}
|
||||
// The global oldest call is also the oldest of its ID, so both answers pop an ID head.
|
||||
if (this.byId && call.callId !== undefined) {
|
||||
const queue = this.byId.get(call.callId)
|
||||
if (queue && call.nextWithId) {
|
||||
queue.first = call.nextWithId
|
||||
} else {
|
||||
this.byId.delete(call.callId)
|
||||
}
|
||||
}
|
||||
if (!this.first) {
|
||||
this.byId = undefined
|
||||
}
|
||||
return call.slot
|
||||
}
|
||||
}
|
||||
|
||||
/** Named results answer the oldest call with that ID; unnamed results answer the oldest call. */
|
||||
export function pairToolBlocks(
|
||||
blocks: readonly NativeChatBlock[],
|
||||
limit = Infinity
|
||||
): NativeChatToolPair[] {
|
||||
const pairs: NativeChatToolPair[] = []
|
||||
pairToolBlocksReportingUnpaired(blocks, limit, pairs)
|
||||
return pairs
|
||||
}
|
||||
|
||||
export function unpairedToolResultIndices(blocks: readonly NativeChatBlock[]): ReadonlySet<number> {
|
||||
const unpaired = new Set<number>()
|
||||
pairToolBlocksReportingUnpaired(blocks, Infinity, undefined, (index) => unpaired.add(index))
|
||||
return unpaired
|
||||
}
|
||||
|
||||
function pairToolBlocksReportingUnpaired(
|
||||
blocks: readonly NativeChatBlock[],
|
||||
limit: number,
|
||||
pairs?: NativeChatToolPair[],
|
||||
onUnpaired?: (index: number) => void
|
||||
): void {
|
||||
let pairCount = 0
|
||||
const pending = new PendingToolCalls()
|
||||
for (let index = 0; index < blocks.length; index += 1) {
|
||||
if (pairCount >= limit && pending.empty) {
|
||||
break
|
||||
}
|
||||
const block = blocks[index]
|
||||
if (block.type === 'tool-call') {
|
||||
if (pairCount < limit) {
|
||||
pending.add(pairCount, block.callId)
|
||||
pairs?.push({ call: block })
|
||||
pairCount += 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (block.type !== 'tool-result') {
|
||||
continue
|
||||
}
|
||||
const slot = pending.take(block.callId)
|
||||
if (slot === undefined) {
|
||||
onUnpaired?.(index)
|
||||
if (pairCount < limit) {
|
||||
pairs?.push({ result: block })
|
||||
pairCount += 1
|
||||
}
|
||||
} else if (pairs) {
|
||||
pairs[slot]!.result = block
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { foldToolMessages, pairToolBlocks } from './native-chat-tool-fold'
|
||||
import { nativeChatToolRunOutcome } from './native-chat-tool-run-outcome'
|
||||
import { nativeChatWorkRunMember } from './native-chat-work-run'
|
||||
import { nativeChatMessagesShareTranscriptRow } from './native-chat-types'
|
||||
import type {
|
||||
NativeChatBlock,
|
||||
NativeChatMessage,
|
||||
NativeChatToolCallBlock,
|
||||
NativeChatToolResultBlock
|
||||
} from './native-chat-types'
|
||||
|
||||
function row(
|
||||
id: string,
|
||||
role: NativeChatMessage['role'],
|
||||
blocks: NativeChatBlock[]
|
||||
): NativeChatMessage {
|
||||
return { id, role, blocks, timestamp: 1, source: 'transcript' }
|
||||
}
|
||||
|
||||
function call(callId: string): NativeChatToolCallBlock {
|
||||
return { type: 'tool-call', name: 'Bash', callId, input: { command: callId } }
|
||||
}
|
||||
|
||||
function result(
|
||||
callId: string | undefined,
|
||||
output = callId ?? 'anonymous'
|
||||
): NativeChatToolResultBlock {
|
||||
return { type: 'tool-result', ...(callId === undefined ? {} : { callId }), output }
|
||||
}
|
||||
|
||||
describe('unmatched named tool outputs', () => {
|
||||
it('never authorizes an earlier result after a later call is folded or the rows are folded twice', () => {
|
||||
const old = result('x', 'OLD X')
|
||||
const input = [
|
||||
row('a', 'assistant', [call('a')]),
|
||||
row('old', 'tool', [old]),
|
||||
row('x', 'assistant', [call('x')])
|
||||
]
|
||||
const once = foldToolMessages(input)
|
||||
expect(once.map((message) => message.id)).toEqual(['a', 'old'])
|
||||
expect(pairToolBlocks(once[0]!.blocks)).toEqual([{ call: call('a') }, { call: call('x') }])
|
||||
expect(once[1]).toMatchObject({ id: 'old', role: 'tool', blocks: [old] })
|
||||
expect(foldToolMessages(once)).toEqual(once)
|
||||
const roundTrip: NativeChatMessage[] = JSON.parse(JSON.stringify(once))
|
||||
expect(foldToolMessages(roundTrip)).toEqual(once)
|
||||
expect(input.map((message) => message.blocks)).toEqual([[call('a')], [old], [call('x')]])
|
||||
})
|
||||
|
||||
it('keeps a leading named output visible and never matches it retroactively', () => {
|
||||
const old = row('old', 'tool', [result('x')])
|
||||
const once = foldToolMessages([old, row('x', 'assistant', [call('x')])])
|
||||
expect(once.map((message) => message.id)).toEqual(['old', 'x'])
|
||||
expect(pairToolBlocks(once[0]!.blocks)).toEqual([{ result: result('x') }])
|
||||
expect(pairToolBlocks(once[1]!.blocks)).toEqual([{ call: call('x') }])
|
||||
expect(foldToolMessages(once)).toEqual(once)
|
||||
})
|
||||
|
||||
it('retains source ordering around activity and prose boundaries', () => {
|
||||
const roster: NativeChatBlock = { type: 'subagent-group', groupId: 'group', agents: [] }
|
||||
const input = [
|
||||
row('calls', 'assistant', [call('a')]),
|
||||
row('before', 'tool', [result('before')]),
|
||||
row('roster', 'system', [roster]),
|
||||
row('after', 'tool', [result('after'), result('a', 'A')]),
|
||||
row('prompt', 'user', [{ type: 'text', text: 'next prompt' }])
|
||||
]
|
||||
const once = foldToolMessages(input)
|
||||
expect(once.map((message) => message.id)).toEqual([
|
||||
'calls',
|
||||
'before',
|
||||
'roster',
|
||||
'after',
|
||||
'prompt'
|
||||
])
|
||||
expect(once[0]?.blocks).toEqual([call('a'), result('a', 'A')])
|
||||
expect(once[1]?.blocks).toEqual([result('before')])
|
||||
expect(once[3]?.blocks).toEqual([result('after')])
|
||||
expect(foldToolMessages(once)).toEqual(once)
|
||||
})
|
||||
|
||||
it('uses distinct stable source IDs when both words and orphan results draw', () => {
|
||||
const source = row('source', 'assistant', [
|
||||
{ type: 'text', text: 'kept prose' },
|
||||
call('a'),
|
||||
result('missing')
|
||||
])
|
||||
const reserved = row('unpaired-tool-results:["source",0]', 'user', [
|
||||
{ type: 'text', text: 'next' }
|
||||
])
|
||||
const once = foldToolMessages([source, reserved])
|
||||
expect(once.map((message) => message.id)).toEqual([
|
||||
'source',
|
||||
'unpaired-tool-results:["source",1]',
|
||||
reserved.id
|
||||
])
|
||||
expect(once[0]?.blocks).toEqual([{ type: 'text', text: 'kept prose' }, call('a')])
|
||||
expect(once[1]?.blocks).toEqual([result('missing')])
|
||||
expect(new Set(once.map((message) => message.id)).size).toBe(once.length)
|
||||
expect(foldToolMessages(once)).toEqual(once)
|
||||
expect(foldToolMessages([source, reserved])).toEqual(once)
|
||||
})
|
||||
|
||||
it('retains harness sidecar text and independently attributed results with source cursors', () => {
|
||||
const assistant = row('a', 'assistant', [call('a')])
|
||||
assistant.journalPosition = { sequence: 1, index: 0 }
|
||||
const harness = row('harness', 'user', [
|
||||
result('missing'),
|
||||
{ type: 'text', text: '<system-reminder>Keep going.</system-reminder>' },
|
||||
result('a', 'A')
|
||||
])
|
||||
harness.journalPosition = { sequence: 3, index: 0 }
|
||||
harness.transcriptOffset = 90
|
||||
const once = foldToolMessages([assistant, harness])
|
||||
expect(once).toHaveLength(3)
|
||||
expect(once[0]).toMatchObject({
|
||||
id: 'a',
|
||||
blocks: [call('a'), result('a', 'A')],
|
||||
foldedJournalPosition: harness.journalPosition
|
||||
})
|
||||
expect(once[1]).toMatchObject({
|
||||
role: 'tool',
|
||||
blocks: [result('missing')],
|
||||
journalPosition: harness.journalPosition,
|
||||
transcriptOffset: 90
|
||||
})
|
||||
expect(once[2]).toMatchObject({ id: 'harness', role: 'user', blocks: [harness.blocks[1]] })
|
||||
expect(nativeChatMessagesShareTranscriptRow(once[1]!, once[2]!)).toBe(true)
|
||||
expect(foldToolMessages(once)).toEqual(once)
|
||||
})
|
||||
|
||||
it('keeps orphan errors outside owned call outcomes and work runs', () => {
|
||||
const error = { ...result('missing'), isError: true }
|
||||
const once = foldToolMessages([row('a', 'assistant', [call('a'), result('a', 'OK'), error])])
|
||||
expect(nativeChatToolRunOutcome(once[0]!.blocks, {})).toMatchObject({
|
||||
failedCallCount: 0,
|
||||
succeeded: true
|
||||
})
|
||||
expect(once[1]?.blocks).toEqual([error])
|
||||
expect(nativeChatWorkRunMember(once[1]!, false, false)).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves result metadata while retaining historical anonymous orphan behavior', () => {
|
||||
const output: NativeChatToolResultBlock = {
|
||||
...result('named'),
|
||||
isError: true,
|
||||
editPatch: {
|
||||
filePath: 'example.ts',
|
||||
hunks: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-old', '+new'] }]
|
||||
}
|
||||
}
|
||||
const once = foldToolMessages([row('outputs', 'tool', [result(undefined), output])])
|
||||
expect(once).toHaveLength(1)
|
||||
expect(once[0]).toMatchObject({ id: 'outputs', role: 'tool', blocks: [output] })
|
||||
expect(once[0]?.blocks[0]).toBe(output)
|
||||
expect(foldToolMessages([row('anonymous', 'tool', [result(undefined)])])).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,265 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalRenderItem,
|
||||
AgentJournalTurnScope
|
||||
} from './agent-session-journal-types'
|
||||
import { foldToolMessages } from './native-chat-tool-fold'
|
||||
import { nativeChatRowsInDrawOrder } from './native-chat-turn-grouping'
|
||||
import { nativeChatTurnMembership } from './native-chat-turn-membership'
|
||||
import type { NativeChatMessage, NativeChatRole } from './native-chat-types'
|
||||
import { projectStructuredItemsToNativeChat } from './structured-agent-session-projection'
|
||||
|
||||
const THREAD: AgentJournalTurnScope = { kind: 'thread' }
|
||||
const inTurn = (turnItemId: string): AgentJournalTurnScope => ({ kind: 'turn', turnItemId })
|
||||
|
||||
function item(
|
||||
itemId: string,
|
||||
sequence: number,
|
||||
body: AgentJournalItemBody,
|
||||
turnScope: AgentJournalTurnScope | null = THREAD
|
||||
): AgentJournalRenderItem {
|
||||
return {
|
||||
itemId,
|
||||
sequence,
|
||||
body,
|
||||
revision: 0,
|
||||
observedAt: sequence,
|
||||
...(turnScope ? { turnScope } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function text(
|
||||
id: string,
|
||||
sequence: number,
|
||||
role: NativeChatRole,
|
||||
scope: AgentJournalTurnScope | null = THREAD
|
||||
): AgentJournalRenderItem {
|
||||
return item(id, sequence, { kind: 'message', role, blocks: [{ type: 'text', text: id }] }, scope)
|
||||
}
|
||||
|
||||
function turn(
|
||||
id: string,
|
||||
sequence: number,
|
||||
userItemId: string,
|
||||
scope: AgentJournalTurnScope | null = THREAD,
|
||||
state: 'completed' | 'running' = 'completed'
|
||||
): AgentJournalRenderItem {
|
||||
return item(id, sequence, { kind: 'turn', turnId: id, userItemId, state }, scope)
|
||||
}
|
||||
|
||||
function mixedResult(
|
||||
id: string,
|
||||
sequence: number,
|
||||
scope: AgentJournalTurnScope | null
|
||||
): AgentJournalRenderItem {
|
||||
return item(
|
||||
id,
|
||||
sequence,
|
||||
{
|
||||
kind: 'message',
|
||||
role: 'assistant',
|
||||
blocks: [
|
||||
{ type: 'text', text: id },
|
||||
{ type: 'tool-result', callId: 'unavailable-call', output: `output ${id}` }
|
||||
]
|
||||
},
|
||||
scope
|
||||
)
|
||||
}
|
||||
|
||||
function orphan(id: string, sequence: number, index = 0): NativeChatMessage {
|
||||
return {
|
||||
id,
|
||||
role: 'tool',
|
||||
source: 'transcript',
|
||||
timestamp: sequence,
|
||||
journalPosition: { sequence, index },
|
||||
blocks: [{ type: 'tool-result', callId: 'unavailable-call', output: id }]
|
||||
}
|
||||
}
|
||||
|
||||
function projected(items: readonly AgentJournalRenderItem[]): NativeChatMessage[] {
|
||||
return foldToolMessages(projectStructuredItemsToNativeChat(items))
|
||||
}
|
||||
|
||||
describe('derived tool row turn ownership', () => {
|
||||
it.each([true, false])(
|
||||
'keeps output before the prompt waiting behind its source turn (host scopes: %s)',
|
||||
(statesScopes) => {
|
||||
const scope = (id?: string) => (statesScopes ? (id ? inTurn(id) : THREAD) : null)
|
||||
const items = [
|
||||
text('A', 1, 'user', scope()),
|
||||
turn('tA', 2, 'A', scope()),
|
||||
text('a1', 3, 'assistant', scope('tA')),
|
||||
text('B', 4, 'user', scope('tA')),
|
||||
mixedResult('source', 5, scope('tA')),
|
||||
turn('tB', 6, 'B', scope(), 'running'),
|
||||
text('b1', 7, 'assistant', scope('tB'))
|
||||
]
|
||||
const messages = projected(items)
|
||||
const output = messages.find((message) => message.role === 'tool')
|
||||
expect(output?.id).not.toBe('source')
|
||||
expect(output?.journalPosition).toEqual({ sequence: 5, index: 0 })
|
||||
const membership = nativeChatTurnMembership(messages, { items, submissions: [] })
|
||||
const drawn = nativeChatRowsInDrawOrder(
|
||||
messages.map((message, index) => [message.id, membership.turnKeys[index]]),
|
||||
membership.drawOrder
|
||||
)
|
||||
expect(drawn).toEqual([
|
||||
['A', 'A'],
|
||||
['a1', 'A'],
|
||||
['source', 'A'],
|
||||
[output?.id, 'A'],
|
||||
['B', 'B'],
|
||||
['b1', 'B']
|
||||
])
|
||||
expect(membership.liveTurnKey).toBe('B')
|
||||
}
|
||||
)
|
||||
|
||||
it('uses an older host’s journal order when a derived output follows a newer prompt', () => {
|
||||
const items = [
|
||||
text('A', 1, 'user', null),
|
||||
turn('tA', 2, 'A', null),
|
||||
text('source', 3, 'assistant', null),
|
||||
text('B', 4, 'user', null)
|
||||
]
|
||||
const messages = [...projectStructuredItemsToNativeChat(items), orphan('derived', 3)]
|
||||
const membership = nativeChatTurnMembership(messages, { items, submissions: [] })
|
||||
expect(membership.turnKeys).toEqual(['A', 'A', 'B', 'A'])
|
||||
expect(membership.liveTurnKey).toBe('B')
|
||||
})
|
||||
|
||||
it('keeps thread-scoped output outside turns rather than inheriting the preceding turn', () => {
|
||||
const items = [
|
||||
text('A', 1, 'user'),
|
||||
turn('tA', 2, 'A', THREAD, 'running'),
|
||||
text('a1', 3, 'assistant', inTurn('tA')),
|
||||
mixedResult('source', 4, THREAD)
|
||||
]
|
||||
const membership = nativeChatTurnMembership(projected(items), { items, submissions: [] })
|
||||
expect(membership.turnKeys).toEqual(['A', 'A', undefined, undefined])
|
||||
expect(membership.liveTurnKey).toBe('A')
|
||||
})
|
||||
|
||||
it('distinguishes differently scoped items created by the same lifecycle batch', () => {
|
||||
const items = [
|
||||
text('A', 1, 'user'),
|
||||
turn('tA', 2, 'A'),
|
||||
mixedResult('owned-source', 3, inTurn('tA')),
|
||||
{ ...mixedResult('thread-source', 3, THREAD), sequenceIndex: 1 }
|
||||
]
|
||||
const membership = nativeChatTurnMembership(projected(items), { items, submissions: [] })
|
||||
expect(membership.turnKeys).toEqual(['A', 'A', 'A', undefined, undefined])
|
||||
})
|
||||
|
||||
it('keeps a derived tail in the partially loaded live turn', () => {
|
||||
const items = [mixedResult('tail-source', 30, inTurn('tA'))]
|
||||
const membership = nativeChatTurnMembership(projected(items), {
|
||||
items,
|
||||
submissions: [],
|
||||
latestTurn: {
|
||||
itemId: 'tA',
|
||||
observedAt: 2,
|
||||
turn: { turnId: 'tA', userItemId: 'A', state: 'running' }
|
||||
}
|
||||
})
|
||||
expect(membership.turnKeys).toEqual(['tA', 'tA'])
|
||||
expect(membership.liveTurnKey).toBe('tA')
|
||||
expect(membership.partialTurnKey).toBe('tA')
|
||||
})
|
||||
|
||||
it('prefers a row’s own journal identity over inconsistent source position metadata', () => {
|
||||
const items = [
|
||||
text('A', 1, 'user'),
|
||||
turn('tA', 2, 'A'),
|
||||
text('source', 3, 'assistant', inTurn('tA')),
|
||||
text('B', 4, 'user'),
|
||||
turn('tB', 5, 'B'),
|
||||
text('known', 6, 'assistant', inTurn('tB'))
|
||||
]
|
||||
const membership = nativeChatTurnMembership([orphan('known', 3)], { items, submissions: [] })
|
||||
expect(membership.turnKeys).toEqual(['B'])
|
||||
})
|
||||
|
||||
it('does not replace an older host’s known but unattributed item using another row’s position', () => {
|
||||
const items = [
|
||||
text('known', 1, 'assistant', null),
|
||||
text('A', 2, 'user', null),
|
||||
turn('tA', 3, 'A', null),
|
||||
text('source', 4, 'assistant', null),
|
||||
text('B', 5, 'user', null)
|
||||
]
|
||||
const prompt = projectStructuredItemsToNativeChat([items[4]!])
|
||||
const membership = nativeChatTurnMembership([...prompt, orphan('known', 4)], {
|
||||
items,
|
||||
submissions: []
|
||||
})
|
||||
expect(membership.turnKeys).toEqual(['B', 'B'])
|
||||
})
|
||||
|
||||
it('leaves unknown or ambiguous journal positions unattributed', () => {
|
||||
const items = [
|
||||
text('A', 1, 'user'),
|
||||
turn('tA', 2, 'A'),
|
||||
text('source', 3, 'assistant', inTurn('tA')),
|
||||
text('conflicting-source', 3, 'assistant', THREAD)
|
||||
]
|
||||
const membership = nativeChatTurnMembership(
|
||||
[orphan('ambiguous', 3), orphan('unknown-sequence', 50), orphan('unknown-index', 3, 2)],
|
||||
{ items, submissions: [] }
|
||||
)
|
||||
expect(membership.turnKeys).toEqual([undefined, undefined, undefined])
|
||||
})
|
||||
|
||||
it('indexes source positions once for many derived rows and skips that work for known rows', () => {
|
||||
let sequenceReads = 0
|
||||
const sources = Array.from({ length: 1_024 }, (_, index) => {
|
||||
const source = text(`source-${index}`, index + 3, 'assistant', inTurn('tA'))
|
||||
return {
|
||||
...source,
|
||||
get sequence() {
|
||||
sequenceReads += 1
|
||||
return source.sequence
|
||||
}
|
||||
}
|
||||
})
|
||||
const items = [text('A', 1, 'user'), turn('tA', 2, 'A'), ...sources]
|
||||
const journal = { items, submissions: [], latestTurn: null }
|
||||
const derived = sources.map((source, index) => orphan(`derived-${source.itemId}`, index + 3))
|
||||
const membership = nativeChatTurnMembership(derived, journal)
|
||||
expect(membership.turnKeys).toEqual(sources.map(() => 'A'))
|
||||
expect(sequenceReads).toBe(sources.length)
|
||||
|
||||
sequenceReads = 0
|
||||
const known = sources.map((source, index) => orphan(source.itemId, index + 3))
|
||||
expect(nativeChatTurnMembership(known, journal).turnKeys).toEqual(sources.map(() => 'A'))
|
||||
expect(sequenceReads).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores terminal transcript offsets and preserves positional grouping without a journal', () => {
|
||||
const items = [
|
||||
text('A', 1, 'user'),
|
||||
turn('tA', 2, 'A'),
|
||||
text('source', 3, 'assistant', inTurn('tA')),
|
||||
text('B', 4, 'user')
|
||||
]
|
||||
const source = orphan('offset-only', 3)
|
||||
const { journalPosition: _position, ...withoutPosition } = source
|
||||
const messages = [
|
||||
...projectStructuredItemsToNativeChat(items),
|
||||
{ ...withoutPosition, transcriptOffset: 3 },
|
||||
orphan('positioned', 3)
|
||||
]
|
||||
expect(nativeChatTurnMembership(messages).turnKeys).toEqual(['A', 'A', 'B', 'B', 'B'])
|
||||
expect(nativeChatTurnMembership(messages).drawOrder).toBeNull()
|
||||
expect(nativeChatTurnMembership(messages, { items, submissions: [] }).turnKeys).toEqual([
|
||||
'A',
|
||||
'A',
|
||||
'B',
|
||||
undefined,
|
||||
'A'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -25,12 +25,13 @@ import {
|
||||
nativeChatUserRowOpensTurn,
|
||||
type NativeChatOpensTurn
|
||||
} from './native-chat-turn-grouping'
|
||||
import type { NativeChatRole } from './native-chat-types'
|
||||
import type { NativeChatMessage, NativeChatRole } from './native-chat-types'
|
||||
import { dispatchWasWithdrawn } from './structured-agent-session-dispatch-rejection'
|
||||
import { inSendOrder } from './native-chat-send-order'
|
||||
import { runningStructuredAgentSessionTurnScope } from './structured-agent-session-live-turn'
|
||||
import { nativeChatOpeningTurnKey } from './native-chat-messages-waiting-behind-live-turn'
|
||||
import type { AgentSessionLatestTurn } from './agent-session-wire'
|
||||
import { nativeChatTurnRowAttribution } from './native-chat-turn-row-attribution'
|
||||
|
||||
/** Whether the host writing this journal states each row's turn. Only a host that runs `/compact`
|
||||
* as a turn of the send path does, so this is also how a client tells that host from an older one. */
|
||||
@@ -248,7 +249,7 @@ export type NativeChatTurnMembership = {
|
||||
partialTurnKey?: string
|
||||
}
|
||||
|
||||
type NativeChatTurnMember = { id: string; role: NativeChatRole; unsent?: true }
|
||||
type NativeChatTurnMember = Pick<NativeChatMessage, 'id' | 'role' | 'unsent' | 'journalPosition'>
|
||||
|
||||
/**
|
||||
* Places each row in its turn. A user entry that anchors a turn, or is scoped to none, keys
|
||||
@@ -271,14 +272,9 @@ export function nativeChatTurnMembership(
|
||||
const running = runningStructuredAgentSessionTurnScope(journal)
|
||||
if (!hostStatesTurnScopes(journal.items)) {
|
||||
const recordKeys = namedRecordKeys(journal.items, anchors)
|
||||
const turnKeys = withoutUnsent(
|
||||
messages,
|
||||
nativeChatRowTurnKeys(
|
||||
messages,
|
||||
nativeChatJournalOrderTurnKeys(journal.items, recordKeys),
|
||||
opens
|
||||
)
|
||||
)
|
||||
const journalKeys = nativeChatJournalOrderTurnKeys(journal.items, recordKeys)
|
||||
const rowKeys = nativeChatTurnRowAttribution(messages, journal.items, journalKeys)
|
||||
const turnKeys = withoutUnsent(messages, nativeChatRowTurnKeys(messages, rowKeys, opens))
|
||||
const runningNamed = running.kind === 'turn' ? recordKeys.get(running.turnItemId) : null
|
||||
return {
|
||||
turnKeys,
|
||||
@@ -291,7 +287,11 @@ export function nativeChatTurnMembership(
|
||||
}
|
||||
const runningKey = running.kind === 'turn' ? anchors.get(running.turnItemId) : undefined
|
||||
const anchoring = new Set(anchors.values())
|
||||
const scopes = new Map(journal.items.map((item) => [item.itemId, item.turnScope]))
|
||||
const scopes = nativeChatTurnRowAttribution(
|
||||
messages,
|
||||
journal.items,
|
||||
new Map(journal.items.map((item) => [item.itemId, item.turnScope]))
|
||||
)
|
||||
const runningRecordLoaded = running.kind === 'turn' && scopes.has(running.turnItemId)
|
||||
const turnKeys = messages.map((message) => {
|
||||
if (message.unsent === true) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { agentJournalItemPosition } from './agent-session-journal-position'
|
||||
import type { AgentJournalRenderItem } from './agent-session-journal-types'
|
||||
import type { NativeChatMessage } from './native-chat-types'
|
||||
|
||||
/** A split result row keeps its source's journal position while gaining its own render identity. */
|
||||
export function nativeChatTurnRowAttribution<T>(
|
||||
messages: readonly Pick<NativeChatMessage, 'id' | 'role' | 'journalPosition'>[],
|
||||
items: readonly AgentJournalRenderItem[],
|
||||
attribution: ReadonlyMap<string, T>
|
||||
): ReadonlyMap<string, T> {
|
||||
const detached = messages.filter(
|
||||
(message) =>
|
||||
message.role === 'tool' &&
|
||||
message.journalPosition !== undefined &&
|
||||
!attribution.has(message.id)
|
||||
)
|
||||
if (detached.length === 0) {
|
||||
return attribution
|
||||
}
|
||||
const itemIds = new Set<string>()
|
||||
const sources = new Map<string, string | null>()
|
||||
for (const item of items) {
|
||||
itemIds.add(item.itemId)
|
||||
const { sequence, index } = agentJournalItemPosition(item)
|
||||
const position = `${sequence}:${index}`
|
||||
sources.set(position, sources.has(position) ? null : item.itemId)
|
||||
}
|
||||
let attributed: Map<string, T> | undefined
|
||||
for (const message of detached) {
|
||||
const position = message.journalPosition
|
||||
if (!position || itemIds.has(message.id)) {
|
||||
continue
|
||||
}
|
||||
const sourceId = sources.get(`${position.sequence}:${position.index}`)
|
||||
const value = sourceId ? attribution.get(sourceId) : undefined
|
||||
if (value !== undefined) {
|
||||
attributed ??= new Map(attribution)
|
||||
attributed.set(message.id, value)
|
||||
}
|
||||
}
|
||||
return attributed ?? attribution
|
||||
}
|
||||
@@ -260,6 +260,8 @@ export type NativeChatMessage = AgentJournalProducerLinkage & {
|
||||
/** Set only by the tool fold, on a row that absorbed later tool rows: the newest
|
||||
* absorbed row's journal position. The row still sorts by its own. */
|
||||
foldedJournalPosition?: AgentJournalPosition
|
||||
/** Private presentation row; raw host messages never publish this fold-only marker. */
|
||||
unpairedToolResults?: true
|
||||
}
|
||||
|
||||
/** Split reasoning and its answer share the provider's row identity. */
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { expect } from '@stablyai/playwright-test'
|
||||
import type { BrowserTabSummary, ExplorerFileSummary, TerminalTabSummary } from './runtime-types'
|
||||
import type { BrowserTabSummary, ExplorerFileSummary } from './runtime-types'
|
||||
|
||||
/** Read a value from the Zustand store. Returns the raw JS value. */
|
||||
export async function getStoreState<T>(page: Page, selector: string): Promise<T> {
|
||||
@@ -57,7 +57,7 @@ export async function getWorktreeTabs(
|
||||
}
|
||||
|
||||
const state = store.getState()
|
||||
return (state.tabsByWorktree[worktreeId] ?? []).map((tab): TerminalTabSummary => ({
|
||||
return (state.tabsByWorktree[worktreeId] ?? []).map((tab) => ({
|
||||
id: tab.id,
|
||||
title: tab.customTitle || tab.title
|
||||
}))
|
||||
@@ -86,7 +86,7 @@ export async function getTabBarOrder(page: Page, worktreeId: string): Promise<st
|
||||
const activeGroup = activeGroupId
|
||||
? groups.find((g: { id: string }) => g.id === activeGroupId)
|
||||
: groups[0]
|
||||
if (activeGroup?.tabOrder?.length > 0) {
|
||||
if (activeGroup?.tabOrder && activeGroup.tabOrder.length > 0) {
|
||||
const unifiedTabs = state.unifiedTabsByWorktree?.[worktreeId] ?? []
|
||||
return activeGroup.tabOrder.map((itemId: string) => {
|
||||
const tab = unifiedTabs.find((t: { id: string }) => t.id === itemId)
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import { waitForActivePaneHookDescriptor, waitForActiveTerminalManager } from './helpers/terminal'
|
||||
import {
|
||||
claudeTranscriptLines,
|
||||
enableNativeChatSetting,
|
||||
seedClaudeProviderSession,
|
||||
toggleTerminalTabToChatView
|
||||
} from './helpers/native-chat-transcript-fixture'
|
||||
|
||||
test.use({
|
||||
minimumSeededWorktreeCount: 1,
|
||||
orcaAppExtraEnv: { ORCA_BACKGROUND_LAUNCH: '1' }
|
||||
})
|
||||
|
||||
const SILENT_CALL_COUNT = 32
|
||||
const ACCEPTED_TASK = 'Keep the accepted task'
|
||||
const REJECTED_TASK = 'Reject this failed replacement'
|
||||
const COMMANDS = ['printf pairing_first', 'printf pairing_second', 'printf pairing_third']
|
||||
const OUTPUTS = ['PAIR_OWNER_FIRST', 'PAIR_OWNER_SECOND', 'PAIR_OWNER_THIRD']
|
||||
|
||||
function pairingTranscript(sessionId: string): string {
|
||||
const calls = [
|
||||
...Array.from({ length: SILENT_CALL_COUNT }, (_, index) => ({
|
||||
type: 'tool_use',
|
||||
id: `silent-${index}`,
|
||||
name: 'Bash',
|
||||
input: { command: `printf silent_${index}` }
|
||||
})),
|
||||
...COMMANDS.map((command, index) => ({
|
||||
type: 'tool_use',
|
||||
id: `named-${index}`,
|
||||
name: 'Bash',
|
||||
input: { command }
|
||||
})),
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'accepted-tasks',
|
||||
name: 'TodoWrite',
|
||||
input: { todos: [{ content: ACCEPTED_TASK, status: 'pending' }] }
|
||||
},
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'rejected-tasks',
|
||||
name: 'TodoWrite',
|
||||
input: { todos: [{ content: REJECTED_TASK, status: 'completed' }] }
|
||||
}
|
||||
]
|
||||
const results = [
|
||||
{ type: 'tool_result', tool_use_id: 'outside-run', content: 'UNATTRIBUTED_OUTPUT' },
|
||||
...OUTPUTS.map((output, index) => ({
|
||||
type: 'tool_result',
|
||||
tool_use_id: `named-${index}`,
|
||||
content: output
|
||||
})).toReversed(),
|
||||
{ type: 'tool_result', tool_use_id: 'accepted-tasks', content: 'Tasks saved' },
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'rejected-tasks',
|
||||
content: 'Task update rejected',
|
||||
is_error: true
|
||||
}
|
||||
]
|
||||
const records = [
|
||||
{
|
||||
sessionId,
|
||||
uuid: `${sessionId}-calls`,
|
||||
timestamp: new Date(Date.now() + 4_000).toISOString(),
|
||||
type: 'assistant',
|
||||
message: { role: 'assistant', model: 'claude-opus-4', content: calls }
|
||||
},
|
||||
{
|
||||
sessionId,
|
||||
uuid: `${sessionId}-results`,
|
||||
timestamp: new Date(Date.now() + 6_000).toISOString(),
|
||||
type: 'user',
|
||||
message: { role: 'user', content: results }
|
||||
}
|
||||
]
|
||||
return `${claudeTranscriptLines({
|
||||
sessionId,
|
||||
userText: 'Check tool output ownership behind silent calls',
|
||||
assistantText: 'Each named completion belongs to the command that produced it.'
|
||||
})}${records.map((record) => JSON.stringify(record)).join('\n')}\n`
|
||||
}
|
||||
|
||||
test('keeps named output owners and accepted tasks while retaining a standalone orphan', async ({
|
||||
electronApp,
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
const launch = await electronApp.evaluate(({ app, BrowserWindow }) => ({
|
||||
appPath: app.getAppPath(),
|
||||
windows: BrowserWindow.getAllWindows().map((window) => ({
|
||||
visible: window.isVisible(),
|
||||
focused: window.isFocused()
|
||||
}))
|
||||
}))
|
||||
expect(launch.appPath).toBe(process.cwd())
|
||||
expect(launch.windows.length).toBeGreaterThan(0)
|
||||
expect(launch.windows.every((window) => !window.visible && !window.focused)).toBe(true)
|
||||
const launchEvidence = testInfo.outputPath('hidden-launch-identity.json')
|
||||
writeFileSync(launchEvidence, JSON.stringify({ ...launch, processId: electronApp.process().pid }))
|
||||
await testInfo.attach('hidden-launch-identity', {
|
||||
path: launchEvidence,
|
||||
contentType: 'application/json'
|
||||
})
|
||||
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
const descriptor = await waitForActivePaneHookDescriptor(orcaPage)
|
||||
const [tabId] = descriptor.paneKey.split(':')
|
||||
const sessionId = `e2e-tool-pairing-${randomUUID()}`
|
||||
const scratchDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-tool-pairing-'))
|
||||
const transcriptPath = path.join(scratchDir, `${sessionId}.jsonl`)
|
||||
|
||||
try {
|
||||
writeFileSync(transcriptPath, pairingTranscript(sessionId))
|
||||
await enableNativeChatSetting(orcaPage)
|
||||
await seedClaudeProviderSession(orcaPage, {
|
||||
paneKey: descriptor.paneKey,
|
||||
worktreeId: descriptor.worktreeId,
|
||||
sessionId,
|
||||
transcriptPath
|
||||
})
|
||||
await toggleTerminalTabToChatView(orcaPage, { tabId, worktreeId: descriptor.worktreeId })
|
||||
|
||||
const chat = orcaPage.locator('[data-native-chat-root="true"]')
|
||||
await expect(chat).toBeVisible({ timeout: 15_000 })
|
||||
const runs = chat.locator('[data-native-chat-tool-run-state]')
|
||||
await expect(runs).toHaveCount(2, { timeout: 30_000 })
|
||||
const run = runs.first()
|
||||
await run.click()
|
||||
await expect(run).toHaveAttribute('aria-expanded', 'true')
|
||||
const members = chat.locator('[data-native-chat-tool-run-members]').first()
|
||||
await expect(members).toBeVisible()
|
||||
|
||||
const silent = members.getByRole('button', { name: /printf silent_0$/ })
|
||||
await expect(silent).toBeVisible()
|
||||
await expect(silent).not.toHaveAttribute('aria-expanded', /true|false/)
|
||||
|
||||
for (const [index, command] of COMMANDS.entries()) {
|
||||
const line = members.getByRole('button', { name: new RegExp(command) })
|
||||
await line.click()
|
||||
const detail = line.locator('..')
|
||||
await expect(detail.getByText(OUTPUTS[index]!, { exact: true })).toBeVisible()
|
||||
for (const other of OUTPUTS.filter((_, outputIndex) => outputIndex !== index)) {
|
||||
await expect(detail.getByText(other, { exact: true })).toHaveCount(0)
|
||||
}
|
||||
}
|
||||
await expect(members.getByRole('button', { name: /^Result\b/ })).toHaveCount(0)
|
||||
await expect(members.getByText('UNATTRIBUTED_OUTPUT', { exact: true })).toHaveCount(0)
|
||||
await members.evaluate(async (element) => {
|
||||
await Promise.all(
|
||||
element
|
||||
.getAnimations({ subtree: true })
|
||||
.filter((animation) => animation.effect?.getComputedTiming().iterations !== Infinity)
|
||||
.map((animation) => animation.finished.catch(() => undefined))
|
||||
)
|
||||
})
|
||||
const outputsScreenshot = testInfo.outputPath('named-tool-outputs.png')
|
||||
await orcaPage.screenshot({ path: outputsScreenshot })
|
||||
await testInfo.attach('named-tool-outputs', {
|
||||
path: outputsScreenshot,
|
||||
contentType: 'image/png'
|
||||
})
|
||||
|
||||
const taskToggle = chat.getByRole('button', { name: /Tasks.*0 of 1 tasks completed/ })
|
||||
await expect(taskToggle).toBeVisible()
|
||||
await taskToggle.click()
|
||||
const composerTaskList = taskToggle.locator('..').getByRole('list', { name: 'Tasks' })
|
||||
await expect(composerTaskList.getByText(ACCEPTED_TASK, { exact: true })).toBeVisible()
|
||||
await expect(composerTaskList.getByText(REJECTED_TASK, { exact: true })).toHaveCount(0)
|
||||
const tasksScreenshot = testInfo.outputPath('accepted-task-list.png')
|
||||
await orcaPage.screenshot({ path: tasksScreenshot })
|
||||
await testInfo.attach('accepted-task-list', {
|
||||
path: tasksScreenshot,
|
||||
contentType: 'image/png'
|
||||
})
|
||||
|
||||
const orphanRun = runs.nth(1)
|
||||
await expect(orphanRun).toContainText('Result')
|
||||
await expect(orphanRun).not.toContainText('tool call')
|
||||
await orphanRun.click()
|
||||
await expect(orphanRun).toHaveAttribute('aria-expanded', 'true')
|
||||
const orphanMembers = chat.locator('[data-native-chat-tool-run-members]').nth(1)
|
||||
const orphan = orphanMembers.getByRole('button', { name: /^Result\b/ })
|
||||
await expect(orphan).toHaveCount(1)
|
||||
await orphan.click()
|
||||
await expect(orphan.locator('..').locator('pre[data-native-chat-code-content]')).toHaveText(
|
||||
'UNATTRIBUTED_OUTPUT'
|
||||
)
|
||||
for (const output of OUTPUTS) {
|
||||
await expect(orphanMembers.getByText(output, { exact: true })).toHaveCount(0)
|
||||
}
|
||||
await orphanMembers.evaluate(async (element) => {
|
||||
await Promise.all(
|
||||
element
|
||||
.getAnimations({ subtree: true })
|
||||
.filter((animation) => animation.effect?.getComputedTiming().iterations !== Infinity)
|
||||
.map((animation) => animation.finished.catch(() => undefined))
|
||||
)
|
||||
})
|
||||
const orphanScreenshot = testInfo.outputPath('orphan-result-and-retained-tasks.png')
|
||||
await orcaPage.screenshot({ path: orphanScreenshot })
|
||||
await testInfo.attach('orphan-result-and-retained-tasks', {
|
||||
path: orphanScreenshot,
|
||||
contentType: 'image/png'
|
||||
})
|
||||
expect(
|
||||
await electronApp.evaluate(({ BrowserWindow }) =>
|
||||
BrowserWindow.getAllWindows().every((window) => !window.isVisible() && !window.isFocused())
|
||||
)
|
||||
).toBe(true)
|
||||
} finally {
|
||||
rmSync(scratchDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user