import { isolateWorkflowHome } from './helpers/workflow-home.js'
isolateWorkflowHome()
import { afterEach, expect, test } from 'bun:test'
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { z } from 'zod'
import { QueryEngine } from '../src/engine/queryEngine.js'
import { CLEARED_PREFIX, CLEAR_KEEP_RESULTS, clearOldToolResults, clearedNote } from '../src/engine/compact.js'
import { createAppStore } from '../src/state/store.js'
import { FileReadTool } from '../src/tools/impl/FileReadTool.js'
import type { LLMClient, LLMMessage, LLMRequestOptions, StreamEvent, TokenCount, ToolResultBlock } from '../src/llm/types.js'
import type { Tool } from '../src/tools/tool.js'

// ---------------------------------------------------------------------------
// clearOldToolResults: order and budget
// ---------------------------------------------------------------------------

type Call = { id: string; name: string; input: Record<string, unknown>; result?: string; error?: boolean }
const long = (tag: string) => `${tag} ${'r'.repeat(2000)}`
/**
 * A FileRead result as FileReadTool words it: numbered lines first-last of a file of `total` lines,
 * and the "Showing lines" note when that is not the whole file. Over 1,000 characters.
 */
function shown(first: number, last: number, total = last, capped = false): string {
  const width = Math.max(1, Math.ceil(2000 / (last - first + 1)))
  const lines = Array.from({ length: last - first + 1 }, (_, i) => `${first + i}\t${'l'.repeat(width)}`).join('\n')
  if (first === 1 && last === total) return lines
  return `${lines}\n\n[Showing lines ${first}-${last} of ${total}${last < total
    ? `. ${total - last} more lines: call FileRead with offset=${last + 1} to continue.${capped ? ' One call returns at most about 31,000 characters.' : ''}]`
    : ': the end of the file.]'}`
}
/** One round per call: the call, then its result; CLEAR_KEEP_RESULTS short-enough-to-matter newest rounds after them. */
function conversation(calls: Call[], newest = CLEAR_KEEP_RESULTS): LLMMessage[] {
  const messages: LLMMessage[] = [{ role: 'user', content: 'go' }]
  const padding = Array.from({ length: newest }, (_, i): Call => ({ id: `p${i}`, name: 'Bash', input: { command: `pad ${i}` } }))
  for (const call of [...calls, ...padding]) {
    messages.push({ role: 'assistant', content: [{ type: 'tool_use', id: call.id, name: call.name, input: call.input }] })
    messages.push({ role: 'user', content: [{ type: 'tool_result', tool_use_id: call.id, content: call.result ?? (call.name === 'FileRead' ? shown(1, 10) : long(call.id)), is_error: call.error ?? false }] })
  }
  return messages
}
const noteIds = (messages: LLMMessage[]) => messages.flatMap(message => Array.isArray(message.content)
  ? message.content.filter((block): block is ToolResultBlock => block.type === 'tool_result' && typeof block.content === 'string' && block.content.startsWith(CLEARED_PREFIX)).map(block => block.tool_use_id) : [])
/** Every eligible result frees exactly 1,000 units, so a budget of k thousand clears exactly k. */
const unit = (block: ToolResultBlock) => typeof block.content === 'string' && block.content.startsWith(CLEARED_PREFIX) ? 0 : 1000
/** The order the results are cleared in: the one budget k*1000 adds over (k-1)*1000. */
function clearOrder(calls: Call[]): string[] {
  const order: string[] = []
  for (let k = 1; ; k++) {
    const messages = conversation(calls)
    const result = clearOldToolResults(messages, 0, { budget: k * 1000, size: unit })
    const ids = noteIds(messages)
    if (ids.length < k) return order
    expect(result.cleared).toBe(k)
    expect(result.freed).toBe(k * 1000)
    order.push(ids.find(id => !order.includes(id))!)
  }
}

test('superseded results go first, then other tools, then FileRead, oldest first within each', () => {
  const order = clearOrder([
    { id: 'readA', name: 'FileRead', input: { file_path: 'src/a.ts' } },
    { id: 'bash1', name: 'Bash', input: { command: 'ls -la' } },
    { id: 'grep1', name: 'Grep', input: { pattern: 'x', path: 'src' } },
    { id: 'readB', name: 'FileRead', input: { file_path: 'src/b.ts' } },
    { id: 'grep2', name: 'Grep', input: { path: 'src', pattern: 'x' } }, // the same input, keys in another order
    { id: 'readA2', name: 'FileRead', input: { file_path: 'src/a.ts' } },
    { id: 'readC', name: 'FileRead', input: { file_path: 'src/c.ts', offset: 10, limit: 50 } },
    { id: 'writeC', name: 'FileWrite', input: { file_path: 'src/c.ts', content: 'b' }, result: 'written' },
    { id: 'web1', name: 'WebFetch', input: { url: 'https://example.com', prompt: 'p' } },
  ])
  // readA (read again later), grep1 (run again), readC (written over later); then bash1, grep2, web1; then readB, readA2.
  expect(order).toEqual(['readA', 'grep1', 'readC', 'bash1', 'grep2', 'web1', 'readB', 'readA2'])
})

test('a later call supersedes only when it returned the same content again', () => {
  const order = clearOrder([
    // A later read of fewer lines does not cover this one.
    { id: 'readWide', name: 'FileRead', input: { file_path: 'a.ts' }, result: shown(1, 100) },
    { id: 'readNarrow', name: 'FileRead', input: { file_path: 'a.ts', offset: 50, limit: 10 }, result: shown(50, 59, 100) },
    // A later read answered "unchanged" points back at this one.
    { id: 'readB', name: 'FileRead', input: { file_path: 'b.ts' } },
    { id: 'readB2', name: 'FileRead', input: { file_path: 'b.ts' }, result: '[File unchanged since last read]' },
    // A failed write changed nothing.
    { id: 'readC', name: 'FileRead', input: { file_path: 'c.ts' } },
    { id: 'writeC', name: 'FileWrite', input: { file_path: 'c.ts', content: 'x' }, result: 'permission denied', error: true },
    // A repeat answered "same result" points back at this one; Bash is never superseded.
    { id: 'grep1', name: 'Grep', input: { pattern: 'y' } },
    { id: 'grep2', name: 'Grep', input: { pattern: 'y' }, result: '[Same result as the identical Grep call earlier in this turn (grep1); nothing changed.]' },
    { id: 'bash1', name: 'Bash', input: { command: 'make' } },
    { id: 'bash2', name: 'Bash', input: { command: 'make' } },
    // A later whole read covers a ranged read; a NotebookEdit leaves the read of the notebook it edits.
    { id: 'readD', name: 'FileRead', input: { file_path: 'd.ts', offset: 5, limit: 20 }, result: shown(5, 24, 100) },
    { id: 'readD2', name: 'FileRead', input: { file_path: 'd.ts' }, result: shown(1, 100) },
    { id: 'readNb', name: 'FileRead', input: { file_path: 'n.ipynb' } },
    { id: 'editNb', name: 'NotebookEdit', input: { notebook_path: 'n.ipynb', new_source: 'x' }, result: 'ok' },
  ])
  expect(order).toEqual(['readD', 'grep1', 'bash1', 'bash2', 'readWide', 'readNarrow', 'readB', 'readC', 'readD2', 'readNb'])
})

test('a later FileRead supersedes only with the lines it returned, not the lines it asked for', () => {
  // The c2 chunked read: lines 801-1200, then a read from line 1 with no limit that the character cap
  // stopped at line 460. Lines 801-1200 are in no other result, so the older Bash output goes first.
  expect(clearOrder([
    { id: 'bash1', name: 'Bash', input: { command: 'bun test' } },
    { id: 'chunk', name: 'FileRead', input: { file_path: 'src/q.ts', offset: 801, limit: 400 }, result: shown(801, 1200, 2400) },
    { id: 'head', name: 'FileRead', input: { file_path: 'src/q.ts' }, result: shown(1, 460, 2400, true) },
  ])).toEqual(['bash1', 'chunk', 'head'])
  const order = clearOrder([
    // A later read that returned past the earlier one's lines covers it, though it asked for fewer.
    { id: 'chunkA', name: 'FileRead', input: { file_path: 'a.ts', offset: 801, limit: 400 }, result: shown(801, 1200, 2400) },
    { id: 'wideA', name: 'FileRead', input: { file_path: 'a.ts', offset: 700, limit: 600 }, result: shown(700, 1299, 2400) },
    // An earlier read the cap stopped early holds only its lines: a later read of those covers it.
    { id: 'cutB', name: 'FileRead', input: { file_path: 'b.ts', limit: 2000 }, result: shown(1, 300, 2400, true) },
    { id: 'againB', name: 'FileRead', input: { file_path: 'b.ts', limit: 300 }, result: shown(1, 300, 2400) },
    // The start of one long line holds no whole line: it covers nothing.
    { id: 'chunkC', name: 'FileRead', input: { file_path: 'c.ts', offset: 801, limit: 400 }, result: shown(801, 1200, 2400) },
    { id: 'longC', name: 'FileRead', input: { file_path: 'c.ts', offset: 801 },
      result: `801\t${'x'.repeat(3000)}\n\n[Line 801 is 90,000 characters long; only its start is shown. Use Bash (for example cut -c or head -c) to read the rest of it. The file has 2400 lines; call FileRead with offset=802 for the next one.]` },
    // A read to the end of the file, and a whole file with no note, cover what they returned.
    { id: 'chunkD', name: 'FileRead', input: { file_path: 'd.ts', offset: 801, limit: 400 }, result: shown(801, 1200, 2400) },
    { id: 'tailD', name: 'FileRead', input: { file_path: 'd.ts', offset: 801 }, result: shown(801, 2400, 2400) },
    { id: 'chunkE', name: 'FileRead', input: { file_path: 'e.ts', offset: 3, limit: 4 }, result: shown(3, 6, 40) },
    { id: 'wholeE', name: 'FileRead', input: { file_path: 'e.ts' }, result: shown(1, 40) },
    // A later read cut for context does not hold all its lines; a notice holds none.
    { id: 'chunkF', name: 'FileRead', input: { file_path: 'f.ts', offset: 5, limit: 10 }, result: shown(5, 14, 40) },
    { id: 'cutF', name: 'FileRead', input: { file_path: 'f.ts' }, result: `${shown(1, 40).slice(0, 1500)}\n\n[Truncated for context management: kept the first 20 of 40 lines (1,500 of 2,000 characters). Call FileRead with offset=21 to see the rest.]` },
    { id: 'chunkG', name: 'FileRead', input: { file_path: 'g.ts', offset: 5, limit: 10 }, result: shown(5, 14, 40) },
    { id: 'pastG', name: 'FileRead', input: { file_path: 'g.ts', offset: 900 }, result: '[Offset 900 is past the end of the file: it has 40 lines. Use an offset from 1 to 40.]' },
  ])
  expect(order).toEqual(['chunkA', 'cutB', 'chunkD', 'chunkE', 'wideA', 'againB', 'chunkC', 'longC', 'tailD', 'wholeE', 'chunkF', 'cutF', 'chunkG'])
})

test('only a complete FileWrite supersedes a read: the file being edited keeps its only copy', () => {
  // An older 20K Bash result, a 20K read of the file, then an edit of it: the Bash output goes first.
  const big = (first: number) => Array.from({ length: 200 }, (_, i) => `${first + i}\t${'e'.repeat(95)}`).join('\n')
  const messages = conversation([
    { id: 'b1', name: 'Bash', input: { command: 'bun test' }, result: 'b'.repeat(20_000) },
    { id: 't1', name: 'FileRead', input: { file_path: '/p/foo.ts' }, result: big(1) },
    { id: 'e1', name: 'FileEdit', input: { file_path: '/p/foo.ts', old_string: 'a', new_string: 'b' }, result: 'edited' },
  ])
  const result = clearOldToolResults(messages, 0, { budget: 15_000 })
  expect(noteIds(messages)).toEqual(['b1'])
  expect(result.reads).toBe(false)
  expect(clearOrder([
    { id: 'readA', name: 'FileRead', input: { file_path: 'a.ts' } },
    { id: 'editA', name: 'FileEdit', input: { file_path: 'a.ts', old_string: 'a', new_string: 'b' }, result: 'edited' },
    { id: 'readB', name: 'FileRead', input: { file_path: 'b.ts' } },
    { id: 'chunkB', name: 'FileWrite', input: { file_path: 'b.ts', content: 'part', chunk: { transfer_id: 't', index: 0, total: 2 } }, result: 'staged' },
    { id: 'readC', name: 'FileRead', input: { file_path: 'c.ts' } },
    { id: 'writeC', name: 'FileWrite', input: { file_path: 'c.ts', content: 'all of it' }, result: 'written' },
    { id: 'bash1', name: 'Bash', input: { command: 'make' } },
  ])).toEqual(['readC', 'bash1', 'readA', 'readB'])
})

test('paths compare normalized, and a relative path only within one working directory', () => {
  const entered = { name: 'EnterWorktree', input: { name: 'w' }, result: 'Entered worktree /repo/.gcode/worktrees/w' }
  const order = clearOrder([
    // "./src/a.ts" and "src/a.ts" are one file; so are two spellings of one absolute path.
    { id: 'dotA', name: 'FileRead', input: { file_path: './src/a.ts' } },
    { id: 'plainA', name: 'FileRead', input: { file_path: 'src/a.ts' } },
    { id: 'absB', name: 'FileRead', input: { file_path: '/repo/src/../src/b.ts' } },
    { id: 'writeB', name: 'FileWrite', input: { file_path: '/repo/src/b.ts', content: 'x' }, result: 'written' },
    { id: 'grepDot', name: 'Grep', input: { pattern: 'p', path: './src' } },
    { id: 'grepPlain', name: 'Grep', input: { path: 'src', pattern: 'p' } },
    // Before the worktree: the same relative path, the default search path, an absolute path.
    { id: 'mainX', name: 'FileRead', input: { file_path: 'src/x.ts' } },
    { id: 'mainGlob', name: 'Glob', input: { pattern: '*.ts' } },
    { id: 'mainY', name: 'FileRead', input: { file_path: 'src/y.ts' } },
    { id: 'absZ', name: 'FileRead', input: { file_path: '/repo/z.ts' } },
    { id: 'failed', name: 'EnterWorktree', input: { name: 'bad' }, result: 'not a git repository', error: true },
    { id: 'mainY2', name: 'FileRead', input: { file_path: 'src/y.ts' } },
    { id: 'enter', ...entered },
    { id: 'treeX', name: 'FileRead', input: { file_path: 'src/x.ts' } },
    { id: 'treeGlob', name: 'Glob', input: { pattern: '*.ts' } },
    { id: 'treeZ', name: 'FileRead', input: { file_path: '/repo/z.ts' } },
    { id: 'treeW', name: 'FileRead', input: { file_path: 'w.ts' } },
    { id: 'exit', name: 'ExitWorktree', input: {}, result: 'Back in /repo' },
    { id: 'writeW', name: 'FileWrite', input: { file_path: 'w.ts', content: 'x' }, result: 'written' },
  ])
  // Superseded: dotA, absB, grepDot, mainY (the failed switch moved nothing), absZ.
  // Not: mainX, mainGlob and treeW, whose relative paths name other files after a switch.
  expect(order).toEqual(['dotA', 'absB', 'grepDot', 'mainY', 'absZ', 'grepPlain', 'mainGlob', 'treeGlob',
    'plainA', 'mainX', 'mainY2', 'treeX', 'treeZ', 'treeW'])
})

test('clearing stops once the budget is freed, counting each result less its note', () => {
  const calls: Call[] = Array.from({ length: 6 }, (_, i) => ({ id: `r${i}`, name: 'FileRead', input: { file_path: `f${i}.ts` } }))
  const messages = conversation(calls)
  const size = (block: ToolResultBlock) => typeof block.content === 'string' ? block.content.length : 0
  const per = size({ type: 'tool_result', tool_use_id: 'r0', content: shown(1, 10) }) - clearedNote({ name: 'FileRead', input: { file_path: 'f0.ts' } }).length
  // Just over two results' worth: three are cleared, the oldest three.
  const result = clearOldToolResults(messages, 0, { budget: 2 * per + 1, size })
  expect(result.cleared).toBe(3)
  expect(result.freed).toBe(3 * per)
  expect(result.reads).toBe(true)
  expect(noteIds(messages)).toEqual(['r0', 'r1', 'r2'])
  // A budget of nothing clears nothing; without one every candidate is cleared.
  expect(clearOldToolResults(conversation(calls), 0, { budget: 0, size }).cleared).toBe(0)
  expect(clearOldToolResults(conversation(calls), 0).cleared).toBe(6)
})

test('a second pass clears the rest, and restoring both passes gives back the original messages', () => {
  const calls: Call[] = Array.from({ length: 6 }, (_, i) => ({ id: `r${i}`, name: i % 2 ? 'Bash' : 'FileRead', input: i % 2 ? { command: `c${i}` } : { file_path: `f${i}.ts` } }))
  const messages = conversation(calls)
  const originals = [...messages]
  const copies: LLMMessage[] = []
  const first = clearOldToolResults(messages, 0, { budget: 2000, size: unit, copied: (_original, copy) => copies.push(copy) })
  expect(noteIds(messages)).toEqual(['r1', 'r3'])
  expect(first.reads).toBe(false)
  const rest = clearOldToolResults(messages, 0, { size: unit, copied: (_original, copy) => copies.push(copy) })
  expect(rest.cleared).toBe(4)
  expect(rest.reads).toBe(true)
  expect(noteIds(messages)).toEqual(['r0', 'r1', 'r2', 'r3', 'r4', 'r5'])
  expect(copies).toHaveLength(6)
  rest.restore(); first.restore()
  expect(messages).toEqual(originals)
  messages.forEach((message, index) => expect(message).toBe(originals[index]!))
})

test('the note keeps its prefix, names the call, and does not ask for the call again', () => {
  const note = clearedNote({ name: 'FileRead', input: { file_path: '/repo/src/engine/compact.ts', offset: 40, limit: 80 } })
  expect(note).toBe('[Old tool result cleared to free context: FileRead /repo/src/engine/compact.ts (offset 40, limit 80). You saw it earlier; run the call again only if you need its exact text now.]')
  expect(note.startsWith('[Old tool result cleared to free context: ')).toBe(true)
  expect(clearedNote(undefined)).toStartWith('[Old tool result cleared to free context: a tool call. ')
})

// ---------------------------------------------------------------------------
// The engine: the 65% target, the 75% acceptance rule, the second pass, the local path
// ---------------------------------------------------------------------------

const sandbox = () => mkdtempSync(join(tmpdir(), 'gcode-r13b-clearing-'))
const call = (id: string, name: string, input: Record<string, unknown> = {}): StreamEvent[] => [
  { type: 'tool_use_start', id, name }, { type: 'tool_use_input', id, partial: JSON.stringify(input) }, { type: 'tool_use_end', id },
]
async function* done(list: StreamEvent[], stopReason = list.some(event => event.type === 'tool_use_end') ? 'tool_use' : 'end_turn'): AsyncGenerator<StreamEvent> {
  yield* list
  yield { type: 'done', stopReason }
}
type Step = (request: LLMRequestOptions, n: number) => StreamEvent[]
/** A client that answers request n with step(request, n); every request is kept. */
function scripted(step: Step, extra: Partial<LLMClient> = {}): LLMClient & { requests: LLMRequestOptions[] } {
  const requests: LLMRequestOptions[] = []
  return {
    requests, ...extra,
    async chat() { throw new Error('no chat in this test') },
    async *chatStream(request) {
      requests.push(structuredClone({ ...request, signal: undefined }))
      yield* done(step(request, requests.length - 1))
    },
  }
}
const probe = (output: (input: any) => string, name = 'Probe'): Tool => ({
  name, description: 'fixture', prompt: () => `${name}: a test tool.`, inputSchema: z.object({ n: z.number().optional() }).passthrough(),
  checkPermissions: async () => ({ decision: 'allow' }),
  call: async (input: any) => ({ success: true, output: output(input) }),
}) as Tool
const results = (request: LLMRequestOptions) => request.messages.flatMap(message => Array.isArray(message.content) ? message.content.filter((block): block is ToolResultBlock => block.type === 'tool_result') : [])
const isNote = (block: ToolResultBlock) => typeof block.content === 'string' && block.content.startsWith(CLEARED_PREFIX)
/** The results a clear could still take in this request: old enough, not notes, over 1,000 characters. */
const eligible = (request: LLMRequestOptions) => {
  const all = results(request)
  return all.slice(0, Math.max(0, all.length - CLEAR_KEEP_RESULTS)).filter(block => !isNote(block) && JSON.stringify(block.content).length > 1000)
}
/** Runs a turn with verbose stderr captured: the engine's per-pass clearing lines. */
async function withPasses(run: () => Promise<unknown>): Promise<string[]> {
  const lines: string[] = [], write = process.stderr.write.bind(process.stderr)
  process.stderr.write = ((chunk: any, ...rest: any[]) => { if (String(chunk).includes('Pressure clearing')) lines.push(String(chunk)); else return write(chunk, ...rest); return true }) as any
  try { await run() } finally { process.stderr.write = write }
  return lines
}
const clearedFrames = (runtime: any[]) => runtime.filter(event => event.type === 'system' && String(event.text).startsWith('Cleared ')).map(event => String(event.text))

/** The c2 situation: one long session reading distinct files of 4,000-8,000 characters, one per round, under a 60K window. */
function c2Files(cwd: string, count: number): string[] {
  return Array.from({ length: count }, (_, i) => {
    const path = join(cwd, `engine${i}.ts`), size = 4000 + ((i * 1373) % 4001)
    writeFileSync(path, Array.from({ length: Math.ceil(size / 80) }, (_, n) => `export const part${n} = ${i} // ${'c'.repeat(60)}`).join('\n'))
    return path
  })
}

test('c2 replay: a long run of distinct FileReads under pressure clears only the oldest reads the 65% target needs, and a cleared read reads again', async () => {
  const cwd = sandbox()
  const files = c2Files(cwd, 36)
  let reread = -1
  const client = scripted((request, n) => {
    // After the first clear, the model reads the first file again once.
    if (reread < 0 && n > 0 && results(request).some(isNote)) { reread = n; return call(`again${n}`, 'FileRead', { file_path: files[0] }) }
    const index = reread < 0 ? n : n - 1
    return index < files.length ? call(`r${index}`, 'FileRead', { file_path: files[index] }) : [{ type: 'text', text: 'summary' }]
  }, { contextWindow: 60_000 })
  const runtime: any[] = []
  let asked = 0
  const engine = new QueryEngine({ client, store: createAppStore('c2-replay', cwd), model: 'test-model', tools: [FileReadTool as Tool], sessionMemory: false, verbose: true,
    onRuntime: event => runtime.push(event), onContextFull: async () => { asked++; return 'compact' } })
  const passes = await withPasses(() => engine.submitQuery('read the engine files and summarize them'))
  expect(asked).toBe(0)
  const frames = clearedFrames(runtime)
  expect(frames.length).toBeGreaterThan(0)
  // The first request that carries notes: the oldest reads were cleared, and only some of those eligible.
  const firstIndex = client.requests.findIndex(request => results(request).some(isNote))
  const before = client.requests[firstIndex - 1]!, after = client.requests[firstIndex]!
  const notes = results(after).filter(isNote).map(block => block.tool_use_id)
  const candidates = eligible(before).length + 1 // the request before had one result fewer
  expect(notes.length).toBeGreaterThan(0)
  expect(notes.length).toBeLessThan(candidates - 1)
  expect(notes).toEqual(Array.from({ length: notes.length }, (_, i) => `r${i}`))
  expect(eligible(after).length).toBeGreaterThan(0)
  // One pass per clear, and each frees about what the 65% target asks: the request lands between 55% and 75%.
  expect(passes.every(line => !line.includes(', then '))).toBe(true)
  for (const frame of frames) {
    const [, , to] = /: ([\d,]+) → ([\d,]+) tokens\.$/.exec(frame)!.map(value => Number(value.replace(/,/g, '')))
    expect(to / 60_000).toBeLessThan(0.75)
    expect(to / 60_000).toBeGreaterThan(0.4)
  }
  // The read again of a cleared file returns the file, not "unchanged".
  const again = results(client.requests[reread + 1]!).find(block => block.tool_use_id === `again${reread}`)!
  expect(String(again.content)).toContain('export const part0 = 0')
})

test('c2 replay with other results: a read whose file was changed since, and non-FileRead results, go before any other FileRead', async () => {
  const cwd = sandbox()
  const files = c2Files(cwd, 30)
  const listing = Array.from({ length: 60 }, (_, i) => `engine${i % 14}.ts:${i}: export const part${i}`).join('\n')
  // Round plan: reads, with a search and a shell listing early on and an edit of the first file read.
  const plan: Array<[string, Record<string, unknown>]> = [
    ['FileRead', { file_path: files[0] }], ['Grep', { pattern: 'part' }], ['FileRead', { file_path: files[1] }], ['Probe', { n: 1 }],
    ['FileRead', { file_path: files[2] }], ['FileEdit', { file_path: files[0] }],
    ...files.slice(3).map((file): [string, Record<string, unknown>] => ['FileRead', { file_path: file }]),
  ]
  const client = scripted((_request, n) => n < plan.length ? call(`${plan[n]![0]}${n}`, plan[n]![0], plan[n]![1]) : [{ type: 'text', text: 'summary' }], { contextWindow: 60_000 })
  const runtime: any[] = []
  const tools = [FileReadTool as Tool, probe(() => listing, 'Grep'), probe(() => 's'.repeat(3000)), probe(() => 'edited', 'FileEdit')]
  const engine = new QueryEngine({ client, store: createAppStore('c2-mixed', cwd), model: 'test-model', tools, sessionMemory: false,
    onRuntime: event => runtime.push(event), onContextFull: async () => 'compact' })
  await engine.submitQuery('read, search and edit')
  expect(clearedFrames(runtime).length).toBeGreaterThan(0)
  const after = client.requests.find(request => results(request).some(isNote))!
  const notes = results(after).filter(isNote).map(block => block.tool_use_id)
  // FileRead0 (its file edited in round 5), Grep1 and Probe3 are cleared before any other FileRead;
  // those follow oldest first, and only as many as the target needs.
  const first = ['FileRead0', 'Grep1', 'Probe3']
  expect(first.every(id => notes.includes(id))).toBe(true)
  const reads = notes.filter(id => !first.includes(id))
  expect(reads).toEqual(['FileRead2', 'FileRead4', ...files.slice(3).map((_, i) => `FileRead${i + 6}`)].slice(0, reads.length))
  expect(eligible(after).length).toBeGreaterThan(0)
})

/**
 * A provider whose count of a cleared note is `penalty` tokens more than the estimate says: clearing
 * frees less than estimated. Its usage reports the same count.
 */
function penalized(step: Step, penalty: number, extra: Partial<LLMClient> = {}) {
  const exact = (request: LLMRequestOptions) => Math.ceil(JSON.stringify({ s: request.system, m: request.messages, t: request.tools }).length / 4) + results(request).filter(isNote).length * penalty
  const counts: number[] = []
  const client = scripted((request, n) => [{ type: 'usage', usage: { inputTokens: exact(request), outputTokens: 10 } } as StreamEvent, ...step(request, n)], {
    async countTokens(request): Promise<TokenCount> { counts.push(exact(request)); return { tokens: exact(request), source: 'provider', estimated: false } },
    ...extra,
  })
  return { client, counts }
}

test('cloud: when the first pass falls short of 75% the rest are cleared once more and kept when that fits', async () => {
  const chunk = 'z'.repeat(8000)
  const { client } = penalized((_r, n) => n < 24 ? call(`c${n}`, 'Probe', { n }) : [{ type: 'text', text: 'done' }], 1500, { contextWindow: 60_000 })
  const runtime: any[] = []
  const engine = new QueryEngine({ client, store: createAppStore('clear-second', sandbox()), model: 'test-model', tools: [probe(() => chunk)], sessionMemory: false, verbose: true,
    onRuntime: event => runtime.push(event), onContextFull: async () => 'compact' })
  const passes = await withPasses(() => engine.submitQuery('fill'))
  expect(passes.some(line => line.includes(', then '))).toBe(true)
  const frames = clearedFrames(runtime)
  expect(frames.length).toBeGreaterThan(0)
  expect(runtime.some(event => event.type === 'system' && String(event.text).startsWith('Compacted'))).toBe(false)
  // The second pass took every remaining candidate.
  const after = client.requests.find(request => results(request).some(isNote))!
  expect(eligible(after)).toHaveLength(0)
})

test('cloud: when the second pass falls short too, every result comes back and the existing path runs', async () => {
  const chunk = 'z'.repeat(8000)
  const { client } = penalized((_r, n) => n < 24 ? call(`c${n}`, 'Probe', { n }) : [{ type: 'text', text: 'done' }], 2200, { contextWindow: 60_000 })
  const runtime: any[] = []
  let asked = 0
  const engine = new QueryEngine({ client, store: createAppStore('clear-restore', sandbox()), model: 'test-model', tools: [probe(() => chunk)], sessionMemory: false, verbose: true,
    onRuntime: event => runtime.push(event), onContextFull: async () => { asked++; return 'compact' } })
  const passes = await withPasses(() => engine.submitQuery('fill').catch(() => undefined))
  expect(passes).toEqual([])
  expect(clearedFrames(runtime)).toEqual([])
  expect(client.requests.some(request => results(request).some(isNote))).toBe(false)
  expect(asked).toBeGreaterThan(0)
  expect(runtime.some(event => event.type === 'system' && String(event.text).startsWith('Compacted the conversation'))).toBe(true)
})

/** A managed local runtime whose tokenizer and usage both count bytes/3 of the request (plus a note penalty). */
function local(step: Step, penalty = 0, window = 32_768) {
  const exact = (request: LLMRequestOptions) => Math.ceil(Buffer.byteLength(JSON.stringify({ s: request.system, m: request.messages, t: request.tools })) / 3) + results(request).filter(isNote).length * penalty
  return scripted((request, n) => [{ type: 'usage', usage: { inputTokens: exact(request), outputTokens: 10 } } as StreamEvent, ...step(request, n)], {
    managedLocal: true, contextWindow: window,
    async countTokens(request): Promise<TokenCount> { return { tokens: exact(request), source: 'runtime-tokenizer', estimated: false } },
  })
}

test('local: the current turn clears in the same order, only to the 65% target, with no acceptance rule', async () => {
  const big = 'q'.repeat(4500)
  const client = local((_r, n) => n < 24 ? call(`c${n}`, n % 2 ? 'Probe' : 'Grep', { n }) : [{ type: 'text', text: 'done' }])
  const runtime: any[] = []
  const engine = new QueryEngine({ client, store: createAppStore('clear-local-order', sandbox()), tools: [probe(() => big), probe(() => big, 'Grep')], sessionMemory: false, verbose: true, onRuntime: event => runtime.push(event) })
  const passes = await withPasses(() => engine.submitQuery('read many big things'))
  expect(clearedFrames(runtime).length).toBeGreaterThan(0)
  expect(passes.every(line => !line.includes(', then '))).toBe(true)
  const after = client.requests.find(request => results(request).some(isNote))!
  // Oldest first (both are "other" tools), and fewer than every eligible result.
  const notes = results(after).filter(isNote).map(block => block.tool_use_id)
  expect(notes).toEqual(Array.from({ length: notes.length }, (_, i) => `c${i}`))
  expect(eligible(after).length).toBeGreaterThan(0)
})

test('local: a first pass that leaves the turn over the threshold clears the rest, and the clearing is kept', async () => {
  const big = 'q'.repeat(4500)
  const client = local((_r, n) => n < 24 ? call(`c${n}`, 'Probe', { n }) : [{ type: 'text', text: 'done' }], 1400)
  const runtime: any[] = []
  const engine = new QueryEngine({ client, store: createAppStore('clear-local-second', sandbox()), tools: [probe(() => big)], sessionMemory: false, verbose: true, onRuntime: event => runtime.push(event) })
  const passes = await withPasses(() => engine.submitQuery('read many big things').catch(() => undefined))
  expect(passes.some(line => line.includes(', then '))).toBe(true)
  expect(clearedFrames(runtime).length).toBeGreaterThan(0)
})
