diff --git a/.agents/types/tools.ts b/.agents/types/tools.ts index 848bb5a0b7..1c0333d0dd 100644 --- a/.agents/types/tools.ts +++ b/.agents/types/tools.ts @@ -347,6 +347,8 @@ export interface EditTransactionParams { path: string type: 'write_file' content: string + /** Optional whole-file-covering cap.v3 from a fresh complete whole-file read. Only a full-file capability with a hash matching current content may authorize overwrite; partial ranges never authorize write_file. */ + basedOnRead?: string } )[] } @@ -1084,6 +1086,8 @@ export interface WriteFileParams { instructions: string /** Complete file content to write to the file. */ content: string + /** Optional whole-file-covering cap.v3 from a fresh complete whole-file read (paths or full-file range). Only a capability that covers the entire current file (startLine=1 through the current line count) with a hash matching current content may authorize overwrite; partial range capabilities never authorize write_file. */ + basedOnRead?: string } /** diff --git a/agents/__tests__/base2.test.ts b/agents/__tests__/base2.test.ts index 813f74fc7d..e7fc469dbf 100644 --- a/agents/__tests__/base2.test.ts +++ b/agents/__tests__/base2.test.ts @@ -80,6 +80,7 @@ function attestedReviewerResult( reviewCall: any, verdict: 'LOOKS_GOOD' | 'NON_BLOCKING' | 'BLOCKING' = 'LOOKS_GOOD', findings: string[] = [], + coverage: 'covered' | 'missing' | 'n/a' = 'covered', ) { const prompt = String(reviewCall?.input?.agents?.[0]?.prompt ?? '') const fingerprint = @@ -101,7 +102,7 @@ function attestedReviewerResult( snapshotFingerprint: fingerprint, reviewedFiles: files, findings, - coverage: 'covered', + coverage, dimensions: { correctness: 'pass', security: 'pass', @@ -336,9 +337,14 @@ describe('base2 validation/reviewer coordination prompts', () => { ) expect(base2.systemPrompt).toContain('product, Openbuff') expect(base2.systemPrompt).not.toContain('product, Codebuff') + // gateAwarenessSection: runtime-owned hooks → automated reviewer (not the + // older "runtime automatically runs configured validation hooks…" phrasing). expect(base2.systemPrompt).toContain( - 'the runtime automatically runs configured validation hooks and a code-reviewer gate', + 'the runtime-owned path is: configured file-change hooks', ) + expect(base2.systemPrompt).toContain('run_file_change_hooks') + expect(base2.systemPrompt).toContain('automated code-reviewer') + expect(base2.systemPrompt).toContain('finalization allowed when green') expect(base2.systemPrompt).not.toContain( '- Spawn a code-reviewer to review the changes after you have implemented the changes.', ) @@ -2772,6 +2778,117 @@ describe('base2 verification and reviewer gates', () => { expect(gen.next().value).toBe('STEP') }) + // All-coverage blocker sets must not co-spawn repair-editor. + test('all-coverage reviewer findings route exclusively to test-writer', () => { + const base2 = createBase2('default') + const agentState = { agentId: 'base2' } + const gen = base2.handleSteps!({ + agentState, + prompt: 'Make the requested change now please', + params: {}, + } as any) + + expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) + expect( + gen.next({ toolResult: [{ type: 'json', value: { status: '' } }] } as any) + .value, + ).toMatchObject({ toolName: 'spawn_agent_inline' }) + expect(gen.next().value).toBe('STEP') + expect( + gen.next({ + stepsComplete: true, + toolResult: [{ type: 'json', value: { file: 'src/a.ts' } }], + } as any).value, + ).toMatchObject({ toolName: 'git_status' }) + expect( + gen.next({ + toolResult: [{ type: 'json', value: { status: ' M src/a.ts' } }], + } as any).value, + ).toMatchObject({ toolName: 'run_file_change_hooks' }) + const reviewCall = gen.next({ + toolResult: [{ type: 'json', value: [] }], + } as any).value + expect(reviewCall).toMatchObject({ toolName: 'spawn_agents' }) + // coverage: 'missing' with empty findings produces only the synthetic + // all-coverage blocker classified by isTestCoverageReviewerFinding. + const afterReview = gen.next( + attestedReviewerResult(reviewCall, 'NON_BLOCKING', [], 'missing') as any, + ) + expect(afterReview.value).toMatchObject({ + toolName: 'add_message', + input: { role: 'user' }, + }) + expect((afterReview.value as any).input.content).toContain('test-writer') + expect((afterReview.value as any).input.content).not.toContain( + 'to repair-editor', + ) + + const repairSpawn = gen.next().value as any + expect(repairSpawn).toMatchObject({ + toolName: 'spawn_agents', + input: { agents: [{ agent_type: 'test-writer' }] }, + }) + expect(repairSpawn.input.agents).toHaveLength(1) + expect(repairSpawn.input.agents[0].agent_type).not.toBe('repair-editor') + expect((agentState as any).base2ActiveWork.nextRequiredAction).toContain( + 'Test-writer must add coverage', + ) + }) + + test('mixed coverage and code findings keep repair-editor only', () => { + const base2 = createBase2('default') + const agentState = { agentId: 'base2' } + const gen = base2.handleSteps!({ + agentState, + prompt: 'Make the requested change now please', + params: {}, + } as any) + + expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) + expect( + gen.next({ toolResult: [{ type: 'json', value: { status: '' } }] } as any) + .value, + ).toMatchObject({ toolName: 'spawn_agent_inline' }) + expect(gen.next().value).toBe('STEP') + expect( + gen.next({ + stepsComplete: true, + toolResult: [{ type: 'json', value: { file: 'src/a.ts' } }], + } as any).value, + ).toMatchObject({ toolName: 'git_status' }) + expect( + gen.next({ + toolResult: [{ type: 'json', value: { status: ' M src/a.ts' } }], + } as any).value, + ).toMatchObject({ toolName: 'run_file_change_hooks' }) + const reviewCall = gen.next({ + toolResult: [{ type: 'json', value: [] }], + } as any).value + expect(reviewCall).toMatchObject({ toolName: 'spawn_agents' }) + // Code finding + coverage missing => mixed set must stay on repair-editor. + const afterReview = gen.next( + attestedReviewerResult( + reviewCall, + 'BLOCKING', + ['Fix the edge case.'], + 'missing', + ) as any, + ) + expect(afterReview.value).toMatchObject({ + toolName: 'add_message', + input: { role: 'user' }, + }) + expect((afterReview.value as any).input.content).toContain('repair-editor') + + const repairSpawn = gen.next().value as any + expect(repairSpawn).toMatchObject({ + toolName: 'spawn_agents', + input: { agents: [{ agent_type: 'repair-editor' }] }, + }) + expect(repairSpawn.input.agents).toHaveLength(1) + expect(repairSpawn.input.agents[0].agent_type).not.toBe('test-writer') + }) + test('repair-editor with mutation progress continues into re-validation even when receipt is blocked', () => { const base2 = createBase2('default') const agentState = { agentId: 'base2' } @@ -3820,22 +3937,13 @@ describe('base2 verification and reviewer gates', () => { } as any).value as any expect(reviewCall).toMatchObject({ toolName: 'spawn_agents' }) const reviewPrompt = reviewCall.input.agents[0].prompt as string - // Co-changed test files are surfaced as readable "Coverage evidence" so - // the reviewer can confirm coverage; without this the reviewer could never - // see the changed test file and always reported coverage missing/uncertain. - expect(reviewPrompt).toContain( - 'Coverage evidence (co-changed tests; readable and citable, not part of the reviewed fingerprint):', - ) expect(reviewPrompt).toContain( - 'Co-changed test files are listed under "Coverage evidence"', + 'list every pending changed file in reviewedFiles (including tests)', ) - expect(reviewPrompt).toContain('satisfied (not uncertain)') expect(reviewPrompt).toContain( - 'when a Coverage evidence test file covers the changed behavior', - ) - expect(reviewPrompt).toContain( - 'Use coverage: missing only when no covering test exists in the Coverage evidence list or anywhere in the repo', + 'Changed tests are first-class review targets and may also be cited as coverage evidence.', ) + expect(reviewPrompt).not.toContain('not part of the reviewed fingerprint') }) test('reviewer attestation citing the changed test file clears the gate test-coverage requirement', () => { @@ -4295,40 +4403,8 @@ describe('base2 verification and reviewer gates', () => { }) }) -describe('base2 static-review-only concurrency (M3.1)', () => { - test('default path (staticReviewOnly absent) does not spawn the reviewer in the background', () => { - const base2 = createBase2('default') - const agentState = { agentId: 'base2-custom' } - const gen = base2.handleSteps!({ - agentState, - prompt: 'Make the requested change now please', - params: {}, - } as any) - - expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) - expect( - gen.next({ toolResult: [{ type: 'json', value: { status: '' } }] } as any) - .value, - ).toMatchObject({ toolName: 'spawn_agent_inline' }) - expect(gen.next().value).toBe('STEP') - expect( - gen.next({ - stepsComplete: true, - toolResult: [{ type: 'json', value: { file: 'src/a.ts' } }], - } as any).value, - ).toMatchObject({ toolName: 'git_status' }) - const afterGit = gen.next({ - toolResult: [{ type: 'json', value: { status: ' M src/a.ts' } }], - } as any) - // No background reviewer: validation runs first (foreground), exactly as - // the existing sequential behavior. - expect(afterGit.value).toMatchObject({ toolName: 'run_file_change_hooks' }) - expect( - (agentState as any).base2ActiveWork.staticReviewerJobId, - ).toBeUndefined() - }) - - test('staticReviewOnly spawns the reviewer in the background before validation and stashes jobId', () => { +describe('base2 validation-first reviewer snapshots', () => { + test('staticReviewOnly still validates before spawning the final reviewer', () => { const base2 = createBase2('default') const agentState = { agentId: 'base2-custom', @@ -4352,323 +4428,20 @@ describe('base2 static-review-only concurrency (M3.1)', () => { toolResult: [{ type: 'json', value: { file: 'src/a.ts' } }], } as any).value, ).toMatchObject({ toolName: 'git_status' }) - const bgSpawn = gen.next({ + const validation = gen.next({ toolResult: [{ type: 'json', value: { status: ' M src/a.ts' } }], } as any) - // Reviewer spawned in the background BEFORE validation hooks run. - expect(bgSpawn.value).toMatchObject({ toolName: 'spawn_agents' }) - const agents = (bgSpawn.value as any).input.agents as any[] - expect(agents[0].agent_type).toBe('code-reviewer') - expect(agents[0].background).toBe(true) - expect(agents[0].prompt as string).toContain( - 'Reviewer running concurrently with validation (static-review-only mode).', - ) - - const afterReport = gen.next({ - toolResult: [ - { - type: 'json', - value: { - background: true, - jobId: 'bg-reviewer-1', - message: 'Reviewer running in the background.', - }, - }, - ], - } as any) - expect((afterReport.value as any).toolName).toBe('run_file_change_hooks') - expect((agentState as any).base2ActiveWork.staticReviewerJobId).toBe( - 'bg-reviewer-1', - ) - }) - - test('staticReviewOnly: validation failure does not consult the background reviewer', () => { - const base2 = createBase2('default') - const agentState = { - agentId: 'base2-custom', - base2ActiveWork: { staticReviewOnly: true }, - } - const gen = base2.handleSteps!({ - agentState, - prompt: 'Make the requested change now please', - params: {}, - } as any) + expect(validation.value).toMatchObject({ toolName: 'run_file_change_hooks' }) + expect((agentState as any).base2ActiveWork.staticReviewerJobId).toBeUndefined() - expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) - expect( - gen.next({ toolResult: [{ type: 'json', value: { status: '' } }] } as any) - .value, - ).toMatchObject({ toolName: 'spawn_agent_inline' }) - expect(gen.next().value).toBe('STEP') - expect( - gen.next({ - stepsComplete: true, - toolResult: [{ type: 'json', value: { file: 'src/a.ts' } }], - } as any).value, - ).toMatchObject({ toolName: 'git_status' }) - const bgSpawn = gen.next({ - toolResult: [{ type: 'json', value: { status: ' M src/a.ts' } }], - } as any) - expect(bgSpawn.value).toMatchObject({ toolName: 'spawn_agents' }) - - const afterReport = gen.next({ - toolResult: [ - { - type: 'json', - value: { background: true, jobId: 'bg-reviewer-2', message: 'ok' }, - }, - ], - } as any) - expect((afterReport.value as any).toolName).toBe('run_file_change_hooks') - expect((agentState as any).base2ActiveWork.staticReviewerJobId).toBe( - 'bg-reviewer-2', - ) - - // Validation fails (unparseable failure -> blocked, no repair loop). - const afterHooks = gen.next({ - toolResult: [ - { - type: 'json', - value: [{ hookName: 'typecheck', exitCode: 1, stderr: 'boom' }], - }, - ], - } as any) - // Validation failure cancels speculative review before surfacing the - // blocking validation diagnostics. - expect((afterHooks.value as any).toolName).toBe('check_background_agent') - expect((afterHooks.value as any).input).toMatchObject({ - jobId: 'bg-reviewer-2', - cancel: true, - }) - const blocked = gen.next({ toolResult: [] } as any) - expect((blocked.value as any).toolName).toBe('add_message') - const text = (blocked.value as any).input.content as string - expect(text).toContain('Verification gate') - expect( - (agentState as any).base2ActiveWork.staticReviewerJobId, - ).toBeUndefined() - }) - - test('staticReviewOnly: validation pass joins the background reviewer via check_background_agent', () => { - const base2 = createBase2('default') - const agentState = { - agentId: 'base2-custom', - base2ActiveWork: { staticReviewOnly: true }, - } - const gen = base2.handleSteps!({ - agentState, - prompt: 'Make the requested change now please', - params: {}, - } as any) - - expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) - expect( - gen.next({ toolResult: [{ type: 'json', value: { status: '' } }] } as any) - .value, - ).toMatchObject({ toolName: 'spawn_agent_inline' }) - expect(gen.next().value).toBe('STEP') - expect( - gen.next({ - stepsComplete: true, - toolResult: [{ type: 'json', value: { file: 'src/a.ts' } }], - } as any).value, - ).toMatchObject({ toolName: 'git_status' }) - const bgSpawn = gen.next({ - toolResult: [{ type: 'json', value: { status: ' M src/a.ts' } }], - } as any) - expect(bgSpawn.value).toMatchObject({ toolName: 'spawn_agents' }) - expect((bgSpawn.value as any).input.agents[0].background).toBe(true) - - const afterReport = gen.next({ - toolResult: [ - { - type: 'json', - value: { background: true, jobId: 'bg-reviewer-3', message: 'ok' }, - }, - ], - } as any) - expect((afterReport.value as any).toolName).toBe('run_file_change_hooks') - expect((agentState as any).base2ActiveWork.staticReviewerJobId).toBe( - 'bg-reviewer-3', - ) - - // Validation passes (hooks skipped). - const afterHooks = gen.next({ - toolResult: [ - { - type: 'json', - value: [ - { - validationStatus: 'hooks_skipped', - message: - 'Configured file-change hooks were skipped because none matched the changed files.', - configuredHookCount: 1, - changedFiles: ['src/a.ts'], - }, - ], - }, - ], - } as any) - // Join the background reviewer instead of a foreground spawn_agents, using - // the stashed jobId. - expect(afterHooks.value).toMatchObject({ - toolName: 'check_background_agent', - }) - expect((afterHooks.value as any).input).toMatchObject({ - jobId: 'bg-reviewer-3', - timeout_seconds: 120, - }) - expect((afterHooks.value as any).input).not.toHaveProperty('wait_for') - - // The reviewer returns LOOKS_GOOD via the background result; feed it through - // the existing collectReviewerBlockers / getReviewerFinalizationVerdict - // path (unchanged) to reach finalization. - const gatePassed = gen.next( - attestedBackgroundReviewerResult(agentState, 'LOOKS_GOOD') as any, - ) - expect(gatePassed.value).toMatchObject({ - toolName: 'add_message', - input: { role: 'user' }, - }) - expect((gatePassed.value as any).input.content.toLowerCase()).toContain( - 'reviewer gate passed with looks_good', - ) - expect((agentState as any).base2ActiveWork).toMatchObject({ - currentPhase: 'final_response_allowed', - pendingGateFiles: [], - gatePassedReviewerVerdict: 'LOOKS_GOOD', - staticReviewerJobId: undefined, - }) - }) - - test('staticReviewOnly: background reviewer NON_BLOCKING result finalizes after validation pass', () => { - const base2 = createBase2('default') - const agentState = { - agentId: 'base2-custom', - base2ActiveWork: { staticReviewOnly: true }, - } - const gen = base2.handleSteps!({ - agentState, - prompt: 'Make the requested change now please', - params: {}, - } as any) - - expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) - expect( - gen.next({ toolResult: [{ type: 'json', value: { status: '' } }] } as any) - .value, - ).toMatchObject({ toolName: 'spawn_agent_inline' }) - expect(gen.next().value).toBe('STEP') - expect( - gen.next({ - stepsComplete: true, - toolResult: [{ type: 'json', value: { file: 'src/a.ts' } }], - } as any).value, - ).toMatchObject({ toolName: 'git_status' }) - expect( - gen.next({ - toolResult: [{ type: 'json', value: { status: ' M src/a.ts' } }], - } as any).value, - ).toMatchObject({ toolName: 'spawn_agents' }) - expect( - gen.next({ - toolResult: [ - { type: 'json', value: { background: true, jobId: 'bg-reviewer-4' } }, - ], - } as any).value, - ).toMatchObject({ toolName: 'run_file_change_hooks' }) - const afterHooks = gen.next({ - toolResult: [{ type: 'json', value: [] }], - } as any) - - expect(afterHooks.value).toMatchObject({ - toolName: 'check_background_agent', - input: { jobId: 'bg-reviewer-4', timeout_seconds: 120 }, - }) - expect((afterHooks.value as any).input).not.toHaveProperty('wait_for') - - const gatePassed = gen.next( - attestedBackgroundReviewerResult(agentState, 'NON_BLOCKING', [ - 'Minor suggestion.', - ]) as any, - ) - expect(gatePassed.value).toMatchObject({ toolName: 'add_message' }) - expect((gatePassed.value as any).input.content).toContain( - 'Reviewer gate passed with NON_BLOCKING', - ) - expect((agentState as any).base2ActiveWork).toMatchObject({ - currentPhase: 'final_response_allowed', - pendingGateFiles: [], - gatePassedReviewerVerdict: 'NON_BLOCKING', - staticReviewerJobId: undefined, - }) - }) - - test('staticReviewOnly: background reviewer BLOCKING result reopens the turn after validation pass', () => { - const base2 = createBase2('default') - const agentState = { - agentId: 'base2-custom', - base2ActiveWork: { staticReviewOnly: true }, - } - const gen = base2.handleSteps!({ - agentState, - prompt: 'Make the requested change now please', - params: {}, - } as any) - - expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) - expect( - gen.next({ toolResult: [{ type: 'json', value: { status: '' } }] } as any) - .value, - ).toMatchObject({ toolName: 'spawn_agent_inline' }) - expect(gen.next().value).toBe('STEP') - expect( - gen.next({ - stepsComplete: true, - toolResult: [{ type: 'json', value: { file: 'src/a.ts' } }], - } as any).value, - ).toMatchObject({ toolName: 'git_status' }) - expect( - gen.next({ - toolResult: [{ type: 'json', value: { status: ' M src/a.ts' } }], - } as any).value, - ).toMatchObject({ toolName: 'spawn_agents' }) - expect( - gen.next({ - toolResult: [ - { type: 'json', value: { background: true, jobId: 'bg-reviewer-5' } }, - ], - } as any).value, - ).toMatchObject({ toolName: 'run_file_change_hooks' }) - const afterHooks = gen.next({ + const review = gen.next({ toolResult: [{ type: 'json', value: [] }], } as any) - - expect(afterHooks.value).toMatchObject({ - toolName: 'check_background_agent', - input: { jobId: 'bg-reviewer-5', timeout_seconds: 120 }, - }) - expect((afterHooks.value as any).input).not.toHaveProperty('wait_for') - - const afterReview = gen.next( - attestedBackgroundReviewerResult(agentState, 'BLOCKING', [ - 'Fix the static path.', - ]) as any, - ) - expect(afterReview.value).toMatchObject({ - toolName: 'add_message', - input: { role: 'user' }, - }) - expect((afterReview.value as any).input.content).toContain( - 'BLOCKING: Fix the static path.', - ) - expect((agentState as any).base2ActiveWork).toMatchObject({ - currentPhase: 'blocked', - pendingGateFiles: ['src/a.ts'], - openReviewerBlockers: ['BLOCKING: Fix the static path.'], - nextRequiredAction: - 'Resolve the reviewer feedback below before any unrelated work, final response, or another review.', + expect(review.value).toMatchObject({ + toolName: 'spawn_agents', + input: { agents: [{ agent_type: 'code-reviewer' }] }, }) + expect((review.value as any).input.agents[0]).not.toHaveProperty('background') }) }) @@ -5028,4 +4801,37 @@ describe('base2 test-writer aux-gate completion path', () => { expect(nextYield.input.agent_type).not.toBe('test-writer') } }) + + test('proactive query_index fires only for code-intent prompts', () => { + const firstYield = (prompt: string) => { + const base2 = createBase2('default') + const gen = base2.handleSteps!({ + agentState: { agentId: 'base2-classify' }, + prompt, + params: {}, + config: base2.programmaticConfig, + } as any) + return gen.next().value as any + } + + // A code-intent prompt with no concrete file path triggers a proactive + // query_index (mode: 'search') as the very first step. + expect( + firstYield('Refactor the authentication module code.'), + ).toMatchObject({ toolName: 'query_index', input: { mode: 'search' } }) + + // A prompt naming a concrete file path already identifies the relevant + // file, so proactive retrieval is skipped and the turn starts at git_status. + expect(firstYield('Update src/app.ts with the new export')).toMatchObject({ + toolName: 'git_status', + }) + + // Too-short prompts skip proactive retrieval. + expect(firstYield('fix it')).toMatchObject({ toolName: 'git_status' }) + + // Continuation prompts skip proactive retrieval. + expect( + firstYield('continue working on the previous task'), + ).toMatchObject({ toolName: 'git_status' }) + }) }) diff --git a/agents/__tests__/gate-paths-parity.test.ts b/agents/__tests__/gate-paths-parity.test.ts index 5cba25b868..44ca05c6c6 100644 --- a/agents/__tests__/gate-paths-parity.test.ts +++ b/agents/__tests__/gate-paths-parity.test.ts @@ -188,13 +188,11 @@ describe('gate-path helpers — inline copies match canonical exports', () => { const pathInputs: string[] = [ // reviewable source (expect true) 'src/foo.ts', - // __tests__/ path (false) + // Tests are first-class reviewable files. 'src/__tests__/foo.ts', - // .test.ts path (false) 'src/foo.test.ts', - // JS-flavored test/spec files (false) — parity must hold across the - // broadened (?:tsx?|jsx?|mjs|cjs) exclusion so a `.test.js`/`.spec.mjs` - // test file is not classified as reviewable source by the inline copy. + // JS-flavored test/spec files are reviewable and remain separately + // identifiable as coverage evidence. 'src/foo.test.js', 'src/foo.spec.mjs', 'src/foo.test.cjs', @@ -234,7 +232,7 @@ describe('gate-path helpers — inline copies match canonical exports', () => { // selectReviewableGateFiles normalizes + filters + dedupes internally, so // pass raw path lists. const listInputs: string[][] = [ - // mixed source + bookkeeping: only source survives, normalized + deduped + // mixed source + tests + bookkeeping: source and tests survive [ 'src/foo.ts', './src/foo.ts', @@ -367,12 +365,12 @@ describe('gate-path helpers — canonical export behavior', () => { expect(normalizeGateFilePath(' ')).toBe('') }) - test('selectReviewableGateFiles keeps reviewable source and drops tests/generated/docs/data/bookkeeping', () => { + test('selectReviewableGateFiles keeps source and tests and drops generated/docs/data/bookkeeping', () => { const selected = selectReviewableGateFiles([ // reviewable source (kept): .ts and .py 'src/foo.ts', 'scripts/tool.py', - // __tests__/ and .test/.spec (dropped) + // __tests__/ and .test/.spec (kept) 'src/__tests__/foo.ts', 'src/foo.test.ts', 'src/foo.spec.ts', @@ -392,7 +390,13 @@ describe('gate-path helpers — canonical export behavior', () => { 'evals/case.ts', '.agents/sessions/slug/STATE.json', ]) - expect(selected).toEqual(['src/foo.ts', 'scripts/tool.py']) + expect(selected).toEqual([ + 'src/foo.ts', + 'scripts/tool.py', + 'src/__tests__/foo.ts', + 'src/foo.test.ts', + 'src/foo.spec.ts', + ]) // empty list -> [] expect(selectReviewableGateFiles([])).toEqual([]) }) @@ -421,7 +425,11 @@ describe('gate-path helpers — canonical export behavior', () => { 'src/__tests__/feature.ts', ] - expect(selectReviewableGateFiles(inputs)).toEqual(['src/feature.ts']) + expect(selectReviewableGateFiles(inputs)).toEqual([ + 'src/feature.ts', + 'src/feature.test.ts', + 'src/__tests__/feature.ts', + ]) expect(selectCoverageEvidenceFiles(inputs)).toEqual([ 'src/feature.test.ts', 'src/__tests__/feature.ts', @@ -443,12 +451,9 @@ describe('gate-path helpers — canonical export behavior', () => { ).toEqual(['src/feature.test.ts', 'src/__tests__/feature.ts']) }) - test('JS-flavored test files are excluded from reviewable source and treated as coverage evidence', () => { - // RF-3/RF-9: widen the test/spec exclusion so JS-flavored test files - // (foo.test.mjs/.cjs/.jsx, foo.spec.*) are treated as tests rather than - // reviewable source. Without this, the reviewable include regex (which - // already accepts mjs|cjs|jsx as source extensions) would classify a JS - // test file as reviewable source. + test('JS-flavored test files are reviewable and treated as coverage evidence', () => { + // Tests participate in reviewer attestation while remaining identifiable + // as coverage evidence. for (const testFile of [ 'src/foo.test.mjs', 'src/foo.test.cjs', @@ -457,7 +462,7 @@ describe('gate-path helpers — canonical export behavior', () => { 'src/foo.spec.cjs', 'src/foo.spec.jsx', ]) { - expect(isReviewableGateFile(testFile)).toBe(false) + expect(isReviewableGateFile(testFile)).toBe(true) expect(isCoverageEvidenceFile(testFile)).toBe(true) } // Non-test JS-flavored source stays reviewable and is not coverage evidence. diff --git a/agents/__tests__/gate-paths.test.ts b/agents/__tests__/gate-paths.test.ts index 03b4857471..745c768ed8 100644 --- a/agents/__tests__/gate-paths.test.ts +++ b/agents/__tests__/gate-paths.test.ts @@ -134,7 +134,7 @@ describe('gate-paths helpers', () => { expect(gateFileSetsEqual(['a', 'b'], ['a', 'c'])).toBe(false) }) - test('isReviewableGateFile includes real source and excludes tests/generated/docs/data/bookkeeping', () => { + test('isReviewableGateFile includes source and tests while excluding generated/docs/data/bookkeeping', () => { // Reviewable source extensions (expect true). for (const reviewable of [ 'src/a.ts', @@ -148,19 +148,19 @@ describe('gate-paths helpers', () => { 'cmd/main.go', 'svc/App.java', 'app/Main.kt', - ]) { - expect(isReviewableGateFile(reviewable)).toBe(true) - } - - // Tests, generated code, docs, data/config, env, and bookkeeping dirs - // (expect false). - for (const excluded of [ 'src/__tests__/a.ts', 'src/a.test.ts', 'src/a.spec.tsx', 'src/a.test.mjs', 'src/a.spec.cjs', 'src/a.test.jsx', + ]) { + expect(isReviewableGateFile(reviewable)).toBe(true) + } + + // Generated code, docs, data/config, env, and bookkeeping dirs + // (expect false). + for (const excluded of [ 'src/a.generated.ts', 'src/a.generated.tsx', 'README.md', @@ -206,14 +206,14 @@ describe('gate-paths helpers', () => { } }) - test('selectReviewableGateFiles normalizes, dedupes, drops empties, keeps only reviewable source', () => { + test('selectReviewableGateFiles normalizes, dedupes, and keeps source and tests', () => { const cwd = process.cwd().replace(/\\/g, '/').replace(/\/+$/, '') const result = selectReviewableGateFiles([ 'src/a.ts', './src/a.ts', // dedupes with src/a.ts after normalization `${cwd}/src/a.ts`, // in-cwd absolute -> src/a.ts (dedupe) 'src\\b.ts', // backslashes -> src/b.ts - 'src/a.test.ts', // test file excluded + 'src/a.test.ts', // test file included 'src/a.generated.ts', // generated excluded 'README.md', // doc excluded 'docs/guide.ts', // docs/ excluded @@ -221,7 +221,7 @@ describe('gate-paths helpers', () => { '', // empty dropped ' ', // whitespace dropped ]) - expect(result).toEqual(['src/a.ts', 'src/b.ts']) + expect(result).toEqual(['src/a.ts', 'src/b.ts', 'src/a.test.ts']) }) test('selectCoverageEvidenceFiles normalizes, dedupes, and keeps only test files', () => { diff --git a/agents/__tests__/gate-reviewer-parity.test.ts b/agents/__tests__/gate-reviewer-parity.test.ts new file mode 100644 index 0000000000..6f8538ad19 --- /dev/null +++ b/agents/__tests__/gate-reviewer-parity.test.ts @@ -0,0 +1,142 @@ +import { readFileSync } from 'node:fs' + +import { describe, expect, test } from 'bun:test' + +import { isTestCoverageReviewerFinding } from '../base2/gate-reviewer' + +// Parity: inline base2 mirror must match the gate-reviewer export used for +// exclusive all-coverage → test-writer routing. +type GateReviewerHelpers = { + isTestCoverageReviewerFinding: (text: string) => boolean +} + +type GateReviewerFunctionName = keyof GateReviewerHelpers +type InlineHelperFactory = () => GateReviewerHelpers + +const INLINE_HELPER_NAMES: GateReviewerFunctionName[] = [ + 'isTestCoverageReviewerFinding', +] + +function extractInlineFunctionSource( + source: string, + functionName: string, +): string { + const declarationStart = source.indexOf(`function ${functionName}(`) + if (declarationStart < 0) { + throw new Error(`Unable to find inline ${functionName} declaration`) + } + + const bodyStart = source.indexOf('{', declarationStart) + if (bodyStart < 0) { + throw new Error(`Unable to find inline ${functionName} body`) + } + + let depth = 0 + for (let index = bodyStart; index < source.length; index += 1) { + const character = source[index] + if (character === '{') depth += 1 + if (character === '}') depth -= 1 + if (depth === 0) { + return source.slice(declarationStart, index + 1) + } + } + + throw new Error(`Unable to find end of inline ${functionName} declaration`) +} + +function loadInlineGateReviewerHelpers(): GateReviewerHelpers { + const base2Source = readFileSync( + new URL('../base2/base2.ts', import.meta.url), + 'utf8', + ) + const transpiler = new Bun.Transpiler({ loader: 'ts' }) + const base2JavaScript = transpiler.transformSync(base2Source) + const helperSource = INLINE_HELPER_NAMES.map((functionName) => + extractInlineFunctionSource(base2JavaScript, functionName), + ).join('\n\n') + const buildHelpers = new Function( + `"use strict";\n${helperSource}\nreturn { isTestCoverageReviewerFinding }`, + ) as InlineHelperFactory + + return buildHelpers() +} + +describe('gate-reviewer helpers — inline copies match canonical exports', () => { + test('isTestCoverageReviewerFinding parity across representative inputs', () => { + const inlineHelpers = loadInlineGateReviewerHelpers() + + const inputs: unknown[] = [ + // coverage-only findings (expect true) + 'BLOCKING: test coverage missing for changed behavior (add a case to the relevant *.test.ts)', + 'test coverage is insufficient', + 'TEST COVERAGE missing', + 'BLOCKING: coverage gap: add a case to src/foo.test.ts for the new behavior', + 'coverage missing; extend widget.test.tsx', + // generic test/coverage mentions that keep the repair-editor path (false) + 'BLOCKING: add tests for the parser', + 'BLOCKING: this path is not tested', + 'BLOCKING: coverage of edge cases is unclear', + 'BLOCKING: update foo.test.ts to match the new API', + 'BLOCKING: fix the null dereference in parse()', + '', + ' ', + // non-string inputs (false) + undefined, + null, + 42, + {}, + ['test coverage'], + ] + + for (const input of inputs) { + expect( + inlineHelpers.isTestCoverageReviewerFinding(input as string), + ).toBe(isTestCoverageReviewerFinding(input as string)) + } + }) +}) + +// Direct behavioral coverage for the canonical gate-reviewer.ts export (as +// opposed to the parity suite above, which only asserts the inline base2 copy +// matches the export). These assertions also keep the export consumed so it +// is not dead code. +describe('gate-reviewer helpers — canonical export behavior', () => { + test('isTestCoverageReviewerFinding keys on the test-coverage bigram or coverage plus a .test.* token', () => { + expect( + isTestCoverageReviewerFinding( + 'BLOCKING: test coverage missing for changed behavior (add a case to the relevant *.test.ts)', + ), + ).toBe(true) + expect(isTestCoverageReviewerFinding('TEST COVERAGE missing')).toBe(true) + expect( + isTestCoverageReviewerFinding('coverage missing; extend widget.test.tsx'), + ).toBe(true) + }) + + test('isTestCoverageReviewerFinding stays conservative for generic test/coverage mentions', () => { + expect( + isTestCoverageReviewerFinding('BLOCKING: add tests for the parser'), + ).toBe(false) + expect( + isTestCoverageReviewerFinding( + 'BLOCKING: coverage of edge cases is unclear', + ), + ).toBe(false) + expect( + isTestCoverageReviewerFinding( + 'BLOCKING: update foo.test.ts to match the new API', + ), + ).toBe(false) + expect(isTestCoverageReviewerFinding('')).toBe(false) + }) + + test('isTestCoverageReviewerFinding rejects non-string inputs', () => { + expect( + isTestCoverageReviewerFinding(undefined as unknown as string), + ).toBe(false) + expect(isTestCoverageReviewerFinding(null as unknown as string)).toBe( + false, + ) + expect(isTestCoverageReviewerFinding(42 as unknown as string)).toBe(false) + }) +}) diff --git a/agents/__tests__/gate-reviewer.test.ts b/agents/__tests__/gate-reviewer.test.ts index d79cb3313d..534cb787b2 100644 --- a/agents/__tests__/gate-reviewer.test.ts +++ b/agents/__tests__/gate-reviewer.test.ts @@ -8,6 +8,7 @@ import { collectReviewerFindingRecords, detectReviewerCrash, getReviewerFinalizationVerdict, + isTestCoverageReviewerFinding, stripReviewerPreamble, } from '../base2/gate-reviewer' @@ -611,4 +612,64 @@ describe('gate-reviewer helpers', () => { ), ).toBe('') }) + + test('isTestCoverageReviewerFinding keys on the test-coverage bigram or coverage plus a .test.* token', () => { + // The synthetic coverage blocker from collectReviewerBlockers must classify + // as coverage so the all-coverage set routes exclusively to test-writer. + expect( + isTestCoverageReviewerFinding( + 'BLOCKING: test coverage missing for changed behavior (add a case to the relevant *.test.ts)', + ), + ).toBe(true) + expect( + isTestCoverageReviewerFinding('test coverage is insufficient'), + ).toBe(true) + expect(isTestCoverageReviewerFinding('TEST COVERAGE missing')).toBe(true) + expect( + isTestCoverageReviewerFinding( + 'BLOCKING: coverage gap: add a case to src/foo.test.ts for the new behavior', + ), + ).toBe(true) + expect( + isTestCoverageReviewerFinding('coverage missing; extend widget.test.tsx'), + ).toBe(true) + }) + + test('isTestCoverageReviewerFinding stays conservative for generic test/coverage mentions', () => { + // Generic requirements mentioning test(s)/tested or bare coverage must + // keep routing to repair-editor (status quo). + expect( + isTestCoverageReviewerFinding('BLOCKING: add tests for the parser'), + ).toBe(false) + expect( + isTestCoverageReviewerFinding('BLOCKING: this path is not tested'), + ).toBe(false) + expect( + isTestCoverageReviewerFinding( + 'BLOCKING: coverage of edge cases is unclear', + ), + ).toBe(false) + expect( + isTestCoverageReviewerFinding( + 'BLOCKING: update foo.test.ts to match the new API', + ), + ).toBe(false) + expect( + isTestCoverageReviewerFinding( + 'BLOCKING: fix the null dereference in parse()', + ), + ).toBe(false) + expect(isTestCoverageReviewerFinding('')).toBe(false) + }) + + test('isTestCoverageReviewerFinding rejects non-string inputs', () => { + expect( + isTestCoverageReviewerFinding(undefined as unknown as string), + ).toBe(false) + expect(isTestCoverageReviewerFinding(null as unknown as string)).toBe( + false, + ) + expect(isTestCoverageReviewerFinding(42 as unknown as string)).toBe(false) + expect(isTestCoverageReviewerFinding({} as unknown as string)).toBe(false) + }) }) diff --git a/agents/__tests__/general-agent.test.ts b/agents/__tests__/general-agent.test.ts index 955c0c11dd..98e2dd4072 100644 --- a/agents/__tests__/general-agent.test.ts +++ b/agents/__tests__/general-agent.test.ts @@ -34,6 +34,19 @@ describe('general-agent programmatic tools', () => { }) }) + test('routes ripgrep-style search through code-searcher with required params', () => { + // general-agent is not granted code_search directly; its prompt must tell + // it to spawn code-searcher and to pass the required params.searchQueries, + // otherwise the runtime rejects the direct code_search call and an empty + // code-searcher spawn fails with "Missing required: searchQueries". + const agent = createGeneralAgent({ model: 'opus' }) + + expect(agent.toolNames).not.toContain('code_search') + expect(agent.instructionsPrompt).toContain('code_search') + expect(agent.instructionsPrompt).toContain('not granted to you') + expect(agent.instructionsPrompt).toContain('params.searchQueries') + }) + test('binds durable audit shards to composable snapshot receipts', () => { const agent = createGeneralAgent({ model: 'opus' }) const params = agent.inputSchema?.params?.properties @@ -71,4 +84,87 @@ describe('general-agent programmatic tools', () => { 'Audit completion was rejected', ) }) + + test('breaks the audit loop once a matching structural receipt is present', () => { + const agent = createGeneralAgent({ model: 'opus' }) + const generator = agent.handleSteps!({ + prompt: 'Audit service completeness', + params: { + sessionSlug: 'readiness', + shardId: 'services', + snapshotId: 'snapshot-1', + }, + } as any) + + expect(generator.next().value).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + expect(generator.next({ toolResult: [] } as any).value).toBe('STEP') + + const completion = generator.next({ + stepsComplete: true, + agentState: { + messageHistory: [ + { + role: 'tool', + content: [ + { + type: 'json', + value: { structuralReceipt: { snapshot_id: 'snapshot-1' } }, + }, + ], + }, + ], + }, + toolResult: [], + } as any) + + expect(completion.done).toBe(true) + expect((completion.value as any)?.toolName).not.toBe('add_message') + }) + + test('breaks the audit loop after exhausting completion retries', () => { + const agent = createGeneralAgent({ model: 'opus' }) + const generator = agent.handleSteps!({ + prompt: 'Audit service completeness', + params: { + sessionSlug: 'readiness', + shardId: 'services', + snapshotId: 'snapshot-1', + }, + } as any) + + const noReceiptStep = { + stepsComplete: true, + agentState: { messageHistory: [] }, + toolResult: [], + } as any + + // First completion step: rejected -> add_message (retries 0 -> 1). + expect(generator.next().value).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + expect(generator.next({ toolResult: [] } as any).value).toBe('STEP') + expect(generator.next(noReceiptStep).value).toMatchObject({ + toolName: 'add_message', + }) + + // Second completion step: rejected -> add_message (retries 1 -> 2). + expect(generator.next().value).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + expect(generator.next({ toolResult: [] } as any).value).toBe('STEP') + expect(generator.next(noReceiptStep).value).toMatchObject({ + toolName: 'add_message', + }) + + // Third completion step: retries exhausted -> break without add_message. + expect(generator.next().value).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + expect(generator.next({ toolResult: [] } as any).value).toBe('STEP') + const final = generator.next(noReceiptStep) + expect(final.done).toBe(true) + expect((final.value as any)?.toolName).not.toBe('add_message') + }) }) diff --git a/agents/__tests__/quality-prompt-snapshot.test.ts b/agents/__tests__/quality-prompt-snapshot.test.ts index c4f98d944b..951e28ee0c 100644 --- a/agents/__tests__/quality-prompt-snapshot.test.ts +++ b/agents/__tests__/quality-prompt-snapshot.test.ts @@ -120,16 +120,52 @@ describe('shared craftsmanship prompt sections', () => { expect(gitDisciplineSection).toContain( 'Spawning git-committer is not available yet', ) + // Wait-and-commit guidance added for gate ergonomics: the gate re-arms per + // edit, the block is normal ordering (not an error), and the commit lands + // automatically once the gate clears. + expect(gitDisciplineSection).toContain('re-arms on every new edit') + expect(gitDisciplineSection).toContain( + 'Treat this as normal ordering, not an error', + ) + expect(gitDisciplineSection).toContain( + 'the commit will land automatically once the gate clears', + ) }) test('gateAwarenessSection contains the required gate-awareness topics (not byte-frozen)', () => { // gateAwarenessSection is advisory guidance that may evolve; only assert // topic coverage so future tightening does not silently drop the - // don't-double-spawn-code-reviewer guidance. + // don't-double-spawn-code-reviewer guidance and the four hooks-vs-gate + // clarifications (re-arm ownership, targeted validation ≠ gate, commit + // recovery, pending-set authority). expect(gateAwarenessSection).toContain('# Automated Validation & Review Gate') expect(gateAwarenessSection).toContain('code-reviewer') - expect(gateAwarenessSection).toContain('validation hooks') - expect(gateAwarenessSection).toContain('before finalization') + expect(gateAwarenessSection).toContain('run_file_change_hooks') + expect(gateAwarenessSection).toContain('finalization allowed when green') + // 1) Re-arm ownership: runtime-owned hooks→reviewer; no manual re-spawn + // for the same pending set; wait when awaiting_validation. + expect(gateAwarenessSection).toContain('Do not double-spawn code-reviewer') + expect(gateAwarenessSection).toContain('same pending set') + expect(gateAwarenessSection).toContain('awaiting_validation') + // 2) run_targeted_validation is NOT the gate (scoped evidence only). + expect(gateAwarenessSection).toContain('run_targeted_validation') + expect(gateAwarenessSection).toContain('is NOT the gate') + expect(gateAwarenessSection).toContain( + 'does not clear reviewer findings by itself', + ) + expect(gateAwarenessSection).toContain('does **not** unlock') + expect(gateAwarenessSection).toContain('Basher typechecks') + // 3) Commit recovery: git-committer only after gate green; re-arms; no tight-loop. + expect(gateAwarenessSection).toContain('git-committer') + expect(gateAwarenessSection).toContain('re-arms on every new edit') + expect(gateAwarenessSection).toContain('tight-loop') + expect(gateAwarenessSection).toContain('not available yet') + // 4) Pending-set authority: full pendingGateFiles set, not last-edited file. + expect(gateAwarenessSection).toContain('pendingGateFiles') + expect(gateAwarenessSection).toContain('full related set') + expect(gateAwarenessSection).toContain( + 'authoritative over conversational memory', + ) }) test('securityReviewSection contains the required security-review topics (not byte-frozen)', () => { @@ -171,6 +207,7 @@ describe('shared craftsmanship prompt sections', () => { expect(base2.systemPrompt).toContain(gateAwarenessSection) expect(base2.systemPrompt).toContain(gitDisciplineSection) expect(base2.systemPrompt).toContain(securityReviewSection) + expect(base2.systemPrompt).toContain(specialistRoutingSection) expect(baseDeep.systemPrompt).toContain(qualitySection) expect(baseDeep.systemPrompt).toContain(PLACEHOLDER.FRONTEND_SECTION) @@ -179,6 +216,7 @@ describe('shared craftsmanship prompt sections', () => { expect(baseDeep.systemPrompt).toContain(gateAwarenessSection) expect(baseDeep.systemPrompt).toContain(gitDisciplineSection) expect(baseDeep.systemPrompt).toContain(securityReviewSection) + expect(baseDeep.systemPrompt).toContain(specialistRoutingSection) // gitDisciplineSection is intentionally NOT interpolated into the editor — // the editor is for code editing, not git work; the git-committer agent diff --git a/agents/__tests__/specialists.test.ts b/agents/__tests__/specialists.test.ts index bde290ee1f..3c111bad3d 100644 --- a/agents/__tests__/specialists.test.ts +++ b/agents/__tests__/specialists.test.ts @@ -126,9 +126,18 @@ describe('specialist agents', () => { expect(compatibilityReviewer.spawnerPrompt).toContain( 'Requires params.snapshot_id', ) + expect(compatibilityReviewer.spawnerPrompt).toContain( + 'assigned gate snapshot fingerprint for this spawn', + ) expect( (compatibilityReviewer.inputSchema as any).params.properties.snapshot_id .description, - ).toContain('get_change_review_bundle') + ).toContain('assigned gate snapshot fingerprint for this spawn') + expect(dependencyReviewer.instructionsPrompt).toContain( + 'authoritative assigned snapshot', + ) + expect(dependencyReviewer.instructionsPrompt).toContain( + 'do not emit stale-snapshot solely because the live bundle moved', + ) }) }) diff --git a/agents/base2/base-deep.ts b/agents/base2/base-deep.ts index a2c84e7771..ea71fe9504 100644 --- a/agents/base2/base-deep.ts +++ b/agents/base2/base-deep.ts @@ -1,5 +1,3 @@ -import { buildArray } from '@codebuff/common/util/array' - import { PLACEHOLDER, type SecretAgentDefinition, @@ -31,7 +29,7 @@ function buildDeepSystemPrompt( noAskUser ? '' : ` -- **Ask the user about important decisions or guidance using the ask_user tool:** You should feel free to stop and ask the user for guidance if there's a an important decision to make or you need an important clarification or you're stuck and don't know what to try next. Use the ask_user tool to collaborate with the user to acheive the best possible result! Prefer to gather context first before asking questions in case you end up answering your own question.` +- **Ask the user about important decisions or guidance using the ask_user tool:** You should feel free to stop and ask the user for guidance if there's an important decision to make or you need an important clarification or you're stuck and don't know what to try next. Use the ask_user tool to collaborate with the user to achieve the best possible result! Prefer to gather context first before asking questions in case you end up answering your own question.` } - **Be careful about terminal commands:** Be careful about instructing subagents to run terminal commands that could be destructive or have effects that are hard to undo (e.g. git push, git commit, running any scripts -- especially ones that could alter production environments (!), installing packages globally, etc). Don't run any of these effectful commands unless the user explicitly asks you to. - **Validation is dependency-neutral:** A test, typecheck, lint, or build request authorizes only that validation command. Never prepend or append install/add/remove/update/sync/restore commands. If validation cannot start because dependencies are missing, report that exact blocker; use dependency-manager only after separate explicit user authorization. @@ -50,10 +48,10 @@ Use the spawn_agents tool to spawn specialized agents to help you complete the u - **Thinker delegation:** Spawn thinker only after enough context exists for complex architecture, design tradeoff, risk, debugging strategy, spec/plan critique, or repeated-failure reasoning. Do not use thinker as a substitute for reading files or for straightforward edits. - **Release/deployment flow:** Treat releases, deployments, publishing, migrations against shared environments, production-affecting scripts, git commits, and git pushes as high-impact actions. Do not run or ask subagents to run them unless the user explicitly requested that action in this task or confirms after you explain the exact command, target environment, and rollback/verification plan. When requested, follow the deterministic sequence: inspect worktree, fetch remote state/tags, decide rebase/merge with the user when non-fast-forward or conflicts appear, push, wait for CI/CD, trigger the release, verify artifact/tag/package publication, then sync and report local branch state. - **Plan artifact maintenance:** In PLAN mode create and maintain durable artifacts; in EXECUTE_PLAN keep STATUS.md and LESSONS.md current at phase boundaries, blocker discovery/resolution, validation/review results, and finalization. Use update_plan_status for incremental STATUS/LESSONS updates and create_plan for SPEC/PLAN rewrites or missing artifacts. Do not update plan artifacts for ordinary implementation mode unless the user requested plan/session work. -- **Tool choice:** Prefer dedicated tools over shell fallbacks: repository status and configured file-change hooks are runtime-owned and injected automatically; use read_files/read_outline/read_subtree/glob/list_directory/query_index for inspection, read_image for screenshots/images and rendered/exported visual artifacts (3D render frames, image/video exports, generated diagrams, and charts), edit_transaction with the narrowest edit type for project mutations, browser_use/codebuff_local_cli for visual smoke tests, and basher only for commands without a dedicated tool. +- **Tool choice:** Prefer dedicated tools over shell fallbacks: repository status and configured file-change hooks are runtime-owned and injected automatically; use read_files/read_outline/read_subtree/glob/list_directory/query_index for inspection, read_image for screenshots/images and rendered/exported visual artifacts (3D render frames, image/video exports, generated diagrams, and charts), edit_transaction with the narrowest edit type for project mutations, browser_use/codebuff_local_cli for visual smoke tests, and basher only for commands without a dedicated tool. \`run_targeted_validation\` is scoped evidence only — it never unlocks the gate/commit path; hooks + automated reviewer remain runtime-owned. - **Sequence agents properly:** Keep in mind dependencies when spawning different agents. Don't spawn agents in parallel that depend on each other. - **Parallel join discipline:** When spawning agents in parallel, wait for every required result before moving to the next dependent phase. A timeout, failed validation, or \`BLOCKING:\` reviewer/security finding blocks completion until repaired or explicitly scoped out. -- **Validation selection:** Validate every non-trivial or risky edit with the narrowest relevant typecheck/test/lint/build command or configured file-change hooks. Map changed paths to suites deterministically when possible: agents/base2/* -> agents typecheck plus prompt/gate tests or e2e subset when behavior changes; agents/* -> agents typecheck and relevant agent tests; packages/sdk/* -> SDK typecheck/tests; packages/agent-runtime/* -> runtime typecheck/tests; common/* -> common checks plus dependent package typechecks; cli/src/components/* or cli/src/hooks/* -> CLI typecheck plus CLI visual smoke; docs/prompt-only changes -> configured hooks or explicit skip reason. Skip validation only for docs/prompt-only changes, tiny low-risk edits, explicit no-validation modes, or when the user forbids it; state the skip reason. Validation failures/timeouts are blocking and must be repaired or explicitly scoped out. +- **Validation selection:** Validate every non-trivial or risky edit with the narrowest relevant typecheck/test/lint/build command or configured file-change hooks. Map changed paths to suites deterministically when possible: agents/base2/* -> agents typecheck plus prompt/gate tests or e2e subset when behavior changes; agents/* -> agents typecheck and relevant agent tests; packages/sdk/* -> SDK typecheck/tests; packages/agent-runtime/* -> runtime typecheck/tests; common/* -> common checks plus dependent package typechecks; cli/src/components/* or cli/src/hooks/* -> CLI typecheck plus CLI visual smoke; docs/prompt-only changes -> configured hooks or explicit skip reason. Skip validation only for docs/prompt-only changes, tiny low-risk edits, explicit no-validation modes, or when the user forbids it; state the skip reason. Validation failures/timeouts are blocking and must be repaired or explicitly scoped out. Green basher typechecks or \`run_targeted_validation\` are optional evidence only — never a substitute for the runtime hooks+reviewer gate. - **Reviewer selection:** Use the automated reviewer gate for edited code in default mode. Spawn code-reviewer manually only for user-requested extra review, advisory/pre-edit review, significant diffs outside the automated gate, or changed code whose risk warrants another perspective; spawn security-reviewer for auth, crypto, secrets, permissions, injection, sandboxing, path/process/network handling, supply-chain, or production-risk changes; spawn test-writer when behavior changes lack coverage; spawn debugger after repeated validation failure, runtime failure, or unclear crash behavior. Do not duplicate the same post-edit review manually. - **Validation/reviewer coordination:** It is fine to run validation bashers and reviewers in parallel only when the reviewer is asked for static code review that explicitly does not depend on validation output. Always wait for both. Treat the final decision as a join of both results: validation failure/timeout blocks completion even if review looks good, and reviewer \`BLOCKING:\` blocks completion even if validation passes. When the review needs validation results, run validation first and include the completed validation summary in the reviewer prompt. - For broad codebase questions or tasks where relevant files are not already obvious, call query_index early yourself to get indexed file candidates, then verify the best candidates, matchedSnippets, and relatedFiles with read_files/read_subtree and/or spawn file-picker/code-searcher agents as needed. Use graph modes when useful: search for ranked discovery, explain for ranking rationale, neighbors to expand around a known file, path to connect two known files, and commands to find package scripts, CI workflows, task runners, and validation docs. Do not rely on query_index alone for correctness. @@ -63,7 +61,7 @@ Use the spawn_agents tool to spawn specialized agents to help you complete the u - Spawn bashers for validation/test coverage after edits when validation is appropriate; if validation fails, repair the exact failure before broadening scope. - Spawn the debugger after repeated validation failures, runtime failures, or unclear crash behavior where focused diagnosis is needed. - Spawn code-reviewer/security-reviewer after meaningful edits when user scope or risk calls for review. Spawn doc-writer/test-writer when documentation or test coverage is required or directly implied by acceptance criteria. - - Spawn bashers sequentially if the second command depends on the the first. + - Spawn bashers sequentially if the second command depends on the first. - **No need to include context:** When prompting an agent, realize that many agents can already see the entire conversation history, so you can be brief in prompting them without needing to include context. - **Never spawn the context-pruner agent:** This agent is spawned automatically for you and you don't need to spawn it yourself. @@ -253,7 +251,15 @@ Thoroughly validate the changes: - For a CLI tool: run it with relevant arguments - For a library: write and run a small integration script - For config/infra changes: validate the configuration is correct -4. If E2E verification reveals issues, fix them and re-validate.${ +4. If E2E verification reveals issues, fix them and re-validate. + +## Phase 6 — Final Review + +The automated runtime gate handles the final validation and code review after all implementation and validation-driven edits are complete. Do not manually duplicate its post-edit review for the same file set. + +1. **Let the automated gate run last:** The runtime detects the final changed-file set, reruns configured validation hooks, and then spawns code-reviewer before finalization. +2. **If the reviewer returns BLOCKING:** Treat that finding as the controlling next action. Fix it, rerun the relevant Phase 5 validation, then let the final gate re-run. +3. **Optional advisory review:** Before the final gate, you MAY request a focused security/design/architecture review when a specific concern warrants it. Advisory approval never replaces the final gate.${ noLearning ? '' : ` @@ -288,23 +294,15 @@ Capture learnings for future sessions: b. If the thinker suggests valid improvements or new skill ideas, update the relevant files accordingly. c. After updating, you MUST spawn thinker again to re-critique and brainstorm further. d. Repeat until the thinker finds no new substantive improvements or skill ideas. Do NOT skip the re-critique — every revision must be verified.` - }${ + } + +Make sure to narrate to the user what you are doing and why you are doing it as you go along. Give a very short summary of what you accomplished at the end of your turn before suggesting followups.${ noAskUser ? '' : ` -${noLearning ? '1' : '4'}. After writing a user-visible completion summary, use suggest_followups to suggest ~3 next steps the user might want to take.` +After writing a user-visible completion summary, use suggest_followups to suggest ~3 next steps the user might want to take.` } -## Phase 6 — Final Review - -The automated runtime gate handles the final validation and code review after all implementation and validation-driven edits are complete. Do not manually duplicate its post-edit review for the same file set. - -1. **Let the automated gate run last:** The runtime detects the final changed-file set, reruns configured validation hooks, and then spawns code-reviewer before finalization. -2. **If the reviewer returns BLOCKING:** Treat that finding as the controlling next action. Fix it, rerun the relevant Phase 5 validation, then let the final gate re-run. -3. **Optional advisory review:** Before the final gate, you MAY request a focused security/design/architecture review when a specific concern warrants it. Advisory approval never replaces the final gate. - -Make sure to narrate to the user what you are doing and why you are doing it as you go along. Give a very short summary of what you accomplished at the end of your turn before suggesting followups. - ## Followup Requests If the full ${totalPhases}-phase workflow has already been completed in this conversation and the user is asking for a followup change (e.g. "also add X" or "tweak Y"), you do NOT need to repeat the entire workflow. Use your judgement to run only the phases that are relevant — for example, directly make the requested changes (Phase 4), validate them (Phase 5), and let the final review gate run (Phase 6). Skip the spec and plan phases if the request is a straightforward extension of the work already done.${noLearning ? '' : ' Still update LESSONS.md and skills if you learn anything new.'} diff --git a/agents/base2/base2.ts b/agents/base2/base2.ts index 07c83a236b..59f07cc472 100644 --- a/agents/base2/base2.ts +++ b/agents/base2/base2.ts @@ -208,7 +208,7 @@ ${ ? '- **Live visual analysis:** Use browser-use only for read-only inspection of an already available URL. Do not start dev servers or request browser interactions in plan mode.' : '- **Live visual verification:** Visual verification extends beyond web apps. Image artifacts from 3D renders (e.g. Blender frames), image/video exports, generated diagrams, and charts must be inspected with read_image, not inferred from text logs alone. The workflow is: render/export -> poll the background job to completion -> read_image the emitted artifacts -> assess the result -> make a targeted edit -> re-render. Polling (check_job/check_background_agent/read_logs) is only the bridge to artifact inspection; do not re-poll a finished or unchanging job indefinitely. After 2-3 unmatched polls that produce no new actionable artifact or progress, proceed with independent work, cancel/retry with a targeted edit, or ask the user. For web app visual checks specifically, start any long-running dev server through a BACKGROUND basher, keep its returned jobId, use check_job to wait for readiness, then spawn browser-use for screenshots/navigation/interaction.' } -- **Prefer dedicated harness tools over shell fallbacks:** Repository status is injected automatically by the runtime; do not spawn basher merely to run git status. Use read_files/read_outline/read_subtree/glob/list_directory/query_index for file and codebase inspection instead of shelling out to cat/ls/find/grep. Use basher for commands that do not have a dedicated tool, such as tests, builds, package scripts, and one-off project CLIs. Never embed a multi-KB file body or heredoc (\`<<'EOF' ... EOF\`) inside \`basher.params.command\`; the transport truncates large payloads and the JSON normalizer intentionally fails closed on truncated input. Author files with \`write_file\`/\`edit_transaction\` and run them via a short basher command instead. For ripgrep-style content search, spawn the code-searcher agent (and file-picker for fuzzy file discovery): \`code_search\`/\`find_files_matching_content\` are registered runtime tools but are intentionally not granted to you as root, so calling them directly is rejected. When you spawn an agent, pass its required params or the spawn fails: code-searcher needs \`params.searchQueries\` (an array of { pattern } objects) and basher needs \`params.command\` (a shell string); put these in \`params\`, not only in the prose prompt. +- **Prefer dedicated harness tools over shell fallbacks:** Repository status is injected automatically by the runtime; do not spawn basher merely to run git status. Use read_files/read_outline/read_subtree/glob/list_directory/query_index for file and codebase inspection instead of shelling out to cat/ls/find/grep. Use basher for commands that do not have a dedicated tool, such as tests, builds, package scripts, and one-off project CLIs. Never embed a multi-KB file body or heredoc (\`<<'EOF' ... EOF\`) inside \`basher.params.command\`; the transport truncates large payloads and the JSON normalizer intentionally fails closed on truncated input. Author files with \`write_file\`/\`edit_transaction\` and run them via a short basher command instead. For ripgrep-style content search, spawn the code-searcher agent (and file-picker for fuzzy file discovery): \`code_search\`/\`find_files_matching_content\` are registered runtime tools but are intentionally not granted to you as root, so calling them directly is rejected. When you spawn an agent, pass its required params or the spawn fails: code-searcher needs \`params.searchQueries\` (an array of { pattern } objects) and basher needs \`params.command\` (a shell string); put these in \`params\`, not only in the prose prompt. Correct spawn_agents shape: { "agents": [{ "agent_type": "code-searcher", "prompt": "...", "params": { "searchQueries": [{ "pattern": "..." }] } }] } — prompt and params go INSIDE each agent entry, never as siblings of agents, and agents is a real array (never a JSON string). # Code Editing Mandates @@ -267,11 +267,11 @@ Use the spawn_agents tool to spawn specialized agents to help you complete the u - **Thinker delegation:** Spawn thinker only after enough context exists for complex architecture, design tradeoff, risk, debugging strategy, spec/plan critique, or repeated-failure reasoning. Do not use thinker as a substitute for reading files or for straightforward edits. - **Release/deployment flow:** Treat releases, deployments, publishing, migrations against shared environments, production-affecting scripts, git commits, and git pushes as high-impact actions. Do not run or ask subagents to run them unless the user explicitly requested that action in this task or confirms after you explain the exact command, target environment, and rollback/verification plan. When requested, follow the deterministic sequence: inspect worktree, fetch remote state/tags, decide rebase/merge with the user when non-fast-forward or conflicts appear, push, wait for CI/CD, trigger the release, verify artifact/tag/package publication, then sync and report local branch state. - **Plan artifact maintenance:** In PLAN mode create and maintain durable artifacts; in EXECUTE_PLAN keep STATUS.md and LESSONS.md current at phase boundaries, blocker discovery/resolution, validation/review results, and finalization. Use update_plan_status for incremental STATUS/LESSONS updates and create_plan for SPEC/PLAN rewrites or missing artifacts. Do not update plan artifacts for ordinary implementation mode unless the user requested plan/session work. -- **Tool choice:** Prefer dedicated tools over shell fallbacks: repository status and configured file-change hooks are runtime-owned and injected automatically; use read_files/read_outline/read_subtree/glob/list_directory/query_index for source inspection, inspect_3d_asset/render_3d_preview for 3D assets, read_image for other screenshots/images, edit_3d_asset for guarded Blender changes, edit_transaction for text project mutations, browser_use/codebuff_local_cli for visual smoke tests, and basher only for commands without a dedicated tool. +- **Tool choice:** Prefer dedicated tools over shell fallbacks: repository status and configured file-change hooks are runtime-owned and injected automatically; use read_files/read_outline/read_subtree/glob/list_directory/query_index for source inspection, inspect_3d_asset/render_3d_preview for 3D assets, read_image for other screenshots/images, edit_3d_asset for guarded Blender changes, edit_transaction for text project mutations, browser_use/codebuff_local_cli for visual smoke tests, and basher only for commands without a dedicated tool. \`run_targeted_validation\` is scoped evidence only — it never unlocks the gate/commit path; hooks + automated reviewer remain runtime-owned. - **Sequence agents properly:** Keep in mind dependencies when spawning different agents. Don't spawn agents in parallel that depend on each other. - **Subagent deadlines:** Omit top-level \`timeout_seconds\` for editor and other productive subagents; omitted and \`-1\` mean no wall-clock deadline. Set a positive deadline only when the user explicitly requests one or the child is intentionally bounded diagnostic work. - **Parallel join discipline:** When spawning agents in parallel, wait for every required result before moving to the next dependent phase. A timeout, failed validation, or \`BLOCKING:\` reviewer/security finding blocks completion until repaired or explicitly scoped out. -- **Validation selection:** Validate every non-trivial or risky edit with the narrowest relevant typecheck/test/lint/build command or configured file-change hooks. Map changed paths to suites deterministically when possible: agents/base2/* -> agents typecheck plus prompt/gate tests or e2e subset when behavior changes; agents/* -> agents typecheck and relevant agent tests; packages/sdk/* -> SDK typecheck/tests; packages/agent-runtime/* -> runtime typecheck/tests; common/* -> common checks plus dependent package typechecks; cli/src/components/* or cli/src/hooks/* -> CLI typecheck plus CLI visual smoke; docs/prompt-only changes -> configured hooks or explicit skip reason. Skip validation only for docs/prompt-only changes, tiny low-risk edits, explicit no-validation modes, or when the user forbids it; state the skip reason. Validation failures/timeouts are blocking and must be repaired or explicitly scoped out. +- **Validation selection:** Validate every non-trivial or risky edit with the narrowest relevant typecheck/test/lint/build command or configured file-change hooks. Map changed paths to suites deterministically when possible: agents/base2/* -> agents typecheck plus prompt/gate tests or e2e subset when behavior changes; agents/* -> agents typecheck and relevant agent tests; packages/sdk/* -> SDK typecheck/tests; packages/agent-runtime/* -> runtime typecheck/tests; common/* -> common checks plus dependent package typechecks; cli/src/components/* or cli/src/hooks/* -> CLI typecheck plus CLI visual smoke; docs/prompt-only changes -> configured hooks or explicit skip reason. Skip validation only for docs/prompt-only changes, tiny low-risk edits, explicit no-validation modes, or when the user forbids it; state the skip reason. Validation failures/timeouts are blocking and must be repaired or explicitly scoped out. Green basher typechecks or \`run_targeted_validation\` are optional evidence only — never a substitute for the runtime hooks+reviewer gate. - **Reviewer selection:** Use the automated reviewer gate for edited code in default mode. Spawn code-reviewer manually only for user-requested extra review, advisory/pre-edit review, significant diffs outside the automated gate, or changed code whose risk warrants another perspective; spawn security-reviewer for auth, crypto, secrets, permissions, injection, sandboxing, path/process/network handling, supply-chain, or production-risk changes; spawn test-writer when behavior changes lack coverage; spawn debugger after repeated validation failure, runtime failure, or unclear crash behavior. Do not duplicate the same post-edit review manually. - **Validation/reviewer coordination:** It is fine to run validation bashers and reviewers in parallel only when the reviewer is asked for static code review that explicitly does not depend on validation output. Always wait for both. Treat the final decision as a join of both results: validation failure/timeout blocks completion even if review looks good, and reviewer \`BLOCKING:\` blocks completion even if validation passes. When the review needs validation results, run validation first and include the completed validation summary in the reviewer prompt. ${buildArray( @@ -446,10 +446,12 @@ ${specialistRoutingSection} const mutableAgentState = (agentState ?? {}) as Base2AgentState const agentId = mutableAgentState.agentId const configuredHasNoValidation = config?.hasNoValidation + const configuredPlanOnly = config?.planOnly === true const runValidationGate = - typeof configuredHasNoValidation === 'boolean' + !configuredPlanOnly && + (typeof configuredHasNoValidation === 'boolean' ? !configuredHasNoValidation - : agentId !== 'base2-fast' && agentId !== 'base2-fast-no-validation' + : agentId !== 'base2-fast' && agentId !== 'base2-fast-no-validation') // M3 (R1a–R1d) automated phase-gate predicates. These mirror the // advisory glob list in securityReviewSection (quality-prompt-section.ts) // so the automated gate and the advisory prompt agree on what is @@ -479,13 +481,6 @@ ${specialistRoutingSection} const reviewerAgentType = 'code-reviewer' const MAX_REPAIR_ROUNDS = 3 const MAX_REVIEWER_NO_VERDICT_RETRIES = 1 - // static-review-only concurrency (M3.1): when the reviewer is configured - // for static-only review, it can run concurrently with the blocking - // validation hooks. Defaults to false so the existing sequential - // validation-then-reviewer behavior is preserved. - const staticReviewOnlyEnabled = !!( - mutableAgentState.base2ActiveWork?.staticReviewOnly ?? false - ) const existingActiveWorkState = mutableAgentState.base2ActiveWork const hadPendingGateFiles = !!existingActiveWorkState && @@ -2125,95 +2120,22 @@ ${specialistRoutingSection} break } - // Verification gate: after the model thinks it's done, run configured - // file-change hooks (typecheck/lint/test). If any failed, surface the - // failures and keep the turn open so the model fixes them. The runtime's - // max step limit bounds pathological retry loops; the gate itself must - // not silently skip validation after repeated failures. - // - // static-review-only concurrency (M3.1): when the reviewer is - // static-only (base2ActiveWork.staticReviewOnly), spawn it in the - // background BEFORE the blocking validation hooks so static review - // runs concurrently. The join contract is preserved: a validation - // failure still `continue`s below and ignores this background job; we - // only `check_background_agent` for its result if validation passes. + // Validation runs before the final reviewer because hooks may mutate + // generated or formatted output. The exact source and test snapshot is + // re-captured at the reviewer spawn boundary below. const requiredReviewerAgentType = activeWorkState.requiredReviewerRevalidation ?? reviewerAgentType - const staticReviewConcurrency = - runReviewerGate && - editsHappened && - staticReviewOnlyEnabled && - requiredReviewerAgentType === reviewerAgentType - // Fix 1/2/3 (reviewer-gate scoping): the final code-reviewer must only - // ever be asked to attest to reviewable *source* files, never - // bookkeeping/docs/plan artifacts (e.g. .agents/sessions/** - // STATE.json/EVENTS.jsonl/STATUS.md/LESSONS.md). Drive the reviewer's - // snapshot details and pending-file attestation off the reviewable - // subset so the fingerprint the reviewer echoes matches what - // attestation checks. Validation-hook behavior is unchanged; only the - // reviewer spawn/attestation scoping uses this subset. - const reviewablePendingFiles = selectReviewableGateFiles( + let reviewablePendingFiles = selectReviewableGateFiles( Array.from(pendingGateFiles), ) - const reviewSnapshotDetails = buildGateSnapshotDetails( + let reviewSnapshotDetails = buildGateSnapshotDetails( reviewablePendingFiles, '', ) - const reviewSnapshotFingerprint = hashGateSnapshotDetails( + let reviewSnapshotFingerprint = hashGateSnapshotDetails( reviewSnapshotDetails, ) - // Fingerprint of the reviewable subset for the R5 skip-re-review - // short-circuit. Equal to reviewSnapshotFingerprint today, but kept - // as its own binding so the intent (reviewable-set identity) is clear - // and stable if the review snapshot inputs ever change. - const reviewableFingerprint = hashGateSnapshotDetails( - buildGateSnapshotDetails(reviewablePendingFiles, ''), - ) - // Co-changed test files are excluded from the reviewable fingerprint - // set (isReviewableGateFile drops tests), which historically made it - // impossible for the reviewer to confirm coverage — it could never see - // the changed test file, so it always reported coverage missing/ - // uncertain and the gate looped. Surface them as readable, additive - // "coverage evidence" context. This does NOT enter reviewSnapshot - // Fingerprint or attestation (those stay scoped to reviewable source). - const coverageEvidenceFiles = selectCoverageEvidenceFiles( - Array.from(pendingGateFiles), - ) - const coverageEvidenceDetails = coverageEvidenceFiles.length - ? buildGateSnapshotDetails(coverageEvidenceFiles, '') - : '(no co-changed test files)' - if (staticReviewConcurrency && !activeWorkState.staticReviewerJobId) { - const bgReview = yield { - toolName: 'spawn_agents', - input: { - agents: [ - { - agent_type: requiredReviewerAgentType, - background: true, - prompt: [ - 'Review the completed default-flow code changes before finalization.', - '', - `Pending changed files: ${reviewablePendingFiles.join(', ') || '(unknown)'}`, - `Snapshot fingerprint (echo exactly): ${reviewSnapshotFingerprint}`, - 'Snapshot details (read for file membership; do not echo):', - reviewSnapshotDetails, - 'Coverage evidence (co-changed tests; readable and citable, not part of the reviewed fingerprint):', - coverageEvidenceDetails, - 'Validation gate summary: Reviewer running concurrently with validation (static-review-only mode).', - '', - 'Return the required structured review object. Echo snapshotFingerprint exactly, list every reviewed file, evaluate all review dimensions, and map every user requirement to evidence. Co-changed test files are listed under "Coverage evidence" below; they are readable and citable even though they are not part of the reviewed fingerprint. Test-coverage requirements are satisfied (not uncertain) when a Coverage evidence test file covers the changed behavior — cite that test file and the covering test name(s) as the requirement evidence. Use coverage: missing only when no covering test exists in the Coverage evidence list or anywhere in the repo, and requirement status uncertain only when you could not inspect a listed Coverage evidence test file at all.', - ].join('\n'), - }, - ], - }, - } as any - const bgJobId = extractBackgroundAgentJobId( - (bgReview as any) && (bgReview as any).toolResult, - ) - if (bgJobId) { - activeWorkState.staticReviewerJobId = bgJobId - } - } + let reviewableFingerprint = reviewSnapshotFingerprint let validationSummary = 'No file changes were detected, so no validation hooks ran.' const validationInfrastructureBypassed = @@ -2777,6 +2699,20 @@ ${specialistRoutingSection} activeWorkState.currentPhase = 'awaiting_review' } + // Validation has completed. Re-capture the exact source+test snapshot + // immediately before any final reviewer spawn or skip decision. + reviewablePendingFiles = selectReviewableGateFiles( + Array.from(pendingGateFiles), + ) + reviewSnapshotDetails = buildGateSnapshotDetails( + reviewablePendingFiles, + '', + ) + reviewSnapshotFingerprint = hashGateSnapshotDetails( + reviewSnapshotDetails, + ) + reviewableFingerprint = reviewSnapshotFingerprint + let reviewerFinalizationVerdict: 'LOOKS_GOOD' | 'NON_BLOCKING' | '' = reviewerProtocolBypassAuthorized ? 'NON_BLOCKING' : '' if (reviewerProtocolBypassAuthorized) { @@ -2882,52 +2818,29 @@ ${specialistRoutingSection} ) { activeWorkState.lastReviewerGateSkipReason = '' markActiveWorkStateChanged() - let reviewerToolResult: unknown - if (staticReviewConcurrency && activeWorkState.staticReviewerJobId) { - const checkResult = yield { - toolName: 'check_background_agent', - input: { - jobId: activeWorkState.staticReviewerJobId, - timeout_seconds: 120, - }, - } as any - // check_background_agent returns { result } where `result` is the - // subagent's final output (same shape a foreground spawn_agents - // toolResult wraps). Wait for the background reviewer to settle - // rather than matching only one verdict token: LOOKS_GOOD and - // NON_BLOCKING both pass, while BLOCKING must be parsed below as - // actionable feedback. On error/timeout with no result, fall - // through to the existing 'did not return LOOKS_GOOD/NON_BLOCKING' - // blocked handling below. - reviewerToolResult = - (checkResult as any)?.toolResult?.result ?? - (checkResult as any)?.toolResult - } else { - const review = yield { - toolName: 'spawn_agents', - input: { - agents: [ - { - agent_type: requiredReviewerAgentType, - prompt: [ - `Review the completed ${requiredReviewerAgentType} changes before finalization.`, - '', - `Pending changed files: ${reviewablePendingFiles.join(', ') || '(unknown)'}`, - `Snapshot fingerprint (echo exactly): ${reviewSnapshotFingerprint}`, - 'Snapshot details (read for file membership; do not echo):', - reviewSnapshotDetails, - 'Coverage evidence (co-changed tests; readable and citable, not part of the reviewed fingerprint):', - coverageEvidenceDetails, - `Validation gate summary: ${validationSummary}`, - '', - 'Return the required structured review object. Echo snapshotFingerprint exactly, list every reviewed file, evaluate all review dimensions, and map every user requirement to evidence. Co-changed test files are listed under "Coverage evidence" below; they are readable and citable even though they are not part of the reviewed fingerprint. Test-coverage requirements are satisfied (not uncertain) when a Coverage evidence test file covers the changed behavior — cite that test file and the covering test name(s) as the requirement evidence. Use coverage: missing only when no covering test exists in the Coverage evidence list or anywhere in the repo, and requirement status uncertain only when you could not inspect a listed Coverage evidence test file at all.', - ].join('\n'), - }, - ], - }, - } as any - reviewerToolResult = (review as any) && (review as any).toolResult - } + const review = yield { + toolName: 'spawn_agents', + input: { + agents: [ + { + agent_type: requiredReviewerAgentType, + prompt: [ + `Review the completed ${requiredReviewerAgentType} changes before finalization.`, + '', + `Pending changed files: ${reviewablePendingFiles.join(', ') || '(unknown)'}`, + `Snapshot fingerprint (echo exactly): ${reviewSnapshotFingerprint}`, + 'Snapshot details (read for file membership; do not echo):', + reviewSnapshotDetails, + `Validation gate summary: ${validationSummary}`, + '', + 'Return the required structured review object. Echo snapshotFingerprint exactly, list every pending changed file in reviewedFiles (including tests), evaluate all review dimensions, and map every user requirement to evidence. Changed tests are first-class review targets and may also be cited as coverage evidence. Use coverage: missing only when no covering test exists in the changed files or elsewhere in the repo.', + ].join('\n'), + }, + ], + }, + } as any + let reviewerToolResult: unknown = + (review as any) && (review as any).toolResult const reviewerCrashedBeforeAttestation = detectReviewerCrash(reviewerToolResult) let attestationIssues = reviewerCrashedBeforeAttestation @@ -2961,8 +2874,6 @@ ${specialistRoutingSection} `Snapshot fingerprint (echo exactly): ${reviewSnapshotFingerprint}`, 'Snapshot details (read for file membership; do not echo):', reviewSnapshotDetails, - 'Coverage evidence (co-changed tests; readable and citable, not part of the reviewed fingerprint):', - coverageEvidenceDetails, `Validation gate summary: ${validationSummary}`, '', 'Protocol errors from the prior response:', @@ -3017,6 +2928,18 @@ ${specialistRoutingSection} activeWorkState.reviewerProtocolRetryCount = 0 const blockers = collectReviewerBlockers(reviewerToolResult) if (blockers.length > 0) { + // Coverage-style findings (a missing/uncertain test-coverage gap) + // are not code-diagnostic repairs: repair-editor cannot author the + // missing assertions and would return an empty receipt, parking the + // gate in blocked. Route an ALL-coverage set exclusively to + // test-writer, which can author the tests. Mixed or code-only sets + // keep the repair-editor path and converge across iterations. + const allCoverageFindings = blockers.every( + isTestCoverageReviewerFinding, + ) + const repairAgentLabel = allCoverageFindings + ? 'Test-writer' + : 'Repair-editor' activeWorkState.reviewerRepairRoundCount = Number( activeWorkState.reviewerRepairRoundCount ?? 0, ) + 1 @@ -3043,7 +2966,7 @@ ${specialistRoutingSection} input: { role: 'user', content: [ - `Reviewer gate: ${reviewerAgentType} returned blocking feedback. The harness will send these exact findings to repair-editor:`, + `Reviewer gate: ${reviewerAgentType} returned blocking feedback. The harness will send these exact findings to ${allCoverageFindings ? 'test-writer' : 'repair-editor'}:`, '', ...blockers, '', @@ -3058,97 +2981,187 @@ ${specialistRoutingSection} activeWorkState.repairSessionId = reviewerRepairSessionId activeWorkState.requiredReviewerRevalidation = requiredReviewerAgentType activeWorkState.currentPhase = 'repair_loop' - activeWorkState.nextRequiredAction = - 'Repair-editor must address every open reviewer finding, then targeted validation and a fresh reviewer pass must run.' + activeWorkState.nextRequiredAction = allCoverageFindings + ? 'Test-writer must add coverage for every open reviewer finding, then targeted validation and a fresh reviewer pass must run.' + : 'Repair-editor must address every open reviewer finding, then targeted validation and a fresh reviewer pass must run.' const reviewerRepairResult = yield { toolName: 'spawn_agents', input: { agents: [ - { - agent_type: 'repair-editor', - handoff: { - schemaVersion: 1, - taskId: reviewerRepairSessionId, - role: 'repair-editor', - objective: - 'Resolve every open reviewer finding without unrelated changes. Read the current file contents before editing; conversational summaries are not source evidence.', - requirements: activeWorkState.openReviewerFindings.map( - ({ id, text }) => ({ id, text, required: true }), - ), - acceptanceCriteria: - activeWorkState.openReviewerFindings.map(({ id }) => ({ - id: `clear-${id}`, - behavior: `Finding ${id} is addressed in the live workspace.`, - verification: - 'Targeted validation passes and a fresh snapshot-bound reviewer clears the finding.', - })), - context: [], - invariants: [ - 'Read every target from the live filesystem before editing.', - 'Treat every finding ID as open until a fresh reviewer clears it.', - ], - nonGoals: [ - 'Unrelated diagnostics, refactors, or cleanup.', - ], - risks: [ - 'Reviewer findings may be stale if the workspace snapshot changed.', - ], - unknowns: [], - findings: activeWorkState.openReviewerFindings.map( - ({ id, text, files, snapshotFingerprint }) => ({ - id, - text, - files, - snapshotFingerprint, - }), - ), - permissions: { - readablePaths: repairEditorReadablePaths([ - ...pendingGateFiles, - ...activeWorkState.openReviewerFindings.flatMap( - (finding: { files?: string[] }) => - finding.files ?? [], + allCoverageFindings + ? { + agent_type: 'test-writer', + handoff: { + schemaVersion: 1, + taskId: reviewerRepairSessionId, + role: 'test-writer', + objective: + 'Author or extend tests that assert the changed behavior so the reviewer test-coverage dimension is satisfied. For each finding, add or extend a case in the relevant *.test.* file exercising the behavior-changing logic in the gate changed files. Do not modify production source unless strictly required to make a test observable.', + requirements: + activeWorkState.openReviewerFindings.map( + ({ id, text }) => ({ id, text, required: true }), + ), + acceptanceCriteria: + activeWorkState.openReviewerFindings.map( + ({ id }) => ({ + id: `clear-${id}`, + behavior: `Finding ${id} is addressed by new or extended test coverage.`, + verification: + 'Targeted validation passes and a fresh snapshot-bound reviewer clears the finding.', + }), + ), + context: [], + invariants: [ + 'Read every target from the live filesystem before editing.', + 'Treat every finding ID as open until a fresh reviewer clears it.', + ], + nonGoals: [ + 'Unrelated diagnostics, refactors, or cleanup.', + ], + risks: [ + 'Reviewer findings may be stale if the workspace snapshot changed.', + ], + unknowns: [], + findings: activeWorkState.openReviewerFindings.map( + ({ id, text, files, snapshotFingerprint }) => ({ + id, + text, + files, + snapshotFingerprint, + }), ), - ]), - writablePaths: Array.from( - new Set([ - ...pendingGateFiles, - ...activeWorkState.openReviewerFindings.flatMap( - (finding: { files?: string[] }) => - finding.files ?? [], + permissions: { + readablePaths: repairEditorReadablePaths([ + ...pendingGateFiles, + ...activeWorkState.openReviewerFindings.flatMap( + (finding: { files?: string[] }) => + finding.files ?? [], + ), + ]), + writablePaths: Array.from( + new Set([ + ...pendingGateFiles, + ...activeWorkState.openReviewerFindings.flatMap( + (finding: { files?: string[] }) => + finding.files ?? [], + ), + ]), ), - ]), - ), - allowedTools: [ - 'read_files', - 'read_outline', - 'edit_transaction', - ], + allowedTools: [ + 'read_files', + 'read_outline', + 'write_file', + 'str_replace', + 'set_output', + ], + }, + workspaceRevision: + mutableAgentState.workspaceState?.revision, + workspaceSnapshotId: + mutableAgentState.workspaceState?.snapshotId, + artifacts: [], + successCriteria: [ + 'Writer receipt reports changed test files covering the findings.', + ], + constraints: [ + 'Keep every edit within the pending gate file set.', + ], + }, + prompt: [ + 'Add or extend the test coverage that satisfies the blocking reviewer findings below.', + 'Treat every finding ID as open until a fresh reviewer clears it.', + 'Read the changed source and the relevant existing test file before editing.', + '', + ...activeWorkState.openReviewerFindings.map( + (finding) => `${finding.id}: ${finding.text}`, + ), + ].join('\n'), + } + : { + agent_type: 'repair-editor', + handoff: { + schemaVersion: 1, + taskId: reviewerRepairSessionId, + role: 'repair-editor', + objective: + 'Resolve every open reviewer finding without unrelated changes. Read the current file contents before editing; conversational summaries are not source evidence.', + requirements: activeWorkState.openReviewerFindings.map( + ({ id, text }) => ({ id, text, required: true }), + ), + acceptanceCriteria: + activeWorkState.openReviewerFindings.map(({ id }) => ({ + id: `clear-${id}`, + behavior: `Finding ${id} is addressed in the live workspace.`, + verification: + 'Targeted validation passes and a fresh snapshot-bound reviewer clears the finding.', + })), + context: [], + invariants: [ + 'Read every target from the live filesystem before editing.', + 'Treat every finding ID as open until a fresh reviewer clears it.', + ], + nonGoals: [ + 'Unrelated diagnostics, refactors, or cleanup.', + ], + risks: [ + 'Reviewer findings may be stale if the workspace snapshot changed.', + ], + unknowns: [], + findings: activeWorkState.openReviewerFindings.map( + ({ id, text, files, snapshotFingerprint }) => ({ + id, + text, + files, + snapshotFingerprint, + }), + ), + permissions: { + readablePaths: repairEditorReadablePaths([ + ...pendingGateFiles, + ...activeWorkState.openReviewerFindings.flatMap( + (finding: { files?: string[] }) => + finding.files ?? [], + ), + ]), + writablePaths: Array.from( + new Set([ + ...pendingGateFiles, + ...activeWorkState.openReviewerFindings.flatMap( + (finding: { files?: string[] }) => + finding.files ?? [], + ), + ]), + ), + allowedTools: [ + 'read_files', + 'read_outline', + 'edit_transaction', + ], + }, + workspaceRevision: + mutableAgentState.workspaceState?.revision, + workspaceSnapshotId: + mutableAgentState.workspaceState?.snapshotId, + artifacts: [], + successCriteria: [ + 'All finding IDs are cleared by a fresh reviewer receipt.', + ], + constraints: [ + 'Keep every edit within the pending gate file set.', + ], + }, + prompt: [ + 'Repair the blocking reviewer findings below.', + 'Treat every finding ID as open until a fresh reviewer clears it.', + 'Do not claim a finding is stale because unrelated tests or another task passed.', + 'Read every target from the live filesystem before editing.', + 'Keep unrelated diagnostics secondary to this finding set.', + '', + ...activeWorkState.openReviewerFindings.map( + (finding) => `${finding.id}: ${finding.text}`, + ), + ].join('\n'), }, - workspaceRevision: - mutableAgentState.workspaceState?.revision, - workspaceSnapshotId: - mutableAgentState.workspaceState?.snapshotId, - artifacts: [], - successCriteria: [ - 'All finding IDs are cleared by a fresh reviewer receipt.', - ], - constraints: [ - 'Keep every edit within the pending gate file set.', - ], - }, - prompt: [ - 'Repair the blocking reviewer findings below.', - 'Treat every finding ID as open until a fresh reviewer clears it.', - 'Do not claim a finding is stale because unrelated tests or another task passed.', - 'Read every target from the live filesystem before editing.', - 'Keep unrelated diagnostics secondary to this finding set.', - '', - ...activeWorkState.openReviewerFindings.map( - (finding) => `${finding.id}: ${finding.text}`, - ), - ].join('\n'), - }, ], }, } as any @@ -3158,8 +3171,8 @@ ${specialistRoutingSection} if (repairCrash) { activeWorkState.currentPhase = 'blocked' activeWorkState.nextRequiredAction = - 'Repair-editor failed while addressing reviewer findings. Inspect the failure before retrying.' - activeWorkState.latestWorkSummary = `Repair-editor failed: ${repairCrash}` + `${repairAgentLabel} failed while addressing reviewer findings. Inspect the failure before retrying.` + activeWorkState.latestWorkSummary = `${repairAgentLabel} failed: ${repairCrash}` markActiveWorkStateChanged() break } @@ -3188,7 +3201,7 @@ ${specialistRoutingSection} ) { activeWorkState.currentPhase = 'blocked' activeWorkState.nextRequiredAction = - 'Repair-editor did not return a completed receipt addressing every open reviewer finding.' + `${repairAgentLabel} did not return a completed receipt addressing every open reviewer finding.` activeWorkState.latestWorkSummary = 'Reviewer repair receipt was incomplete or missing.' markActiveWorkStateChanged() @@ -3214,7 +3227,7 @@ ${specialistRoutingSection} if (repairedSnapshotFingerprint === reviewSnapshotFingerprint) { activeWorkState.currentPhase = 'blocked' activeWorkState.nextRequiredAction = - 'Repair-editor made no snapshot-visible progress on the reviewer findings. Stop retrying and inspect the finding or handoff.' + `${repairAgentLabel} made no snapshot-visible progress on the reviewer findings. Stop retrying and inspect the finding or handoff.` activeWorkState.latestWorkSummary = 'Reviewer repair produced no workspace fingerprint change.' markActiveWorkStateChanged() @@ -3239,7 +3252,7 @@ ${specialistRoutingSection} activeWorkState.currentPhase = 'awaiting_review' activeWorkState.nextRequiredAction = '' activeWorkState.latestWorkSummary = - 'Repair-editor addressed reviewer findings; validation re-ran inline and a fresh reviewer pass is required.' + `${repairAgentLabel} addressed reviewer findings; validation re-ran inline and a fresh reviewer pass is required.` markActiveWorkStateChanged() emitGateTelemetry({ currentPhase: 'awaiting_review', @@ -3255,7 +3268,7 @@ ${specialistRoutingSection} activeWorkState.lastReviewerGateSkipReason = 'validation-hook-failures' activeWorkState.currentPhase = 'blocked' - activeWorkState.latestWorkSummary = `Repair-editor addressed reviewer findings but ${reFailures.length} validation failure(s) remain.` + activeWorkState.latestWorkSummary = `${repairAgentLabel} addressed reviewer findings but ${reFailures.length} validation failure(s) remain.` markActiveWorkStateChanged() emitGateTelemetry({ currentPhase: 'blocked', @@ -3270,7 +3283,7 @@ ${specialistRoutingSection} input: { role: 'user', content: [ - `Repair-editor addressed the reviewer findings but ${reFailures.length} validation failure(s) remain. Fix these before ending your turn:`, + `${repairAgentLabel} addressed the reviewer findings but ${reFailures.length} validation failure(s) remain. Fix these before ending your turn:`, '', ...reFailures, '', @@ -3559,8 +3572,10 @@ ${specialistRoutingSection} continue } if (editsHappened) { - activeWorkState.lastReviewerGateSkipReason = - 'validation-and-reviewer-gates-disabled' + const disabledGateReason = configuredPlanOnly + ? 'plan-only-automatic-finalization-gate-disabled' + : 'validation-and-reviewer-gates-disabled' + activeWorkState.lastReviewerGateSkipReason = disabledGateReason markActiveWorkStateChanged() emitGateTelemetry({ currentPhase: activeWorkState.currentPhase, @@ -3568,19 +3583,21 @@ ${specialistRoutingSection} pendingFiles: Array.from(pendingGateFiles), reviewerStatus: 'skipped', validationStatus: 'skipped', - skipReason: 'validation-and-reviewer-gates-disabled', + skipReason: disabledGateReason, }) yield { toolName: 'add_message', input: { role: 'user', content: [ - 'Validation and reviewer gates are disabled for this agent mode; skipping automated gate checks even though edits were detected.', + configuredPlanOnly + ? 'PLAN-only mode disables the automatic validation/reviewer finalization gate; manual reviewer-family analysis remains available.' + : 'Validation and reviewer gates are disabled for this agent mode; skipping automated gate checks even though edits were detected.', `Pending edited files: ${Array.from(pendingGateFiles).join(', ') || '(unknown files)'}`, formatGateStateBlock( 'validation/reviewer', 'skipped', - `validation-and-reviewer-gates-disabled: skipped automated gate checks for pending files: ${Array.from(pendingGateFiles).join(', ') || '(unknown files)'}`, + `${disabledGateReason}: skipped automated gate checks for pending files: ${Array.from(pendingGateFiles).join(', ') || '(unknown files)'}`, ), ].join('\n'), }, @@ -4197,12 +4214,10 @@ ${specialistRoutingSection} // Kept byte-identical (minus the export keyword) because handleSteps is // serialized via .toString() + new Function(...) and cannot reference // module-scope imports; a parity test asserts behavioral equality. - // Returns true only for reviewable source files; excludes tests, - // generated code, docs, config/data (.jsonl bookkeeping like - // EVENTS.jsonl), .env files, and docs/ / evals/ / .agents/ paths. + // Returns true for reviewable source and test files; excludes generated + // code, docs, config/data (.jsonl bookkeeping like EVENTS.jsonl), .env + // files, and docs/ / evals/ / .agents/ paths. function isReviewableGateFile(filePath: string): boolean { - if (/__tests__\//.test(filePath)) return false - if (/\.(test|spec)\.(?:tsx?|jsx?|mjs|cjs)$/.test(filePath)) return false if (/\.generated\.tsx?$/.test(filePath)) return false if (/\.(md|mdx|json|jsonl|yml|yaml|toml)$/.test(filePath)) return false if (/(^|\/)\.env($|\.)/.test(filePath)) return false @@ -5496,34 +5511,16 @@ ${specialistRoutingSection} return remaining } - function extractBackgroundAgentJobId( - toolResult: unknown, - ): string | undefined { - // The background spawn_agents report is an array of tool-result parts; - // the background entry has value.background === true with a jobId. Also - // tolerate a direct object shape (value or the part itself) so this - // stays robust to small runtime report-shape variations. - const candidates: unknown[] = [] - if (Array.isArray(toolResult)) { - for (const part of toolResult) { - const value = - part && (part as any).type === 'json' ? (part as any).value : part - candidates.push(value) - } - } else if (toolResult && typeof toolResult === 'object') { - candidates.push((toolResult as any).value ?? toolResult) - } - for (const value of candidates) { - if ( - value && - typeof value === 'object' && - (value as any).background === true && - typeof (value as any).jobId === 'string' - ) { - return (value as any).jobId - } - } - return undefined + // Inline mirror of isTestCoverageReviewerFinding in + // agents/base2/gate-reviewer.ts (all-coverage → exclusive test-writer). + // Keep in sync: handleSteps is serialized, so this helper cannot depend + // on imports. + function isTestCoverageReviewerFinding(text: string): boolean { + if (typeof text !== 'string') return false + const t = text.toLowerCase() + if (t.includes('test coverage')) return true + if (t.includes('coverage') && /\.test\.[a-z0-9]+/.test(t)) return true + return false } function collectReviewerBlockers(toolResult: unknown): string[] { diff --git a/agents/base2/gate-paths.ts b/agents/base2/gate-paths.ts index dac53753d9..b5a7476d2b 100644 --- a/agents/base2/gate-paths.ts +++ b/agents/base2/gate-paths.ts @@ -66,16 +66,12 @@ export function gateFileSetsEqual(left: string[], right: string[]): boolean { return left.every((file) => rightFiles.has(file)) } -// Returns true only for reviewable source files. Everything else — tests, -// generated code, docs, config/data files (including .jsonl bookkeeping like -// EVENTS.jsonl), .env files, and anything under docs/, evals/, or .agents/ — -// is excluded so the final code-reviewer gate never fires on -// bookkeeping/docs/plan artifacts. Mirrors the exclusion style of -// isPublicApiSourceFile in base2.ts but ALSO drops `.jsonl` and `.env` -// basenames. Operates on an already-normalized path (caller normalizes). +// Returns true for reviewable source and test files. Generated code, docs, +// config/data files (including .jsonl bookkeeping like EVENTS.jsonl), .env +// files, and anything under docs/, evals/, or .agents/ remain excluded so the +// final code-reviewer gate never attests to bookkeeping/docs/plan artifacts. +// Operates on an already-normalized path (caller normalizes). export function isReviewableGateFile(filePath: string): boolean { - if (/__tests__\//.test(filePath)) return false - if (/\.(test|spec)\.(?:tsx?|jsx?|mjs|cjs)$/.test(filePath)) return false if (/\.generated\.tsx?$/.test(filePath)) return false if (/\.(md|mdx|json|jsonl|yml|yaml|toml)$/.test(filePath)) return false if (/(^|\/)\.env($|\.)/.test(filePath)) return false @@ -101,11 +97,9 @@ export function selectReviewableGateFiles(files: string[]): string[] { return reviewableFiles } -// Co-changed test files (the complement of isReviewableGateFile's test -// exclusion). These are surfaced to the final reviewer as readable -// "coverage evidence" so it can confirm the changed behavior is tested, -// WITHOUT adding tests to the reviewed-for-defects fingerprint set. -// Operates on an already-normalized path (caller normalizes). +// Co-changed test files remain identifiable for coverage-specific prompting, +// but they are also first-class reviewable files and participate in the final +// reviewer fingerprint and reviewedFiles attestation. export function isCoverageEvidenceFile(filePath: string): boolean { if (/__tests__\//.test(filePath)) return true if (/\.(test|spec)\.(?:tsx?|jsx?|mjs|cjs)$/.test(filePath)) return true diff --git a/agents/base2/gate-reviewer.ts b/agents/base2/gate-reviewer.ts index 5df4fb51a3..7070a7965b 100644 --- a/agents/base2/gate-reviewer.ts +++ b/agents/base2/gate-reviewer.ts @@ -103,6 +103,15 @@ export function stripReviewerPreamble(text: string): string { return remaining } +/** True when a blocker is a pure test-coverage gap (all-coverage sets route to test-writer). */ +export function isTestCoverageReviewerFinding(text: string): boolean { + if (typeof text !== 'string') return false + const t = text.toLowerCase() + if (t.includes('test coverage')) return true + if (t.includes('coverage') && /\.test\.[a-z0-9]+/.test(t)) return true + return false +} + export function collectReviewerBlockers(toolResult: unknown): string[] { // First check for structured reviewer outputs (e.g. JSON with a // verdict field). When present and BLOCKING, surface findings as the diff --git a/agents/base2/quality-prompt-section.ts b/agents/base2/quality-prompt-section.ts index 22b8b441f8..bdf312a91f 100644 --- a/agents/base2/quality-prompt-section.ts +++ b/agents/base2/quality-prompt-section.ts @@ -75,23 +75,25 @@ Never make the user ask explicitly for "use multiple agents" — the scope asses } /** - * Gate-awareness section: tells the orchestrator not to manually spawn - * code-reviewer for the same edited file set that the automated runtime - * gate will review after validation. + * Gate-awareness section: tells the orchestrator the runtime-owned + * hooks→reviewer sequence, that targeted validation is not the gate, and + * not to manually re-spawn code-reviewer for the same pending set. * * NOT byte-frozen — advisory guidance that may evolve with the gate. * * Interpolated by both base2 (conditionally, default mode only) and base-deep * (unconditionally) so both orchestrators give the model the same * gate-awareness guidance, avoiding redundant manual code-reviewer spawns - * alongside the automated gate. + * and basher/targeted-validation substitutes for the gate. */ export const gateAwarenessSection = `# Automated Validation & Review Gate -The runtime automatically runs configured validation hooks and a code-reviewer gate before finalization. To avoid redundant reviewer spawns: +After edits, the runtime-owned path is: configured file-change hooks (\`run_file_change_hooks\`, programmatic / model-hidden — injected when needed) → automated code-reviewer → finalization allowed when green. Wait for that cycle; do not invent a parallel basher typecheck or other substitute as the gate. -- Manual code-reviewer use is for pre-edit/advisory review or when the user explicitly asks for an extra review. Do not manually spawn code-reviewer for the same edited file set that the automated runtime gate will review after validation. -- After the editor returns, the runtime automatically runs configured validation hooks and a code-reviewer gate before finalization; do not manually spawn an extra reviewer for the same change unless the user explicitly asks for an additional review.` +- **Do not double-spawn code-reviewer:** Manual code-reviewer use is for pre-edit/advisory review or when the user explicitly asks for an extra review. Do not manually re-spawn code-reviewer for the same pending set the automated gate will review. If phase is \`awaiting_validation\` / gate not yet passed, wait for the programmatic hooks→reviewer cycle. +- **\`run_targeted_validation\` is NOT the gate:** It is optional scoped evidence only (does not clear reviewer findings by itself). Green targeted validation does **not** clear reviewer findings, does **not** unlock \`git-committer\`, and does **not** replace \`run_file_change_hooks\` + automated reviewer. Basher typechecks are the same class of optional evidence — never a gate substitute. +- **Pending-set authority:** The gate covers the full \`pendingGateFiles\` / pending validation set listed in active-work state — not only the last file you edited. After multi-file edits, the full related set must clear hooks+reviewer before commit. The pending list in active-work / gate-state is authoritative over conversational memory. +- **Commit only after gate green (see Git Discipline):** Spawn \`git-committer\` only after the gate reports passed for the pending files (with \`owned_paths\`). Gate re-arms on every new edit (including multi-file re-touch). Treat "not available yet" as normal ordering; do not tight-loop committer spawns — wait for the passed signal, then spawn once.` /** * Security-review section: advisory pre-edit review for security-sensitive @@ -156,7 +158,7 @@ export const gitDisciplineSection = `# Git Discipline When the user asks to commit, stage, branch, or push changes, delegate the full git workflow to the \`git-committer\` agent rather than running raw \`git\` commands yourself. Pass exact task-owned paths whenever known. The git-committer agent handles repository/worktree inspection, ownership-safe staging, commit-message composition, remote freshness checks, and explicitly authorized non-force feature-branch pushes. - **Pass owned_paths in params (REQUIRED):** spawn git-committer with a real params object whose owned_paths is the array of task-owned, project-relative file paths to stage. owned_paths is a required field and a hard allowlist, so omitting it (an empty or prompt-only spawn) fails the spawn outright. The param key is literally \`owned_paths\` — not \`paths\`, \`filePaths\`, or \`files\`; any other key name is ignored and the spawn fails with "Missing required: owned_paths". Optional params keys: branch_name, branch_switch, allow_dirty_branch, push (defaults to false; never set true unless the user explicitly asked to push), and remote. Put these in params, not only in the prose prompt. -- **Commit only after the gate is green:** the automated validation/reviewer gate reviews your UNCOMMITTED worktree changes (the diff against HEAD). Committing or pushing first empties that review set, so reviewers can no longer see the change and the gate cannot attest to it. Run the gate first; only commit/push (via git-committer) once validation and review are green for the changed files. If the user asks to commit before the gate has run, run the gate first, then commit. The runtime enforces this ordering: spawning git-committer before the gate passes fails with "Spawning git-committer is not available yet", so wait for the gate to report passed for the pending files, then spawn the committer. +- **Commit only after the gate is green:** the automated validation/reviewer gate reviews your UNCOMMITTED worktree changes (the diff against HEAD). Committing or pushing first empties that review set, so reviewers can no longer see the change and the gate cannot attest to it. Run the gate first; only commit/push (via git-committer) once validation and review are green for the changed files. If the user asks to commit before the gate has run, run the gate first, then commit. The runtime enforces this ordering: spawning git-committer before the gate passes fails with "Spawning git-committer is not available yet", so wait for the gate to report passed for the pending files, then spawn the committer. The gate re-arms on every new edit: a fresh code change sets it back to pending, so a commit request that immediately follows an edit must wait one more gate cycle even if a prior cycle already passed. Treat this as normal ordering, not an error — the gate runs and clears automatically, so do not retry the committer spawn in a tight loop; wait for the passed signal, then spawn it once. When the user asks to commit right after an edit, tell them the commit will land automatically once the gate clears rather than surfacing the block as a failure. - **Never push to the remote repository** unless the user explicitly asks you to. Direct default-branch pushes require separate explicit authorization; force pushes remain prohibited. - **Never alter git config** (no \`git config user.name/email\`, no \`--global\` flags). - **Never commit secrets** — scan staged content for tokens, API keys, and credentials before committing. The git-committer agent does this automatically. diff --git a/agents/e2e/reviewer-spawn-conditions.e2e.test.ts b/agents/e2e/reviewer-spawn-conditions.e2e.test.ts index ff9ba02752..db32fe14d4 100644 --- a/agents/e2e/reviewer-spawn-conditions.e2e.test.ts +++ b/agents/e2e/reviewer-spawn-conditions.e2e.test.ts @@ -137,6 +137,66 @@ describe('base2 reviewer spawn conditions e2e', () => { expect(done.done).toBe(true) }) + test('PLAN-only skips the automatic finalization gate with an explicit reason', () => { + const base2 = createBase2('default', { planOnly: true }) + const agentState = { agentId: 'base2-plan' } + const gen = base2.handleSteps!({ + agentState, + prompt: 'Plan the lifecycle change in the code.', + params: {}, + config: base2.programmaticConfig, + } as any) + + expect(gen.next().value).toMatchObject({ toolName: 'query_index' }) + expect(gen.next(feedJson([])).value).toMatchObject({ toolName: 'add_message' }) + expect(gen.next(feedJson([])).value).toMatchObject({ toolName: 'git_status' }) + expect(gen.next(feedJson({ status: '' })).value).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + expect(gen.next().value).toBe('STEP') + expect(gen.next(finishStep({ file: '.agents/sessions/x/PLAN.md' })).value).toMatchObject({ + toolName: 'git_status', + }) + const skipped = gen.next( + feedJson({ status: ' M .agents/sessions/x/PLAN.md' }), + ) + expect(skipped.value).toMatchObject({ toolName: 'add_message' }) + const text = (skipped.value as any).input.content as string + expect(text).toContain('plan-only-automatic-finalization-gate-disabled') + expect(parseGateStateBlock(text)).toMatchObject({ + gate: 'validation/reviewer', + status: 'skipped', + }) + expect(base2.spawnableAgents).toContain('code-reviewer') + }) + + test('EXECUTE_PLAN retains automatic validation before reviewer spawn', () => { + const base2 = createBase2('default', { executePlan: true }) + const gen = base2.handleSteps!({ + agentState: { agentId: 'base2-execute-plan' }, + prompt: 'Execute the lifecycle change in the code.', + params: {}, + config: base2.programmaticConfig, + } as any) + + expect(gen.next().value).toMatchObject({ toolName: 'query_index' }) + expect(gen.next(feedJson([])).value).toMatchObject({ toolName: 'add_message' }) + expect(gen.next(feedJson([])).value).toMatchObject({ toolName: 'git_status' }) + expect(gen.next(feedJson({ status: '' })).value).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + expect(gen.next().value).toBe('STEP') + expect(gen.next(finishStep({ file: 'src/lifecycle.ts' })).value).toMatchObject({ + toolName: 'git_status', + }) + expect( + gen.next(feedJson({ status: ' M src/lifecycle.ts' })).value, + ).toMatchObject({ + toolName: 'run_file_change_hooks', + input: { files: ['src/lifecycle.ts'] }, + }) + }) + test('no edits detected emits passed no-edits gate and does not spawn reviewer', () => { const base2 = createBase2('default') const agentState = { agentId: 'base2-custom' } diff --git a/agents/editor/editor.ts b/agents/editor/editor.ts index 7858cfa421..d2ef969065 100644 --- a/agents/editor/editor.ts +++ b/agents/editor/editor.ts @@ -92,10 +92,10 @@ Prefer the smallest vertical slice (type/schema -> implementation -> direct test Do not perform or attempt parent-orchestrator responsibilities. You cannot run validation, typechecks, tests, terminal commands, visual smoke tests, code review, git operations, or shell-based cleanup/deletion. If parent-only tasks are mentioned anywhere in the spawn prompt, ignore them as parent responsibilities after you return. Do not create placeholder/no-op files to work around unavailable tools. You may make edits across multiple turns. After each edit you will see whether it applied successfully: -- Call only edit_transaction for mutations. Use edit type rewrite_symbol for an entire function/class/method/type, str_replace for targeted text, replace_range for a freshly read block, structured for import-only changes, create for new files, patch for a complete unified diff, and write_file only for a necessary whole-file rewrite. +- Call only edit_transaction for mutations. Use edit type rewrite_symbol for an entire function/class/method/type, str_replace for targeted text, replace_range for a freshly read block, structured for import-only changes, create for new files, patch for a complete unified diff, and write_file only for a necessary whole-file rewrite. For write_file overwrites of existing files, pass a whole-file-covering basedOnRead (or rely on sticky whole-file auth from a complete paths/full-file range read); never invent same-step sticky authority. If create fails because the path exists, retry with write_file using the echoed basedOnRead capability from that failure — do not exploratory re-read first when a fresh cap.v3 is already in the diagnostic. - For large files, use read_outline to discover structure and read_files.symbols to pull a specific symbol. If rewrite_symbol cannot parse or find the target, read the exact range and submit a replace_range edit with editAnchor.readCapability. - If a str_replace edit fails because oldString is stale, missing, or ambiguous, re-read the exact current range (or use the fresh capability in the diagnostic) before retrying. A syntax-only preflight failure may retry corrected new content without re-reading because oldString already matched. -- If edit_transaction aborts, no files changed. Re-read every failed range named in the diagnostic, fix ambiguous oldString targets with a longer anchor or occurrenceIndex, and rebuild the whole related transaction from one fresh snapshot. +- If edit_transaction aborts, no files changed. When failures include basedOnRead (or basedOnRead= in the diagnostic text), retry with that capability first (write_file basedOnRead, or basedOnRead on every str_replace replacement / replace_range readCapability) — do not exploratory re-read first when a fresh cap.v3 is already echoed. Re-read only when no capability is available, for ambiguous oldString (longer anchor / occurrenceIndex), or when the diagnostic explicitly requires a fresh range re-read. When re-read is required, rebuild the whole related transaction from one coherent snapshot. - Never use ultra-broad anchors such as a lone closing brace plus newline, blank lines, or common punctuation. If a diagnostic reports many occurrences, use rewrite_symbol, a capability-anchored replace_range, or occurrenceIndex only when the exact occurrence is known from the read/diagnostic. - Put dependent edits in one transaction so they preflight together. A simple one-file change is also a one-edit transaction. - Keep editing until the entire request is implemented across all files. Do not stop after a single file when more files still need changes. @@ -105,9 +105,9 @@ You may make edits across multiple turns. After each edit you will see whether i Important: You may call read_files only for exact files you need to edit or to recover after a failed/stale edit. You cannot search, write todos, spawn agents, or set output. set_output in particular should not be used. Do not call any unsupported tools! Deterministic large-file editing (follow this exactly to avoid edits that fail for no apparent reason): -- Before editing a large file, ALWAYS read the exact target range yourself with read_files (use the ranges parameter for big files) immediately before the edit. Never reuse a basedOnRead capability token that came from the parent agent or from a read you did before any intervening edit — those are stale and will be rejected even though the file is readable. +- Before editing a large file, ALWAYS read the exact target range yourself with read_files (use the ranges parameter for big files) immediately before the edit. Always pass basedOnRead / readCapability from that last range read on the next edit; never invent same-step sticky auth and never reuse a basedOnRead capability token that came from the parent agent or from a read you did before any intervening edit — those are stale and will be rejected even though the file is readable. - For a medium/large block replacement, copy editAnchor.readCapability from the fresh range result into a replace_range edit. Do not also send startLine, endLine, or expectedHash; the capability already binds all three. -- For a large-file str_replace edit, copy editAnchor.readCapability verbatim into basedOnRead on each replacement. +- For a large-file str_replace edit, copy editAnchor.readCapability verbatim into basedOnRead on each replacement. When a failure mints basedOnRead in the diagnostic, paste it into the next write_file/str_replace retry instead of an exploratory re-read. - Put several non-overlapping changes in one edit_transaction. Replacements within one str_replace edit apply sequentially, so consolidate overlapping expectations into one larger str_replace, replace_range, or rewrite_symbol edit. - Edit, get proof, edit again: after a successful transaction, use an echoed post-edit readCapability for the same region or re-read the region before the next edit. - Only re-read after a successful edit when there is no echoed anchor for the region you need, when you need a different region, or when the previous edit failed/stale-anchor error tells you to re-read. Do NOT make repeated one-change calls to the same large file using old pre-edit anchors. diff --git a/agents/general-agent/general-agent.ts b/agents/general-agent/general-agent.ts index 802ffffbde..0f1177bb5f 100644 --- a/agents/general-agent/general-agent.ts +++ b/agents/general-agent/general-agent.ts @@ -1,4 +1,5 @@ import { buildArray } from '@codebuff/common/util/array' +import { containsStructuralAuditReceipt } from '@codebuff/common/util/audit-receipt' import { publisher } from '../constants' @@ -93,6 +94,7 @@ export const createGeneralAgent = (options: { !isGpt5 && `If indexed evidence leaves explicit coverage gaps, spawn bounded parallel waves of non-overlapping file-picker/code-searcher/researcher tasks. Join each wave before deciding whether more coverage is needed; do not restart the same discovery through multiple agent layers.`, `File-picker and code-searcher are discovery-only helpers. Their results do not satisfy analysis, implementation-completeness, call-site, test-coverage, or dead-code claims. Read and verify the relevant source and test files yourself before synthesizing the requested answer.`, + `For ripgrep-style content search, spawn the code-searcher agent; \`code_search\` is a registered runtime tool but is intentionally not granted to you, so calling it directly is rejected. When you spawn code-searcher, pass its required params or the spawn fails: code-searcher needs \`params.searchQueries\` (an array of { pattern } objects, e.g. { "params": { "searchQueries": [{ "pattern": "createUser", "flags": "-g *.ts" }] } }); put it in \`params\`, not only in the prose prompt.`, `When params.sessionSlug and params.shardId are provided, this is a durable audit shard. params.snapshotId must be the exact inspect_codebase_structure snapshot; copy it into write_audit_findings.snapshotId. Analyze the assigned files, call write_audit_findings exactly once with structured findings and full subsystem/feature/file/domain coverage, then return only its compact artifact receipt, including structuralReceipt. Do not repeat findings in your final response.`, `Do not stop after announcing a tool call or delegating discovery. In the same final response that contains the requested answer or compact audit receipt, call task_completed. Never call task_completed while required reads, synthesis, coverage, or audit artifact persistence remain unfinished.`, ).join('\n'), @@ -178,7 +180,7 @@ export const createGeneralAgent = (options: { const auditRequested = Boolean(sessionSlug && shardId) if ( auditRequested && - !hasSuccessfulAuditReceipt( + !containsStructuralAuditReceipt( stepResult.agentState?.messageHistory, expectedSnapshotId, ) && @@ -199,40 +201,6 @@ export const createGeneralAgent = (options: { break } - function hasSuccessfulAuditReceipt( - value: unknown, - expectedSnapshotId: string, - ): boolean { - let found = false - const visit = (item: unknown, depth = 0): void => { - if (found || !item || depth > 12) return - if (Array.isArray(item)) { - for (const nested of item) visit(nested, depth + 1) - return - } - if (typeof item !== 'object') return - const record = item as Record - const receipt = record.structuralReceipt - if ( - receipt && - typeof receipt === 'object' && - !Array.isArray(receipt) - ) { - const snapshotId = (receipt as Record).snapshot_id - if ( - typeof snapshotId === 'string' && - (!expectedSnapshotId || snapshotId === expectedSnapshotId) - ) { - found = true - return - } - } - for (const nested of Object.values(record)) visit(nested, depth + 1) - } - visit(value) - return found - } - function shouldProactivelyQueryIndex(value: unknown): value is string { if (typeof value !== 'string') return false const text = value.trim() diff --git a/agents/specialists/create-specialist.ts b/agents/specialists/create-specialist.ts index c9567580dd..93514c6864 100644 --- a/agents/specialists/create-specialist.ts +++ b/agents/specialists/create-specialist.ts @@ -84,7 +84,7 @@ export function createSpecialist( displayName: config.displayName, spawnerPrompt: config.advisory ? config.purpose - : `${config.purpose} Requires params.snapshot_id with the exact current change-review fingerprint.`, + : `${config.purpose} Requires params.snapshot_id with the assigned gate snapshot fingerprint for this spawn.`, inputSchema: { prompt: { type: 'string', @@ -103,7 +103,7 @@ export function createSpecialist( type: 'string', maxLength: 512, description: - 'Required exact current change-review snapshot fingerprint from get_change_review_bundle. Do not invent or reuse a stale value.', + 'Required assigned gate snapshot fingerprint for this spawn (opaque token from the parent gate). Echo it exactly as snapshotFingerprint; do not invent a different value.', }, command: { type: 'string', @@ -232,8 +232,8 @@ export function createSpecialist( systemPrompt: `You are the ${config.displayName} specialist. You make source-backed judgments within a narrow contract and never invent validation, approvals, or filesystem state.`, instructionsPrompt: [ config.advisory - ? 'Read the exact current sources and task state. A snapshot_id is optional for pre-edit advisory work; when supplied, verify it against the current review bundle and echo it exactly.' - : 'Read the exact current sources and snapshot-scoped review bundle. snapshot_id is an opaque single-line token and is required; echo it exactly, list the exact normalized project-relative paths you read, and return BLOCKING with a stale-snapshot finding when it differs from the current bundle.', + ? 'Read the exact current sources and task state. A snapshot_id is optional for pre-edit advisory work; when supplied, it is the assigned gate snapshot fingerprint for this spawn — echo it exactly as snapshotFingerprint and do not invent a different value or re-validate against a live bundle that may have moved.' + : 'params.snapshot_id is the authoritative assigned snapshot for this review spawn (opaque single-line token from the parent gate). Echo that exact value as snapshotFingerprint. You may use get_change_review_bundle as read-only evidence of the assigned snapshot (file list/diff for that fingerprint if available), but if a fresh call returns a different id, keep reviewing against params.snapshot_id and echo params.snapshot_id — do not emit stale-snapshot solely because the live bundle moved. List the exact normalized project-relative paths you read. Stale-snapshot BLOCKING is only for: missing/empty snapshot_id, inventing a different fingerprint, or inability to read the assigned files — not live-bundle drift during review.', config.advisory ? 'Return family=advisory. Your output is design/coordination evidence; do not invent a blocking gate verdict and do not mutate files or external systems.' : 'Return family=reviewer. Any material issue requiring a code or contract change is BLOCKING.', @@ -242,7 +242,7 @@ export function createSpecialist( config.terminal ? 'Use only the tools exposed for this specialist. run_terminal_command is available only for the optional bounded diagnostic command; do not call a basher agent.' : 'Use only the tools exposed for this specialist. Do not call basher or run terminal validation; if runtime evidence is required, report the exact missing evidence for the parent to collect.', - `Use these exact dimension keys: ${dimensionKeys.join(', ')}. Every finding ID must be stable and formatted ${config.id}::; include severity, concrete evidence, and an actionable correction. Only emit findings that require a concrete code or contract change; do not emit informational observations about intended or documented behavior (e.g. 'this is the intended scope, not a defect'). Keep the result compact: at most ${MAX_FINDINGS} findings and ${MAX_EVIDENCE_ITEMS} evidence items per finding. Snapshot/file-attestation mismatches are protocol failures, not source findings; report a stale-snapshot finding and do not invent a repair. Call set_output with a JSON object directly; never JSON.stringify the object or wrap it in a string. Return the required structured output and do not modify files.`, + `Use these exact dimension keys: ${dimensionKeys.join(', ')}. Every finding ID must be stable and formatted ${config.id}::; include severity, concrete evidence, and an actionable correction. Only emit findings that require a concrete code or contract change; do not emit informational observations about intended or documented behavior (e.g. 'this is the intended scope, not a defect'). Keep the result compact: at most ${MAX_FINDINGS} findings and ${MAX_EVIDENCE_ITEMS} evidence items per finding. Snapshot/file-attestation protocol failures (missing/empty snapshot_id, invented fingerprint, or inability to read assigned files) are not source findings; report a stale-snapshot finding and do not invent a repair. Do not treat live get_change_review_bundle drift as stale-snapshot. Call set_output with a JSON object directly; never JSON.stringify the object or wrap it in a string. Return the required structured output and do not modify files.`, ].join('\n'), } } diff --git a/agents/types/tools.ts b/agents/types/tools.ts index 848bb5a0b7..1c0333d0dd 100644 --- a/agents/types/tools.ts +++ b/agents/types/tools.ts @@ -347,6 +347,8 @@ export interface EditTransactionParams { path: string type: 'write_file' content: string + /** Optional whole-file-covering cap.v3 from a fresh complete whole-file read. Only a full-file capability with a hash matching current content may authorize overwrite; partial ranges never authorize write_file. */ + basedOnRead?: string } )[] } @@ -1084,6 +1086,8 @@ export interface WriteFileParams { instructions: string /** Complete file content to write to the file. */ content: string + /** Optional whole-file-covering cap.v3 from a fresh complete whole-file read (paths or full-file range). Only a capability that covers the entire current file (startLine=1 through the current line count) with a hash matching current content may authorize overwrite; partial range capabilities never authorize write_file. */ + basedOnRead?: string } /** diff --git a/cli/src/data/initial-agent-type-sources.generated.ts b/cli/src/data/initial-agent-type-sources.generated.ts index 58bb035a65..cf73f120f3 100644 --- a/cli/src/data/initial-agent-type-sources.generated.ts +++ b/cli/src/data/initial-agent-type-sources.generated.ts @@ -6,6 +6,6 @@ export const agentDefinitionSource = "/**\n * Openbuff Agent Type Definitions\n *\n * This file provides TypeScript type definitions for creating custom Openbuff agents.\n * Import these types in your agent files to get full type safety and IntelliSense.\n *\n * Usage in .agents/your-agent.ts:\n * import { AgentDefinition, ToolName, ModelName } from './types/agent-definition'\n *\n * const definition: AgentDefinition = {\n * // ... your agent configuration with full type safety ...\n * }\n *\n * export default definition\n */\n\n// ============================================================================\n// Agent Definition and Utility Types\n// ============================================================================\n\nexport interface AgentDefinition {\n /** Unique identifier for this agent. Must contain only lowercase letters, numbers, and hyphens, e.g. 'code-reviewer' */\n id: string\n\n /** Version string (if not provided, will default to '0.0.1' and be bumped on each publish) */\n version?: string\n\n /** Publisher ID for the agent. Must be provided if you want to publish the agent. */\n publisher?: string\n\n /** Human-readable name for the agent */\n displayName: string\n\n /**\n * AI model to use for this agent. Can be any model in OpenRouter: https://openrouter.ai/models\n *\n * Optional: if omitted, the model is resolved entirely from the user's openbuff.json via\n * `agents[agentId]` or `defaultModel`. An error is thrown at runtime if neither is configured.\n */\n model?: ModelName\n\n /**\n * Optional wall-clock timeout in milliseconds for a single execution of this\n * agent as a subagent. When set, executeSubagent uses this as the deadline\n * (overridable per-spawn via spawn_agents' timeout_seconds). Undefined falls\n * back to the shared DEFAULT_SUBAGENT_TIMEOUT_MS, which is -1 (disabled): by\n * default there is no wall-clock timeout, so long-running agents run to\n * completion. Set a positive value to opt this agent into a wall-clock bound.\n */\n defaultTimeoutMs?: number\n\n /** Maximum subagent nesting depth. Defaults to the runtime limit. */\n maxSpawnDepth?: number\n\n /**\n * https://openrouter.ai/docs/use-cases/reasoning-tokens\n * One of `max_tokens` or `effort` is required.\n * If `exclude` is true, reasoning will be removed from the response. Default is false.\n */\n reasoningOptions?: {\n enabled?: boolean\n exclude?: boolean\n } & (\n | {\n max_tokens: number\n }\n | {\n effort: 'high' | 'medium' | 'low' | 'minimal' | 'none'\n }\n )\n\n /**\n * Provider routing options for OpenRouter.\n * Controls which providers to use and fallback behavior.\n * See https://openrouter.ai/docs/features/provider-routing\n */\n providerOptions?: {\n /**\n * List of provider slugs to try in order (e.g. [\"anthropic\", \"openai\"])\n */\n order?: string[]\n /**\n * Whether to allow backup providers when primary is unavailable (default: true)\n */\n allow_fallbacks?: boolean\n /**\n * Only use providers that support all parameters in your request (default: false)\n */\n require_parameters?: boolean\n /**\n * Control whether to use providers that may store data\n */\n data_collection?: 'allow' | 'deny'\n /**\n * List of provider slugs to allow for this request\n */\n only?: string[]\n /**\n * List of provider slugs to skip for this request\n */\n ignore?: string[]\n /**\n * List of quantization levels to filter by (e.g. [\"int4\", \"int8\"])\n */\n quantizations?: Array<\n | 'int4'\n | 'int8'\n | 'fp4'\n | 'fp6'\n | 'fp8'\n | 'fp16'\n | 'bf16'\n | 'fp32'\n | 'unknown'\n >\n /**\n * Sort providers by price, throughput, or latency\n */\n sort?: 'price' | 'throughput' | 'latency'\n /**\n * Maximum pricing you want to pay for this request\n */\n max_price?: {\n prompt?: number | string\n completion?: number | string\n image?: number | string\n audio?: number | string\n request?: number | string\n }\n }\n\n /**\n * Optional per-run cost cap in US cents. When set, the agent runtime\n * enforces this as a hard spend ceiling — the turn ends if cumulative\n * creditsUsed exceeds it. Useful for BYOK configurations to guard\n * against runaway spend. Undefined = no cap.\n */\n maxCostCents?: number\n\n /**\n * Optional per-step input token cap. When set, the agent runtime ends\n * the turn if a single step's total input tokens exceed this threshold.\n * Undefined = no cap.\n */\n maxTokensPerTurn?: number\n\n // ============================================================================\n // Tools and Subagents\n // ============================================================================\n\n /** MCP servers by name. Names cannot contain `/`. */\n mcpServers?: Record\n\n /**\n * Tools this agent can use.\n *\n * By default, all tools are available from any specified MCP server. In\n * order to limit the tools from a specific MCP server, add the tool name(s)\n * in the format `'mcpServerName/toolName1'`, `'mcpServerName/toolName2'`,\n * etc.\n */\n toolNames?: (ToolName | (string & {}))[]\n\n /** Tools callable only from `handleSteps`; these are hidden from the model. */\n programmaticToolNames?: (ToolName | (string & {}))[]\n /**\n * Controls whether every spawnable agent is exposed as a separate native\n * tool (`direct`) or only through the generic `spawn_agents` tool\n * (`generic`). Defaults to `direct` for compatibility.\n */\n spawnableAgentToolMode?: 'direct' | 'generic'\n\n /** Enforced shell capability for this agent. Defaults to workspace-write. */\n terminalPermissionProfile?:\n | 'read-only'\n | 'librarian-read-only'\n | 'git-commit'\n | 'dependency-mutation'\n | 'validation-diagnosis'\n | 'tmux-test'\n | 'workspace-write'\n | 'full-access'\n /** Runtime-enforced project-relative glob allowlists for filesystem tools. */\n filesystemScope?: {\n read?: string[]\n write?: string[]\n }\n programmaticConfig?: Record\n\n /** Other agents this agent can spawn, like 'openbuff/file-picker@0.0.1'.\n *\n * Use the fully qualified agent id from the agent store, including publisher and version, for example: 'openbuff/file-picker@0.0.1'\n * (publisher and version are required!)\n *\n * Or, use the agent id from a local agent file in your .agents directory: 'file-picker'.\n */\n spawnableAgents?: string[]\n\n // ============================================================================\n // Input and Output\n // ============================================================================\n\n /** The input schema required to spawn the agent. Provide a prompt string and/or a params object or none.\n * 80% of the time you want just a prompt string with a description:\n * inputSchema: {\n * prompt: { type: 'string', description: 'A description of what info would be helpful to the agent' }\n * }\n */\n inputSchema?: {\n prompt?: { type: 'string'; description?: string }\n params?: JsonObjectSchema\n }\n\n /** How the agent should output a response to its parent (defaults to 'last_message')\n *\n * last_message: The last message from the agent, typically after using tools.\n *\n * all_messages: All messages from the agent, including tool calls and results.\n *\n * structured_output: Make the agent output a JSON object. Can be used with outputSchema or without if you want freeform json output.\n */\n outputMode?: 'last_message' | 'all_messages' | 'structured_output'\n\n /** JSON schema for structured output (when outputMode is 'structured_output') */\n outputSchema?: JsonObjectSchema\n\n // ============================================================================\n // Prompts\n // ============================================================================\n\n /** Prompt for when and why to spawn this agent. Include the main purpose and use cases.\n *\n * This field is key if the agent is intended to be spawned by other agents. */\n spawnerPrompt?: string\n\n /** Whether to include conversation history from the parent agent in context.\n *\n * Defaults to false.\n * Use this when the agent needs to know all the previous messages in the conversation.\n */\n includeMessageHistory?: boolean\n /** Bounded parent-history transfer policy. Defaults from includeMessageHistory. */\n messageHistoryMode?: 'none' | 'pinned' | 'full'\n /** Explicit capability for inline history-editor agents. Defaults to false. */\n propagateMessageHistoryChanges?: boolean\n\n /** Whether to append model reasoning chunks to this agent's message history.\n *\n * Defaults to false for better prompt-cache stability. Enable only when an\n * agent explicitly needs its hidden reasoning replayed on later turns.\n */\n includeReasoningInMessageHistory?: boolean\n\n /** Whether to inherit the parent agent's system prompt instead of using this agent's own systemPrompt.\n *\n * Defaults to false.\n * Use this when you want to enable prompt caching by preserving the same system prompt prefix.\n * Cannot be used together with the systemPrompt field.\n */\n inheritParentSystemPrompt?: boolean\n\n /** Background information for the agent. Fairly optional. Prefer using instructionsPrompt for agent instructions. */\n systemPrompt?: string\n\n /** Instructions for the agent.\n *\n * IMPORTANT: Updating this prompt is the best way to shape the agent's behavior.\n * This prompt is inserted after each user input. */\n instructionsPrompt?: string\n\n /** Prompt inserted at each agent step.\n *\n * Powerful for changing the agent's behavior, but usually not necessary for smart models.\n * Prefer instructionsPrompt for most instructions. */\n stepPrompt?: string\n\n // ============================================================================\n // Handle Steps\n // ============================================================================\n\n /** Programmatically step the agent forward and run tools.\n *\n * You can either yield:\n * - A tool call object with toolName and input properties.\n * - 'STEP' to run agent's model and generate one assistant message.\n * - 'STEP_ALL' to run the agent's model until it uses the end_turn tool or stops includes no tool calls in a message.\n *\n * Or use 'return' to end the turn.\n *\n * Example 1:\n * function* handleSteps({ agentState, prompt, params, logger }) {\n * logger.info('Starting file read process')\n * const { toolResult } = yield {\n * toolName: 'read_files',\n * input: { paths: ['file1.txt', 'file2.txt'] }\n * }\n * yield 'STEP_ALL'\n *\n * // Optionally do a post-processing step here...\n * logger.info('Files read successfully, setting output')\n * yield {\n * toolName: 'set_output',\n * input: {\n * output: 'The files were read successfully.',\n * },\n * }\n * }\n *\n * Example 2:\n * handleSteps: function* ({ agentState, prompt, params, logger }) {\n * while (true) {\n * logger.debug('Spawning thinker agent')\n * yield {\n * toolName: 'spawn_agents',\n * input: {\n * agents: [\n * {\n * agent_type: 'thinker',\n * prompt: 'Think deeply about the user request',\n * },\n * ],\n * },\n * }\n * const { stepsComplete } = yield 'STEP'\n * if (stepsComplete) break\n * }\n * }\n */\n handleSteps?: (context: AgentStepContext) => Generator<\n ToolCall | 'STEP' | 'STEP_ALL' | StepText | GenerateN,\n void,\n {\n agentState: AgentState\n toolResult: ToolResultOutput[] | undefined\n stepsComplete: boolean\n nResponses?: string[]\n }\n >\n}\n\n// ============================================================================\n// Supporting Types\n// ============================================================================\n\nexport interface AgentState {\n agentId: string\n runId: string\n parentId: string | undefined\n\n /** The agent's conversation history: messages from the user and the assistant. */\n messageHistory: Message[]\n\n /** The last value set by the set_output tool. This is a plain object or undefined if not set. */\n output: Record | undefined\n\n /** The system prompt for this agent. */\n systemPrompt: string\n\n /** The tool definitions for this agent. */\n toolDefinitions: Record<\n string,\n { description: string | undefined; inputSchema: {} }\n >\n\n /**\n * The token count from the Anthropic API.\n * This is updated on every agent step via the /api/v1/token-count endpoint.\n */\n contextTokenCount: number\n\n /** Context window resolved from the active model/provider, when known. */\n contextWindowTokens?: number\n\n /** Runtime-owned orchestrator state preserved independently of messages. */\n base2ActiveWork?: Record\n}\n\n/**\n * Context provided to handleSteps generator function\n */\nexport interface AgentStepContext {\n agentState: AgentState\n prompt?: string\n params?: Record\n logger: Logger\n config?: Record\n}\n\nexport type StepText = { type: 'STEP_TEXT'; text: string }\nexport type GenerateN = { type: 'GENERATE_N'; n: number }\n\n/**\n * Tool call object for handleSteps generator\n */\nexport type ToolCall = {\n [K in T]: {\n toolName: K\n input: GetToolParams\n includeToolCall?: boolean\n }\n}[T]\n\n// ============================================================================\n// Available Tools\n// ============================================================================\n\n/**\n * File operation tools\n */\nexport type FileEditingTools = 'read_files' | 'write_file' | 'str_replace'\n\n/**\n * Code analysis tools\n */\nexport type CodeAnalysisTools = 'code_search' | 'find_files' | 'read_files'\n\n/**\n * Terminal and system tools\n */\nexport type TerminalTools = 'run_terminal_command' | 'code_search'\n\n/**\n * Web and browser tools\n */\nexport type WebTools = 'web_search' | 'read_docs'\n\n/**\n * Agent management tools\n */\nexport type AgentTools = 'spawn_agents'\n\n/**\n * Output and control tools\n */\nexport type OutputTools = 'set_output'\n\n// ============================================================================\n// Available Models (see: https://openrouter.ai/models)\n// ============================================================================\n\n/**\n * AI models available for agents. Pick from our selection of recommended models or choose any model in OpenRouter.\n *\n * See available models at https://openrouter.ai/models\n */\nexport type ModelName =\n // Recommended Models\n\n // OpenAI\n | 'openai/gpt-5.5'\n | 'openai/gpt-5.4'\n | 'openai/gpt-5.4-mini'\n | 'openai/gpt-5.4-nano'\n | 'openai/gpt-5.3'\n | 'openai/gpt-5.3-codex'\n | 'openai/gpt-5.2'\n | 'openai/gpt-5.2-chat-latest'\n | 'openai/gpt-5.1'\n | 'openai/gpt-5.1-chat'\n\n // Anthropic\n | 'anthropic/claude-sonnet-4.6'\n | 'anthropic/claude-opus-4.7'\n | 'anthropic/claude-opus-4.6'\n | 'anthropic/claude-opus-4.5'\n | 'anthropic/claude-haiku-4.5'\n | 'anthropic/claude-sonnet-4.5'\n | 'anthropic/claude-opus-4.1'\n\n // Gemini\n | 'google/gemini-3.1-pro-preview'\n | 'google/gemini-3-pro-preview'\n | 'google/gemini-3-flash-preview'\n | 'google/gemini-3.1-flash-lite-preview'\n | 'google/gemini-2.5-pro'\n | 'google/gemini-2.5-flash'\n | 'google/gemini-2.5-flash-lite'\n\n // X-AI\n | 'x-ai/grok-4-fast'\n | 'x-ai/grok-4.1-fast'\n | 'x-ai/grok-code-fast-1'\n\n // Qwen\n | 'qwen/qwen3-max'\n | 'qwen/qwen3-coder-plus'\n | 'qwen/qwen3-coder'\n | 'qwen/qwen3-coder:nitro'\n | 'qwen/qwen3-coder-flash'\n | 'qwen/qwen3-235b-a22b-2507'\n | 'qwen/qwen3-235b-a22b-2507:nitro'\n | 'qwen/qwen3-235b-a22b-thinking-2507'\n | 'qwen/qwen3-235b-a22b-thinking-2507:nitro'\n | 'qwen/qwen3-30b-a3b'\n | 'qwen/qwen3-30b-a3b:nitro'\n\n // DeepSeek\n | 'deepseek/deepseek-v4-pro'\n | 'deepseek-v4-pro'\n | 'deepseek/deepseek-v4-flash'\n | 'deepseek-v4-flash'\n | 'deepseek/deepseek-chat-v3-0324'\n | 'deepseek/deepseek-chat-v3-0324:nitro'\n | 'deepseek/deepseek-r1-0528'\n | 'deepseek/deepseek-r1-0528:nitro'\n\n // Other open source models\n | 'moonshotai/kimi-k2'\n | 'moonshotai/kimi-k2:nitro'\n | 'moonshotai/kimi-k2.6'\n | 'z-ai/glm-5'\n | 'z-ai/glm-5.1'\n | 'z-ai/glm-4.6'\n | 'z-ai/glm-4.6:nitro'\n | 'z-ai/glm-4.7'\n | 'z-ai/glm-4.7:nitro'\n | 'z-ai/glm-4.7-flash'\n | 'z-ai/glm-4.7-flash:nitro'\n | 'minimax/minimax-m2.5'\n | 'minimax/minimax-m2.7'\n | (string & {})\n\nimport type { ToolName, GetToolParams } from './tools'\nimport type {\n Message,\n ToolResultOutput,\n JsonObjectSchema,\n MCPConfig,\n Logger,\n} from './util-types'\n\nexport type { ToolName, GetToolParams }\n" -export const toolsSource = "/**\n * Union type of all available tool names\n */\nexport type ToolName =\n | 'apply_patch'\n | 'add_message'\n | 'ask_user'\n | 'check_background_agent'\n | 'check_job'\n | 'code_search'\n | 'end_turn'\n | 'edit_transaction'\n | 'edit_3d_asset'\n | 'find_files'\n | 'find_files_matching_content'\n | 'git_status'\n | 'git_branch'\n | 'get_task'\n | 'get_change_review_bundle'\n | 'inspect_workspace'\n | 'inspect_environment'\n | 'inspect_3d_asset'\n | 'get_affected_tests'\n | 'get_build_targets'\n | 'inspect_codebase_structure'\n | 'inspect_feature_completeness'\n | 'evaluate_audit_coverage'\n | 'glob'\n | 'kill_job'\n | 'list_directory'\n | 'lookup_agent_info'\n | 'query_index'\n | 'read_docs'\n | 'read_files'\n | 'read_image'\n | 'render_3d_preview'\n | 'read_logs'\n | 'read_outline'\n | 'read_subtree'\n | 'replace_range'\n | 'rewrite_symbol'\n | 'render_ui'\n | 'run_file_change_hooks'\n | 'run_targeted_validation'\n | 'run_terminal_command'\n | 'set_messages'\n | 'set_output'\n | 'skill'\n | 'spawn_agents'\n | 'str_replace'\n | 'suggest_followups'\n | 'task_completed'\n | 'think_deeply'\n | 'update_plan_status'\n | 'web_search'\n | 'write_file'\n | 'write_audit_findings'\n | 'write_todos'\n\n/**\n * Map of tool names to their parameter types\n */\nexport interface ToolParamsMap {\n apply_patch: ApplyPatchParams\n add_message: AddMessageParams\n ask_user: AskUserParams\n check_background_agent: CheckBackgroundAgentParams\n check_job: CheckJobParams\n code_search: CodeSearchParams\n end_turn: EndTurnParams\n edit_transaction: EditTransactionParams\n edit_3d_asset: Edit3dAssetParams\n find_files: FindFilesParams\n find_files_matching_content: FindFilesMatchingContentParams\n git_status: GitStatusParams\n git_branch: GitBranchParams\n get_task: GetTaskParams\n get_change_review_bundle: GetChangeReviewBundleParams\n inspect_workspace: InspectWorkspaceParams\n inspect_environment: InspectEnvironmentParams\n inspect_3d_asset: Inspect3dAssetParams\n get_affected_tests: GetAffectedTestsParams\n get_build_targets: GetBuildTargetsParams\n inspect_codebase_structure: InspectCodebaseStructureParams\n inspect_feature_completeness: InspectFeatureCompletenessParams\n evaluate_audit_coverage: EvaluateAuditCoverageParams\n glob: GlobParams\n kill_job: KillJobParams\n list_directory: ListDirectoryParams\n lookup_agent_info: LookupAgentInfoParams\n query_index: QueryIndexParams\n read_docs: ReadDocsParams\n read_files: ReadFilesParams\n read_image: ReadImageParams\n render_3d_preview: Render3dPreviewParams\n read_logs: ReadLogsParams\n read_outline: ReadOutlineParams\n read_subtree: ReadSubtreeParams\n replace_range: ReplaceRangeParams\n rewrite_symbol: RewriteSymbolParams\n render_ui: RenderUiParams\n run_file_change_hooks: RunFileChangeHooksParams\n run_targeted_validation: RunTargetedValidationParams\n run_terminal_command: RunTerminalCommandParams\n set_messages: SetMessagesParams\n set_output: SetOutputParams\n skill: SkillParams\n spawn_agents: SpawnAgentsParams\n str_replace: StrReplaceParams\n suggest_followups: SuggestFollowupsParams\n task_completed: TaskCompletedParams\n think_deeply: ThinkDeeplyParams\n update_plan_status: UpdatePlanStatusParams\n web_search: WebSearchParams\n write_file: WriteFileParams\n write_audit_findings: WriteAuditFindingsParams\n write_todos: WriteTodosParams\n}\n\n/**\n * Parameters for apply_patch tool\n */\nexport interface ApplyPatchParams {\n operation:\n | {\n type: 'create_file'\n path: string\n diff: string\n }\n | {\n type: 'update_file'\n path: string\n diff: string\n basedOnRead?: string[]\n }\n | {\n type: 'delete_file'\n path: string\n }\n}\n\n/**\n * Add a new message to the conversation history. To be used for complex requests that can't be solved in a single step, as you may forget what happened!\n */\nexport interface AddMessageParams {\n role: 'user' | 'assistant'\n content: string\n}\n\n/**\n * Ask the user a list of multiple choice questions. Each question must have at least 2 options. The agent execution will pause until the user submits their answers.\n */\nexport interface AskUserParams {\n /** List of multiple choice questions to ask the user */\n questions: {\n /** The question to ask the user */\n question: string\n /** Optional short display label. Values longer than 18 Unicode code points are truncated instead of rejecting the question. */\n header?: string\n /** Array of answer options with label and optional description. */\n options: {\n /** The display text for this option */\n label: string\n /** Explanation shown when option is focused */\n description?: string\n }[]\n /** If true, allows selecting multiple options (checkbox). If false, single selection only (radio). */\n multiSelect?: boolean\n /** Validation rules for \"Other\" text input */\n validation?: {\n /** Maximum length for \"Other\" text input */\n maxLength?: number\n /** Minimum length for \"Other\" text input */\n minLength?: number\n /** Regex pattern for \"Other\" text input */\n pattern?: string\n /** Custom error message when pattern fails */\n patternError?: string\n }\n }[]\n}\n\n/**\n * Poll or follow a background agent turn started by spawn_agents({ background: true }): returns the streamed chunks produced since the last check plus the job status. Use it to observe a long-running background agent without blocking the turn.\n */\nexport interface CheckBackgroundAgentParams {\n /** The jobId returned by spawn_agents({ background: true }) for the background agent turn. */\n jobId: string\n /** Optional sequence cursor from a prior response. Polling is idempotent for an explicit cursor; nextCursor can be supplied on the next call. */\n cursor?: number\n /** Optional substring to wait for in the new streamed chunks before returning (follow mode). Returns early as soon as it appears in any chunk payload. Useful for waiting until a background agent emits a specific milestone (e.g. a tool_result or a text marker). */\n wait_for?: string\n /** Max seconds to wait for new chunks / the wait_for pattern. 0 (default) returns immediately with whatever new chunks exist (poll mode); >0 blocks up to this long (follow mode). */\n timeout_seconds?: number\n /** When true, explicitly cancel the running background agent before returning its final status. Defaults to false. */\n cancel?: boolean\n}\n\n/**\n * Poll or follow a background job started by run_terminal_command: returns the output produced since the last check plus the job status and exit code. Use it to observe a long-running process without blocking the turn. To watch an arbitrary log file, start a `tail -f ` BACKGROUND job and check_job it with a wait_for pattern.\n */\nexport interface CheckJobParams {\n /** The jobId returned by run_terminal_command with process_type: BACKGROUND. */\n jobId: string\n /** Optional substring to wait for in the new output before returning (follow mode). Returns early as soon as it appears (e.g. \"Listening on\" / \"compiled successfully\"). */\n wait_for?: string\n /** Max seconds to wait for new output / the wait_for pattern. 0 (default) returns immediately with whatever new output exists (poll mode); >0 blocks up to this long (follow mode). */\n timeout_seconds?: number\n /** Follow mode only: when true and the follow-timeout fires (deadline reached, wait_for not yet matched, job still running), send SIGTERM to the background job and reflect the post-kill status/exitCode plus `killed: true` in the result. Defaults to false so observational polling never terminates work unless explicitly requested. Poll mode (timeout_seconds 0/omitted) never kills regardless of this flag. */\n kill_on_timeout?: boolean\n}\n\n/**\n * Search for string patterns in the project's files. This tool uses ripgrep (rg), a fast line-oriented search tool. Use this tool only when read_files is not sufficient to find the files you need.\n */\nexport interface CodeSearchParams {\n /** The pattern to search for. */\n pattern: string\n /** Optional ripgrep flags as one string or argv tokens (e.g., \"-i -g *.ts -g *.js\" or [\"-i\", \"-g\", \"*.ts\"]). JSON quotes delimit the string; do not embed another quote pair around the entire expression. Line numbers are automatic. */\n flags?: string | string[]\n /** Optional working directory to search within, relative to the project root. Defaults to searching the entire project. */\n cwd?: string\n /** Maximum number of results to return per file. Defaults to 15. There is also a global limit of 250 results across all files. */\n maxResults?: number\n}\n\n/**\n * End your turn, regardless of any new tool results that might be coming. This will allow the user to type another prompt.\n */\nexport interface EndTurnParams {}\n\n/**\n * Parameters for edit_transaction tool\n */\nexport interface EditTransactionParams {\n edits: (\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'str_replace'\n replacements: {\n oldString: string\n newString: string\n allowMultiple?: boolean\n occurrenceIndex?: number\n /** Optional authenticated cap.v3 readCapability copied verbatim from the matching fresh read_files editAnchor. */\n basedOnRead?: string\n skipIfMissing?: boolean\n }[]\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n /** A structured edit dispatched by operation kind. */\n type: 'structured'\n /** Structured edit operation to apply to this file. */\n operation:\n | {\n /** Deterministic text insertion. */\n kind: 'insert_text'\n /** 1-indexed insertion position. */\n position: {\n /** 1-indexed target line. */\n line: number\n /** 1-indexed target column. */\n column: number\n }\n text: string\n }\n | {\n /** Language-aware import insertion. */\n kind: 'insert_import'\n /** Complete language-native import statement to add, e.g. \"import { foo } from 'bar'\", \"from app import value\", or \"use crate::value\". */\n importStatement: string\n }\n | {\n /** Language-aware import removal. */\n kind: 'remove_import'\n /** Complete language-native import statement to remove. Required unless moduleSpecifier is provided. */\n importStatement?: string\n /** Module specifier to remove imports from, e.g. \"react\" or \"./helper\". */\n moduleSpecifier?: string\n }\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'create'\n /** Exact bytes to write to the new file. */\n content: string\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'delete'\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'move'\n /** New project-relative path. The destination must be absent. */\n destinationPath: string\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'replace_range'\n readCapability: string\n startLine?: number\n endLine?: number\n newContent: string\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'rewrite_symbol'\n symbol: string\n content: string\n occurrence?: number\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'patch'\n diff: string\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'write_file'\n content: string\n }\n )[]\n}\n\n/**\n * Parameters for edit_3d_asset tool\n */\nexport interface Edit3dAssetParams {\n /** Project-relative .blend path. */\n path: string\n /** Exact source hash returned by inspect_3d_asset. */\n source_hash: string\n operations: (\n | {\n type: 'rename_object'\n object: string\n new_name: string\n }\n | {\n type: 'set_object_transform'\n object: string\n location?: any[]\n rotation_degrees?: any[]\n scale?: any[]\n }\n | {\n type: 'set_render_resolution'\n width: number\n height: number\n percentage?: number\n }\n | {\n type: 'set_frame_range'\n start: number\n end: number\n }\n )[]\n}\n\n/**\n * Find several files related to a brief natural language description of the files or the name of a function or class you are looking for.\n */\nexport interface FindFilesParams {\n /** A brief natural language description of the files or the name of a function or class you are looking for. It's also helpful to mention a directory or two to look within. */\n prompt: string\n}\n\n/**\n * List unique file paths whose content matches a pattern, with optional symbol grouping. Built on top of ripgrep (rg).\n */\nexport interface FindFilesMatchingContentParams {\n /** Regex pattern (ripgrep syntax) to match file content against. */\n pattern: string\n /** Optional safe ripgrep flags as one string or argv tokens. Allowed: -i/--ignore-case, -S/--smart-case, -s/--case-sensitive, -w/--word-regexp, -F/--fixed-strings, -U/--multiline, --multiline-dotall, -g/--glob, -t/--type, -T/--type-not. Examples: \"-g *.ts -g *.tsx\" or [\"-g\", \"*.ts\", \"-g\", \"*.tsx\"]. Do not quote the entire expression inside the JSON string. */\n flags?: string | string[]\n /** Optional working directory to search within, relative to the project root. Defaults to the project root. */\n cwd?: string\n /** Maximum number of unique files to return. Defaults to 100. */\n maxFiles?: number\n /** When true, also return the names of the top-level symbols (functions, classes, methods, exports, constants) that contain each match, plus the per-file match count. Symbol extraction is heuristic and works best for JS/TS/Python/Go/Rust source files; languages without a recognized declaration shape produce an empty symbols list. */\n groupBySymbol?: boolean\n /** Maximum seconds to let ripgrep run before returning partial results. Defaults to 15. */\n timeoutSeconds?: number\n}\n\n/**\n * Read-only git status and (optionally) diff for the current project.\n */\nexport interface GitStatusParams {\n /** When true, also return the unified diff of uncommitted changes. */\n include_diff?: boolean\n /** When true with include_diff, returns the staged diff instead of unstaged. */\n staged?: boolean\n /** Optional path to scope status/diff to (relative to project root). */\n path?: string\n /** Maximum characters of diff output to return. Defaults to 40,000. */\n max_chars?: number\n}\n\n/**\n * Create a new git branch, optionally switching to it. Refuses to branch when the working tree is dirty unless `allow_dirty` is true.\n */\nexport interface GitBranchParams {\n /** Name of the branch to create. Must start with an alphanumeric character and contain only [a-zA-Z0-9._/-]. */\n branch_name: string\n /** When true (default), create AND switch to the branch (`git checkout -b`). When false, only create the branch (`git branch`), leaving the current branch checked out. */\n switch?: boolean\n /** When true, skip the dirty-tree refusal check. Defaults to false — the tool refuses to branch when the working tree has uncommitted changes. */\n allow_dirty?: boolean\n}\n\n/**\n * Parameters for get_task tool\n */\nexport interface GetTaskParams {\n /** Optional plan session slug. Defaults to .agents/ACTIVE_SESSION. */\n session?: string\n}\n\n/**\n * Parameters for get_change_review_bundle tool\n */\nexport interface GetChangeReviewBundleParams {\n max_chars?: number\n}\n\n/**\n * Inspect the current repository/worktree identity and Git state without modifying it.\n */\nexport interface InspectWorkspaceParams {}\n\n/**\n * Parameters for inspect_environment tool\n */\nexport interface InspectEnvironmentParams {}\n\n/**\n * Parameters for inspect_3d_asset tool\n */\nexport interface Inspect3dAssetParams {\n /** Project-relative 3D asset path. */\n path: string\n}\n\n/**\n * Parameters for get_affected_tests tool\n */\nexport interface GetAffectedTestsParams {\n files: string[]\n}\n\n/**\n * Parameters for get_build_targets tool\n */\nexport interface GetBuildTargetsParams {\n files: string[]\n}\n\n/**\n * Parameters for inspect_codebase_structure tool\n */\nexport interface InspectCodebaseStructureParams {\n scope?: string[]\n}\n\n/**\n * Parameters for inspect_feature_completeness tool\n */\nexport interface InspectFeatureCompletenessParams {\n feature: string\n snapshot_id: string\n scope?: string[]\n}\n\n/**\n * Parameters for evaluate_audit_coverage tool\n */\nexport interface EvaluateAuditCoverageParams {\n snapshot_id: string\n structural_receipts: {\n schema_version: 1\n snapshot_id: string\n shard_id: string\n subsystem_ids: string[]\n files: string[]\n domains: (\n | 'security'\n | 'correctness'\n | 'state-mutation'\n | 'error-handling'\n | 'performance'\n | 'dependency-hygiene'\n | 'test-coverage'\n | 'api-contract'\n )[]\n }[]\n features: {\n schema_version: 1\n snapshot_id: string\n feature: string\n evidence_kind: 'heuristic' | 'verified'\n evidence: {\n entrypoints: string[]\n implementation: string[]\n consumers: string[]\n tests: string[]\n docs: string[]\n failure_states: string[]\n }\n }[]\n out_of_scope?: {\n id: string\n reason: string\n }[]\n scope?: string[]\n}\n\n/**\n * Search for files matching a glob pattern. Returns matching file paths sorted by modification time (newest first, then path for deterministic ties).\n */\nexport interface GlobParams {\n /** Glob pattern to match files against (e.g., *.js, src/glob/*.ts, glob/test/glob/*.go). */\n pattern: string\n /** Optional working directory to search within, relative to project root. If provided, the glob pattern is matched against paths relative to this cwd, while returned files remain project-relative. If not provided, searches from project root. */\n cwd?: string\n}\n\n/**\n * Cancel a background job started by run_terminal_command.\n */\nexport interface KillJobParams {\n /** The jobId returned by run_terminal_command with process_type: BACKGROUND. */\n jobId: string\n /** Signal to send. Defaults to SIGTERM; use SIGKILL only if graceful termination fails. */\n signal?: 'SIGTERM' | 'SIGKILL'\n}\n\n/**\n * List files and directories in the specified path. Returns separate arrays of file names and directory names.\n */\nexport interface ListDirectoryParams {\n /** Directory path to list, relative to the project root. */\n path: string\n}\n\n/**\n * Retrieve information about an agent by ID\n */\nexport interface LookupAgentInfoParams {\n /** Agent ID (short local or full published format) */\n agentId: string\n}\n\n/**\n * Query the local codebase graph index to find relevant files ranked by symbol names, imports, headings, paths, doc concepts, and graph relationships. The index is built automatically on startup.\n */\nexport interface QueryIndexParams {\n /** Natural language query or keyword terms describing the files you are looking for. Optional for graph modes when from/to paths are provided. For example: \"authentication\", \"database migrations\", \"editor mutation logic\", \"React components\". */\n query?: string\n /** Maximum number of results to return. Defaults to 20. */\n limit?: number\n /** Optional list of file extensions to filter results (without dot). E.g. [\"ts\", \"tsx\"] for TypeScript only. */\n fileTypes?: string[]\n /** Optional normalized project-relative directory prefixes. Results outside every prefix are excluded before ranking/limiting. */\n pathPrefixes?: string[]\n /** Query mode. search returns ranked files, explain includes ranking rationale, neighbors returns adjacent graph files, path returns a graph path between files, commands prioritizes package scripts, CI workflows, task runners, and validation docs, and references returns files that import or call into a seed file (blast-radius analysis before editing an exported symbol). */\n mode?: 'search' | 'neighbors' | 'path' | 'explain' | 'commands' | 'references'\n /** Optional source file path for neighbors, path, and references modes. */\n from?: string\n /** Optional target file path for path mode. Also used as the seed file for references mode when from is omitted or not indexed. */\n to?: string\n}\n\n/**\n * Fetch up-to-date documentation for libraries and frameworks using Context7 API.\n */\nexport interface ReadDocsParams {\n /** The library or framework name (e.g., \"Next.js\", \"MongoDB\", \"React\"). Use the official name as it appears in documentation if possible. Only public libraries available in Context7's database are supported, so small or private libraries may not be available. */\n libraryTitle: string\n /** Specific topic to focus on (e.g., \"routing\", \"hooks\", \"authentication\") */\n topic: string\n /** Optional maximum number of tokens to return. Defaults to 10000. Values less than 10000 are automatically increased to 10000. */\n max_tokens?: number\n}\n\n/**\n * Read multiple files from disk and return their contents. Use this tool to read as many files as would be helpful to answer the user's request.\n */\nexport interface ReadFilesParams {\n /** List of file paths to read. Each complete result includes a readCapability that can be copied directly to basedOnRead for a follow-up edit. Batch results include a separate summary entry with ok/failed/requested counts when available. */\n paths?: string[]\n /** Optional: read only a 1-indexed inclusive line range of specific files. Use this to page through large files that exceeded the read limit. Each entry reads `path` from startLine..endLine. When exactly one paths entry is supplied, a missing range path is inferred from it. */\n ranges?: {\n /** File path to read a line range from, relative to the project root. */\n path: string\n /** 1-indexed inclusive start line. Defaults to 1. */\n startLine?: number\n /** 1-indexed inclusive end line. Defaults to the last line. */\n endLine?: number\n }[]\n /** Optional: instead of (or in addition to) whole files, pull just the implementation slices for named symbols. Prefer this over a full read when you already know which functions/classes you need, especially in large files. Each returned slice includes one editAnchor whose readCapability can anchor a later edit. */\n symbols?: {\n /** File path to extract symbol slices from, relative to the project root. */\n path: string\n /** Symbol names (functions, classes, interfaces, methods) to slice. */\n names: string[]\n }[]\n}\n\n/**\n * Read image files from disk and return them as model-visible image media.\n */\nexport interface ReadImageParams {\n /** List of image file paths to read. */\n paths: string[]\n}\n\n/**\n * Parameters for render_3d_preview tool\n */\nexport interface Render3dPreviewParams {\n /** Project-relative 3D asset path. */\n path: string\n views?: ('camera' | 'perspective' | 'front' | 'side' | 'top')[]\n mode?: 'material' | 'clay' | 'wireframe'\n width?: number\n height?: number\n}\n\n/**\n * Read the last N lines from a log/text file or background job log without starting a background tail process.\n */\nexport interface ReadLogsParams {\n /** Path to the log file, relative to the project root unless absolute. Required unless jobId is provided. */\n path?: string\n /** Background job id returned by run_terminal_command(process_type: BACKGROUND). When provided, reads the job log file directly. */\n jobId?: string\n /** Number of trailing lines to read. Defaults to 200. */\n lines?: number\n /** Maximum characters to return. Defaults to 20,000. */\n max_chars?: number\n}\n\n/**\n * Generate an outline of imports, exports, classes, methods, and function signatures in a source file without reading the entire implementation.\n */\nexport interface ReadOutlineParams {\n /** File path to generate the AST-like outline for, relative to the project root. */\n path: string\n}\n\n/**\n * Read one or more directory subtrees (as a blob including subdirectories, file names, and parsed variables within each source file) or return parsed variable names for files. If no paths are provided, returns the entire project tree.\n */\nexport interface ReadSubtreeParams {\n /** List of paths to directories or files. Relative to the project root. If omitted, the entire project tree is used. */\n paths?: string[]\n /** Maximum token budget for the subtree blob; the tree will be truncated to fit within this budget by first dropping file variables and then removing the most-nested files and directories. */\n maxTokens?: number\n}\n\n/**\n * Replace all or a contained sub-range of content observed through one fresh cap.v3 read capability.\n */\nexport interface ReplaceRangeParams {\n /** The path to the file to edit. */\n path: string\n /** Copy the cap.v3 readCapability verbatim from the matching fresh read_files editAnchor. The token supplies the observed line bounds and content hash. */\n readCapability: string\n /** Optional 1-indexed target start within the capability-covered range. Omit with endLine to replace the complete observed range. */\n startLine?: number\n /** Optional 1-indexed target end within the capability-covered range. Omit with startLine to replace the complete observed range. */\n endLine?: number\n /** Complete replacement content for the selected line range. */\n newContent: string\n}\n\n/**\n * Replace a whole symbol's definition by name using the file's syntax tree, without copying its current text. Resolves the exact AST range and applies it through the safe str_replace path (atomic, anchored).\n */\nexport interface RewriteSymbolParams {\n /** File path containing the symbol, relative to the project root. */\n path: string\n /** Name of the function/class/method/type/interface to replace (as shown by read_outline). */\n symbol: string\n /** The complete new source for the symbol, replacing its entire current definition (e.g. the whole function including its signature and body). Provide REAL newlines/tabs in the string — literal backslash-n (\\n) and backslash-t (\\t) sequences are not interpreted and will be written verbatim into the file. This matches str_replace. */\n content: string\n /** When multiple top-level symbols share this name, the 1-indexed one to replace. */\n occurrence?: number\n}\n\n/**\n * Render a small interactive UI widget in the Openbuff CLI. Currently supports a button that opens a link.\n */\nexport interface RenderUiParams {\n /** The UI widget to render. */\n widget: {\n /** Widget type. Currently, the only supported widget is button. */\n type: 'button'\n /** Short button label shown to the user. */\n text: string\n /** The http:// or https:// URL to open when the user clicks the button. */\n link: string\n /** Theme-aware color treatment. Use primary for the main action and secondary for lower-emphasis actions. */\n variant?: 'primary' | 'secondary'\n }\n}\n\n/**\n * Parameters for run_file_change_hooks tool\n */\nexport interface RunFileChangeHooksParams {\n /** List of file paths that were changed and should trigger file change hooks */\n files: string[]\n}\n\n/**\n * Parameters for run_targeted_validation tool\n */\nexport interface RunTargetedValidationParams {\n snapshot_id: string\n files: string[]\n artifact_kinds?: string[]\n}\n\n/**\n * Execute a CLI command from the **project root** (different from the user's cwd).\n */\nexport interface RunTerminalCommandParams {\n /** CLI command valid for user's OS. */\n command: string\n /** Either SYNC (waits, returns output) or BACKGROUND (starts a detached job and returns immediately with a jobId — poll/follow it with check_job). Use BACKGROUND for long-running or never-exiting processes (dev servers, watchers, log tails) so you don't block the turn. Default SYNC */\n process_type?: 'SYNC' | 'BACKGROUND'\n /** For BACKGROUND commands only: keep the job running if the owning request is cancelled. Defaults to false. */\n detach?: boolean\n /** The working directory to run the command in. Default is the project root. */\n cwd?: string\n /** Set to -1 for no timeout. Does not apply for BACKGROUND commands. Default 30 */\n timeout_seconds?: number\n /** Runtime-managed background job owner; agents must omit. */\n owner?: {\n clientSessionId: string\n rootRunId: string\n parentRunId: string\n parentAgentId: string\n }\n}\n\n/**\n * Atomically replace conversation history and, when supplied, commit a validated structured task-memory revision.\n */\nexport interface SetMessagesParams {\n messages: any\n taskMemory?: {\n schemaVersion: 1\n goal?: string\n requirements?: string[]\n decisions?: string[]\n filesInspected?: string[]\n editsMade?: string[]\n validationResults?: string[]\n reviewReceipts?: string[]\n blockers?: string[]\n nextActions?: string[]\n historicalSummary?: string\n evidence?: {\n id: string\n kind:\n | 'requirement'\n | 'decision'\n | 'read'\n | 'edit'\n | 'validation'\n | 'review'\n | 'blocker'\n | 'handoff'\n | 'note'\n summary: string\n source?: string\n path?: string\n freshnessHash?: string\n workspaceRevision?: number\n verifiedAt?: number\n supersedes?: string[]\n stale?: boolean\n }[]\n workspaceRevision?: number\n workspaceSnapshotId?: string\n }\n expectedTaskMemoryRevision?: number\n}\n\n/**\n * JSON object to set as the agent output. The shape of the parameters are specified dynamically further down in the conversation. This completely replaces any previous output. If the agent was spawned, this value will be passed back to its parent. If the agent has an outputSchema defined, the output will be validated against it.\n */\nexport interface SetOutputParams {\n data?: Record\n [key: string]: any\n}\n\n/**\n * Load a skill by name to get its full instructions. Skills provide reusable behaviors and instructions.\n */\nexport interface SkillParams {\n /** The name of the skill to load */\n name: string\n}\n\n/**\n * Spawn up to 8 agents and send a prompt and/or parameters to each of them. These agents will run in parallel. Note that that means they will run independently. Split larger work into bounded waves. If you need to run agents sequentially, use spawn_agents with one agent at a time instead.\n */\nexport interface SpawnAgentsParams {\n agents: {\n /** Agent to spawn */\n agent_type: string\n /** Prompt to send to the agent */\n prompt?: string\n /** If true, launch the agent detached from this turn. spawn_agents returns immediately with a jobId; the agent runs as an in-process coroutine. Poll its progress with check_background_agent. Use for long-running, non-blocking work (e.g. indexing, eval runs, multi-step research) where you do not need the result before ending your turn. The background agent shares the same process so it cannot outlive this CLI session. Defaults to false (blocking). */\n background?: boolean\n /** Optional structured handoff payload. Purely additive — children that do not consume `handoff` continue to receive `prompt` and `params` as before. */\n handoff?:\n | {\n schemaVersion: 1\n taskId: string\n role:\n | 'orchestrator'\n | 'explorer'\n | 'thinker'\n | 'editor'\n | 'repair-editor'\n | 'test-writer'\n | 'doc-writer'\n | 'dependency-manager'\n | 'debugger'\n | 'validator'\n | 'reviewer'\n | 'security-reviewer'\n | 'committer'\n | 'synthesizer'\n | 'specialist'\n | 'general'\n objective: string\n requirements: {\n id: string\n text: string\n required: boolean\n }[]\n acceptanceCriteria: {\n id: string\n behavior: string\n verification: string\n }[]\n context:\n | {\n path: string\n symbols: string[]\n reason: string\n confidence: 'confirmed' | 'inferred' | 'unknown'\n freshnessHash?: string\n workspaceRevision?: number\n }[]\n | Record\n | string\n currentBehavior?: string\n desiredBehavior?: string\n invariants?: string[]\n nonGoals: string[]\n risks?: string[]\n unknowns?: string[]\n findings: {\n id: string\n text: string\n files: string[]\n snapshotFingerprint: string\n }[]\n permissions: {\n readablePaths: string[]\n writablePaths: string[]\n allowedTools: string[]\n }\n workspaceRevision?: number\n workspaceSnapshotId?: string\n summary?: string\n artifacts?: string[]\n successCriteria?: string[]\n constraints?: string[]\n }\n | Record\n /** Optional per-spawn wall-clock deadline in seconds. Omit it or set -1 for no timeout. Positive deadlines are opt-in and should be used only when the caller deliberately wants to stop a long-running child. A configured agent template defaultTimeoutMs still applies when present. */\n timeout_seconds?: number\n /** Parameters object for the agent */\n params?: {\n /** Terminal command to run (basher, tmux-cli) */\n command?: string\n /** What information from the command output is desired (basher) */\n what_to_summarize?: string\n /** Timeout for command. Set to -1 for no timeout. Default 30 (basher) */\n timeout_seconds?: number\n /** Save full command output to a /tmp log and extract failure lines for long SYNC command output (basher) */\n save_full_log?: boolean\n /** grep -E failure extraction pattern used with save_full_log (basher) */\n failure_pattern?: string\n /** Maximum extracted failure lines to return with save_full_log (basher) */\n max_failure_lines?: number\n /** Array of code search queries (code-searcher) */\n searchQueries?: {\n /** The pattern to search for */\n pattern: string\n /** Optional ripgrep flags as one string or argv tokens (e.g. \"-i -g *.ts\" or [\"-i\", \"-g\", \"*.ts\"]). Do not quote the entire expression inside the JSON string. */\n flags?: string | string[]\n /** Optional working directory relative to project root */\n cwd?: string\n /** Max results per file. Default 15 */\n maxResults?: number\n }[]\n /** Relevant file paths to read (general-agent) */\n filePaths?: string[]\n /** Relevant directory paths to inventory (general-agent) */\n directoryPaths?: string[]\n /** Directories to search within (file-picker) */\n directories?: string[]\n /** Starting URL to navigate to (browser-use) */\n url?: string\n /** Optional agent-specific prompts */\n prompts?: string[]\n [key: string]: any\n }\n }[]\n}\n\n/**\n * Parameters for str_replace tool\n */\nexport interface StrReplaceParams {\n /** The file to edit. */\n path: string\n atomic?: boolean\n replacements: {\n oldString: string\n newString: string\n allowMultiple?: boolean\n occurrenceIndex?: number\n /** Optional authenticated cap.v3 readCapability copied verbatim from the matching fresh read_files editAnchor. */\n basedOnRead?: string\n skipIfMissing?: boolean\n }[]\n}\n\n/**\n * Suggest clickable followup prompts to the user. Each followup becomes a card the user can click to send that prompt.\n */\nexport interface SuggestFollowupsParams {\n /** List of suggested followup prompts the user can click to send */\n followups: {\n /** The full prompt text to send as a user message when clicked */\n prompt: string\n /** Short display label for the card (defaults to truncated prompt if not provided) */\n label?: string\n }[]\n}\n\n/**\n * Signal that the task is complete. Use this tool when:\n- The user's request is completely fulfilled\n- You need clarification from the user before continuing\n- You are stuck or need help from the user to continue\n\nThis tool explicitly marks the end of your work on the current task.\n */\nexport interface TaskCompletedParams {}\n\n/**\n * Deeply consider complex tasks by brainstorming approaches and tradeoffs step-by-step.\n */\nexport interface ThinkDeeplyParams {\n /** Detailed step-by-step analysis. Initially keep each step concise (max ~5-7 words per step). */\n thought: string\n}\n\n/**\n * Parameters for update_plan_status tool\n */\nexport interface UpdatePlanStatusParams {\n /** Artifact path. Must be `.agents/sessions//PLAN.md`, `.agents/sessions//STATUS.md`, or `.agents/sessions//LESSONS.md`. Absolute paths and `..` traversal are rejected. Editing PLAN.md is permitted only for tri-state task toggles (not full overwrites). */\n path: string\n /** Targeted updates applied in order. Each entry rewrites at most one matching checklist line; unmatched updates fall through to `append`. */\n updates?: {\n /** Stable task ID at the start of a checklist line (for example `P2-T3`). Preferred over substring matching. */\n taskId?: string\n /** Substring of the existing task/checklist line to match (case-insensitive). The first matching `- [ ]`/`-[x]`/`-[~]`/`-[/]`/`-[!]` line in the artifact will be updated in place. */\n task?: string\n /** When provided, sets the checkbox state of the matched line (true -> `[x]`, false -> `[ ]`). Ignored when `status` is also provided. */\n completed?: boolean\n /** Explicit tri-state task status. When provided, overrides `completed`. Transitions a task to `in_progress` (`[~]`), `done` (`[x]`), `cancelled` (`[/]`), `blocked` (`[!]`), or back to `pending` (`[ ]`). */\n status?: 'pending' | 'in_progress' | 'done' | 'cancelled' | 'blocked'\n /** Optional short note to append to the matched line in parentheses. Preserves any existing trailing text on the line. */\n note?: string\n }[]\n /** Optional delimited entry appended at the end of the artifact (used when there is no matching task line for the change being recorded). */\n append?: {\n /** Short heading for an appended entry. Used to form a clearly delimited block (`## `). */\n heading: string\n /** Markdown body for the appended entry. Written verbatim under the heading. */\n body: string\n }\n /** Optional session-level status transition. When provided, `.agents/sessions//STATE.json` is created or updated to reflect the new lifecycle status. */\n sessionStatus?:\n | 'draft'\n | 'ready'\n | 'active'\n | 'executing'\n | 'validating'\n | 'reviewing'\n | 'blocked'\n | 'paused'\n | 'completed'\n | 'archived'\n /** Optional current-task pointer written as a `` annotation in PLAN.md. Pass an empty string or omit to clear the pointer. Only takes effect when path targets PLAN.md. */\n currentTask?: string\n /** Optional STATE.json compare-and-swap revision. The update fails without writing when the current revision differs. */\n expectedRevision?: number\n /** Validation or review evidence associated with a stable task ID. Completing a PLAN task requires a passed validation checkpoint with receiptIds. */\n checkpoint?: {\n taskId: string\n phase: 'validation' | 'review'\n passed: boolean\n summary?: string\n receiptIds?: string[]\n }\n}\n\n/**\n * Search the web for current information, or fetch the content of a specific URL.\n */\nexport interface WebSearchParams {\n /** The search query to find relevant web content. Required unless url is provided. */\n query?: string\n /** A specific URL to fetch and read the full text content of. When provided, fetches this page directly instead of searching. Useful for reading documentation, GitHub READMEs, blog posts, or any public web page. */\n url?: string\n /** Search depth - 'standard' for quick results, 'deep' for more comprehensive search. Default is 'standard'. Ignored when url is provided. */\n depth?: 'standard' | 'deep'\n /** When fetching a URL, also extract and return links found on the page. Enables navigation by letting you see what pages are linked. Default: true. */\n include_links?: boolean\n /** Maximum number of links to extract when include_links is true. Default: 40. */\n max_links?: number\n}\n\n/**\n * Create or overwrite a file with the given content.\n */\nexport interface WriteFileParams {\n /** Path to the file relative to the **project root** */\n path: string\n /** What the change is intended to do in only one sentence. */\n instructions: string\n /** Complete file content to write to the file. */\n content: string\n}\n\n/**\n * Parameters for write_audit_findings tool\n */\nexport interface WriteAuditFindingsParams {\n /** Existing durable audit session slug under .agents/sessions/. */\n sessionSlug: string\n /** Unique shard identifier used as the findings filename. */\n shardId: string\n /** Exact snapshotId returned by inspect_codebase_structure. Required for a directly composable structuralReceipt; omitted only for legacy callers. */\n snapshotId?: string\n findings: {\n severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'\n domain:\n | 'security'\n | 'correctness'\n | 'state-mutation'\n | 'error-handling'\n | 'performance'\n | 'dependency-hygiene'\n | 'test-coverage'\n | 'api-contract'\n | 'api-abi'\n path: string\n line?: number\n title: string\n risk: string\n fix: string\n evidence: string\n }[]\n coverage: {\n subsystemIds: string[]\n featureIds: string[]\n files: string[]\n domains?: (\n | 'security'\n | 'correctness'\n | 'state-mutation'\n | 'error-handling'\n | 'performance'\n | 'dependency-hygiene'\n | 'test-coverage'\n | 'api-contract'\n )[]\n }\n noIssuesFound?: boolean\n}\n\n/**\n * Write a todo list to track tasks for multi-step implementations. Use this frequently to maintain an updated step-by-step plan.\n */\nexport interface WriteTodosParams {\n /** List of todos with their completion status. Add ALL of the applicable tasks to the list, so you don't forget to do anything. Try to order the todos the same way you will complete them. Do not mark todos as completed if you have not completed them yet! */\n todos: {\n /** Description of the task */\n task: string\n /** Whether the task is completed */\n completed: boolean\n }[]\n}\n\n/**\n * Get parameters type for a specific tool\n */\nexport type GetToolParams = ToolParamsMap[T]\n" +export const toolsSource = "/**\n * Union type of all available tool names\n */\nexport type ToolName =\n | 'apply_patch'\n | 'add_message'\n | 'ask_user'\n | 'check_background_agent'\n | 'check_job'\n | 'code_search'\n | 'end_turn'\n | 'edit_transaction'\n | 'edit_3d_asset'\n | 'find_files'\n | 'find_files_matching_content'\n | 'git_status'\n | 'git_branch'\n | 'get_task'\n | 'get_change_review_bundle'\n | 'inspect_workspace'\n | 'inspect_environment'\n | 'inspect_3d_asset'\n | 'get_affected_tests'\n | 'get_build_targets'\n | 'inspect_codebase_structure'\n | 'inspect_feature_completeness'\n | 'evaluate_audit_coverage'\n | 'glob'\n | 'kill_job'\n | 'list_directory'\n | 'lookup_agent_info'\n | 'query_index'\n | 'read_docs'\n | 'read_files'\n | 'read_image'\n | 'render_3d_preview'\n | 'read_logs'\n | 'read_outline'\n | 'read_subtree'\n | 'replace_range'\n | 'rewrite_symbol'\n | 'render_ui'\n | 'run_file_change_hooks'\n | 'run_targeted_validation'\n | 'run_terminal_command'\n | 'set_messages'\n | 'set_output'\n | 'skill'\n | 'spawn_agents'\n | 'str_replace'\n | 'suggest_followups'\n | 'task_completed'\n | 'think_deeply'\n | 'update_plan_status'\n | 'web_search'\n | 'write_file'\n | 'write_audit_findings'\n | 'write_todos'\n\n/**\n * Map of tool names to their parameter types\n */\nexport interface ToolParamsMap {\n apply_patch: ApplyPatchParams\n add_message: AddMessageParams\n ask_user: AskUserParams\n check_background_agent: CheckBackgroundAgentParams\n check_job: CheckJobParams\n code_search: CodeSearchParams\n end_turn: EndTurnParams\n edit_transaction: EditTransactionParams\n edit_3d_asset: Edit3dAssetParams\n find_files: FindFilesParams\n find_files_matching_content: FindFilesMatchingContentParams\n git_status: GitStatusParams\n git_branch: GitBranchParams\n get_task: GetTaskParams\n get_change_review_bundle: GetChangeReviewBundleParams\n inspect_workspace: InspectWorkspaceParams\n inspect_environment: InspectEnvironmentParams\n inspect_3d_asset: Inspect3dAssetParams\n get_affected_tests: GetAffectedTestsParams\n get_build_targets: GetBuildTargetsParams\n inspect_codebase_structure: InspectCodebaseStructureParams\n inspect_feature_completeness: InspectFeatureCompletenessParams\n evaluate_audit_coverage: EvaluateAuditCoverageParams\n glob: GlobParams\n kill_job: KillJobParams\n list_directory: ListDirectoryParams\n lookup_agent_info: LookupAgentInfoParams\n query_index: QueryIndexParams\n read_docs: ReadDocsParams\n read_files: ReadFilesParams\n read_image: ReadImageParams\n render_3d_preview: Render3dPreviewParams\n read_logs: ReadLogsParams\n read_outline: ReadOutlineParams\n read_subtree: ReadSubtreeParams\n replace_range: ReplaceRangeParams\n rewrite_symbol: RewriteSymbolParams\n render_ui: RenderUiParams\n run_file_change_hooks: RunFileChangeHooksParams\n run_targeted_validation: RunTargetedValidationParams\n run_terminal_command: RunTerminalCommandParams\n set_messages: SetMessagesParams\n set_output: SetOutputParams\n skill: SkillParams\n spawn_agents: SpawnAgentsParams\n str_replace: StrReplaceParams\n suggest_followups: SuggestFollowupsParams\n task_completed: TaskCompletedParams\n think_deeply: ThinkDeeplyParams\n update_plan_status: UpdatePlanStatusParams\n web_search: WebSearchParams\n write_file: WriteFileParams\n write_audit_findings: WriteAuditFindingsParams\n write_todos: WriteTodosParams\n}\n\n/**\n * Parameters for apply_patch tool\n */\nexport interface ApplyPatchParams {\n operation:\n | {\n type: 'create_file'\n path: string\n diff: string\n }\n | {\n type: 'update_file'\n path: string\n diff: string\n basedOnRead?: string[]\n }\n | {\n type: 'delete_file'\n path: string\n }\n}\n\n/**\n * Add a new message to the conversation history. To be used for complex requests that can't be solved in a single step, as you may forget what happened!\n */\nexport interface AddMessageParams {\n role: 'user' | 'assistant'\n content: string\n}\n\n/**\n * Ask the user a list of multiple choice questions. Each question must have at least 2 options. The agent execution will pause until the user submits their answers.\n */\nexport interface AskUserParams {\n /** List of multiple choice questions to ask the user */\n questions: {\n /** The question to ask the user */\n question: string\n /** Optional short display label. Values longer than 18 Unicode code points are truncated instead of rejecting the question. */\n header?: string\n /** Array of answer options with label and optional description. */\n options: {\n /** The display text for this option */\n label: string\n /** Explanation shown when option is focused */\n description?: string\n }[]\n /** If true, allows selecting multiple options (checkbox). If false, single selection only (radio). */\n multiSelect?: boolean\n /** Validation rules for \"Other\" text input */\n validation?: {\n /** Maximum length for \"Other\" text input */\n maxLength?: number\n /** Minimum length for \"Other\" text input */\n minLength?: number\n /** Regex pattern for \"Other\" text input */\n pattern?: string\n /** Custom error message when pattern fails */\n patternError?: string\n }\n }[]\n}\n\n/**\n * Poll or follow a background agent turn started by spawn_agents({ background: true }): returns the streamed chunks produced since the last check plus the job status. Use it to observe a long-running background agent without blocking the turn.\n */\nexport interface CheckBackgroundAgentParams {\n /** The jobId returned by spawn_agents({ background: true }) for the background agent turn. */\n jobId: string\n /** Optional sequence cursor from a prior response. Polling is idempotent for an explicit cursor; nextCursor can be supplied on the next call. */\n cursor?: number\n /** Optional substring to wait for in the new streamed chunks before returning (follow mode). Returns early as soon as it appears in any chunk payload. Useful for waiting until a background agent emits a specific milestone (e.g. a tool_result or a text marker). */\n wait_for?: string\n /** Max seconds to wait for new chunks / the wait_for pattern. 0 (default) returns immediately with whatever new chunks exist (poll mode); >0 blocks up to this long (follow mode). */\n timeout_seconds?: number\n /** When true, explicitly cancel the running background agent before returning its final status. Defaults to false. */\n cancel?: boolean\n}\n\n/**\n * Poll or follow a background job started by run_terminal_command: returns the output produced since the last check plus the job status and exit code. Use it to observe a long-running process without blocking the turn. To watch an arbitrary log file, start a `tail -f ` BACKGROUND job and check_job it with a wait_for pattern.\n */\nexport interface CheckJobParams {\n /** The jobId returned by run_terminal_command with process_type: BACKGROUND. */\n jobId: string\n /** Optional substring to wait for in the new output before returning (follow mode). Returns early as soon as it appears (e.g. \"Listening on\" / \"compiled successfully\"). */\n wait_for?: string\n /** Max seconds to wait for new output / the wait_for pattern. 0 (default) returns immediately with whatever new output exists (poll mode); >0 blocks up to this long (follow mode). */\n timeout_seconds?: number\n /** Follow mode only: when true and the follow-timeout fires (deadline reached, wait_for not yet matched, job still running), send SIGTERM to the background job and reflect the post-kill status/exitCode plus `killed: true` in the result. Defaults to false so observational polling never terminates work unless explicitly requested. Poll mode (timeout_seconds 0/omitted) never kills regardless of this flag. */\n kill_on_timeout?: boolean\n}\n\n/**\n * Search for string patterns in the project's files. This tool uses ripgrep (rg), a fast line-oriented search tool. Use this tool only when read_files is not sufficient to find the files you need.\n */\nexport interface CodeSearchParams {\n /** The pattern to search for. */\n pattern: string\n /** Optional ripgrep flags as one string or argv tokens (e.g., \"-i -g *.ts -g *.js\" or [\"-i\", \"-g\", \"*.ts\"]). JSON quotes delimit the string; do not embed another quote pair around the entire expression. Line numbers are automatic. */\n flags?: string | string[]\n /** Optional working directory to search within, relative to the project root. Defaults to searching the entire project. */\n cwd?: string\n /** Maximum number of results to return per file. Defaults to 15. There is also a global limit of 250 results across all files. */\n maxResults?: number\n}\n\n/**\n * End your turn, regardless of any new tool results that might be coming. This will allow the user to type another prompt.\n */\nexport interface EndTurnParams {}\n\n/**\n * Parameters for edit_transaction tool\n */\nexport interface EditTransactionParams {\n edits: (\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'str_replace'\n replacements: {\n oldString: string\n newString: string\n allowMultiple?: boolean\n occurrenceIndex?: number\n /** Optional authenticated cap.v3 readCapability copied verbatim from the matching fresh read_files editAnchor. */\n basedOnRead?: string\n skipIfMissing?: boolean\n }[]\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n /** A structured edit dispatched by operation kind. */\n type: 'structured'\n /** Structured edit operation to apply to this file. */\n operation:\n | {\n /** Deterministic text insertion. */\n kind: 'insert_text'\n /** 1-indexed insertion position. */\n position: {\n /** 1-indexed target line. */\n line: number\n /** 1-indexed target column. */\n column: number\n }\n text: string\n }\n | {\n /** Language-aware import insertion. */\n kind: 'insert_import'\n /** Complete language-native import statement to add, e.g. \"import { foo } from 'bar'\", \"from app import value\", or \"use crate::value\". */\n importStatement: string\n }\n | {\n /** Language-aware import removal. */\n kind: 'remove_import'\n /** Complete language-native import statement to remove. Required unless moduleSpecifier is provided. */\n importStatement?: string\n /** Module specifier to remove imports from, e.g. \"react\" or \"./helper\". */\n moduleSpecifier?: string\n }\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'create'\n /** Exact bytes to write to the new file. */\n content: string\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'delete'\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'move'\n /** New project-relative path. The destination must be absent. */\n destinationPath: string\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'replace_range'\n readCapability: string\n startLine?: number\n endLine?: number\n newContent: string\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'rewrite_symbol'\n symbol: string\n content: string\n occurrence?: number\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'patch'\n diff: string\n }\n | {\n /** Optional stable edit identifier echoed in diagnostics. */\n id?: string\n /** The file to edit. */\n path: string\n type: 'write_file'\n content: string\n /** Optional whole-file-covering cap.v3 from a fresh complete whole-file read. Only a full-file capability with a hash matching current content may authorize overwrite; partial ranges never authorize write_file. */\n basedOnRead?: string\n }\n )[]\n}\n\n/**\n * Parameters for edit_3d_asset tool\n */\nexport interface Edit3dAssetParams {\n /** Project-relative .blend path. */\n path: string\n /** Exact source hash returned by inspect_3d_asset. */\n source_hash: string\n operations: (\n | {\n type: 'rename_object'\n object: string\n new_name: string\n }\n | {\n type: 'set_object_transform'\n object: string\n location?: any[]\n rotation_degrees?: any[]\n scale?: any[]\n }\n | {\n type: 'set_render_resolution'\n width: number\n height: number\n percentage?: number\n }\n | {\n type: 'set_frame_range'\n start: number\n end: number\n }\n )[]\n}\n\n/**\n * Find several files related to a brief natural language description of the files or the name of a function or class you are looking for.\n */\nexport interface FindFilesParams {\n /** A brief natural language description of the files or the name of a function or class you are looking for. It's also helpful to mention a directory or two to look within. */\n prompt: string\n}\n\n/**\n * List unique file paths whose content matches a pattern, with optional symbol grouping. Built on top of ripgrep (rg).\n */\nexport interface FindFilesMatchingContentParams {\n /** Regex pattern (ripgrep syntax) to match file content against. */\n pattern: string\n /** Optional safe ripgrep flags as one string or argv tokens. Allowed: -i/--ignore-case, -S/--smart-case, -s/--case-sensitive, -w/--word-regexp, -F/--fixed-strings, -U/--multiline, --multiline-dotall, -g/--glob, -t/--type, -T/--type-not. Examples: \"-g *.ts -g *.tsx\" or [\"-g\", \"*.ts\", \"-g\", \"*.tsx\"]. Do not quote the entire expression inside the JSON string. */\n flags?: string | string[]\n /** Optional working directory to search within, relative to the project root. Defaults to the project root. */\n cwd?: string\n /** Maximum number of unique files to return. Defaults to 100. */\n maxFiles?: number\n /** When true, also return the names of the top-level symbols (functions, classes, methods, exports, constants) that contain each match, plus the per-file match count. Symbol extraction is heuristic and works best for JS/TS/Python/Go/Rust source files; languages without a recognized declaration shape produce an empty symbols list. */\n groupBySymbol?: boolean\n /** Maximum seconds to let ripgrep run before returning partial results. Defaults to 15. */\n timeoutSeconds?: number\n}\n\n/**\n * Read-only git status and (optionally) diff for the current project.\n */\nexport interface GitStatusParams {\n /** When true, also return the unified diff of uncommitted changes. */\n include_diff?: boolean\n /** When true with include_diff, returns the staged diff instead of unstaged. */\n staged?: boolean\n /** Optional path to scope status/diff to (relative to project root). */\n path?: string\n /** Maximum characters of diff output to return. Defaults to 40,000. */\n max_chars?: number\n}\n\n/**\n * Create a new git branch, optionally switching to it. Refuses to branch when the working tree is dirty unless `allow_dirty` is true.\n */\nexport interface GitBranchParams {\n /** Name of the branch to create. Must start with an alphanumeric character and contain only [a-zA-Z0-9._/-]. */\n branch_name: string\n /** When true (default), create AND switch to the branch (`git checkout -b`). When false, only create the branch (`git branch`), leaving the current branch checked out. */\n switch?: boolean\n /** When true, skip the dirty-tree refusal check. Defaults to false — the tool refuses to branch when the working tree has uncommitted changes. */\n allow_dirty?: boolean\n}\n\n/**\n * Parameters for get_task tool\n */\nexport interface GetTaskParams {\n /** Optional plan session slug. Defaults to .agents/ACTIVE_SESSION. */\n session?: string\n}\n\n/**\n * Parameters for get_change_review_bundle tool\n */\nexport interface GetChangeReviewBundleParams {\n max_chars?: number\n}\n\n/**\n * Inspect the current repository/worktree identity and Git state without modifying it.\n */\nexport interface InspectWorkspaceParams {}\n\n/**\n * Parameters for inspect_environment tool\n */\nexport interface InspectEnvironmentParams {}\n\n/**\n * Parameters for inspect_3d_asset tool\n */\nexport interface Inspect3dAssetParams {\n /** Project-relative 3D asset path. */\n path: string\n}\n\n/**\n * Parameters for get_affected_tests tool\n */\nexport interface GetAffectedTestsParams {\n files: string[]\n}\n\n/**\n * Parameters for get_build_targets tool\n */\nexport interface GetBuildTargetsParams {\n files: string[]\n}\n\n/**\n * Parameters for inspect_codebase_structure tool\n */\nexport interface InspectCodebaseStructureParams {\n scope?: string[]\n}\n\n/**\n * Parameters for inspect_feature_completeness tool\n */\nexport interface InspectFeatureCompletenessParams {\n feature: string\n snapshot_id: string\n scope?: string[]\n}\n\n/**\n * Parameters for evaluate_audit_coverage tool\n */\nexport interface EvaluateAuditCoverageParams {\n snapshot_id: string\n structural_receipts: {\n schema_version: 1\n snapshot_id: string\n shard_id: string\n subsystem_ids: string[]\n files: string[]\n domains: (\n | 'security'\n | 'correctness'\n | 'state-mutation'\n | 'error-handling'\n | 'performance'\n | 'dependency-hygiene'\n | 'test-coverage'\n | 'api-contract'\n )[]\n }[]\n features: {\n schema_version: 1\n snapshot_id: string\n feature: string\n evidence_kind: 'heuristic' | 'verified'\n evidence: {\n entrypoints: string[]\n implementation: string[]\n consumers: string[]\n tests: string[]\n docs: string[]\n failure_states: string[]\n }\n }[]\n out_of_scope?: {\n id: string\n reason: string\n }[]\n scope?: string[]\n}\n\n/**\n * Search for files matching a glob pattern. Returns matching file paths sorted by modification time (newest first, then path for deterministic ties).\n */\nexport interface GlobParams {\n /** Glob pattern to match files against (e.g., *.js, src/glob/*.ts, glob/test/glob/*.go). */\n pattern: string\n /** Optional working directory to search within, relative to project root. If provided, the glob pattern is matched against paths relative to this cwd, while returned files remain project-relative. If not provided, searches from project root. */\n cwd?: string\n}\n\n/**\n * Cancel a background job started by run_terminal_command.\n */\nexport interface KillJobParams {\n /** The jobId returned by run_terminal_command with process_type: BACKGROUND. */\n jobId: string\n /** Signal to send. Defaults to SIGTERM; use SIGKILL only if graceful termination fails. */\n signal?: 'SIGTERM' | 'SIGKILL'\n}\n\n/**\n * List files and directories in the specified path. Returns separate arrays of file names and directory names.\n */\nexport interface ListDirectoryParams {\n /** Directory path to list, relative to the project root. */\n path: string\n}\n\n/**\n * Retrieve information about an agent by ID\n */\nexport interface LookupAgentInfoParams {\n /** Agent ID (short local or full published format) */\n agentId: string\n}\n\n/**\n * Query the local codebase graph index to find relevant files ranked by symbol names, imports, headings, paths, doc concepts, and graph relationships. The index is built automatically on startup.\n */\nexport interface QueryIndexParams {\n /** Natural language query or keyword terms describing the files you are looking for. Optional for graph modes when from/to paths are provided. For example: \"authentication\", \"database migrations\", \"editor mutation logic\", \"React components\". */\n query?: string\n /** Maximum number of results to return. Defaults to 20. */\n limit?: number\n /** Optional list of file extensions to filter results (without dot). E.g. [\"ts\", \"tsx\"] for TypeScript only. */\n fileTypes?: string[]\n /** Optional normalized project-relative directory prefixes. Results outside every prefix are excluded before ranking/limiting. */\n pathPrefixes?: string[]\n /** Query mode. search returns ranked files, explain includes ranking rationale, neighbors returns adjacent graph files, path returns a graph path between files, commands prioritizes package scripts, CI workflows, task runners, and validation docs, and references returns files that import or call into a seed file (blast-radius analysis before editing an exported symbol). */\n mode?: 'search' | 'neighbors' | 'path' | 'explain' | 'commands' | 'references'\n /** Optional source file path for neighbors, path, and references modes. */\n from?: string\n /** Optional target file path for path mode. Also used as the seed file for references mode when from is omitted or not indexed. */\n to?: string\n}\n\n/**\n * Fetch up-to-date documentation for libraries and frameworks using Context7 API.\n */\nexport interface ReadDocsParams {\n /** The library or framework name (e.g., \"Next.js\", \"MongoDB\", \"React\"). Use the official name as it appears in documentation if possible. Only public libraries available in Context7's database are supported, so small or private libraries may not be available. */\n libraryTitle: string\n /** Specific topic to focus on (e.g., \"routing\", \"hooks\", \"authentication\") */\n topic: string\n /** Optional maximum number of tokens to return. Defaults to 10000. Values less than 10000 are automatically increased to 10000. */\n max_tokens?: number\n}\n\n/**\n * Read multiple files from disk and return their contents. Use this tool to read as many files as would be helpful to answer the user's request.\n */\nexport interface ReadFilesParams {\n /** List of file paths to read. Each complete result includes a readCapability that can be copied directly to basedOnRead for a follow-up edit. Batch results include a separate summary entry with ok/failed/requested counts when available. */\n paths?: string[]\n /** Optional: read only a 1-indexed inclusive line range of specific files. Use this to page through large files that exceeded the read limit. Each entry reads `path` from startLine..endLine. When exactly one paths entry is supplied, a missing range path is inferred from it. */\n ranges?: {\n /** File path to read a line range from, relative to the project root. */\n path: string\n /** 1-indexed inclusive start line. Defaults to 1. */\n startLine?: number\n /** 1-indexed inclusive end line. Defaults to the last line. */\n endLine?: number\n }[]\n /** Optional: instead of (or in addition to) whole files, pull just the implementation slices for named symbols. Prefer this over a full read when you already know which functions/classes you need, especially in large files. Each returned slice includes one editAnchor whose readCapability can anchor a later edit. */\n symbols?: {\n /** File path to extract symbol slices from, relative to the project root. */\n path: string\n /** Symbol names (functions, classes, interfaces, methods) to slice. */\n names: string[]\n }[]\n}\n\n/**\n * Read image files from disk and return them as model-visible image media.\n */\nexport interface ReadImageParams {\n /** List of image file paths to read. */\n paths: string[]\n}\n\n/**\n * Parameters for render_3d_preview tool\n */\nexport interface Render3dPreviewParams {\n /** Project-relative 3D asset path. */\n path: string\n views?: ('camera' | 'perspective' | 'front' | 'side' | 'top')[]\n mode?: 'material' | 'clay' | 'wireframe'\n width?: number\n height?: number\n}\n\n/**\n * Read the last N lines from a log/text file or background job log without starting a background tail process.\n */\nexport interface ReadLogsParams {\n /** Path to the log file, relative to the project root unless absolute. Required unless jobId is provided. */\n path?: string\n /** Background job id returned by run_terminal_command(process_type: BACKGROUND). When provided, reads the job log file directly. */\n jobId?: string\n /** Number of trailing lines to read. Defaults to 200. */\n lines?: number\n /** Maximum characters to return. Defaults to 20,000. */\n max_chars?: number\n}\n\n/**\n * Generate an outline of imports, exports, classes, methods, and function signatures in a source file without reading the entire implementation.\n */\nexport interface ReadOutlineParams {\n /** File path to generate the AST-like outline for, relative to the project root. */\n path: string\n}\n\n/**\n * Read one or more directory subtrees (as a blob including subdirectories, file names, and parsed variables within each source file) or return parsed variable names for files. If no paths are provided, returns the entire project tree.\n */\nexport interface ReadSubtreeParams {\n /** List of paths to directories or files. Relative to the project root. If omitted, the entire project tree is used. */\n paths?: string[]\n /** Maximum token budget for the subtree blob; the tree will be truncated to fit within this budget by first dropping file variables and then removing the most-nested files and directories. */\n maxTokens?: number\n}\n\n/**\n * Replace all or a contained sub-range of content observed through one fresh cap.v3 read capability.\n */\nexport interface ReplaceRangeParams {\n /** The path to the file to edit. */\n path: string\n /** Copy the cap.v3 readCapability verbatim from the matching fresh read_files editAnchor. The token supplies the observed line bounds and content hash. */\n readCapability: string\n /** Optional 1-indexed target start within the capability-covered range. Omit with endLine to replace the complete observed range. */\n startLine?: number\n /** Optional 1-indexed target end within the capability-covered range. Omit with startLine to replace the complete observed range. */\n endLine?: number\n /** Complete replacement content for the selected line range. */\n newContent: string\n}\n\n/**\n * Replace a whole symbol's definition by name using the file's syntax tree, without copying its current text. Resolves the exact AST range and applies it through the safe str_replace path (atomic, anchored).\n */\nexport interface RewriteSymbolParams {\n /** File path containing the symbol, relative to the project root. */\n path: string\n /** Name of the function/class/method/type/interface to replace (as shown by read_outline). */\n symbol: string\n /** The complete new source for the symbol, replacing its entire current definition (e.g. the whole function including its signature and body). Provide REAL newlines/tabs in the string — literal backslash-n (\\n) and backslash-t (\\t) sequences are not interpreted and will be written verbatim into the file. This matches str_replace. */\n content: string\n /** When multiple top-level symbols share this name, the 1-indexed one to replace. */\n occurrence?: number\n}\n\n/**\n * Render a small interactive UI widget in the Openbuff CLI. Currently supports a button that opens a link.\n */\nexport interface RenderUiParams {\n /** The UI widget to render. */\n widget: {\n /** Widget type. Currently, the only supported widget is button. */\n type: 'button'\n /** Short button label shown to the user. */\n text: string\n /** The http:// or https:// URL to open when the user clicks the button. */\n link: string\n /** Theme-aware color treatment. Use primary for the main action and secondary for lower-emphasis actions. */\n variant?: 'primary' | 'secondary'\n }\n}\n\n/**\n * Parameters for run_file_change_hooks tool\n */\nexport interface RunFileChangeHooksParams {\n /** List of file paths that were changed and should trigger file change hooks */\n files: string[]\n}\n\n/**\n * Parameters for run_targeted_validation tool\n */\nexport interface RunTargetedValidationParams {\n snapshot_id: string\n files: string[]\n artifact_kinds?: string[]\n}\n\n/**\n * Execute a CLI command from the **project root** (different from the user's cwd).\n */\nexport interface RunTerminalCommandParams {\n /** CLI command valid for user's OS. */\n command: string\n /** Either SYNC (waits, returns output) or BACKGROUND (starts a detached job and returns immediately with a jobId — poll/follow it with check_job). Use BACKGROUND for long-running or never-exiting processes (dev servers, watchers, log tails) so you don't block the turn. Default SYNC */\n process_type?: 'SYNC' | 'BACKGROUND'\n /** For BACKGROUND commands only: keep the job running if the owning request is cancelled. Defaults to false. */\n detach?: boolean\n /** The working directory to run the command in. Default is the project root. */\n cwd?: string\n /** Set to -1 for no timeout. Does not apply for BACKGROUND commands. Default 30 */\n timeout_seconds?: number\n /** Runtime-managed background job owner; agents must omit. */\n owner?: {\n clientSessionId: string\n rootRunId: string\n parentRunId: string\n parentAgentId: string\n }\n}\n\n/**\n * Atomically replace conversation history and, when supplied, commit a validated structured task-memory revision.\n */\nexport interface SetMessagesParams {\n messages: any\n taskMemory?: {\n schemaVersion: 1\n goal?: string\n requirements?: string[]\n decisions?: string[]\n filesInspected?: string[]\n editsMade?: string[]\n validationResults?: string[]\n reviewReceipts?: string[]\n blockers?: string[]\n nextActions?: string[]\n historicalSummary?: string\n evidence?: {\n id: string\n kind:\n | 'requirement'\n | 'decision'\n | 'read'\n | 'edit'\n | 'validation'\n | 'review'\n | 'blocker'\n | 'handoff'\n | 'note'\n summary: string\n source?: string\n path?: string\n freshnessHash?: string\n workspaceRevision?: number\n verifiedAt?: number\n supersedes?: string[]\n stale?: boolean\n }[]\n workspaceRevision?: number\n workspaceSnapshotId?: string\n }\n expectedTaskMemoryRevision?: number\n}\n\n/**\n * JSON object to set as the agent output. The shape of the parameters are specified dynamically further down in the conversation. This completely replaces any previous output. If the agent was spawned, this value will be passed back to its parent. If the agent has an outputSchema defined, the output will be validated against it.\n */\nexport interface SetOutputParams {\n data?: Record\n [key: string]: any\n}\n\n/**\n * Load a skill by name to get its full instructions. Skills provide reusable behaviors and instructions.\n */\nexport interface SkillParams {\n /** The name of the skill to load */\n name: string\n}\n\n/**\n * Spawn up to 8 agents and send a prompt and/or parameters to each of them. These agents will run in parallel. Note that that means they will run independently. Split larger work into bounded waves. If you need to run agents sequentially, use spawn_agents with one agent at a time instead.\n */\nexport interface SpawnAgentsParams {\n agents: {\n /** Agent to spawn */\n agent_type: string\n /** Prompt to send to the agent */\n prompt?: string\n /** If true, launch the agent detached from this turn. spawn_agents returns immediately with a jobId; the agent runs as an in-process coroutine. Poll its progress with check_background_agent. Use for long-running, non-blocking work (e.g. indexing, eval runs, multi-step research) where you do not need the result before ending your turn. The background agent shares the same process so it cannot outlive this CLI session. Defaults to false (blocking). */\n background?: boolean\n /** Optional structured handoff payload. Purely additive — children that do not consume `handoff` continue to receive `prompt` and `params` as before. */\n handoff?:\n | {\n schemaVersion: 1\n taskId: string\n role:\n | 'orchestrator'\n | 'explorer'\n | 'thinker'\n | 'editor'\n | 'repair-editor'\n | 'test-writer'\n | 'doc-writer'\n | 'dependency-manager'\n | 'debugger'\n | 'validator'\n | 'reviewer'\n | 'security-reviewer'\n | 'committer'\n | 'synthesizer'\n | 'specialist'\n | 'general'\n objective: string\n requirements: {\n id: string\n text: string\n required: boolean\n }[]\n acceptanceCriteria: {\n id: string\n behavior: string\n verification: string\n }[]\n context:\n | {\n path: string\n symbols: string[]\n reason: string\n confidence: 'confirmed' | 'inferred' | 'unknown'\n freshnessHash?: string\n workspaceRevision?: number\n }[]\n | Record\n | string\n currentBehavior?: string\n desiredBehavior?: string\n invariants?: string[]\n nonGoals: string[]\n risks?: string[]\n unknowns?: string[]\n findings: {\n id: string\n text: string\n files: string[]\n snapshotFingerprint: string\n }[]\n permissions: {\n readablePaths: string[]\n writablePaths: string[]\n allowedTools: string[]\n }\n workspaceRevision?: number\n workspaceSnapshotId?: string\n summary?: string\n artifacts?: string[]\n successCriteria?: string[]\n constraints?: string[]\n }\n | Record\n /** Optional per-spawn wall-clock deadline in seconds. Omit it or set -1 for no timeout. Positive deadlines are opt-in and should be used only when the caller deliberately wants to stop a long-running child. A configured agent template defaultTimeoutMs still applies when present. */\n timeout_seconds?: number\n /** Parameters object for the agent */\n params?: {\n /** Terminal command to run (basher, tmux-cli) */\n command?: string\n /** What information from the command output is desired (basher) */\n what_to_summarize?: string\n /** Timeout for command. Set to -1 for no timeout. Default 30 (basher) */\n timeout_seconds?: number\n /** Save full command output to a /tmp log and extract failure lines for long SYNC command output (basher) */\n save_full_log?: boolean\n /** grep -E failure extraction pattern used with save_full_log (basher) */\n failure_pattern?: string\n /** Maximum extracted failure lines to return with save_full_log (basher) */\n max_failure_lines?: number\n /** Array of code search queries (code-searcher) */\n searchQueries?: {\n /** The pattern to search for */\n pattern: string\n /** Optional ripgrep flags as one string or argv tokens (e.g. \"-i -g *.ts\" or [\"-i\", \"-g\", \"*.ts\"]). Do not quote the entire expression inside the JSON string. */\n flags?: string | string[]\n /** Optional working directory relative to project root */\n cwd?: string\n /** Max results per file. Default 15 */\n maxResults?: number\n }[]\n /** Relevant file paths to read (general-agent) */\n filePaths?: string[]\n /** Relevant directory paths to inventory (general-agent) */\n directoryPaths?: string[]\n /** Directories to search within (file-picker) */\n directories?: string[]\n /** Starting URL to navigate to (browser-use) */\n url?: string\n /** Optional agent-specific prompts */\n prompts?: string[]\n [key: string]: any\n }\n }[]\n}\n\n/**\n * Parameters for str_replace tool\n */\nexport interface StrReplaceParams {\n /** The file to edit. */\n path: string\n atomic?: boolean\n replacements: {\n oldString: string\n newString: string\n allowMultiple?: boolean\n occurrenceIndex?: number\n /** Optional authenticated cap.v3 readCapability copied verbatim from the matching fresh read_files editAnchor. */\n basedOnRead?: string\n skipIfMissing?: boolean\n }[]\n}\n\n/**\n * Suggest clickable followup prompts to the user. Each followup becomes a card the user can click to send that prompt.\n */\nexport interface SuggestFollowupsParams {\n /** List of suggested followup prompts the user can click to send */\n followups: {\n /** The full prompt text to send as a user message when clicked */\n prompt: string\n /** Short display label for the card (defaults to truncated prompt if not provided) */\n label?: string\n }[]\n}\n\n/**\n * Signal that the task is complete. Use this tool when:\n- The user's request is completely fulfilled\n- You need clarification from the user before continuing\n- You are stuck or need help from the user to continue\n\nThis tool explicitly marks the end of your work on the current task.\n */\nexport interface TaskCompletedParams {}\n\n/**\n * Deeply consider complex tasks by brainstorming approaches and tradeoffs step-by-step.\n */\nexport interface ThinkDeeplyParams {\n /** Detailed step-by-step analysis. Initially keep each step concise (max ~5-7 words per step). */\n thought: string\n}\n\n/**\n * Parameters for update_plan_status tool\n */\nexport interface UpdatePlanStatusParams {\n /** Artifact path. Must be `.agents/sessions//PLAN.md`, `.agents/sessions//STATUS.md`, or `.agents/sessions//LESSONS.md`. Absolute paths and `..` traversal are rejected. Editing PLAN.md is permitted only for tri-state task toggles (not full overwrites). */\n path: string\n /** Targeted updates applied in order. Each entry rewrites at most one matching checklist line; unmatched updates fall through to `append`. */\n updates?: {\n /** Stable task ID at the start of a checklist line (for example `P2-T3`). Preferred over substring matching. */\n taskId?: string\n /** Substring of the existing task/checklist line to match (case-insensitive). The first matching `- [ ]`/`-[x]`/`-[~]`/`-[/]`/`-[!]` line in the artifact will be updated in place. */\n task?: string\n /** When provided, sets the checkbox state of the matched line (true -> `[x]`, false -> `[ ]`). Ignored when `status` is also provided. */\n completed?: boolean\n /** Explicit tri-state task status. When provided, overrides `completed`. Transitions a task to `in_progress` (`[~]`), `done` (`[x]`), `cancelled` (`[/]`), `blocked` (`[!]`), or back to `pending` (`[ ]`). */\n status?: 'pending' | 'in_progress' | 'done' | 'cancelled' | 'blocked'\n /** Optional short note to append to the matched line in parentheses. Preserves any existing trailing text on the line. */\n note?: string\n }[]\n /** Optional delimited entry appended at the end of the artifact (used when there is no matching task line for the change being recorded). */\n append?: {\n /** Short heading for an appended entry. Used to form a clearly delimited block (`## `). */\n heading: string\n /** Markdown body for the appended entry. Written verbatim under the heading. */\n body: string\n }\n /** Optional session-level status transition. When provided, `.agents/sessions//STATE.json` is created or updated to reflect the new lifecycle status. */\n sessionStatus?:\n | 'draft'\n | 'ready'\n | 'active'\n | 'executing'\n | 'validating'\n | 'reviewing'\n | 'blocked'\n | 'paused'\n | 'completed'\n | 'archived'\n /** Optional current-task pointer written as a `` annotation in PLAN.md. Pass an empty string or omit to clear the pointer. Only takes effect when path targets PLAN.md. */\n currentTask?: string\n /** Optional STATE.json compare-and-swap revision. The update fails without writing when the current revision differs. */\n expectedRevision?: number\n /** Validation or review evidence associated with a stable task ID. Completing a PLAN task requires a passed validation checkpoint with receiptIds. */\n checkpoint?: {\n taskId: string\n phase: 'validation' | 'review'\n passed: boolean\n summary?: string\n receiptIds?: string[]\n }\n}\n\n/**\n * Search the web for current information, or fetch the content of a specific URL.\n */\nexport interface WebSearchParams {\n /** The search query to find relevant web content. Required unless url is provided. */\n query?: string\n /** A specific URL to fetch and read the full text content of. When provided, fetches this page directly instead of searching. Useful for reading documentation, GitHub READMEs, blog posts, or any public web page. */\n url?: string\n /** Search depth - 'standard' for quick results, 'deep' for more comprehensive search. Default is 'standard'. Ignored when url is provided. */\n depth?: 'standard' | 'deep'\n /** When fetching a URL, also extract and return links found on the page. Enables navigation by letting you see what pages are linked. Default: true. */\n include_links?: boolean\n /** Maximum number of links to extract when include_links is true. Default: 40. */\n max_links?: number\n}\n\n/**\n * Create or overwrite a file with the given content.\n */\nexport interface WriteFileParams {\n /** Path to the file relative to the **project root** */\n path: string\n /** What the change is intended to do in only one sentence. */\n instructions: string\n /** Complete file content to write to the file. */\n content: string\n /** Optional whole-file-covering cap.v3 from a fresh complete whole-file read (paths or full-file range). Only a capability that covers the entire current file (startLine=1 through the current line count) with a hash matching current content may authorize overwrite; partial range capabilities never authorize write_file. */\n basedOnRead?: string\n}\n\n/**\n * Parameters for write_audit_findings tool\n */\nexport interface WriteAuditFindingsParams {\n /** Existing durable audit session slug under .agents/sessions/. */\n sessionSlug: string\n /** Unique shard identifier used as the findings filename. */\n shardId: string\n /** Exact snapshotId returned by inspect_codebase_structure. Required for a directly composable structuralReceipt; omitted only for legacy callers. */\n snapshotId?: string\n findings: {\n severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'\n domain:\n | 'security'\n | 'correctness'\n | 'state-mutation'\n | 'error-handling'\n | 'performance'\n | 'dependency-hygiene'\n | 'test-coverage'\n | 'api-contract'\n | 'api-abi'\n path: string\n line?: number\n title: string\n risk: string\n fix: string\n evidence: string\n }[]\n coverage: {\n subsystemIds: string[]\n featureIds: string[]\n files: string[]\n domains?: (\n | 'security'\n | 'correctness'\n | 'state-mutation'\n | 'error-handling'\n | 'performance'\n | 'dependency-hygiene'\n | 'test-coverage'\n | 'api-contract'\n )[]\n }\n noIssuesFound?: boolean\n}\n\n/**\n * Write a todo list to track tasks for multi-step implementations. Use this frequently to maintain an updated step-by-step plan.\n */\nexport interface WriteTodosParams {\n /** List of todos with their completion status. Add ALL of the applicable tasks to the list, so you don't forget to do anything. Try to order the todos the same way you will complete them. Do not mark todos as completed if you have not completed them yet! */\n todos: {\n /** Description of the task */\n task: string\n /** Whether the task is completed */\n completed: boolean\n }[]\n}\n\n/**\n * Get parameters type for a specific tool\n */\nexport type GetToolParams = ToolParamsMap[T]\n" export const utilTypesSource = "// ===== JSON Types =====\nexport type JSONValue =\n | null\n | string\n | number\n | boolean\n | JSONObject\n | JSONArray\n\nexport type JSONObject = { [key: string]: JSONValue }\n\nexport type JSONArray = JSONValue[]\n\n/**\n * JSON Schema definition (for prompt schema or output schema)\n */\nexport type JsonSchema = {\n type?:\n | 'object'\n | 'array'\n | 'string'\n | 'number'\n | 'boolean'\n | 'null'\n | 'integer'\n description?: string\n properties?: Record\n required?: string[]\n enum?: Array\n [k: string]: unknown\n}\nexport type JsonObjectSchema = JsonSchema & { type: 'object' }\n\n// ===== Data Content Types =====\nexport type DataContent = string | Uint8Array | ArrayBuffer | Buffer\n\n// ===== Provider Metadata Types =====\nexport type ProviderMetadata = Record>\n\n// ===== Content Part Types =====\nexport type TextPart = {\n type: 'text'\n text: string\n providerOptions?: ProviderMetadata\n}\n\nexport type ImagePart = {\n type: 'image'\n image: DataContent\n mediaType?: string\n providerOptions?: ProviderMetadata\n}\n\nexport type FilePart = {\n type: 'file'\n data: DataContent\n filename?: string\n mediaType: string\n providerOptions?: ProviderMetadata\n}\n\nexport type ReasoningPart = {\n type: 'reasoning'\n text: string\n providerOptions?: ProviderMetadata\n}\n\nexport type ToolCallPart = {\n type: 'tool-call'\n toolCallId: string\n toolName: string\n input: Record\n providerOptions?: ProviderMetadata\n providerExecuted?: boolean\n}\n\nexport type ToolResultOutput =\n | {\n type: 'json'\n value: JSONValue\n }\n | {\n type: 'media'\n data: string\n mediaType: string\n }\n\n// ===== Message Types =====\nexport type AuxiliaryMessageData = {\n providerOptions?: ProviderMetadata\n tags?: string[]\n\n /** @deprecated Use tags instead. */\n timeToLive?: 'agentStep' | 'userPrompt'\n /** @deprecated Use tags instead. */\n keepDuringTruncation?: boolean\n /** @deprecated Use tags instead. */\n keepLastTags?: string[]\n}\n\nexport type SystemMessage = {\n role: 'system'\n content: TextPart[]\n} & AuxiliaryMessageData\n\nexport type UserMessage = {\n role: 'user'\n content: (TextPart | ImagePart | FilePart)[]\n} & AuxiliaryMessageData\n\nexport type AssistantMessage = {\n role: 'assistant'\n content: (TextPart | ReasoningPart | ToolCallPart)[]\n} & AuxiliaryMessageData\n\nexport type ToolMessage = {\n role: 'tool'\n toolCallId: string\n toolName: string\n content: ToolResultOutput[]\n} & AuxiliaryMessageData\n\nexport type Message =\n | SystemMessage\n | UserMessage\n | AssistantMessage\n | ToolMessage\n\n// ===== MCP Server Types =====\n\n/**\n * MCP server configuration for stdio-based servers.\n *\n * Environment variables in `env` can be:\n * - A plain string value (hardcoded, e.g., `'production'`)\n * - A `$VAR_NAME` reference to read from local environment (e.g., `'$NOTION_TOKEN'`)\n *\n * The `$VAR_NAME` syntax reads from `process.env.VAR_NAME` at agent load time.\n * This keeps secrets out of your agent definitions - store them in `.env.local` instead.\n *\n * @example\n * ```typescript\n * env: {\n * // Read NOTION_TOKEN from local .env file\n * NOTION_TOKEN: '$NOTION_TOKEN',\n * // Read MY_API_KEY from local env, pass as API_KEY to MCP server\n * API_KEY: '$MY_API_KEY',\n * // Hardcoded value (non-secret)\n * NODE_ENV: 'production',\n * }\n * ```\n */\nexport type MCPConfig =\n | {\n type?: 'stdio'\n command: string\n args?: string[]\n env?: Record\n }\n | {\n type?: 'http' | 'sse'\n url: string\n params?: Record\n headers?: Record\n }\n\n// ============================================================================\n// Logger Interface\n// ============================================================================\nexport interface Logger {\n debug: (data: any, msg?: string) => void\n info: (data: any, msg?: string) => void\n warn: (data: any, msg?: string) => void\n error: (data: any, msg?: string) => void\n}\n" diff --git a/common/src/templates/initial-agents-dir/types/tools.ts b/common/src/templates/initial-agents-dir/types/tools.ts index 848bb5a0b7..1c0333d0dd 100644 --- a/common/src/templates/initial-agents-dir/types/tools.ts +++ b/common/src/templates/initial-agents-dir/types/tools.ts @@ -347,6 +347,8 @@ export interface EditTransactionParams { path: string type: 'write_file' content: string + /** Optional whole-file-covering cap.v3 from a fresh complete whole-file read. Only a full-file capability with a hash matching current content may authorize overwrite; partial ranges never authorize write_file. */ + basedOnRead?: string } )[] } @@ -1084,6 +1086,8 @@ export interface WriteFileParams { instructions: string /** Complete file content to write to the file. */ content: string + /** Optional whole-file-covering cap.v3 from a fresh complete whole-file read (paths or full-file range). Only a capability that covers the entire current file (startLine=1 through the current line count) with a hash matching current content may authorize overwrite; partial range capabilities never authorize write_file. */ + basedOnRead?: string } /** diff --git a/common/src/tools/params/__tests__/edit-transaction.schema.test.ts b/common/src/tools/params/__tests__/edit-transaction.schema.test.ts index 491062c0f5..06a6f91e8d 100644 --- a/common/src/tools/params/__tests__/edit-transaction.schema.test.ts +++ b/common/src/tools/params/__tests__/edit-transaction.schema.test.ts @@ -244,4 +244,24 @@ describe('editTransactionParams inputSchema transform — whole-file readCapabil expect(decoded.endLine).toBe(4) expect(decoded.hash).toBe(getContentHash(wholeFileContent)) }) + + it('outputSchema accepts residual failures with basedOnRead capability', () => { + const parsed = editTransactionParams.outputSchema.safeParse([ + { + type: 'json', + value: { + errorMessage: 'edit_transaction blocked', + failures: [ + { + editIndex: 0, + path, + errorMessage: 'no read authorization', + basedOnRead: wholeFileCap, + }, + ], + }, + }, + ]) + expect(parsed.success).toBe(true) + }) }) diff --git a/common/src/tools/params/tool/edit-transaction.ts b/common/src/tools/params/tool/edit-transaction.ts index 3478474230..aad8aa7cb7 100644 --- a/common/src/tools/params/tool/edit-transaction.ts +++ b/common/src/tools/params/tool/edit-transaction.ts @@ -263,6 +263,9 @@ const writeFileEditSchema = editBaseSchema.extend({ content: z.string().refine((value) => !isObviousEditPlaceholder(value), { message: 'content is an explicit placeholder; provide exact file bytes.', }), + basedOnRead: basedOnReadSchema.describe( + 'Optional whole-file-covering cap.v3 from a fresh complete whole-file read. Only a full-file capability with a hash matching current content may authorize overwrite; partial ranges never authorize write_file.', + ), }) export const transactionEditSchema = z.discriminatedUnion('type', [ @@ -361,6 +364,9 @@ export const editTransactionResultSchema = z.union([ id: z.string().optional(), path: z.string(), errorMessage: z.string(), + basedOnRead: basedOnReadSchema.optional().describe( + 'Ready-to-paste whole-file or recovery capability echoed on residual failures.', + ), }), ), }), diff --git a/common/src/tools/params/tool/write-file.ts b/common/src/tools/params/tool/write-file.ts index a9cf033d6b..b6f527d208 100644 --- a/common/src/tools/params/tool/write-file.ts +++ b/common/src/tools/params/tool/write-file.ts @@ -1,5 +1,6 @@ import z from 'zod/v4' +import { basedOnReadSchema } from '../based-on-read' import { updateFileResultSchema } from './str-replace' import { $getNativeToolCallExampleString, @@ -27,6 +28,9 @@ const inputSchema = z 'content is an explicit placeholder. Provide the complete file content; write_file cannot read an out-of-band patch.', }) .describe(`Complete file content to write to the file.`), + basedOnRead: basedOnReadSchema.describe( + 'Optional whole-file-covering cap.v3 from a fresh complete whole-file read (paths or full-file range). Only a capability that covers the entire current file (startLine=1 through the current line count) with a hash matching current content may authorize overwrite; partial range capabilities never authorize write_file.', + ), }) .describe(`Create or overwrite a file with the given content.`) const description = ` @@ -37,6 +41,8 @@ Never pass references such as "[see patch above]"; every call must contain the c #### Additional Info +Existing-file overwrites require a fresh whole-file sticky authorization or an explicit whole-file-covering basedOnRead capability (from a complete paths/full-file range read). Partial range caps never authorize overwrite; capability echo on write_file failures is retry-usable as basedOnRead without an exploratory re-read. + Do not use this tool to delete or rename a file. Use apply_patch.delete_file for a simple deletion, or edit_transaction delete/move for coordinated deletes and renames so the mutation remains authority-checked and visible in receipts and summaries. Examples: diff --git a/common/src/util/__tests__/audit-receipt.test.ts b/common/src/util/__tests__/audit-receipt.test.ts new file mode 100644 index 0000000000..d1b561a601 --- /dev/null +++ b/common/src/util/__tests__/audit-receipt.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'bun:test' + +import { containsStructuralAuditReceipt } from '../audit-receipt' + +describe('containsStructuralAuditReceipt', () => { + it('returns true when a nested structuralReceipt.snapshot_id matches the expected id', () => { + const value = { + messageHistory: [ + { role: 'user', content: 'noise' }, + { + role: 'tool', + content: [ + { type: 'text', text: 'more noise' }, + { + type: 'json', + value: { + structuralReceipt: { snapshot_id: 'snapshot-1' }, + }, + }, + ], + }, + ], + } + + expect(containsStructuralAuditReceipt(value, 'snapshot-1')).toBe(true) + }) + + it('returns true for any structuralReceipt with a string snapshot_id when expected id is undefined or empty', () => { + const value = [ + { structuralReceipt: { snapshot_id: 'whatever' } }, + ] + + expect(containsStructuralAuditReceipt(value)).toBe(true) + expect(containsStructuralAuditReceipt(value, '')).toBe(true) + }) + + it('returns false when the expected id does not match any present receipt', () => { + const value = { + nested: { + deep: { structuralReceipt: { snapshot_id: 'snapshot-1' } }, + }, + } + + expect(containsStructuralAuditReceipt(value, 'snapshot-2')).toBe(false) + }) + + it('returns false when there is no structuralReceipt anywhere', () => { + expect( + containsStructuralAuditReceipt({ foo: { bar: [{ baz: 1 }] } }, 'snapshot-1'), + ).toBe(false) + }) + + it('terminates and returns false for a cyclic object graph without a receipt', () => { + const value: Record = { nested: {} } + value.self = value + ;(value.nested as Record).parent = value + + expect(containsStructuralAuditReceipt(value, 'snapshot-1')).toBe(false) + }) + + it('returns true for a receipt nested deeper than the old depth limit', () => { + let value: Record = { + structuralReceipt: { snapshot_id: 'snapshot-deep' }, + } + for (let depth = 0; depth < 13; depth += 1) { + value = { nested: value } + } + + expect(containsStructuralAuditReceipt(value, 'snapshot-deep')).toBe(true) + }) + + it('returns false for non-object inputs', () => { + expect(containsStructuralAuditReceipt(null, 'snapshot-1')).toBe(false) + expect(containsStructuralAuditReceipt('snapshot-1', 'snapshot-1')).toBe(false) + expect(containsStructuralAuditReceipt(42, 'snapshot-1')).toBe(false) + expect(containsStructuralAuditReceipt(undefined, 'snapshot-1')).toBe(false) + }) + + it('finds a receipt via a shorter path even after the shared node was first reached deeper', () => { + // Shared node holds the receipt a few levels below it. + const shared: Record = { + level1: { level2: { structuralReceipt: { snapshot_id: 'deep-shared' } } }, + } + + // Build a deep wrapper chain (well over MAX_TRAVERSAL_DEPTH = 32 levels) + // whose innermost node points at `shared`. From this side the receipt sits + // beyond the depth budget, so this path alone cannot reach it. + let deepChain: Record = { shared } + for (let depth = 0; depth < 40; depth += 1) { + deepChain = { nested: deepChain } + } + + // Deep chain is the FIRST key so Object.values visits it first, recording + // `shared` at a deep depth; the short path is a LATER key that re-reaches + // `shared` with a much shallower depth and enough budget to find it. + const root: Record = { + deep: deepChain, + short: shared, + } + + expect(containsStructuralAuditReceipt(root, 'deep-shared')).toBe(true) + }) + + it('returns false when structuralReceipt exists but snapshot_id is missing or non-string', () => { + expect( + containsStructuralAuditReceipt({ structuralReceipt: {} }), + ).toBe(false) + expect( + containsStructuralAuditReceipt({ + structuralReceipt: { snapshot_id: 123 }, + }), + ).toBe(false) + expect( + containsStructuralAuditReceipt( + { structuralReceipt: { snapshot_id: 123 } }, + 'snapshot-1', + ), + ).toBe(false) + }) +}) diff --git a/common/src/util/audit-receipt.ts b/common/src/util/audit-receipt.ts new file mode 100644 index 0000000000..56715089ef --- /dev/null +++ b/common/src/util/audit-receipt.ts @@ -0,0 +1,53 @@ +const MAX_TRAVERSAL_DEPTH = 32 + +/** + * Recursively searches an arbitrary value for a nested `structuralReceipt` + * object whose `snapshot_id` matches `expectedSnapshotId`. When + * `expectedSnapshotId` is empty/undefined, any structuralReceipt with a string + * `snapshot_id` counts as a match. Depth-bounded (<=32) to stay conservative + * on deeply nested inputs, with min-depth revisit tracking for cyclic/shared + * graphs: each object records the shallowest depth it was reached at, so a + * later shorter path (which has more remaining depth budget) re-traverses, + * while equal-or-deeper revisits (including cycles) are skipped. + * Shared by the general-agent handleSteps gate and the runtime + * buildRuntimeAgentReceipt gate so the two do not drift. + */ +export function containsStructuralAuditReceipt( + value: unknown, + expectedSnapshotId?: string, +): boolean { + let found = false + const visited = new WeakMap() + const visit = (item: unknown, depth = 0): void => { + if ( + found || + !item || + depth > MAX_TRAVERSAL_DEPTH || + typeof item !== 'object' + ) { + return + } + const existing = visited.get(item) + if (existing !== undefined && existing <= depth) return + visited.set(item, depth) + if (Array.isArray(item)) { + for (const nested of item) visit(nested, depth + 1) + return + } + const record = item as Record + const receipt = record.structuralReceipt + if (receipt && typeof receipt === 'object' && !Array.isArray(receipt)) { + const snapshotId = (receipt as Record).snapshot_id + if ( + typeof snapshotId === 'string' && + (!expectedSnapshotId || snapshotId === expectedSnapshotId) + ) { + found = true + return + } + } + for (const nested of Object.values(record)) visit(nested, depth + 1) + } + visit(value) + return found +} diff --git a/docs/agents-and-tools.md b/docs/agents-and-tools.md index 7f907464d4..6f278226f0 100644 --- a/docs/agents-and-tools.md +++ b/docs/agents-and-tools.md @@ -54,7 +54,7 @@ The root orchestrator has a versioned, local control-plane surface for work that - `get_change_review_bundle` produces snapshot-bound changed-file and diff evidence for reviewers. - `inspect_environment` recursively reports nested JavaScript, Python, Rust, Go, Maven/Gradle, .NET, and Swift workspaces, their manifests/lockfiles, inferred managers with explicit confidence, and locally available toolchains without executing project code. - `get_affected_tests` and `get_build_targets` map changed files to the nearest workspace, existing test candidates, and manager-specific validation/build commands. Targets report `confirmed`, `inferred`, or `unknown` confidence instead of presenting guesses as discovered facts. -- `run_targeted_validation` executes only an explicitly selected target against an expected snapshot and rejects stale or mid-run-mutated snapshots. +- `run_targeted_validation` executes only an explicitly selected target against an expected snapshot and rejects stale or mid-run-mutated snapshots. It is **snapshot-scoped optional evidence only**: green targeted validation does **not** clear reviewer findings, does **not** unlock `git-committer`, and does **not** replace the validation/reviewer gate. - `inspect_codebase_structure` creates the authoritative snapshot-bound audit inventory, including subsystems, entrypoints, routes, commands, public APIs, tests, generated sources, and a detected language/framework capability packet. - `inspect_feature_completeness` follows a claimed feature vertically across runtime wiring, consumers, tests, docs, and failure-state evidence. - `write_audit_findings` returns a snapshot-bound `structuralReceipt` when the shard supplies the exact snapshot and explicit domain coverage, and `inspect_feature_completeness` returns a `coverageReceipt`; pass those receipts directly to `evaluate_audit_coverage` instead of reconstructing them from prose or count summaries. Feature receipts remain heuristic until their cited files are verified with exact reads. @@ -66,8 +66,8 @@ Specialists receive only the read intelligence their role requires. For example, Control-plane records use compare-and-swap revisions, atomic local persistence, content hashes, expiring verified knowledge, single-use approvals, ownership receipts, and exclusive workspace leases. High-impact operations and external connector mutations are classified centrally rather than authorized by prompt wording alone. -`run_file_change_hooks` runs user-configured hooks and, only when a trusted -project explicitly sets `autoFileChangeHooks: true`, combines them with bounded +`run_file_change_hooks` + the automated code-reviewer are the **validation/reviewer gate** that unlocks finalization and commit. Hooks run user-configured checks and, only when a trusted +project explicitly sets `autoFileChangeHooks: true`, combine them with bounded manifest inference. The opt-in is required because compilers, plugins, build scripts, and tests can execute repository-controlled code. Inference prefers explicit project scripts and supports native checks for JavaScript/TypeScript, Python, Rust, Go, Java/Kotlin, .NET, C/C++, Ruby, PHP, @@ -77,7 +77,7 @@ original bounded stdout/stderr remains available for recovery. - Automated security/test/doc auxiliary agents have explicit lifecycle handling. Their done flags are written only after successful completion; crashes and blocking security verdicts persist as blockers. Test/doc writers run automatically only when the user request explicitly includes those deliverables, and mixed-package test targets are routed to package-specific commands. - Productive agent steps and subagent wall-clock duration are unlimited by default. A repeated-step watchdog stops identical no-progress loops, while cancellation, explicitly configured subagent deadlines, cost/token budgets, spawn-depth limits, and context compaction remain independent safeguards. Users may still configure a positive `maxAgentSteps`, agent-template `defaultTimeoutMs`, or per-spawn `timeout_seconds` cap; `-1` explicitly selects unlimited mode. Reviewer crashes retry once; repeated crashes require the explicit user phrase `bypass reviewer gate` before finalization can continue. -- Root-orchestrator mutating/control gate operations such as Git-status observation, file-change hooks, and structural inventory are model-hidden programmatic tools. Their results are injected when needed, so the harness remains active without paying for those schemas on every provider request. The read-only `get_change_review_bundle` tool remains model-visible so an orchestrator can refresh a stale reviewer snapshot after compaction. Fresh greetings and simple gratitude prompts take a narrow conversational fast path only when no pending work or reviewer blocker exists. +- Root-orchestrator mutating/control gate operations such as Git-status observation, file-change hooks, and structural inventory are model-hidden programmatic tools. Their results are injected when needed after edits, so the harness remains active without paying for those schemas on every provider request. The orchestrator must **not** treat basher typechecks or `run_targeted_validation` as gate substitutes — only the runtime-owned hooks→reviewer cycle clears the gate. The read-only `get_change_review_bundle` tool remains model-visible so an orchestrator can refresh a stale reviewer snapshot after compaction. Fresh greetings and simple gratitude prompts take a narrow conversational fast path only when no pending work or reviewer blocker exists. **Pattern-specific agents** are intentionally **excluded** from `spawnableAgents` because they have a narrow contract that only makes sense within a specific workflow pattern. They are spawned by the pattern flow itself, not by the orchestrator: diff --git a/docs/deterministic-edit-system.md b/docs/deterministic-edit-system.md index f4ec145003..11bef2cd0c 100644 --- a/docs/deterministic-edit-system.md +++ b/docs/deterministic-edit-system.md @@ -97,14 +97,57 @@ common cause of "I already read this file, why is my edit blocked?": post-edit content, including edits originally authorized by a scoped range. - A read and edit emitted in one provider response still execute in safe order, but the new implicit whole-file authorization is not usable until the next - model step. This prevents edit arguments authored before the model saw the - read result from borrowing authority from that unseen result. Explicit - authenticated capabilities from earlier visible context remain valid. -- Semantic compaction and emergency mechanical trimming revoke implicit - whole-file authorizations because the exact read bodies were removed from - model-visible context. The runtime persists a `context_compacted` reread - reason; a fresh read clears it. Authenticated scoped capabilities retained in + model step **unless** the edit carries an explicit capability or the server + performs its one-shot auto-reread (below). Explicit authenticated capabilities + from earlier visible context remain valid. +- Semantic compaction and emergency mechanical trimming **no longer wipe** + sticky whole-file authorizations/hashes. They record a `context_compacted` + reread reason. For `str_replace`, hash-fresh unique edits may still proceed + and clear the marker only after a successful unique apply (unique `oldString` + is the safety bound); failed/no-match `str_replace` and proper-subset/scoped + reads do **not** clear it. For **`write_file`**, `context_compacted` **blocks** + a whole-file overwrite even when the sticky hash still matches disk — only a + complete whole-file `read_files` grant (paths whole-file content, or full-file + range `1..totalLines` with `sourceContent`) **or** an explicit whole-file-covering + `basedOnRead` (hash-fresh, startLine=1 through current line count, same project/ + path/run) clears that marker before overwrite is allowed. Changed files still + fail closed on hash mismatch. Authenticated scoped capabilities retained in operational memory are still verified against live content when used. +- Complete full-file range reads (`startLine === 1` and `endLine === totalLines`, + with complete `sourceContent`) grant sticky whole-file authorization the same + way as `read_files.paths`. Proper-subset and truncated ranges stay scoped-only. +- On a strict auth miss (no fresh whole-file hash match and no `basedOnRead`), + `str_replace` / `edit_transaction` `str_replace` may **auto-reread once** + server-side via `requestOptionalFile` and authorize **only that** unique + replacement in-process (`allowMultiple` false/undefined). Multi-match + replacements with `allowMultiple: true` **never** auto-reread — they fail + closed and require real sticky auth or explicit `basedOnRead`. Auto-reread + **does not** mint durable sticky whole-file authorization (so a later + `write_file` cannot chain a blind overwrite off a server re-read). A successful + applied unique edit may still refresh sticky from observed **post-edit** + content. Still fail-closed if the file is missing, the circuit breaker is open, + the match is ambiguous, or zero matches remain after re-read; residual failures + mint a whole-file `basedOnRead` capability in the error recovery payload. + Explicit `basedOnRead` still works same-batch. + **`write_file` (standalone and `edit_transaction` `write_file`) never + auto-rereads** — whole-file overwrites require a prior complete whole-file + sticky hash match from a real read / successful observed edit, **or** an + optional whole-file-covering `basedOnRead` from a fresh complete whole-file + read (paths or full-file range). Partial range caps **never** authorize + overwrite. Capability echo on `write_file` / create-on-existing failures is + retry-usable as `basedOnRead` without an exploratory re-read. Sticky maps alone + do not make partial-range `basedOnRead` work for overwrite. +- `edit_transaction` create-on-existing recovery points to `write_file` or + `str_replace` with whole-file sticky auth **or** `basedOnRead` on the + `write_file` / replacement (end-to-end reachable: mint capability → pass as + `basedOnRead` on `write_file`). When a capability is already echoed, primary + guidance is capability-retry (`write_file`/`str_replace` + `basedOnRead`) — + not an exploratory `read_files` first. The same preference applies to strict + auth-miss and residual process-failure recovery messages: mint/`basedOnRead` + first when content is known; `read_files` remains the fallback only when no + capability can be minted. Auto-reread for transaction `str_replace` does **not** + clear `context_compacted`/failed-edit markers pre-apply — only successful unique + apply (or a real whole-file basedOnRead/read) clears them. - Input-only and preflight failures that never reached the client preserve a still-current whole-file authorization. Failures that make filesystem state uncertain revoke it and persist a typed reread reason across turns; the next @@ -118,6 +161,7 @@ Structured v1 `read_files` responses are correlated back to their requested selectors before any authorization is derived from them. A response whose items do not line up with the requested selector index, kind, and path fails closed: no content or capability from that batch is allowed to mint whole-file + or scoped read authorization. The failure is reported per selector — each requested selector keeps its own genuine per-item diagnostic (for example a real `not_found`/`blocked` error for that path) instead of being collapsed diff --git a/docs/request-flow.md b/docs/request-flow.md index 5a7d1724ac..555732ae6a 100644 --- a/docs/request-flow.md +++ b/docs/request-flow.md @@ -76,7 +76,7 @@ through a hosted Openbuff/Codebuff service in this primary flow. - `inspect_workspace`, `get_task`, `get_change_review_bundle` → snapshot-bound workspace/task/review evidence - `inspect_environment`, `get_affected_tests`, `get_build_targets` → read-only toolchain and validation-target intelligence - `inspect_codebase_structure`, `inspect_feature_completeness`, `evaluate_audit_coverage` → snapshot-bound broad-audit inventory and completeness gating - - `run_targeted_validation` → snapshot-checked scoped validation + - `run_targeted_validation` → snapshot-checked scoped validation (scoped evidence only; not the full hooks+reviewer gate) - Custom tool definitions and MCP tools 6. **Action handlers** stream provider output back to the CLI: - `response-chunk` → streams text to the CLI diff --git a/packages/agent-runtime/src/__tests__/process-edit-transaction.test.ts b/packages/agent-runtime/src/__tests__/process-edit-transaction.test.ts index c19231d446..d551c95049 100644 --- a/packages/agent-runtime/src/__tests__/process-edit-transaction.test.ts +++ b/packages/agent-runtime/src/__tests__/process-edit-transaction.test.ts @@ -1119,7 +1119,7 @@ describe('processEditTransaction', () => { } }) - it('does not coalesce same-file str_replace edits across other edit types', async () => { + it('blocks a replace_range that follows a same-file str_replace before later edits', async () => { const initialContent = 'const a = 1\nconst b = 1\nconst c = 1\n' const result = await processEditTransaction({ initialContentByPath: new Map([['src/helper.ts', initialContent]]), @@ -1178,13 +1178,18 @@ describe('processEditTransaction', () => { expect('error' in result).toBe(true) if ('error' in result) { + // The replace_range at edit index 1 follows a str_replace that already + // changed src/helper.ts, so the line-shift guard aborts before the third + // edit is ever reached. expect(result.failures[0]).toEqual( expect.objectContaining({ - editIndex: 2, + editIndex: 1, path: 'src/helper.ts', }), ) - expect(result.failures[0]?.errorMessage).toContain('basedOnRead') + expect(result.failures[0]?.errorMessage).toContain( + 'a prior non-replace_range edit changed this file earlier in the transaction', + ) } }) @@ -1629,4 +1634,137 @@ describe('processEditTransaction', () => { expect(retry.failures[0]!.errorMessage).toBe(message) } }) + + it('reports a narrowed sub-range when replace_range targets fewer lines than the capability covers', async () => { + const initial = 'alpha\nbeta\ngamma\n' + const issuer = { projectId: '/project', runId: 'run-narrowed-subrange' } + const result = await processEditTransaction({ + initialContentByPath: new Map([['src/file.ts', initial]]), + logger, + readCapabilityIssuer: issuer, + edits: [ + { + type: 'replace_range', + path: 'src/file.ts', + ...readAuthorization({ + path: 'src/file.ts', + startLine: 1, + endLine: 3, + content: 'alpha\nbeta\ngamma', + issuer, + }), + startLine: 2, + endLine: 2, + newContent: 'BETA', + }, + ], + }) + + expect('files' in result).toBe(true) + if ('files' in result) { + expect(result.files[0].content).toBe('alpha\nBETA\ngamma\n') + expect(result.files[0].messages).toContain( + 'Replaced lines 2-2 in src/file.ts within the readCapability-covered range.', + ) + } + }) + + it('aborts overlapping replace_range edits in one transaction without applying changes', async () => { + const initial = 'one\ntwo\nthree\nfour\n' + const issuer = { projectId: '/project', runId: 'run-overlap-abort' } + const result = await processEditTransaction({ + initialContentByPath: new Map([['src/file.ts', initial]]), + logger, + readCapabilityIssuer: issuer, + edits: [ + { + type: 'replace_range', + path: 'src/file.ts', + ...readAuthorization({ + path: 'src/file.ts', + startLine: 1, + endLine: 2, + content: 'one\ntwo', + issuer, + }), + startLine: 1, + endLine: 2, + newContent: 'ONE\nTWO', + }, + { + type: 'replace_range', + path: 'src/file.ts', + ...readAuthorization({ + path: 'src/file.ts', + startLine: 2, + endLine: 3, + content: 'two\nthree', + issuer, + }), + startLine: 2, + endLine: 3, + newContent: 'TWO\nTHREE', + }, + ], + }) + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.failures[0]).toEqual( + expect.objectContaining({ editIndex: 1, path: 'src/file.ts' }), + ) + expect(result.failures[0]?.errorMessage).toContain( + 'overlap a prior replace_range', + ) + } + expect('files' in result).toBe(false) + }) + + it('blocks a replace_range after a prior non-replace_range edit changed the same file', async () => { + const initial = 'const a = 1\nconst b = 1\nconst c = 1\n' + const issuer = { projectId: '/project', runId: 'run-nonrange-block' } + const result = await processEditTransaction({ + initialContentByPath: new Map([['src/file.ts', initial]]), + logger, + readCapabilityIssuer: issuer, + edits: [ + { + type: 'str_replace', + path: 'src/file.ts', + replacements: [ + { + oldString: 'const a = 1', + newString: 'const a = 1\nconst inserted = true', + allowMultiple: false, + }, + ], + }, + { + type: 'replace_range', + path: 'src/file.ts', + ...readAuthorization({ + path: 'src/file.ts', + startLine: 3, + endLine: 3, + content: 'const c = 1', + issuer, + }), + startLine: 3, + endLine: 3, + newContent: 'const c = 2', + }, + ], + }) + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.failures[0]).toEqual( + expect.objectContaining({ editIndex: 1, path: 'src/file.ts' }), + ) + expect(result.failures[0]?.errorMessage).toContain( + 'a prior non-replace_range edit changed this file earlier in the transaction', + ) + } + expect('files' in result).toBe(false) + }) }) diff --git a/packages/agent-runtime/src/__tests__/process-str-replace.test.ts b/packages/agent-runtime/src/__tests__/process-str-replace.test.ts index 3702ec159e..2d1e5a3ec1 100644 --- a/packages/agent-runtime/src/__tests__/process-str-replace.test.ts +++ b/packages/agent-runtime/src/__tests__/process-str-replace.test.ts @@ -1447,6 +1447,10 @@ function test3() { expect(result).toHaveProperty('error') if ('error' in result) { expect(result.error).toContain('different project, path, or agent run') + expect(result.error).toContain('Cross-path and cross-run capability replay') + expect(result.error).not.toContain('may refer to content that changed') + expect(result.error).not.toContain('content may have been removed') + expect(result.error).not.toContain('Before attempting another str_replace') } }) diff --git a/packages/agent-runtime/src/__tests__/read-files-edit-state.test.ts b/packages/agent-runtime/src/__tests__/read-files-edit-state.test.ts index 5bd545c4e8..435773230a 100644 --- a/packages/agent-runtime/src/__tests__/read-files-edit-state.test.ts +++ b/packages/agent-runtime/src/__tests__/read-files-edit-state.test.ts @@ -5,6 +5,7 @@ import { getInitialSessionState } from '@codebuff/common/types/session-state' import { describe, expect, it } from 'bun:test' import { handleEditTransaction } from '../tools/handlers/tool/edit-transaction' +import { strictEditAuthorizationError } from '../tools/handlers/tool/edit-read-state' import { handleReadFiles } from '../tools/handlers/tool/read-files' import { handleReplaceRange } from '../tools/handlers/tool/replace-range' import { handleStrReplace } from '../tools/handlers/tool/str-replace' @@ -517,9 +518,9 @@ describe('read_files edit-state recovery', () => { } }) - it('does not turn a range-only read into whole-file authorization', async () => { + it('does not turn a proper-subset range read into whole-file authorization', async () => { const path = 'src/ranged.ts' - const sourceContent = 'line 1\nline 2' + const sourceContent = 'line 1' const rangeHash = getContentHash(sourceContent) const fileProcessingState = createFileProcessingState() fileProcessingState.strictReadBeforeEdit = true @@ -529,7 +530,8 @@ describe('read_files edit-state recovery', () => { toolCall: { toolCallId: 'read-range-only', toolName: 'read_files', - input: { ranges: [{ path, startLine: 1, endLine: 2 }] }, + // Proper subset: lines 1..1 of a 2-line file — must stay scoped-only. + input: { ranges: [{ path, startLine: 1, endLine: 1 }] }, }, fileContext: mockFileContext, fileProcessingState, @@ -540,15 +542,15 @@ describe('read_files edit-state recovery', () => { requestIndex: 0, path, status: 'ok', - content: '1\tline 1\n2\tline 2', + content: '1\tline 1', sourceContent, startLine: 1, - endLine: 2, + endLine: 1, totalLines: 2, complete: true, editAnchor: { startLine: 1, - endLine: 2, + endLine: 1, contentHash: rangeHash, readCapability: 'cap.v3.test', }, @@ -564,7 +566,7 @@ describe('read_files edit-state recovery', () => { expect(value.results[0].content).not.toContain('[RANGE_BLOCK') expect(value.results[0].editAnchor).toMatchObject({ startLine: 1, - endLine: 2, + endLine: 1, contentHash: rangeHash, readCapability: expect.stringMatching(/^cap\.v3\./), }) @@ -573,6 +575,52 @@ describe('read_files edit-state recovery', () => { } }) + it('promotes a complete full-file range read (1..totalLines) to sticky whole-file auth', async () => { + const path = 'src/full-range.ts' + const sourceContent = 'line 1\nline 2' + const rangeHash = getContentHash(sourceContent) + const fileProcessingState = createFileProcessingState() + fileProcessingState.strictReadBeforeEdit = true + + await handleReadFiles({ + previousToolCallFinished: Promise.resolve(), + toolCall: { + toolCallId: 'read-full-file-range', + toolName: 'read_files', + input: { ranges: [{ path, startLine: 1, endLine: 2 }] }, + }, + fileContext: mockFileContext, + fileProcessingState, + requestFiles: async () => + buildReadFilesResultV1([ + { + selector: 'range', + requestIndex: 0, + path, + status: 'ok', + content: '1\tline 1\n2\tline 2', + sourceContent, + startLine: 1, + endLine: 2, + totalLines: 2, + complete: true, + editAnchor: { + startLine: 1, + endLine: 2, + contentHash: rangeHash, + readCapability: 'cap.v3.test', + }, + }, + ]), + logger, + } as any) + + expect(fileProcessingState.readAuthorizationsByPath?.[path]).toBe(true) + expect(fileProcessingState.readAuthorizationHashesByPath?.[path]).toBe( + getContentHash(sourceContent), + ) + }) + it('rejects str_replace when the client returns no application result', async () => { const path = 'src/helper.ts' const diskContent = 'export const value = 1\n' @@ -620,10 +668,15 @@ describe('read_files edit-state recovery', () => { expect(fileProcessingState.promisesByPath[path]).toBeUndefined() }) - it('invalidates prepared str_replace state when the client rejects the patch', async () => { + it('preserves authorization when the client explicitly rejects str_replace without applying', async () => { const path = 'src/rejected.ts' const diskContent = 'export const value = 1\n' const fileProcessingState = createFileProcessingState() + fileProcessingState.strictReadBeforeEdit = true + fileProcessingState.readAuthorizationsByPath = { [path]: true } + fileProcessingState.readAuthorizationHashesByPath = { + [path]: getContentHash(diskContent), + } const result = await handleStrReplace({ previousToolCallFinished: Promise.resolve(), @@ -660,8 +713,11 @@ describe('read_files edit-state recovery', () => { errorMessage: 'client rejected patch', }) } - expect(fileProcessingState.failedEditRequiresReadByPath[path]).toBe(true) + expect( + fileProcessingState.failedEditRequiresReadByPath[path], + ).toBeUndefined() expect(fileProcessingState.promisesByPath[path]).toBeUndefined() + expect(fileProcessingState.readAuthorizationsByPath?.[path]).toBe(true) }) it('[ABI-M05] sends exact whole-file bytes and rejects an unconfirmed client result', async () => { @@ -724,7 +780,7 @@ describe('read_files edit-state recovery', () => { expect(fileProcessingState.promisesByPath[path]).toBeUndefined() }) - it('does not grant write authorization or retain prepared state when the client rejects write_file', async () => { + it('preserves existing write authorization when the client explicitly rejects write_file', async () => { const path = 'src/rejected-write.ts' const diskContent = 'export const value = 1\n' const fileProcessingState = createFileProcessingState() @@ -769,15 +825,17 @@ describe('read_files edit-state recovery', () => { errorMessage: 'client rejected write', }) } - expect(fileProcessingState.failedEditRequiresReadByPath[path]).toBe(true) - expect(fileProcessingState.promisesByPath[path]).toBeUndefined() - expect(fileProcessingState.readAuthorizationsByPath?.[path]).toBeUndefined() expect( - fileProcessingState.readAuthorizationHashesByPath?.[path], + fileProcessingState.failedEditRequiresReadByPath[path], ).toBeUndefined() + expect(fileProcessingState.promisesByPath[path]).toBeUndefined() + expect(fileProcessingState.readAuthorizationsByPath?.[path]).toBe(true) + expect(fileProcessingState.readAuthorizationHashesByPath?.[path]).toBe( + getContentHash(diskContent), + ) }) - it('detects a write_file client error in any output part and revokes authorization', async () => { + it('detects a late explicit write_file rejection and preserves authorization', async () => { const path = 'src/rejected-late-write.ts' const fileProcessingState = createFileProcessingState() fileProcessingState.strictReadBeforeEdit = true @@ -813,9 +871,11 @@ describe('read_files edit-state recovery', () => { } as any) expect(result.output).toHaveLength(2) - expect(fileProcessingState.failedEditRequiresReadByPath[path]).toBe(true) + expect( + fileProcessingState.failedEditRequiresReadByPath[path], + ).toBeUndefined() expect(fileProcessingState.promisesByPath[path]).toBeUndefined() - expect(fileProcessingState.readAuthorizationsByPath?.[path]).toBeUndefined() + expect(fileProcessingState.readAuthorizationsByPath?.[path]).toBe(true) }) it('registers write_file processing before waiting for previous tool completion', async () => { @@ -1153,10 +1213,15 @@ describe('read_files edit-state recovery', () => { } }) - it('blocks later str_replace calls after edit_transaction preflight fails', async () => { + it('preserves valid read authorization after edit_transaction preflight fails', async () => { const path = 'src/helper.ts' const diskContent = 'export const value = 1\n' const fileProcessingState = createFileProcessingState() + fileProcessingState.strictReadBeforeEdit = true + fileProcessingState.readAuthorizationsByPath = { [path]: true } + fileProcessingState.readAuthorizationHashesByPath = { + [path]: getContentHash(diskContent), + } const transactionResult = await handleEditTransaction({ previousToolCallFinished: Promise.resolve(), @@ -1193,8 +1258,12 @@ describe('read_files edit-state recovery', () => { if (output.type === 'json') { expect(output.value).toHaveProperty('errorMessage') } - expect(fileProcessingState.failedEditRequiresReadByPath[path]).toBe(true) + expect( + fileProcessingState.failedEditRequiresReadByPath[path], + ).toBeUndefined() + expect(fileProcessingState.readAuthorizationsByPath?.[path]).toBe(true) + let followUpApplied = false const strReplaceResult = await handleStrReplace({ previousToolCallFinished: Promise.resolve(), toolCall: { @@ -1215,27 +1284,23 @@ describe('read_files edit-state recovery', () => { logger, requestOptionalFile: async ({ filePath }: { filePath: string }) => filePath === path ? diskContent : null, - requestClientToolCall: async () => { - throw new Error('should not apply blocked edit') + requestClientToolCall: async (clientToolCall: any) => { + followUpApplied = true + return confirmedMutationOutput(clientToolCall) }, writeToClient: () => {}, } as any) + expect(followUpApplied).toBe(true) const replaceOutput = strReplaceResult.output[0] - expect(fileProcessingState.editRereadRequirementsByPath?.[path]).toEqual({ - reason: 'preflight_failed', - sourceTool: 'edit_transaction', - }) + expect(fileProcessingState.editRereadRequirementsByPath?.[path]).toBeUndefined() expect(replaceOutput.type).toBe('json') if (replaceOutput.type === 'json') { - expect(replaceOutput.value).toHaveProperty('errorMessage') - expect( - String((replaceOutput.value as { errorMessage?: string }).errorMessage), - ).toContain('previous edit_transaction preflight failed') + expect(replaceOutput.value).not.toHaveProperty('errorMessage') } }) - it('marks all transaction paths as requiring re-read when client rejects a patch', async () => { + it('preserves valid authorization for all transaction paths when the client explicitly rejects without applying', async () => { const path = 'src/helper.ts' const otherPath = 'src/other.ts' const diskContentByPath: Record = { @@ -1243,6 +1308,15 @@ describe('read_files edit-state recovery', () => { [otherPath]: 'export const other = 1\n', } const fileProcessingState = createFileProcessingState() + fileProcessingState.strictReadBeforeEdit = true + fileProcessingState.readAuthorizationsByPath = { + [path]: true, + [otherPath]: true, + } + fileProcessingState.readAuthorizationHashesByPath = { + [path]: getContentHash(diskContentByPath[path]), + [otherPath]: getContentHash(diskContentByPath[otherPath]), + } const transactionResult = await handleEditTransaction({ previousToolCallFinished: Promise.resolve(), @@ -1302,11 +1376,16 @@ describe('read_files edit-state recovery', () => { } expect(fileProcessingState.promisesByPath[path]).toBeUndefined() expect(fileProcessingState.promisesByPath[otherPath]).toBeUndefined() - expect(fileProcessingState.failedEditRequiresReadByPath[path]).toBe(true) - expect(fileProcessingState.failedEditRequiresReadByPath[otherPath]).toBe( - true, - ) + expect( + fileProcessingState.failedEditRequiresReadByPath[path], + ).toBeUndefined() + expect( + fileProcessingState.failedEditRequiresReadByPath[otherPath], + ).toBeUndefined() + expect(fileProcessingState.readAuthorizationsByPath?.[path]).toBe(true) + expect(fileProcessingState.readAuthorizationsByPath?.[otherPath]).toBe(true) + let followUpApplied = false const strReplaceResult = await handleStrReplace({ previousToolCallFinished: Promise.resolve(), toolCall: { @@ -1316,7 +1395,7 @@ describe('read_files edit-state recovery', () => { path, replacements: [ { - oldString: 'export const value = 2', + oldString: 'export const value = 1', newString: 'export const value = 3', allowMultiple: false, }, @@ -1327,23 +1406,19 @@ describe('read_files edit-state recovery', () => { logger, requestOptionalFile: async ({ filePath }: { filePath: string }) => diskContentByPath[filePath] ?? null, - requestClientToolCall: async () => { - throw new Error('should not apply blocked edit') + requestClientToolCall: async (clientToolCall: any) => { + followUpApplied = true + return confirmedMutationOutput(clientToolCall) }, writeToClient: () => {}, } as any) + expect(followUpApplied).toBe(true) const replaceOutput = strReplaceResult.output[0] - expect(fileProcessingState.editRereadRequirementsByPath?.[path]).toEqual({ - reason: 'application_rejected', - sourceTool: 'edit_transaction', - }) + expect(fileProcessingState.editRereadRequirementsByPath?.[path]).toBeUndefined() expect(replaceOutput.type).toBe('json') if (replaceOutput.type === 'json') { - expect(replaceOutput.value).toHaveProperty('errorMessage') - expect( - String((replaceOutput.value as { errorMessage?: string }).errorMessage), - ).toContain('previous edit_transaction application was rejected') + expect(replaceOutput.value).not.toHaveProperty('errorMessage') } }) @@ -1932,16 +2007,17 @@ describe('read_files edit-state recovery', () => { } }) - it('strict str_replace blocks without prior read and does not call client apply', async () => { + it('strict str_replace auto-rereads once and applies a unique replacement without prior sticky auth', async () => { const path = 'src/helper.ts' const diskContent = 'export const value = 1\n' const fileProcessingState = createFileProcessingState() fileProcessingState.strictReadBeforeEdit = true + let applied = false const result = await handleStrReplace({ previousToolCallFinished: Promise.resolve(), toolCall: { - toolCallId: 'strict-blocked', + toolCallId: 'strict-auto-reread', toolName: 'str_replace', input: { path, @@ -1958,9 +2034,133 @@ describe('read_files edit-state recovery', () => { logger, requestOptionalFile: async ({ filePath }: { filePath: string }) => filePath === path ? diskContent : null, + requestClientToolCall: async (toolCall: any) => { + applied = true + return confirmedMutationOutput(toolCall) + }, + writeToClient: () => {}, + } as any) + + expect(applied).toBe(true) + const output = result.output[0] + expect(output.type).toBe('json') + if (output.type === 'json') { + expect(output.value).not.toHaveProperty('errorMessage') + } + // Post-apply grant of observed post-edit content may mint sticky; auto-reread + // alone must not mint durable sticky before apply. + expect(fileProcessingState.readAuthorizationsByPath?.[path]).toBe(true) + expect(fileProcessingState.readAuthorizationHashesByPath?.[path]).toBe( + getContentHash('export const value = 2\n'), + ) + }) + + it('auto-reread str_replace does not leave durable sticky that write_file can use without a real read', async () => { + // Security (AUTOREREAD-STICKY-WRITE-CHAIN): auto-reread must not mint + // durable sticky of pre-edit bytes for a later whole-file overwrite. + // After a successful unique apply, sticky may refresh from post-edit + // content (observed bytes). A write_file without a model-visible complete + // read still requires that sticky to match current disk; if we wipe sticky + // after auto-reread-only apply semantics prefer no pre-edit grant. + const path = 'src/chain-overwrite.ts' + const diskContent = 'export const value = 1\n' + const fileProcessingState = createFileProcessingState() + fileProcessingState.strictReadBeforeEdit = true + + // Fail auto-reread apply path: ambiguous/missing match leaves no sticky. + const failResult = await handleStrReplace({ + previousToolCallFinished: Promise.resolve(), + toolCall: { + toolCallId: 'auto-reread-fail-no-sticky', + toolName: 'str_replace', + input: { + path, + replacements: [ + { + oldString: 'export const missing = 99', + newString: 'export const missing = 100', + allowMultiple: false, + }, + ], + }, + }, + fileProcessingState, + logger, + requestOptionalFile: async () => diskContent, + requestClientToolCall: async () => { + throw new Error('must not apply failed auto-reread') + }, + writeToClient: () => {}, + } as any) + + expect(failResult.output[0]?.type).toBe('json') + expect( + fileProcessingState.readAuthorizationsByPath?.[path], + ).toBeUndefined() + + let writeApplied = false + const writeResult = await handleWriteFile({ + previousToolCallFinished: Promise.resolve(), + toolCall: { + toolCallId: 'write-after-failed-auto-reread', + toolName: 'write_file', + input: { path, content: 'export const value = 999\n' }, + }, + agentState: { messageHistory: [] }, + clientSessionId: 'test-session', + fileProcessingState, + fingerprintId: 'test-fingerprint', + logger, + prompt: undefined, + userId: undefined, + userInputId: 'test-input', + requestOptionalFile: async () => diskContent, + requestClientToolCall: async () => { + writeApplied = true + return [] + }, + writeToClient: () => {}, + } as any) + + expect(writeApplied).toBe(false) + expect(writeResult.output[0]?.type).toBe('json') + if (writeResult.output[0]?.type === 'json') { + expect(String((writeResult.output[0].value as any).errorMessage)).toContain( + 'write_file blocked', + ) + expect(String((writeResult.output[0].value as any).errorMessage)).toContain( + 'basedOnRead=', + ) + } + }) + + it('strict str_replace fails closed when auto-reread finds the file missing', async () => { + const path = 'src/missing-helper.ts' + const fileProcessingState = createFileProcessingState() + fileProcessingState.strictReadBeforeEdit = true + + const result = await handleStrReplace({ + previousToolCallFinished: Promise.resolve(), + toolCall: { + toolCallId: 'strict-auto-reread-missing', + toolName: 'str_replace', + input: { + path, + replacements: [ + { + oldString: 'export const value = 1', + newString: 'export const value = 2', + allowMultiple: false, + }, + ], + }, + }, + fileProcessingState, + logger, + requestOptionalFile: async () => null, requestClientToolCall: async () => { throw new Error( - 'client apply must not be called when strict mode blocks the edit', + 'client apply must not be called when the file is missing', ) }, writeToClient: () => {}, @@ -1971,13 +2171,59 @@ describe('read_files edit-state recovery', () => { if (output.type === 'json') { const value = output.value as { file?: string; errorMessage?: string } expect(value.file).toBe(path) - expect(String(value.errorMessage)).toContain( - 'strict read-before-edit is enabled', - ) expect(String(value.errorMessage)).toContain('read_files') } }) + it('strict str_replace with allowMultiple:true does not auto-reread-apply without sticky/basedOnRead', async () => { + // AUTOREREAD-ALLOWMULTIPLE-NOT-FAIL-CLOSED: multi-match must fail closed. + const path = 'src/multi-match.ts' + const diskContent = + 'export const value = 1\nexport const value = 1\n' + const fileProcessingState = createFileProcessingState() + fileProcessingState.strictReadBeforeEdit = true + let applied = false + + const result = await handleStrReplace({ + previousToolCallFinished: Promise.resolve(), + toolCall: { + toolCallId: 'strict-allow-multiple-no-auto-reread', + toolName: 'str_replace', + input: { + path, + replacements: [ + { + oldString: 'export const value = 1', + newString: 'export const value = 2', + allowMultiple: true, + }, + ], + }, + }, + fileProcessingState, + logger, + requestOptionalFile: async () => diskContent, + requestClientToolCall: async () => { + applied = true + return [] + }, + writeToClient: () => {}, + } as any) + + expect(applied).toBe(false) + const output = result.output[0] + expect(output.type).toBe('json') + if (output.type === 'json') { + const value = output.value as { file?: string; errorMessage?: string } + expect(value.file).toBe(path) + expect(String(value.errorMessage)).toMatch(/read_files|basedOnRead|blocked/i) + } + // Auto-reread must not mint sticky for allowMultiple multi-match. + expect( + fileProcessingState.readAuthorizationsByPath?.[path], + ).toBeUndefined() + }) + it('exposes a whole-file readCapability that directly authorizes the next strict edit', async () => { const path = 'client/src/routes/dashboard.ip.tsx' const diskContent = 'export const value = 1\n' @@ -2163,12 +2409,15 @@ describe('read_files edit-state recovery', () => { ) }) - it('fails closed for legacy Boolean-only whole-file authorization', async () => { + it('auto-rereads once for legacy Boolean-only whole-file authorization and applies unique edit', async () => { const path = 'src/legacy-auth.ts' const diskContent = 'export const value = 1\n' const fileProcessingState = createFileProcessingState() fileProcessingState.strictReadBeforeEdit = true + // Boolean-only sticky without a content hash is not usable; auto-reread + // recovers in-process and post-apply grants sticky from post-edit content. fileProcessingState.readAuthorizationsByPath = { [path]: true } + let applied = false const result = await handleStrReplace({ previousToolCallFinished: Promise.resolve(), @@ -2189,19 +2438,23 @@ describe('read_files edit-state recovery', () => { fileProcessingState, logger, requestOptionalFile: async () => diskContent, - requestClientToolCall: async () => { - throw new Error('legacy authorization must not reach client apply') + requestClientToolCall: async (toolCall: any) => { + applied = true + return confirmedMutationOutput(toolCall) }, writeToClient: () => {}, } as any) + expect(applied).toBe(true) expect(result.output[0]?.type).toBe('json') if (result.output[0]?.type === 'json') { - expect(result.output[0].value).toHaveProperty('errorMessage') + expect(result.output[0].value).not.toHaveProperty('errorMessage') } - expect( - fileProcessingState.readAuthorizationsByPath?.[path], - ).toBeUndefined() + // Sticky is from post-edit observed content, not from auto-reread alone. + expect(fileProcessingState.readAuthorizationsByPath?.[path]).toBe(true) + expect(fileProcessingState.readAuthorizationHashesByPath?.[path]).toBe( + getContentHash('export const value = 2\n'), + ) }) it('strict read_files authorizes consecutive str_replaces via sticky read authorization', async () => { @@ -2571,7 +2824,7 @@ describe('read_files edit-state recovery', () => { ).toBe(getContentHash('export const other = 2\n')) }) - it('strict edit_transaction blocks unread paths and lists failures', async () => { + it('strict edit_transaction auto-rereads once for unread str_replace paths and applies', async () => { const path = 'src/helper.ts' const otherPath = 'src/other.ts' const diskContentByPath: Record = { @@ -2580,11 +2833,12 @@ describe('read_files edit-state recovery', () => { } const fileProcessingState = createFileProcessingState() fileProcessingState.strictReadBeforeEdit = true + let applied = false const result = await handleEditTransaction({ previousToolCallFinished: Promise.resolve(), toolCall: { - toolCallId: 'strict-transaction-blocked', + toolCallId: 'strict-transaction-auto-reread', toolName: 'edit_transaction', input: { edits: [ @@ -2617,48 +2871,37 @@ describe('read_files edit-state recovery', () => { logger, requestOptionalFile: async ({ filePath }: { filePath: string }) => diskContentByPath[filePath] ?? null, - requestClientToolCall: async () => { - throw new Error( - 'client apply must not be called for blocked transaction', - ) - }, + requestClientToolCall: async (toolCall: any) => { + applied = true + return confirmedMutationOutput(toolCall) + }, } as any) + expect(applied).toBe(true) const output = result.output[0] expect(output.type).toBe('json') if (output.type === 'json') { - const value = output.value as { - errorMessage?: string - failures?: Array<{ path: string; errorMessage: string }> - } - expect(String(value.errorMessage)).toContain( - 'strict read-before-edit is enabled', - ) - expect(value.failures).toBeDefined() - const failurePaths = (value.failures ?? []).map((f) => f.path).sort() - expect(failurePaths).toEqual([otherPath, path].sort()) + expect(output.value).not.toHaveProperty('errorMessage') } + // Post-apply sticky from observed post-edit content is allowed. + expect(fileProcessingState.readAuthorizationsByPath?.[path]).toBe(true) + expect(fileProcessingState.readAuthorizationsByPath?.[otherPath]).toBe( + true, + ) }) - it('strict edit_transaction allows a path when its str_replace replacement has basedOnRead even without registry authorization', async () => { - const path = 'src/helper.ts' - const diskContent = 'export const value = 1\n' - const rangeContent = 'export const value = 1' - const runId = 'strict-transaction-run' - const readCapability = encodeReadCapabilityToken({ - startLine: 1, - endLine: 1, - hash: getContentHash(rangeContent), - scope: { projectId: mockFileContext.projectRoot, path, runId }, - }) + it('strict edit_transaction str_replace with allowMultiple:true does not get auto-reread authorization', async () => { + const path = 'src/tx-multi-match.ts' + const diskContent = + 'export const value = 1\nexport const value = 1\n' const fileProcessingState = createFileProcessingState() fileProcessingState.strictReadBeforeEdit = true - let applied = false + const result = await handleEditTransaction({ previousToolCallFinished: Promise.resolve(), toolCall: { - toolCallId: 'strict-transaction-anchored', + toolCallId: 'strict-tx-allow-multiple-no-auto-reread', toolName: 'edit_transaction', input: { edits: [ @@ -2669,8 +2912,7 @@ describe('read_files edit-state recovery', () => { { oldString: 'export const value = 1', newString: 'export const value = 2', - allowMultiple: false, - basedOnRead: readCapability, + allowMultiple: true, }, ], }, @@ -2679,105 +2921,106 @@ describe('read_files edit-state recovery', () => { }, fileProcessingState, fileContext: mockFileContext, - runId, + runId: 'tx-allow-multiple-no-auto-reread-run', logger, - requestOptionalFile: async ({ filePath }: { filePath: string }) => - filePath === path ? diskContent : null, - requestClientToolCall: async (toolCall: any) => { + requestOptionalFile: async () => diskContent, + requestClientToolCall: async () => { applied = true - return confirmedMutationOutput(toolCall) + return confirmedMutationOutput({ + toolCallId: 'must-not-apply-allow-multiple', + input: {}, + } as any) }, } as any) - expect(applied).toBe(true) + expect(applied).toBe(false) const output = result.output[0] expect(output.type).toBe('json') if (output.type === 'json') { - expect(output.value).not.toHaveProperty('errorMessage') + const value = output.value as { + errorMessage?: string + failures?: Array<{ errorMessage?: string; basedOnRead?: string }> + } + expect(String(value.errorMessage)).toContain('no read authorization') + expect(String(value.failures?.[0]?.errorMessage)).toContain(path) + // Residual/auth-miss recovery may still echo basedOnRead when content known. + expect(value.failures?.[0]?.basedOnRead).toMatch(/^cap\.v3\./) } + expect( + fileProcessingState.readAuthorizationsByPath?.[path], + ).toBeUndefined() }) - it('strict edit_transaction accepts a scoped replace_range capability without whole-file authorization', async () => { - const path = 'src/range.ts' + it('create-on-existing lifecycle error mentions write_file and mints basedOnRead', async () => { + const path = 'src/exists.ts' const diskContent = 'export const value = 1\n' - const rangeContent = 'export const value = 1' - const runId = 'strict-transaction-range-run' - const readCapability = encodeReadCapabilityToken({ - startLine: 1, - endLine: 1, - hash: getContentHash(rangeContent), - scope: { projectId: mockFileContext.projectRoot, path, runId }, - }) const fileProcessingState = createFileProcessingState() - fileProcessingState.strictReadBeforeEdit = true - const input = editTransactionParams.inputSchema.parse({ - edits: [ - { - type: 'replace_range', - path, - readCapability, - newContent: 'export const value = 2', - }, - ], - }) - let applied = false const result = await handleEditTransaction({ previousToolCallFinished: Promise.resolve(), toolCall: { - toolCallId: 'strict-transaction-range-anchored', + toolCallId: 'create-exists', toolName: 'edit_transaction', - input, + input: { + edits: [ + { + type: 'create', + path, + content: 'export const value = 2\n', + }, + ], + }, }, fileProcessingState, fileContext: mockFileContext, - runId, + runId: 'create-exists-run', logger, - requestOptionalFile: async ({ filePath }: { filePath: string }) => - filePath === path ? diskContent : null, - requestClientToolCall: async (toolCall: any) => { - applied = true - return confirmedMutationOutput(toolCall) + requestOptionalFile: async () => diskContent, + requestClientToolCall: async () => { + throw new Error('must not apply create-on-existing') }, } as any) - expect(applied).toBe(true) - expect(result.output[0]).toMatchObject({ type: 'json' }) - if (result.output[0]?.type === 'json') { - expect(result.output[0].value).not.toHaveProperty('errorMessage') + const output = result.output[0] + expect(output.type).toBe('json') + if (output.type === 'json') { + const value = output.value as { + errorMessage?: string + failures?: Array<{ errorMessage?: string; basedOnRead?: string }> + } + const failureMsg = String(value.failures?.[0]?.errorMessage) + expect(String(value.errorMessage)).toContain('lifecycle preflight') + expect(failureMsg).toContain('write_file') + expect(failureMsg).toContain('str_replace') + expect(failureMsg).toContain('basedOnRead=') + expect(failureMsg).toMatch(/Retry with type "write_file"/) + // Capability-present recovery must not steer to exploratory re-read first. + expect(failureMsg).not.toMatch(/Call read_files paths:.*first/) + expect(failureMsg).toContain( + 'Do not exploratory re-read first when basedOnRead is provided', + ) + expect(value.failures?.[0]?.basedOnRead).toMatch(/^cap\.v3\./) } }) - it('strict str_replace rejects a stale range capability even when oldString is unique', async () => { - const path = 'src/stale-anchor.ts' + it('create-on-existing basedOnRead authorizes a following write_file overwrite', async () => { + const path = 'src/exists-overwrite.ts' const diskContent = 'export const value = 1\n' + const runId = 'create-exists-write-run' const fileProcessingState = createFileProcessingState() fileProcessingState.strictReadBeforeEdit = true - const runId = 'strict-stale-anchor-run' - let applied = false - const result = await handleStrReplace({ + const createResult = await handleEditTransaction({ previousToolCallFinished: Promise.resolve(), toolCall: { - toolCallId: 'strict-stale-anchor', - toolName: 'str_replace', + toolCallId: 'create-exists-for-write', + toolName: 'edit_transaction', input: { - path, - replacements: [ + edits: [ { - oldString: 'export const value = 1', - newString: 'export const value = 2', - allowMultiple: false, - basedOnRead: encodeReadCapabilityToken({ - startLine: 1, - endLine: 1, - hash: getContentHash('export const value = 0'), - scope: { - projectId: mockFileContext.projectRoot, - path, - runId, - }, - }), + type: 'create', + path, + content: 'export const value = 2\n', }, ], }, @@ -2788,372 +3031,322 @@ describe('read_files edit-state recovery', () => { logger, requestOptionalFile: async () => diskContent, requestClientToolCall: async () => { - applied = true - return [] + throw new Error('must not apply create-on-existing') }, - writeToClient: () => {}, } as any) - expect(applied).toBe(false) - expect(result.output[0]?.type).toBe('json') - if (result.output[0]?.type === 'json') { - const value = result.output[0].value as { errorMessage?: string } - expect(String(value.errorMessage)).toContain( - 'basedOnRead did not match the current file content', - ) - } - }) - - it('failed-edit recovery requires a fresh capability on every replacement even when stale path authorization remains', async () => { - const path = 'src/helper.ts' - const diskContent = 'export const value = 1\nexport const other = 1\n' - const firstLine = 'export const value = 1' - const runId = 'strict-multi-anchor-run' - const fileProcessingState = createFileProcessingState() - fileProcessingState.strictReadBeforeEdit = true - fileProcessingState.failedEditRequiresReadByPath[path] = true - fileProcessingState.readAuthorizationsByPath = { [path]: true } + const createOutput = createResult.output[0] + expect(createOutput.type).toBe('json') + if (createOutput.type !== 'json') return + const basedOnRead = ( + createOutput.value as { + failures?: Array<{ basedOnRead?: string }> + } + ).failures?.[0]?.basedOnRead + expect(basedOnRead).toMatch(/^cap\.v3\./) - let clientApplyCount = 0 - const result = await handleStrReplace({ + let writeApplied = false + const writeResult = await handleWriteFile({ previousToolCallFinished: Promise.resolve(), toolCall: { - toolCallId: 'strict-failed-edit-partial-capabilities', - toolName: 'str_replace', + toolCallId: 'write-after-create-exists', + toolName: 'write_file', input: { path, - atomic: true, - replacements: [ - { - oldString: firstLine, - newString: 'export const value = 2', - allowMultiple: false, - basedOnRead: encodeReadCapabilityToken({ - startLine: 1, - endLine: 1, - hash: getContentHash(firstLine), - scope: { - projectId: mockFileContext.projectRoot, - path, - runId, - }, - }), - }, - { - oldString: 'export const other = 1', - newString: 'export const other = 2', - allowMultiple: false, - }, - ], + content: 'export const value = 3\n', + basedOnRead, }, }, - fileProcessingState, + agentState: { messageHistory: [] }, + clientSessionId: 'test-session', fileContext: mockFileContext, runId, + fileProcessingState, + fingerprintId: 'test-fingerprint', logger, + prompt: undefined, + userId: undefined, + userInputId: 'test-input', requestOptionalFile: async () => diskContent, - requestClientToolCall: async () => { - clientApplyCount += 1 - return [] + requestClientToolCall: async (toolCall: any) => { + writeApplied = true + return confirmedMutationOutput(toolCall) }, writeToClient: () => {}, } as any) - expect(clientApplyCount).toBe(0) - const output = result.output[0] - expect(output?.type).toBe('json') - if (output?.type === 'json') { - expect(String((output.value as any).errorMessage)).toContain( - 'replacement 2/2', - ) + expect(writeApplied).toBe(true) + expect(writeResult.output[0]?.type).toBe('json') + if (writeResult.output[0]?.type === 'json') { + expect(writeResult.output[0].value).not.toHaveProperty('errorMessage') } - expect(fileProcessingState.failedEditRequiresReadByPath[path]).toBe(true) }) - it('strict edit_transaction rejects a stale basedOnRead capability', async () => { - const path = 'src/stale-transaction.ts' + it('strict write_file with valid whole-file basedOnRead applies overwrite without prior sticky', async () => { + const path = 'src/basedonread-write.ts' const diskContent = 'export const value = 1\n' + const runId = 'write-whole-file-cap-run' const fileProcessingState = createFileProcessingState() fileProcessingState.strictReadBeforeEdit = true - const runId = 'strict-stale-transaction-run' - let applied = false + const basedOnRead = encodeReadCapabilityToken({ + startLine: 1, + endLine: diskContent.split('\n').length, + hash: getContentHash(diskContent), + scope: { + projectId: mockFileContext.projectRoot, + path, + runId, + }, + }) - const result = await handleEditTransaction({ + let applied = false + const result = await handleWriteFile({ previousToolCallFinished: Promise.resolve(), toolCall: { - toolCallId: 'strict-stale-transaction', - toolName: 'edit_transaction', + toolCallId: 'write-with-whole-file-cap', + toolName: 'write_file', input: { - edits: [ - { - type: 'str_replace', - path, - replacements: [ - { - oldString: 'export const value = 1', - newString: 'export const value = 2', - allowMultiple: false, - basedOnRead: encodeReadCapabilityToken({ - startLine: 1, - endLine: 1, - hash: getContentHash('export const value = 0'), - scope: { - projectId: mockFileContext.projectRoot, - path, - runId, - }, - }), - }, - ], - }, - ], + path, + content: 'export const value = 2\n', + basedOnRead, }, }, - fileProcessingState, + agentState: { messageHistory: [] }, + clientSessionId: 'test-session', fileContext: mockFileContext, runId, + fileProcessingState, + fingerprintId: 'test-fingerprint', logger, + prompt: undefined, + userId: undefined, + userInputId: 'test-input', requestOptionalFile: async () => diskContent, - requestClientToolCall: async () => { + requestClientToolCall: async (toolCall: any) => { applied = true - return [] + return confirmedMutationOutput(toolCall) }, + writeToClient: () => {}, } as any) - expect(applied).toBe(false) + expect(applied).toBe(true) expect(result.output[0]?.type).toBe('json') if (result.output[0]?.type === 'json') { - const value = result.output[0].value as { - errorMessage?: string - failures?: Array<{ errorMessage?: string }> - } - expect(String(value.errorMessage)).toContain( - 'edit_transaction aborted during preflight at edit 1 of 1', - ) - expect(String(value.failures?.[0]?.errorMessage)).toContain( - 'basedOnRead did not match the current file content', - ) - } - }) - - it('write_file blocks traversal paths before reading or applying', async () => { - const fileProcessingState = createFileProcessingState() - - const result = await handleWriteFile({ - previousToolCallFinished: Promise.resolve(), - toolCall: { - toolCallId: 'write-traversal-blocked', - toolName: 'write_file', - input: { - path: '../outside.ts', - instructions: 'Attempt outside write', - content: 'export const value = 1\n', - }, - }, - agentState: { messageHistory: [] }, - clientSessionId: 'test-session', - fileProcessingState, - fingerprintId: 'test-fingerprint', - logger, - prompt: undefined, - userId: undefined, - userInputId: 'test-input', - requestOptionalFile: async () => { - throw new Error( - 'requestOptionalFile must not be called for blocked traversal', - ) - }, - requestClientToolCall: async () => { - throw new Error( - 'client apply must not be called for blocked traversal', - ) - }, - writeToClient: () => {}, - } as any) - - const output = result.output[0] - expect(output.type).toBe('json') - if (output.type === 'json') { - const value = output.value as { file?: string; errorMessage?: string } - expect(value.file).toBe('../outside.ts') - expect(String(value.errorMessage)).toContain('path traversal blocked') + expect(result.output[0].value).not.toHaveProperty('errorMessage') } - expect(fileProcessingState.promisesByPath['']).toBeUndefined() - expect(fileProcessingState.allPromises).toHaveLength(0) + expect(fileProcessingState.readAuthorizationsByPath?.[path]).toBe(true) }) - it('strict write_file blocks existing-file overwrites without prior read and does not call client apply', async () => { - const path = 'src/helper.ts' + it('strict write_file rejects stale-hash basedOnRead', async () => { + const path = 'src/stale-hash-write.ts' const diskContent = 'export const value = 1\n' + const runId = 'write-stale-hash-run' const fileProcessingState = createFileProcessingState() fileProcessingState.strictReadBeforeEdit = true + const basedOnRead = encodeReadCapabilityToken({ + startLine: 1, + endLine: diskContent.split('\n').length, + hash: getContentHash('export const value = 0\n'), + scope: { + projectId: mockFileContext.projectRoot, + path, + runId, + }, + }) + let applied = false const result = await handleWriteFile({ previousToolCallFinished: Promise.resolve(), toolCall: { - toolCallId: 'strict-write-blocked', + toolCallId: 'write-stale-hash-cap', toolName: 'write_file', input: { path, - instructions: 'Update helper value', content: 'export const value = 2\n', + basedOnRead, }, }, agentState: { messageHistory: [] }, clientSessionId: 'test-session', + fileContext: mockFileContext, + runId, fileProcessingState, fingerprintId: 'test-fingerprint', logger, prompt: undefined, userId: undefined, userInputId: 'test-input', - requestOptionalFile: async ({ filePath }: { filePath: string }) => - filePath === path ? diskContent : null, + requestOptionalFile: async () => diskContent, requestClientToolCall: async () => { - throw new Error( - 'client apply must not be called for blocked write_file', - ) + applied = true + return [] }, writeToClient: () => {}, } as any) - const output = result.output[0] - expect(output.type).toBe('json') - if (output.type === 'json') { - const value = output.value as { file?: string; errorMessage?: string } - expect(value.file).toBe(path) - expect(String(value.errorMessage)).toContain('write_file blocked') - expect(String(value.errorMessage)).toContain('read_files') + expect(applied).toBe(false) + expect(result.output[0]?.type).toBe('json') + if (result.output[0]?.type === 'json') { + expect(String((result.output[0].value as any).errorMessage)).toMatch( + /stale hash|did not match the current file content/i, + ) } }) - it('strict write_file blocks a whole-file overwrite after only a prior range-anchored edit', async () => { - const path = 'src/range-edited.ts' + it('strict edit_transaction write_file with valid whole-file basedOnRead applies', async () => { + const path = 'src/tx-write-cap.ts' const diskContent = 'export const value = 1\n' + const runId = 'tx-write-whole-file-cap-run' const fileProcessingState = createFileProcessingState() fileProcessingState.strictReadBeforeEdit = true - fileProcessingState.promisesByPath[path] = [ - Promise.resolve({ - tool: 'str_replace' as const, + const basedOnRead = encodeReadCapabilityToken({ + startLine: 1, + endLine: diskContent.split('\n').length, + hash: getContentHash(diskContent), + scope: { + projectId: mockFileContext.projectRoot, path, - toolCallId: 'range-edit', - content: 'export const value = 2\n', - messages: [], - }), - ] + runId, + }, + }) - let clientApplyCount = 0 - const result = await handleWriteFile({ + let applied = false + const result = await handleEditTransaction({ previousToolCallFinished: Promise.resolve(), toolCall: { - toolCallId: 'whole-write-after-range-edit', - toolName: 'write_file', - input: { path, content: 'export const value = 3\n' }, + toolCallId: 'tx-write-with-cap', + toolName: 'edit_transaction', + input: { + edits: [ + { + type: 'write_file', + path, + content: 'export const value = 2\n', + basedOnRead, + }, + ], + }, }, - agentState: { messageHistory: [] }, - clientSessionId: 'test-session', fileProcessingState, - fingerprintId: 'test-fingerprint', + fileContext: mockFileContext, + runId, logger, - prompt: undefined, - userId: undefined, - userInputId: 'test-input', requestOptionalFile: async () => diskContent, - requestClientToolCall: async () => { - clientApplyCount += 1 - return [] + requestClientToolCall: async (toolCall: any) => { + applied = true + return confirmedMutationOutput(toolCall) }, - writeToClient: () => {}, } as any) - expect(clientApplyCount).toBe(0) - const output = result.output[0] - expect(output?.type).toBe('json') - if (output?.type === 'json') { - expect(String((output.value as any).errorMessage)).toContain( - 'prior range-anchored edit', - ) + expect(applied).toBe(true) + expect(result.output[0]?.type).toBe('json') + if (result.output[0]?.type === 'json') { + expect(result.output[0].value).not.toHaveProperty('errorMessage') } }) - it('write_file failed-edit gate blocks an existing file even when stale whole-file authorization remains', async () => { - const path = 'src/failed-write.ts' + it('strict edit_transaction write_file without prior sticky auth fails closed (no auto-reread)', async () => { + // Security: whole-file overwrite must not inherit str_replace auto-reread. + const path = 'src/overwrite.ts' const diskContent = 'export const value = 1\n' const fileProcessingState = createFileProcessingState() fileProcessingState.strictReadBeforeEdit = true - fileProcessingState.failedEditRequiresReadByPath[path] = true - fileProcessingState.readAuthorizationsByPath = { [path]: true } + let applied = false - let clientApplyCount = 0 - const result = await handleWriteFile({ + const result = await handleEditTransaction({ previousToolCallFinished: Promise.resolve(), toolCall: { - toolCallId: 'blocked-after-failed-write', - toolName: 'write_file', - input: { path, content: 'export const value = 2\n' }, + toolCallId: 'strict-transaction-write-no-auth', + toolName: 'edit_transaction', + input: { + edits: [ + { + type: 'write_file', + path, + content: 'export const value = 2\n', + }, + ], + }, }, - agentState: { messageHistory: [] }, - clientSessionId: 'test-session', fileProcessingState, - fingerprintId: 'test-fingerprint', + fileContext: mockFileContext, + runId: 'write-no-auth-run', logger, - prompt: undefined, - userId: undefined, - userInputId: 'test-input', requestOptionalFile: async () => diskContent, requestClientToolCall: async () => { - clientApplyCount += 1 - return [] + applied = true + return confirmedMutationOutput({ + toolCallId: 'must-not-apply', + input: {}, + } as any) }, - writeToClient: () => {}, } as any) - expect(clientApplyCount).toBe(0) + expect(applied).toBe(false) const output = result.output[0] - expect(output?.type).toBe('json') - if (output?.type === 'json') { - expect(String((output.value as any).errorMessage)).toContain( - 'previous edit failed', - ) + expect(output.type).toBe('json') + if (output.type === 'json') { + const value = output.value as { + errorMessage?: string + failures?: Array<{ errorMessage?: string; basedOnRead?: string }> + } + expect(String(value.errorMessage)).toContain('no read authorization') + expect(String(value.failures?.[0]?.errorMessage)).toContain(path) + expect(value.failures?.[0]?.basedOnRead).toMatch(/^cap\.v3\./) } + // Auto-reread must not mint sticky auth for write_file auth misses. + expect( + fileProcessingState.readAuthorizationsByPath?.[path], + ).toBeUndefined() }) - it('strict write_file allows new-file creation without prior read', async () => { - const path = 'src/new-helper.ts' + it('strict edit_transaction allows a path when its str_replace replacement has basedOnRead even without registry authorization', async () => { + const path = 'src/helper.ts' + const diskContent = 'export const value = 1\n' + const rangeContent = 'export const value = 1' + const runId = 'strict-transaction-run' + const readCapability = encodeReadCapabilityToken({ + startLine: 1, + endLine: 1, + hash: getContentHash(rangeContent), + scope: { projectId: mockFileContext.projectRoot, path, runId }, + }) const fileProcessingState = createFileProcessingState() fileProcessingState.strictReadBeforeEdit = true let applied = false - const result = await handleWriteFile({ + const result = await handleEditTransaction({ previousToolCallFinished: Promise.resolve(), toolCall: { - toolCallId: 'strict-write-new-file', - toolName: 'write_file', + toolCallId: 'strict-transaction-anchored', + toolName: 'edit_transaction', input: { - path, - instructions: 'Create helper value', - content: 'export const value = 1\n', + edits: [ + { + type: 'str_replace', + path, + replacements: [ + { + oldString: 'export const value = 1', + newString: 'export const value = 2', + allowMultiple: false, + basedOnRead: readCapability, + }, + ], + }, + ], }, }, - agentState: { messageHistory: [] }, - clientSessionId: 'test-session', fileProcessingState, - fingerprintId: 'test-fingerprint', + fileContext: mockFileContext, + runId, logger, - prompt: undefined, - userId: undefined, - userInputId: 'test-input', - requestOptionalFile: async () => null, + requestOptionalFile: async ({ filePath }: { filePath: string }) => + filePath === path ? diskContent : null, requestClientToolCall: async (toolCall: any) => { applied = true - return [ - { - type: 'json' as const, - value: { file: toolCall.input.path, message: 'created' }, - }, - ] + return confirmedMutationOutput(toolCall) }, - writeToClient: () => {}, } as any) expect(applied).toBe(true) @@ -3164,59 +3357,1023 @@ describe('read_files edit-state recovery', () => { } }) - it('strict write_file new-file creation grants read auth so a follow-up str_replace can edit without re-reading', async () => { - const path = 'src/newly-written-helper.ts' - const writtenContent = 'export const value = 1\n' - const fileProcessingState = createFileProcessingState() - fileProcessingState.strictReadBeforeEdit = true - - let writeApplied = false - const writeResult = await handleWriteFile({ - previousToolCallFinished: Promise.resolve(), + it('strict edit_transaction accepts a scoped replace_range capability without whole-file authorization', async () => { + const path = 'src/range.ts' + const diskContent = 'export const value = 1\n' + const rangeContent = 'export const value = 1' + const runId = 'strict-transaction-range-run' + const readCapability = encodeReadCapabilityToken({ + startLine: 1, + endLine: 1, + hash: getContentHash(rangeContent), + scope: { projectId: mockFileContext.projectRoot, path, runId }, + }) + const fileProcessingState = createFileProcessingState() + fileProcessingState.strictReadBeforeEdit = true + const input = editTransactionParams.inputSchema.parse({ + edits: [ + { + type: 'replace_range', + path, + readCapability, + newContent: 'export const value = 2', + }, + ], + }) + + let applied = false + const result = await handleEditTransaction({ + previousToolCallFinished: Promise.resolve(), + toolCall: { + toolCallId: 'strict-transaction-range-anchored', + toolName: 'edit_transaction', + input, + }, + fileProcessingState, + fileContext: mockFileContext, + runId, + logger, + requestOptionalFile: async ({ filePath }: { filePath: string }) => + filePath === path ? diskContent : null, + requestClientToolCall: async (toolCall: any) => { + applied = true + return confirmedMutationOutput(toolCall) + }, + } as any) + + expect(applied).toBe(true) + expect(result.output[0]).toMatchObject({ type: 'json' }) + if (result.output[0]?.type === 'json') { + expect(result.output[0].value).not.toHaveProperty('errorMessage') + } + }) + + it('strict str_replace rejects a stale range capability even when oldString is unique', async () => { + const path = 'src/stale-anchor.ts' + const diskContent = 'export const value = 1\n' + const fileProcessingState = createFileProcessingState() + fileProcessingState.strictReadBeforeEdit = true + const runId = 'strict-stale-anchor-run' + let applied = false + + const result = await handleStrReplace({ + previousToolCallFinished: Promise.resolve(), + toolCall: { + toolCallId: 'strict-stale-anchor', + toolName: 'str_replace', + input: { + path, + replacements: [ + { + oldString: 'export const value = 1', + newString: 'export const value = 2', + allowMultiple: false, + basedOnRead: encodeReadCapabilityToken({ + startLine: 1, + endLine: 1, + hash: getContentHash('export const value = 0'), + scope: { + projectId: mockFileContext.projectRoot, + path, + runId, + }, + }), + }, + ], + }, + }, + fileProcessingState, + fileContext: mockFileContext, + runId, + logger, + requestOptionalFile: async () => diskContent, + requestClientToolCall: async () => { + applied = true + return [] + }, + writeToClient: () => {}, + } as any) + + expect(applied).toBe(false) + expect(result.output[0]?.type).toBe('json') + if (result.output[0]?.type === 'json') { + const value = result.output[0].value as { errorMessage?: string } + expect(String(value.errorMessage)).toContain( + 'basedOnRead did not match the current file content', + ) + } + }) + + it('failed-edit recovery requires a fresh capability on every replacement even when stale path authorization remains', async () => { + const path = 'src/helper.ts' + const diskContent = 'export const value = 1\nexport const other = 1\n' + const firstLine = 'export const value = 1' + const runId = 'strict-multi-anchor-run' + const fileProcessingState = createFileProcessingState() + fileProcessingState.strictReadBeforeEdit = true + fileProcessingState.failedEditRequiresReadByPath[path] = true + fileProcessingState.readAuthorizationsByPath = { [path]: true } + + let clientApplyCount = 0 + const result = await handleStrReplace({ + previousToolCallFinished: Promise.resolve(), + toolCall: { + toolCallId: 'strict-failed-edit-partial-capabilities', + toolName: 'str_replace', + input: { + path, + atomic: true, + replacements: [ + { + oldString: firstLine, + newString: 'export const value = 2', + allowMultiple: false, + basedOnRead: encodeReadCapabilityToken({ + startLine: 1, + endLine: 1, + hash: getContentHash(firstLine), + scope: { + projectId: mockFileContext.projectRoot, + path, + runId, + }, + }), + }, + { + oldString: 'export const other = 1', + newString: 'export const other = 2', + allowMultiple: false, + }, + ], + }, + }, + fileProcessingState, + fileContext: mockFileContext, + runId, + logger, + requestOptionalFile: async () => diskContent, + requestClientToolCall: async () => { + clientApplyCount += 1 + return [] + }, + writeToClient: () => {}, + } as any) + + expect(clientApplyCount).toBe(0) + const output = result.output[0] + expect(output?.type).toBe('json') + if (output?.type === 'json') { + expect(String((output.value as any).errorMessage)).toContain( + 'replacement 2/2', + ) + } + expect(fileProcessingState.failedEditRequiresReadByPath[path]).toBe(true) + }) + + it('strict edit_transaction rejects a stale basedOnRead capability', async () => { + const path = 'src/stale-transaction.ts' + const diskContent = 'export const value = 1\n' + const fileProcessingState = createFileProcessingState() + fileProcessingState.strictReadBeforeEdit = true + const runId = 'strict-stale-transaction-run' + let applied = false + + const result = await handleEditTransaction({ + previousToolCallFinished: Promise.resolve(), + toolCall: { + toolCallId: 'strict-stale-transaction', + toolName: 'edit_transaction', + input: { + edits: [ + { + type: 'str_replace', + path, + replacements: [ + { + oldString: 'export const value = 1', + newString: 'export const value = 2', + allowMultiple: false, + basedOnRead: encodeReadCapabilityToken({ + startLine: 1, + endLine: 1, + hash: getContentHash('export const value = 0'), + scope: { + projectId: mockFileContext.projectRoot, + path, + runId, + }, + }), + }, + ], + }, + ], + }, + }, + fileProcessingState, + fileContext: mockFileContext, + runId, + logger, + requestOptionalFile: async () => diskContent, + requestClientToolCall: async () => { + applied = true + return [] + }, + } as any) + + expect(applied).toBe(false) + expect(result.output[0]?.type).toBe('json') + if (result.output[0]?.type === 'json') { + const value = result.output[0].value as { + errorMessage?: string + failures?: Array<{ errorMessage?: string }> + } + expect(String(value.errorMessage)).toContain( + 'edit_transaction aborted during preflight at edit 1 of 1', + ) + expect(String(value.failures?.[0]?.errorMessage)).toContain( + 'basedOnRead did not match the current file content', + ) + } + }) + + it('write_file blocks traversal paths before reading or applying', async () => { + const fileProcessingState = createFileProcessingState() + + const result = await handleWriteFile({ + previousToolCallFinished: Promise.resolve(), + toolCall: { + toolCallId: 'write-traversal-blocked', + toolName: 'write_file', + input: { + path: '../outside.ts', + instructions: 'Attempt outside write', + content: 'export const value = 1\n', + }, + }, + agentState: { messageHistory: [] }, + clientSessionId: 'test-session', + fileProcessingState, + fingerprintId: 'test-fingerprint', + logger, + prompt: undefined, + userId: undefined, + userInputId: 'test-input', + requestOptionalFile: async () => { + throw new Error( + 'requestOptionalFile must not be called for blocked traversal', + ) + }, + requestClientToolCall: async () => { + throw new Error( + 'client apply must not be called for blocked traversal', + ) + }, + writeToClient: () => {}, + } as any) + + const output = result.output[0] + expect(output.type).toBe('json') + if (output.type === 'json') { + const value = output.value as { file?: string; errorMessage?: string } + expect(value.file).toBe('../outside.ts') + expect(String(value.errorMessage)).toContain('path traversal blocked') + } + expect(fileProcessingState.promisesByPath['']).toBeUndefined() + expect(fileProcessingState.allPromises).toHaveLength(0) + }) + + it('strict write_file blocks existing-file overwrites without prior read and does not call client apply', async () => { + const path = 'src/helper.ts' + const diskContent = 'export const value = 1\n' + const fileProcessingState = createFileProcessingState() + fileProcessingState.strictReadBeforeEdit = true + + const result = await handleWriteFile({ + previousToolCallFinished: Promise.resolve(), + toolCall: { + toolCallId: 'strict-write-blocked', + toolName: 'write_file', + input: { + path, + instructions: 'Update helper value', + content: 'export const value = 2\n', + }, + }, + agentState: { messageHistory: [] }, + clientSessionId: 'test-session', + fileProcessingState, + fingerprintId: 'test-fingerprint', + logger, + prompt: undefined, + userId: undefined, + userInputId: 'test-input', + requestOptionalFile: async ({ filePath }: { filePath: string }) => + filePath === path ? diskContent : null, + requestClientToolCall: async () => { + throw new Error( + 'client apply must not be called for blocked write_file', + ) + }, + writeToClient: () => {}, + } as any) + + const output = result.output[0] + expect(output.type).toBe('json') + if (output.type === 'json') { + const value = output.value as { file?: string; errorMessage?: string } + expect(value.file).toBe(path) + expect(String(value.errorMessage)).toContain('write_file blocked') + // When a whole-file capability can be echoed, primary recovery is basedOnRead + // retry (no exploratory re-read); read_files remains only if no capability. + expect(String(value.errorMessage)).toMatch( + /basedOnRead|read_files/, + ) + } + }) + + it('strict write_file blocks a whole-file overwrite after only a prior range-anchored edit', async () => { + const path = 'src/range-edited.ts' + const diskContent = 'export const value = 1\n' + const fileProcessingState = createFileProcessingState() + fileProcessingState.strictReadBeforeEdit = true + fileProcessingState.promisesByPath[path] = [ + Promise.resolve({ + tool: 'str_replace' as const, + path, + toolCallId: 'range-edit', + content: 'export const value = 2\n', + messages: [], + }), + ] + + let clientApplyCount = 0 + const result = await handleWriteFile({ + previousToolCallFinished: Promise.resolve(), + toolCall: { + toolCallId: 'whole-write-after-range-edit', + toolName: 'write_file', + input: { path, content: 'export const value = 3\n' }, + }, + agentState: { messageHistory: [] }, + clientSessionId: 'test-session', + fileProcessingState, + fingerprintId: 'test-fingerprint', + logger, + prompt: undefined, + userId: undefined, + userInputId: 'test-input', + requestOptionalFile: async () => diskContent, + requestClientToolCall: async () => { + clientApplyCount += 1 + return [] + }, + writeToClient: () => {}, + } as any) + + expect(clientApplyCount).toBe(0) + const output = result.output[0] + expect(output?.type).toBe('json') + if (output?.type === 'json') { + expect(String((output.value as any).errorMessage)).toContain( + 'prior range-anchored edit', + ) + } + }) + + it('write_file failed-edit gate blocks an existing file even when stale whole-file authorization remains', async () => { + const path = 'src/failed-write.ts' + const diskContent = 'export const value = 1\n' + const fileProcessingState = createFileProcessingState() + fileProcessingState.strictReadBeforeEdit = true + fileProcessingState.failedEditRequiresReadByPath[path] = true + fileProcessingState.readAuthorizationsByPath = { [path]: true } + + let clientApplyCount = 0 + const result = await handleWriteFile({ + previousToolCallFinished: Promise.resolve(), + toolCall: { + toolCallId: 'blocked-after-failed-write', + toolName: 'write_file', + input: { path, content: 'export const value = 2\n' }, + }, + agentState: { messageHistory: [] }, + clientSessionId: 'test-session', + fileProcessingState, + fingerprintId: 'test-fingerprint', + logger, + prompt: undefined, + userId: undefined, + userInputId: 'test-input', + requestOptionalFile: async () => diskContent, + requestClientToolCall: async () => { + clientApplyCount += 1 + return [] + }, + writeToClient: () => {}, + } as any) + + expect(clientApplyCount).toBe(0) + const output = result.output[0] + expect(output?.type).toBe('json') + if (output?.type === 'json') { + expect(String((output.value as any).errorMessage)).toContain( + 'previous edit failed', + ) + } + }) + + it('strict write_file allows new-file creation without prior read', async () => { + const path = 'src/new-helper.ts' + const fileProcessingState = createFileProcessingState() + fileProcessingState.strictReadBeforeEdit = true + + let applied = false + const result = await handleWriteFile({ + previousToolCallFinished: Promise.resolve(), + toolCall: { + toolCallId: 'strict-write-new-file', + toolName: 'write_file', + input: { + path, + instructions: 'Create helper value', + content: 'export const value = 1\n', + }, + }, + agentState: { messageHistory: [] }, + clientSessionId: 'test-session', + fileProcessingState, + fingerprintId: 'test-fingerprint', + logger, + prompt: undefined, + userId: undefined, + userInputId: 'test-input', + requestOptionalFile: async () => null, + requestClientToolCall: async (toolCall: any) => { + applied = true + return [ + { + type: 'json' as const, + value: { file: toolCall.input.path, message: 'created' }, + }, + ] + }, + writeToClient: () => {}, + } as any) + + expect(applied).toBe(true) + const output = result.output[0] + expect(output.type).toBe('json') + if (output.type === 'json') { + expect(output.value).not.toHaveProperty('errorMessage') + } + }) + + it('strict write_file new-file creation grants read auth so a follow-up str_replace can edit without re-reading', async () => { + const path = 'src/newly-written-helper.ts' + const writtenContent = 'export const value = 1\n' + const fileProcessingState = createFileProcessingState() + fileProcessingState.strictReadBeforeEdit = true + + let writeApplied = false + const writeResult = await handleWriteFile({ + previousToolCallFinished: Promise.resolve(), + toolCall: { + toolCallId: 'strict-write-new-file-grants-auth', + toolName: 'write_file', + input: { + path, + instructions: 'Create helper value', + content: writtenContent, + }, + }, + agentState: { messageHistory: [] }, + clientSessionId: 'test-session', + fileProcessingState, + fingerprintId: 'test-fingerprint', + logger, + prompt: undefined, + userId: undefined, + userInputId: 'test-input', + requestOptionalFile: async () => null, + requestClientToolCall: async (toolCall: any) => { + writeApplied = true + return confirmedMutationOutput(toolCall) + }, + writeToClient: () => {}, + } as any) + + expect(writeApplied).toBe(true) + const writeOutput = writeResult.output[0] + expect(writeOutput.type).toBe('json') + if (writeOutput.type === 'json') { + expect(writeOutput.value).not.toHaveProperty('errorMessage') + } + + // The fix: a successful write_file (even on a brand-new file with no prior + // read) must grant a one-shot read authorization so the very common + // write-then-edit flow does not need a redundant read round-trip. + expect(fileProcessingState.readAuthorizationsByPath?.[path]).toBe(true) + + // A follow-up str_replace must succeed using the just-granted auth + // without the agent having to call read_files separately. + let strReplaceApplied = false + const strReplaceResult = await handleStrReplace({ + previousToolCallFinished: Promise.resolve(), + toolCall: { + toolCallId: 'strict-edit-after-new-write', + toolName: 'str_replace', + input: { + path, + replacements: [ + { + oldString: 'export const value = 1', + newString: 'export const value = 2', + allowMultiple: false, + }, + ], + }, + }, + fileProcessingState, + logger, + requestOptionalFile: async ({ filePath }: { filePath: string }) => + filePath === path ? writtenContent : null, + requestClientToolCall: async (toolCall: any) => { + strReplaceApplied = true + return confirmedMutationOutput(toolCall) + }, + writeToClient: () => {}, + } as any) + + expect(strReplaceApplied).toBe(true) + const strReplaceOutput = strReplaceResult.output[0] + expect(strReplaceOutput.type).toBe('json') + if (strReplaceOutput.type === 'json') { + expect(strReplaceOutput.value).not.toHaveProperty('errorMessage') + } + + // Sticky auth: the write_file grant (and the follow-up str_replace) + // remain in force across subsequent edits on the same path. A third + // str_replace without re-reading must SUCCEED and keep auth alive, + // because the strict gate only re-enables after a failed edit or an + // externally-changed file (anchored with a fresh basedOnRead capability). + let thirdApplyCount = 0 + const thirdResult = await handleStrReplace({ + previousToolCallFinished: Promise.resolve(), + toolCall: { + toolCallId: 'strict-edit-sticky-after-write', + toolName: 'str_replace', + input: { + path, + replacements: [ + { + oldString: 'export const value = 2', + newString: 'export const value = 3', + allowMultiple: false, + }, + ], + }, + }, + fileProcessingState, + logger, + requestOptionalFile: async ({ filePath }: { filePath: string }) => + filePath === path ? writtenContent : null, + requestClientToolCall: async (toolCall: any) => { + thirdApplyCount += 1 + return confirmedMutationOutput(toolCall) + }, + writeToClient: () => {}, + } as any) + + // Third str_replace applied via sticky auth (no re-read required). + expect(thirdApplyCount).toBe(1) + const thirdOutput = thirdResult.output[0] + expect(thirdOutput.type).toBe('json') + if (thirdOutput.type === 'json') { + expect(thirdOutput.value).not.toHaveProperty('errorMessage') + } + // Auth remains true across the entire write -> edit -> edit -> edit + // chain with no intervening read_files calls. + expect(fileProcessingState.readAuthorizationsByPath?.[path]).toBe(true) + }) + + it('strict read_files authorizes one write_file overwrite and the authorization is preserved after success', async () => { + const path = 'src/helper.ts' + const diskContent = 'export const value = 1\n' + const fileProcessingState = createFileProcessingState() + fileProcessingState.strictReadBeforeEdit = true + + await handleReadFiles({ + previousToolCallFinished: Promise.resolve(), + toolCall: { + toolCallId: 'strict-write-read', + toolName: 'read_files', + input: { paths: [path] }, + }, + fileContext: mockFileContext, + fileProcessingState, + requestFiles: async ({ filePaths }: { filePaths: string[] }) => + buildWholeFileReadResultV1(filePaths, (filePath) => + filePath === path ? diskContent : null, + ), + logger, + } as any) + + expect(fileProcessingState.readAuthorizationsByPath?.[path]).toBe(true) + + let applied = false + const result = await handleWriteFile({ + previousToolCallFinished: Promise.resolve(), + toolCall: { + toolCallId: 'strict-write-authorized', + toolName: 'write_file', + input: { + path, + instructions: 'Update helper value', + content: 'export const value = 2\n', + }, + }, + agentState: { messageHistory: [] }, + clientSessionId: 'test-session', + fileProcessingState, + fingerprintId: 'test-fingerprint', + logger, + prompt: undefined, + userId: undefined, + userInputId: 'test-input', + requestOptionalFile: async ({ filePath }: { filePath: string }) => + filePath === path ? diskContent : null, + requestClientToolCall: async (toolCall: any) => { + applied = true + return confirmedMutationOutput(toolCall) + }, + writeToClient: () => {}, + } as any) + + expect(applied).toBe(true) + const output = result.output[0] + expect(output.type).toBe('json') + if (output.type === 'json') { + expect(output.value).not.toHaveProperty('errorMessage') + } + expect(fileProcessingState.readAuthorizationsByPath?.[path]).toBe(true) + expect(fileProcessingState.readAuthorizationHashesByPath?.[path]).toBe( + getContentHash('export const value = 2\n'), + ) + }) + + it('strict replace_range blocks without prior read or freshness anchor and does not call client apply', async () => { + const path = 'src/helper.ts' + const fileProcessingState = createFileProcessingState() + fileProcessingState.strictReadBeforeEdit = true + + const result = await handleReplaceRange({ + previousToolCallFinished: Promise.resolve(), + toolCall: { + toolCallId: 'replace-range-blocked', + toolName: 'replace_range', + input: { + path, + startLine: 1, + endLine: 1, + newContent: 'export const value = 2', + }, + }, + fileProcessingState, + requestClientToolCall: async () => { + throw new Error( + 'client apply must not be called for blocked replace_range', + ) + }, + } as any) + + const output = result.output[0] + expect(output.type).toBe('json') + if (output.type === 'json') { + const value = output.value as { file?: string; errorMessage?: string } + expect(value.file).toBe(path) + expect(String(value.errorMessage)).toContain('replace_range blocked') + expect(String(value.errorMessage)).toContain('read_files') + } + expect(fileProcessingState.failedEditRequiresReadByPath[path]).toBe(true) + }) + + it('replace_range preserves authorization on success but revokes it for stale client snapshots', async () => { + const path = 'src/helper.ts' + let diskContent = 'export const value = 1\n' + const fileProcessingState = createFileProcessingState() + fileProcessingState.strictReadBeforeEdit = true + fileProcessingState.readAuthorizationsByPath = { [path]: true } + fileProcessingState.readAuthorizationHashesByPath = { + [path]: getContentHash(diskContent), + } + + const successResult = await handleReplaceRange({ + previousToolCallFinished: Promise.resolve(), + toolCall: { + toolCallId: 'replace-range-success', + toolName: 'replace_range', + input: { + path, + startLine: 1, + endLine: 1, + expectedHash: 'sha256:current', + newContent: 'export const value = 2', + }, + }, + fileProcessingState, + requestOptionalFile: async () => diskContent, + requestClientToolCall: async (toolCall: any) => { + diskContent = 'export const value = 2\n' + return confirmedMutationOutput(toolCall) + }, + } as any) + + expect(successResult.output[0]?.type).toBe('json') + // Sticky auth: a successful replace_range does NOT consume the auth. + expect(fileProcessingState.readAuthorizationsByPath?.[path]).toBe(true) + expect( + fileProcessingState.failedEditRequiresReadByPath[path], + ).toBeUndefined() + + const errorResult = await handleReplaceRange({ + previousToolCallFinished: Promise.resolve(), toolCall: { - toolCallId: 'strict-write-new-file-grants-auth', + toolCallId: 'replace-range-error', + toolName: 'replace_range', + input: { + path, + startLine: 1, + endLine: 1, + expectedHash: 'sha256:stale', + newContent: 'export const value = 3', + }, + }, + fileProcessingState, + requestOptionalFile: async () => diskContent, + requestClientToolCall: async (toolCall: any) => [ + { + type: 'json' as const, + value: { + file: toolCall.input.path, + errorMessage: 'replace_range rejected: stale range', + }, + }, + ], + } as any) + + expect(errorResult.output[0]?.type).toBe('json') + expect(fileProcessingState.failedEditRequiresReadByPath[path]).toBe(true) + expect( + fileProcessingState.editRereadRequirementsByPath?.[path], + ).toMatchObject({ reason: 'stale_snapshot' }) + expect(fileProcessingState.readAuthorizationsByPath?.[path]).toBeUndefined() + }) + + it('strict replace_range rejects a legacy pathless expectedHash as authorization', async () => { + const path = 'src/helper.ts' + const fileProcessingState = createFileProcessingState() + fileProcessingState.strictReadBeforeEdit = true + // No read authorization registered — only expectedHash as anchor. + + let applied = false + const result = await handleReplaceRange({ + previousToolCallFinished: Promise.resolve(), + toolCall: { + toolCallId: 'replace-range-anchor', + toolName: 'replace_range', + input: { + path, + startLine: 1, + endLine: 1, + expectedHash: 'sha256:fresh', + newContent: 'export const value = 2', + }, + }, + fileProcessingState, + requestClientToolCall: async (toolCall: any) => { + applied = true + return confirmedMutationOutput(toolCall) + }, + } as any) + + expect(applied).toBe(false) + const output = result.output[0] + expect(output.type).toBe('json') + if (output.type === 'json') { + expect(String((output.value as any).errorMessage)).toContain( + 'no fresh path-bound read authorization exists', + ) + expect(String((output.value as any).errorMessage)).toContain( + 'cap.v3 readCapability plus newContent', + ) + } + }) + + it('strict replace_range accepts a cap.v3 token bound to the target and run', async () => { + const path = 'src/helper.ts' + const fileProcessingState = createFileProcessingState() + fileProcessingState.strictReadBeforeEdit = true + const scope = { + projectId: mockFileContext.projectRoot, + path, + runId: 'replace-range-run', + } + const readCapability = encodeReadCapabilityToken({ + startLine: 1, + endLine: 1, + hash: getContentHash('export const value = 1'), + scope, + }) + let applied = false + const result = await handleReplaceRange({ + previousToolCallFinished: Promise.resolve(), + toolCall: { + toolCallId: 'replace-range-bound-anchor', + toolName: 'replace_range', + input: { + path, + startLine: 1, + endLine: 1, + expectedHash: getContentHash('export const value = 1'), + readCapability, + newContent: 'export const value = 2', + }, + }, + fileContext: mockFileContext, + runId: scope.runId, + fileProcessingState, + requestOptionalFile: async () => 'export const value = 1\n', + requestClientToolCall: async (toolCall: any) => { + applied = true + return confirmedMutationOutput(toolCall) + }, + } as any) + + expect(applied).toBe(true) + expect(result.output[0]).toMatchObject({ type: 'json' }) + }) + + it('Reduction D: strict str_replace error message omits "in this turn" and "Recovery required:"', async () => { + const path = 'src/helper.ts' + const fileProcessingState = createFileProcessingState() + fileProcessingState.strictReadBeforeEdit = true + + // File missing so auto-reread fails closed and surfaces the auth error. + const result = await handleStrReplace({ + previousToolCallFinished: Promise.resolve(), + toolCall: { + toolCallId: 'strict-msg-check', + toolName: 'str_replace', + input: { + path, + replacements: [ + { + oldString: 'export const value = 1', + newString: 'export const value = 2', + allowMultiple: false, + }, + ], + }, + }, + fileProcessingState, + requestOptionalFile: async () => null, + requestClientToolCall: async () => { + throw new Error( + 'client apply must not be called for blocked str_replace', + ) + }, + writeToClient: () => {}, + } as any) + + const output = result.output[0] + expect(output.type).toBe('json') + if (output.type === 'json') { + const value = output.value as { errorMessage?: string } + expect(String(value.errorMessage)).not.toContain('in this turn') + expect(String(value.errorMessage)).not.toContain('Recovery required:') + // Should still mention the actionable next step. + expect(String(value.errorMessage)).toContain('read_files') + } + }) + + it('does not let a range basedOnRead capability authorize a whole-file overwrite', async () => { + const path = 'src/helper.ts' + const diskContent = 'export const value = 1\n' + const runId = 'range-write-floor-run' + const fileProcessingState = createFileProcessingState() + fileProcessingState.strictReadBeforeEdit = true + const rangeCapability = encodeReadCapabilityToken({ + startLine: 1, + endLine: 1, + hash: getContentHash(diskContent.trimEnd()), + scope: { + projectId: mockFileContext.projectRoot, + path, + runId, + }, + }) + + // A range capability is not sufficient proof for replacing the whole + // file. Strict mode requires a successful whole-file read authorization. + const writeResult = await handleWriteFile({ + previousToolCallFinished: Promise.resolve(), + toolCall: { + toolCallId: 'e-bypass-write', toolName: 'write_file', input: { path, - instructions: 'Create helper value', - content: writtenContent, + instructions: 'Update helper value', + content: 'export const value = 2\n', + basedOnRead: rangeCapability, }, }, agentState: { messageHistory: [] }, clientSessionId: 'test-session', + fileContext: mockFileContext, + runId, fileProcessingState, fingerprintId: 'test-fingerprint', logger, prompt: undefined, userId: undefined, userInputId: 'test-input', - requestOptionalFile: async () => null, - requestClientToolCall: async (toolCall: any) => { - writeApplied = true - return confirmedMutationOutput(toolCall) + requestOptionalFile: async ({ filePath }: { filePath: string }) => + filePath === path ? diskContent : null, + requestClientToolCall: async (toolCall: any) => [ + { + type: 'json' as const, + value: { file: toolCall.input.path, message: 'applied' }, + }, + ], + writeToClient: () => {}, + } as any) + + const writeOutput = writeResult.output[0] + expect(writeOutput.type).toBe('json') + if (writeOutput.type === 'json') { + const value = writeOutput.value as { errorMessage?: string } + expect(String(value.errorMessage)).toContain( + 'range capability cannot authorize a whole-file overwrite', + ) + } + }) + + it('strict read_files auth survives across separate fileProcessingState instances (cross-turn state isolation)', async () => { + // available, otherwise the strict gate blocks the edit on the first + // attempt and forces a redundant read round-trip. + // + // The fix: readAuthorizationsByPath must be persisted on agentState + // (which survives across turns) and hydrated into the per-turn + // fileProcessingState at the start of each invocation. The test below + // mirrors that hydration: after read_files populates state A, the + // authorization set is copied into state B (the next turn's state). + const path = 'src/helper.ts' + const diskContent = 'export const value = 1\n' + const strictReadBeforeEdit = true + + // --- Turn 1: read_files populates auth on state A --- + const stateA = createFileProcessingState() + stateA.strictReadBeforeEdit = strictReadBeforeEdit + + await handleReadFiles({ + previousToolCallFinished: Promise.resolve(), + toolCall: { + toolCallId: 'cross-turn-read', + toolName: 'read_files', + input: { paths: [path] }, }, - writeToClient: () => {}, + fileContext: mockFileContext, + fileProcessingState: stateA, + requestFiles: async ({ filePaths }: { filePaths: string[] }) => + buildWholeFileReadResultV1(filePaths, (filePath) => + filePath === path ? diskContent : null, + ), + logger, } as any) - expect(writeApplied).toBe(true) - const writeOutput = writeResult.output[0] - expect(writeOutput.type).toBe('json') - if (writeOutput.type === 'json') { - expect(writeOutput.value).not.toHaveProperty('errorMessage') + expect(stateA.readAuthorizationsByPath?.[path]).toBe(true) + + // --- Turn boundary: persist auth from state A to agentState, then + // hydrate a fresh state B (simulating what processStream must do). --- + const persistedAuth = { ...(stateA.readAuthorizationsByPath ?? {}) } + const persistedHashes = { + ...(stateA.readAuthorizationHashesByPath ?? {}), } + expect(persistedAuth[path]).toBe(true) + expect(persistedHashes[path]).toBe(getContentHash(diskContent)) - // The fix: a successful write_file (even on a brand-new file with no prior - // read) must grant a one-shot read authorization so the very common - // write-then-edit flow does not need a redundant read round-trip. - expect(fileProcessingState.readAuthorizationsByPath?.[path]).toBe(true) + const stateB = createFileProcessingState() + stateB.strictReadBeforeEdit = strictReadBeforeEdit + stateB.readAuthorizationsByPath = { ...persistedAuth } + stateB.readAuthorizationHashesByPath = { ...persistedHashes } - // A follow-up str_replace must succeed using the just-granted auth - // without the agent having to call read_files separately. - let strReplaceApplied = false - const strReplaceResult = await handleStrReplace({ + // --- Turn 2: str_replace on the fresh state B must succeed without + // requiring the agent to re-read the file. --- + let applyCount = 0 + const result = await handleStrReplace({ previousToolCallFinished: Promise.resolve(), toolCall: { - toolCallId: 'strict-edit-after-new-write', + toolCallId: 'cross-turn-edit', toolName: 'str_replace', input: { path, @@ -3229,371 +4386,439 @@ describe('read_files edit-state recovery', () => { ], }, }, - fileProcessingState, + fileProcessingState: stateB, logger, requestOptionalFile: async ({ filePath }: { filePath: string }) => - filePath === path ? writtenContent : null, + filePath === path ? diskContent : null, requestClientToolCall: async (toolCall: any) => { - strReplaceApplied = true + applyCount += 1 return confirmedMutationOutput(toolCall) }, writeToClient: () => {}, } as any) - expect(strReplaceApplied).toBe(true) - const strReplaceOutput = strReplaceResult.output[0] - expect(strReplaceOutput.type).toBe('json') - if (strReplaceOutput.type === 'json') { - expect(strReplaceOutput.value).not.toHaveProperty('errorMessage') + expect(applyCount).toBe(1) + const output = result.output[0] + expect(output.type).toBe('json') + if (output.type === 'json') { + expect(output.value).not.toHaveProperty('errorMessage') } + // The fix must keep the auth alive after the cross-turn edit so a + // third turn's edit (or a follow-up edit_transaction) also succeeds + // without re-reading. + expect(stateB.readAuthorizationsByPath?.[path]).toBe(true) + }) - // Sticky auth: the write_file grant (and the follow-up str_replace) - // remain in force across subsequent edits on the same path. A third - // str_replace without re-reading must SUCCEED and keep auth alive, - // because the strict gate only re-enables after a failed edit or an - // externally-changed file (anchored with a fresh basedOnRead capability). - let thirdApplyCount = 0 - const thirdResult = await handleStrReplace({ + it('strict str_replace on a fresh fileProcessingState without hydrated auth auto-rereads once and applies', async () => { + // Without cross-turn hydration, auto-reread-once still recovers for a + // unique str_replace in-process; sticky may refresh after successful apply. + const path = 'src/helper.ts' + const diskContent = 'export const value = 1\n' + + const stateA = createFileProcessingState() + stateA.strictReadBeforeEdit = true + await handleReadFiles({ previousToolCallFinished: Promise.resolve(), toolCall: { - toolCallId: 'strict-edit-sticky-after-write', + toolCallId: 'bug-demo-read', + toolName: 'read_files', + input: { paths: [path] }, + }, + fileContext: mockFileContext, + fileProcessingState: stateA, + requestFiles: async ({ filePaths }: { filePaths: string[] }) => + buildWholeFileReadResultV1(filePaths, (filePath) => + filePath === path ? diskContent : null, + ), + logger, + } as any) + expect(stateA.readAuthorizationsByPath?.[path]).toBe(true) + + const stateB = createFileProcessingState() + stateB.strictReadBeforeEdit = true + expect(stateB.readAuthorizationsByPath?.[path]).toBeUndefined() + + let applyCount = 0 + const result = await handleStrReplace({ + previousToolCallFinished: Promise.resolve(), + toolCall: { + toolCallId: 'bug-demo-edit-auto-reread', toolName: 'str_replace', input: { path, replacements: [ { - oldString: 'export const value = 2', - newString: 'export const value = 3', + oldString: 'export const value = 1', + newString: 'export const value = 2', allowMultiple: false, }, ], }, }, - fileProcessingState, + fileProcessingState: stateB, logger, requestOptionalFile: async ({ filePath }: { filePath: string }) => - filePath === path ? writtenContent : null, + filePath === path ? diskContent : null, requestClientToolCall: async (toolCall: any) => { - thirdApplyCount += 1 + applyCount += 1 return confirmedMutationOutput(toolCall) }, writeToClient: () => {}, } as any) - // Third str_replace applied via sticky auth (no re-read required). - expect(thirdApplyCount).toBe(1) - const thirdOutput = thirdResult.output[0] - expect(thirdOutput.type).toBe('json') - if (thirdOutput.type === 'json') { - expect(thirdOutput.value).not.toHaveProperty('errorMessage') + expect(applyCount).toBe(1) + const output = result.output[0] + expect(output.type).toBe('json') + if (output.type === 'json') { + expect(output.value).not.toHaveProperty('errorMessage') } - // Auth remains true across the entire write -> edit -> edit -> edit - // chain with no intervening read_files calls. - expect(fileProcessingState.readAuthorizationsByPath?.[path]).toBe(true) + // Post-apply sticky from observed post-edit content. + expect(stateB.readAuthorizationsByPath?.[path]).toBe(true) + expect(stateB.readAuthorizationHashesByPath?.[path]).toBe( + getContentHash('export const value = 2\n'), + ) }) - it('strict read_files authorizes one write_file overwrite and the authorization is preserved after success', async () => { - const path = 'src/helper.ts' + it('write_file fails closed under context_compacted even when sticky hash matches disk', async () => { + // COMPACTION-STICKY-BLIND-WRITE: hash-fresh alone must not authorize overwrite. + const path = 'src/compacted-write.ts' const diskContent = 'export const value = 1\n' const fileProcessingState = createFileProcessingState() fileProcessingState.strictReadBeforeEdit = true + fileProcessingState.readAuthorizationsByPath = { [path]: true } + fileProcessingState.readAuthorizationHashesByPath = { + [path]: getContentHash(diskContent), + } + fileProcessingState.editRereadRequirementsByPath = { + [path]: { reason: 'context_compacted', sourceTool: 'compaction' }, + } + fileProcessingState.failedEditRequiresReadByPath[path] = true - await handleReadFiles({ + let writeApplied = false + const writeResult = await handleWriteFile({ previousToolCallFinished: Promise.resolve(), toolCall: { - toolCallId: 'strict-write-read', - toolName: 'read_files', - input: { paths: [path] }, + toolCallId: 'write-after-compaction', + toolName: 'write_file', + input: { path, content: 'export const value = 2\n' }, }, - fileContext: mockFileContext, + agentState: { messageHistory: [] }, + clientSessionId: 'test-session', fileProcessingState, - requestFiles: async ({ filePaths }: { filePaths: string[] }) => - buildWholeFileReadResultV1(filePaths, (filePath) => - filePath === path ? diskContent : null, - ), + fingerprintId: 'test-fingerprint', logger, + prompt: undefined, + userId: undefined, + userInputId: 'test-input', + requestOptionalFile: async () => diskContent, + requestClientToolCall: async () => { + writeApplied = true + return [] + }, + writeToClient: () => {}, } as any) - expect(fileProcessingState.readAuthorizationsByPath?.[path]).toBe(true) + expect(writeApplied).toBe(false) + expect(writeResult.output[0]?.type).toBe('json') + if (writeResult.output[0]?.type === 'json') { + const msg = String((writeResult.output[0].value as any).errorMessage) + expect(msg).toMatch(/context compaction|read_files|basedOnRead/i) + expect(msg).toContain('basedOnRead=') + } + // Marker must remain until a real whole-file re-read or valid basedOnRead. + expect( + fileProcessingState.editRereadRequirementsByPath?.[path]?.reason, + ).toBe('context_compacted') - let applied = false - const result = await handleWriteFile({ + // str_replace unique may still apply on hash-fresh and clear the marker. + let replaceApplied = false + const replaceResult = await handleStrReplace({ previousToolCallFinished: Promise.resolve(), toolCall: { - toolCallId: 'strict-write-authorized', - toolName: 'write_file', + toolCallId: 'str-replace-after-compaction', + toolName: 'str_replace', input: { path, - instructions: 'Update helper value', - content: 'export const value = 2\n', + replacements: [ + { + oldString: 'export const value = 1', + newString: 'export const value = 3', + allowMultiple: false, + }, + ], }, }, - agentState: { messageHistory: [] }, - clientSessionId: 'test-session', fileProcessingState, - fingerprintId: 'test-fingerprint', logger, - prompt: undefined, - userId: undefined, - userInputId: 'test-input', - requestOptionalFile: async ({ filePath }: { filePath: string }) => - filePath === path ? diskContent : null, + requestOptionalFile: async () => diskContent, requestClientToolCall: async (toolCall: any) => { - applied = true + replaceApplied = true return confirmedMutationOutput(toolCall) }, writeToClient: () => {}, } as any) - expect(applied).toBe(true) - const output = result.output[0] - expect(output.type).toBe('json') - if (output.type === 'json') { - expect(output.value).not.toHaveProperty('errorMessage') + expect(replaceApplied).toBe(true) + expect(replaceResult.output[0]?.type).toBe('json') + if (replaceResult.output[0]?.type === 'json') { + expect(replaceResult.output[0].value).not.toHaveProperty('errorMessage') } - expect(fileProcessingState.readAuthorizationsByPath?.[path]).toBe(true) - expect(fileProcessingState.readAuthorizationHashesByPath?.[path]).toBe( - getContentHash('export const value = 2\n'), - ) + expect( + fileProcessingState.editRereadRequirementsByPath?.[path], + ).toBeUndefined() }) - it('strict replace_range blocks without prior read or freshness anchor and does not call client apply', async () => { - const path = 'src/helper.ts' + it('failed str_replace after compaction leaves context_compacted so write_file stays blocked', async () => { + // COMPACTION-MARKER-CLEARED-BY-HASHFRESH-STRREPLACE-ATTEMPT: a no-match + // unique attempt must not clear the marker before apply. + const path = 'src/compacted-failed-replace.ts' + const diskContent = 'export const value = 1\n' const fileProcessingState = createFileProcessingState() fileProcessingState.strictReadBeforeEdit = true + fileProcessingState.readAuthorizationsByPath = { [path]: true } + fileProcessingState.readAuthorizationHashesByPath = { + [path]: getContentHash(diskContent), + } + fileProcessingState.editRereadRequirementsByPath = { + [path]: { reason: 'context_compacted', sourceTool: 'compaction' }, + } + fileProcessingState.failedEditRequiresReadByPath[path] = true - const result = await handleReplaceRange({ + const failResult = await handleStrReplace({ previousToolCallFinished: Promise.resolve(), toolCall: { - toolCallId: 'replace-range-blocked', - toolName: 'replace_range', + toolCallId: 'failed-replace-after-compaction', + toolName: 'str_replace', input: { path, - startLine: 1, - endLine: 1, - newContent: 'export const value = 2', + replacements: [ + { + oldString: 'export const missing = 99', + newString: 'export const missing = 100', + allowMultiple: false, + }, + ], }, }, fileProcessingState, + logger, + requestOptionalFile: async () => diskContent, requestClientToolCall: async () => { - throw new Error( - 'client apply must not be called for blocked replace_range', - ) + throw new Error('must not apply failed str_replace') }, + writeToClient: () => {}, } as any) - const output = result.output[0] - expect(output.type).toBe('json') - if (output.type === 'json') { - const value = output.value as { file?: string; errorMessage?: string } - expect(value.file).toBe(path) - expect(String(value.errorMessage)).toContain('replace_range blocked') - expect(String(value.errorMessage)).toContain('read_files') + expect(failResult.output[0]?.type).toBe('json') + expect( + fileProcessingState.editRereadRequirementsByPath?.[path]?.reason, + ).toBe('context_compacted') + + let writeApplied = false + const writeResult = await handleWriteFile({ + previousToolCallFinished: Promise.resolve(), + toolCall: { + toolCallId: 'write-after-failed-compacted-replace', + toolName: 'write_file', + input: { path, content: 'export const value = 999\n' }, + }, + agentState: { messageHistory: [] }, + clientSessionId: 'test-session', + fileProcessingState, + fingerprintId: 'test-fingerprint', + logger, + prompt: undefined, + userId: undefined, + userInputId: 'test-input', + requestOptionalFile: async () => diskContent, + requestClientToolCall: async () => { + writeApplied = true + return [] + }, + writeToClient: () => {}, + } as any) + + expect(writeApplied).toBe(false) + expect(writeResult.output[0]?.type).toBe('json') + if (writeResult.output[0]?.type === 'json') { + expect( + String((writeResult.output[0].value as any).errorMessage), + ).toMatch(/context compaction|read_files|basedOnRead/i) } - expect(fileProcessingState.failedEditRequiresReadByPath[path]).toBe(true) + expect( + fileProcessingState.editRereadRequirementsByPath?.[path]?.reason, + ).toBe('context_compacted') }) - it('replace_range preserves strict read authorization on success and only flags re-read after client errors', async () => { - const path = 'src/helper.ts' - let diskContent = 'export const value = 1\n' + it('proper-subset range read after compaction does not clear context_compacted', async () => { + // COMPACTION-MARKER-CLEARED-BY-PARTIAL-OR-SCOPED-READ + const path = 'src/compacted-range.ts' + const diskContent = 'export const value = 1\nexport const other = 2\n' + const sourceContent = 'export const value = 1' + const rangeHash = getContentHash(sourceContent) const fileProcessingState = createFileProcessingState() fileProcessingState.strictReadBeforeEdit = true fileProcessingState.readAuthorizationsByPath = { [path]: true } fileProcessingState.readAuthorizationHashesByPath = { [path]: getContentHash(diskContent), } + fileProcessingState.editRereadRequirementsByPath = { + [path]: { reason: 'context_compacted', sourceTool: 'compaction' }, + } + fileProcessingState.failedEditRequiresReadByPath[path] = true - const successResult = await handleReplaceRange({ + await handleReadFiles({ previousToolCallFinished: Promise.resolve(), toolCall: { - toolCallId: 'replace-range-success', - toolName: 'replace_range', - input: { - path, - startLine: 1, - endLine: 1, - expectedHash: 'sha256:current', - newContent: 'export const value = 2', - }, + toolCallId: 'range-after-compaction', + toolName: 'read_files', + input: { ranges: [{ path, startLine: 1, endLine: 1 }] }, }, + fileContext: mockFileContext, fileProcessingState, - requestOptionalFile: async () => diskContent, - requestClientToolCall: async (toolCall: any) => { - diskContent = 'export const value = 2\n' - return confirmedMutationOutput(toolCall) - }, + requestFiles: async () => + buildReadFilesResultV1([ + { + selector: 'range', + requestIndex: 0, + path, + status: 'ok', + content: '1\texport const value = 1', + sourceContent, + startLine: 1, + endLine: 1, + totalLines: 2, + complete: true, + editAnchor: { + startLine: 1, + endLine: 1, + contentHash: rangeHash, + readCapability: 'cap.v3.test', + }, + }, + ]), + logger, } as any) - expect(successResult.output[0]?.type).toBe('json') - // Sticky auth: a successful replace_range does NOT consume the auth. - expect(fileProcessingState.readAuthorizationsByPath?.[path]).toBe(true) expect( - fileProcessingState.failedEditRequiresReadByPath[path], - ).toBeUndefined() + fileProcessingState.editRereadRequirementsByPath?.[path]?.reason, + ).toBe('context_compacted') - const errorResult = await handleReplaceRange({ + let writeApplied = false + const writeResult = await handleWriteFile({ previousToolCallFinished: Promise.resolve(), toolCall: { - toolCallId: 'replace-range-error', - toolName: 'replace_range', - input: { - path, - startLine: 1, - endLine: 1, - expectedHash: 'sha256:stale', - newContent: 'export const value = 3', - }, + toolCallId: 'write-after-compacted-range', + toolName: 'write_file', + input: { path, content: 'export const value = 9\n' }, }, + agentState: { messageHistory: [] }, + clientSessionId: 'test-session', fileProcessingState, + fingerprintId: 'test-fingerprint', + logger, + prompt: undefined, + userId: undefined, + userInputId: 'test-input', requestOptionalFile: async () => diskContent, - requestClientToolCall: async (toolCall: any) => [ - { - type: 'json' as const, - value: { - file: toolCall.input.path, - errorMessage: 'replace_range rejected: stale range', - }, - }, - ], - } as any) - - expect(errorResult.output[0]?.type).toBe('json') - expect(fileProcessingState.failedEditRequiresReadByPath[path]).toBe(true) - }) - - it('strict replace_range rejects a legacy pathless expectedHash as authorization', async () => { - const path = 'src/helper.ts' - const fileProcessingState = createFileProcessingState() - fileProcessingState.strictReadBeforeEdit = true - // No read authorization registered — only expectedHash as anchor. - - let applied = false - const result = await handleReplaceRange({ - previousToolCallFinished: Promise.resolve(), - toolCall: { - toolCallId: 'replace-range-anchor', - toolName: 'replace_range', - input: { - path, - startLine: 1, - endLine: 1, - expectedHash: 'sha256:fresh', - newContent: 'export const value = 2', - }, - }, - fileProcessingState, - requestClientToolCall: async (toolCall: any) => { - applied = true - return confirmedMutationOutput(toolCall) + requestClientToolCall: async () => { + writeApplied = true + return [] }, + writeToClient: () => {}, } as any) - expect(applied).toBe(false) - const output = result.output[0] - expect(output.type).toBe('json') - if (output.type === 'json') { - expect(String((output.value as any).errorMessage)).toContain( - 'no fresh path-bound read authorization exists', - ) - expect(String((output.value as any).errorMessage)).toContain( - 'cap.v3 readCapability plus newContent', - ) - } + expect(writeApplied).toBe(false) + expect(writeResult.output[0]?.type).toBe('json') + expect( + fileProcessingState.editRereadRequirementsByPath?.[path]?.reason, + ).toBe('context_compacted') }) - it('strict replace_range accepts a cap.v3 token bound to the target and run', async () => { - const path = 'src/helper.ts' + it('complete whole-file read_files after compaction clears marker so write_file can proceed', async () => { + const path = 'src/compacted-whole-read.ts' + const diskContent = 'export const value = 1\n' const fileProcessingState = createFileProcessingState() fileProcessingState.strictReadBeforeEdit = true - const scope = { - projectId: mockFileContext.projectRoot, - path, - runId: 'replace-range-run', + fileProcessingState.readAuthorizationsByPath = { [path]: true } + fileProcessingState.readAuthorizationHashesByPath = { + [path]: getContentHash(diskContent), } - const readCapability = encodeReadCapabilityToken({ - startLine: 1, - endLine: 1, - hash: getContentHash('export const value = 1'), - scope, - }) - let applied = false - const result = await handleReplaceRange({ + fileProcessingState.editRereadRequirementsByPath = { + [path]: { reason: 'context_compacted', sourceTool: 'compaction' }, + } + fileProcessingState.failedEditRequiresReadByPath[path] = true + + await handleReadFiles({ previousToolCallFinished: Promise.resolve(), toolCall: { - toolCallId: 'replace-range-bound-anchor', - toolName: 'replace_range', - input: { - path, - startLine: 1, - endLine: 1, - expectedHash: getContentHash('export const value = 1'), - readCapability, - newContent: 'export const value = 2', - }, + toolCallId: 'whole-read-after-compaction', + toolName: 'read_files', + input: { paths: [path] }, }, fileContext: mockFileContext, - runId: scope.runId, fileProcessingState, - requestOptionalFile: async () => 'export const value = 1\n', - requestClientToolCall: async (toolCall: any) => { - applied = true - return confirmedMutationOutput(toolCall) - }, + requestFiles: async ({ filePaths }: { filePaths: string[] }) => + buildWholeFileReadResultV1(filePaths, (filePath) => + filePath === path ? diskContent : null, + ), + logger, } as any) - expect(applied).toBe(true) - expect(result.output[0]).toMatchObject({ type: 'json' }) - }) - - it('Reduction D: strict str_replace error message omits "in this turn" and "Recovery required:"', async () => { - const path = 'src/helper.ts' - const fileProcessingState = createFileProcessingState() - fileProcessingState.strictReadBeforeEdit = true + expect( + fileProcessingState.editRereadRequirementsByPath?.[path], + ).toBeUndefined() + expect(fileProcessingState.readAuthorizationsByPath?.[path]).toBe(true) - const result = await handleStrReplace({ + let writeApplied = false + const writeResult = await handleWriteFile({ previousToolCallFinished: Promise.resolve(), toolCall: { - toolCallId: 'strict-msg-check', - toolName: 'str_replace', - input: { - path, - replacements: [ - { - old_string: 'export const value = 1', - new_string: 'export const value = 2', - }, - ], - }, + toolCallId: 'write-after-compacted-whole-read', + toolName: 'write_file', + input: { path, content: 'export const value = 2\n' }, }, + agentState: { messageHistory: [] }, + clientSessionId: 'test-session', fileProcessingState, - requestClientToolCall: async () => { - throw new Error( - 'client apply must not be called for blocked str_replace', - ) + fingerprintId: 'test-fingerprint', + logger, + prompt: undefined, + userId: undefined, + userInputId: 'test-input', + requestOptionalFile: async () => diskContent, + requestClientToolCall: async (toolCall: any) => { + writeApplied = true + return confirmedMutationOutput(toolCall) }, + writeToClient: () => {}, } as any) - const output = result.output[0] - expect(output.type).toBe('json') - if (output.type === 'json') { - const value = output.value as { errorMessage?: string } - expect(String(value.errorMessage)).not.toContain('in this turn') - expect(String(value.errorMessage)).not.toContain('Recovery required:') - // Should still mention the actionable next step. - expect(String(value.errorMessage)).toContain('read_files') + expect(writeApplied).toBe(true) + expect(writeResult.output[0]?.type).toBe('json') + if (writeResult.output[0]?.type === 'json') { + expect(writeResult.output[0].value).not.toHaveProperty('errorMessage') } }) - it('does not let a range basedOnRead capability authorize a whole-file overwrite', async () => { - const path = 'src/helper.ts' + it('write_file with valid whole-file basedOnRead clears context_compacted and applies', async () => { + const path = 'src/compacted-basedonread-write.ts' const diskContent = 'export const value = 1\n' - const runId = 'range-write-floor-run' + const runId = 'compacted-basedonread-run' const fileProcessingState = createFileProcessingState() fileProcessingState.strictReadBeforeEdit = true - const rangeCapability = encodeReadCapabilityToken({ + fileProcessingState.readAuthorizationsByPath = { [path]: true } + fileProcessingState.readAuthorizationHashesByPath = { + [path]: getContentHash(diskContent), + } + fileProcessingState.editRereadRequirementsByPath = { + [path]: { reason: 'context_compacted', sourceTool: 'compaction' }, + } + fileProcessingState.failedEditRequiresReadByPath[path] = true + const basedOnRead = encodeReadCapabilityToken({ startLine: 1, - endLine: 1, - hash: getContentHash(diskContent.trimEnd()), + endLine: diskContent.split('\n').length, + hash: getContentHash(diskContent), scope: { projectId: mockFileContext.projectRoot, path, @@ -3601,18 +4826,16 @@ describe('read_files edit-state recovery', () => { }, }) - // A range capability is not sufficient proof for replacing the whole - // file. Strict mode requires a successful whole-file read authorization. + let writeApplied = false const writeResult = await handleWriteFile({ previousToolCallFinished: Promise.resolve(), toolCall: { - toolCallId: 'e-bypass-write', + toolCallId: 'write-compacted-with-basedonread', toolName: 'write_file', input: { path, - instructions: 'Update helper value', content: 'export const value = 2\n', - basedOnRead: rangeCapability, + basedOnRead, }, }, agentState: { messageHistory: [] }, @@ -3625,195 +4848,261 @@ describe('read_files edit-state recovery', () => { prompt: undefined, userId: undefined, userInputId: 'test-input', - requestOptionalFile: async ({ filePath }: { filePath: string }) => - filePath === path ? diskContent : null, - requestClientToolCall: async (toolCall: any) => [ - { - type: 'json' as const, - value: { file: toolCall.input.path, message: 'applied' }, - }, - ], + requestOptionalFile: async () => diskContent, + requestClientToolCall: async (toolCall: any) => { + writeApplied = true + return confirmedMutationOutput(toolCall) + }, writeToClient: () => {}, } as any) - const writeOutput = writeResult.output[0] - expect(writeOutput.type).toBe('json') - if (writeOutput.type === 'json') { - const value = writeOutput.value as { errorMessage?: string } - expect(String(value.errorMessage)).toContain( - 'range capability cannot authorize a whole-file overwrite', - ) + expect(writeApplied).toBe(true) + expect(writeResult.output[0]?.type).toBe('json') + if (writeResult.output[0]?.type === 'json') { + expect(writeResult.output[0].value).not.toHaveProperty('errorMessage') } + expect( + fileProcessingState.editRereadRequirementsByPath?.[path], + ).toBeUndefined() + }) + + it('strictEditAuthorizationError prefers basedOnRead retry when capability is present', () => { + const path = 'src/auth-miss.ts' + const fileProcessingState = createFileProcessingState() + fileProcessingState.strictReadBeforeEdit = true + const capability = + 'cap.v3.1.1.test-whole-file-capability-token-for-recovery-message' + + const withCap = strictEditAuthorizationError({ + fileProcessingState, + path, + toolName: 'write_file', + hasFreshWholeFileAuthorization: false, + freshReadCapability: capability, + }) + expect(withCap).toBeDefined() + expect(String(withCap?.errorMessage)).toContain( + 'Next: retry with basedOnRead', + ) + expect(String(withCap?.errorMessage)).toContain(`basedOnRead="${capability}"`) + expect(String(withCap?.errorMessage)).not.toMatch( + /Next: call read_files/, + ) + // Structured recovery may still name read_files as fallback tool. + expect(withCap?.recovery.tool).toBe('read_files') + expect(withCap?.recovery.basedOnRead).toBe(capability) + + const withoutCap = strictEditAuthorizationError({ + fileProcessingState, + path, + toolName: 'write_file', + hasFreshWholeFileAuthorization: false, + }) + expect(String(withoutCap?.errorMessage)).toContain( + 'Next: call read_files', + ) }) - it('strict read_files auth survives across separate fileProcessingState instances (cross-turn state isolation)', async () => { - // available, otherwise the strict gate blocks the edit on the first - // attempt and forces a redundant read round-trip. - // - // The fix: readAuthorizationsByPath must be persisted on agentState - // (which survives across turns) and hydrated into the per-turn - // fileProcessingState at the start of each invocation. The test below - // mirrors that hydration: after read_files populates state A, the - // authorization set is copied into state B (the next turn's state). - const path = 'src/helper.ts' + it('strict edit_transaction write_file auth-miss prefers basedOnRead over Call read_files first', async () => { + const path = 'src/write-auth-miss-msg.ts' const diskContent = 'export const value = 1\n' - const strictReadBeforeEdit = true - - // --- Turn 1: read_files populates auth on state A --- - const stateA = createFileProcessingState() - stateA.strictReadBeforeEdit = strictReadBeforeEdit + const fileProcessingState = createFileProcessingState() + fileProcessingState.strictReadBeforeEdit = true - await handleReadFiles({ + const result = await handleEditTransaction({ previousToolCallFinished: Promise.resolve(), toolCall: { - toolCallId: 'cross-turn-read', - toolName: 'read_files', - input: { paths: [path] }, + toolCallId: 'write-auth-miss-msg', + toolName: 'edit_transaction', + input: { + edits: [ + { + type: 'write_file', + path, + content: 'export const value = 2\n', + }, + ], + }, }, + fileProcessingState, fileContext: mockFileContext, - fileProcessingState: stateA, - requestFiles: async ({ filePaths }: { filePaths: string[] }) => - buildWholeFileReadResultV1(filePaths, (filePath) => - filePath === path ? diskContent : null, - ), + runId: 'write-auth-miss-msg-run', logger, + requestOptionalFile: async () => diskContent, + requestClientToolCall: async () => { + throw new Error('must not apply write auth miss') + }, } as any) - expect(stateA.readAuthorizationsByPath?.[path]).toBe(true) - - // --- Turn boundary: persist auth from state A to agentState, then - // hydrate a fresh state B (simulating what processStream must do). --- - const persistedAuth = { ...(stateA.readAuthorizationsByPath ?? {}) } - const persistedHashes = { - ...(stateA.readAuthorizationHashesByPath ?? {}), + const output = result.output[0] + expect(output.type).toBe('json') + if (output.type === 'json') { + const value = output.value as { + errorMessage?: string + failures?: Array<{ errorMessage?: string; basedOnRead?: string }> + } + const failureMsg = String(value.failures?.[0]?.errorMessage) + expect(failureMsg).toContain('Retry with write_file basedOnRead') + expect(failureMsg).toContain('basedOnRead=') + expect(failureMsg).not.toMatch(/Call read_files with paths:.*before retrying/) + expect(String(value.errorMessage)).toContain( + 'retry with that capability first', + ) + expect(value.failures?.[0]?.basedOnRead).toMatch(/^cap\.v3\./) } - expect(persistedAuth[path]).toBe(true) - expect(persistedHashes[path]).toBe(getContentHash(diskContent)) + }) - const stateB = createFileProcessingState() - stateB.strictReadBeforeEdit = strictReadBeforeEdit - stateB.readAuthorizationsByPath = { ...persistedAuth } - stateB.readAuthorizationHashesByPath = { ...persistedHashes } + it('auto-reread does not clear context_compacted before apply; failed tx leaves marker so write_file stays blocked', async () => { + const path = 'src/tx-autoreread-compacted.ts' + const diskContent = 'export const value = 1\n' + const fileProcessingState = createFileProcessingState() + fileProcessingState.strictReadBeforeEdit = true + fileProcessingState.readAuthorizationsByPath = { [path]: true } + fileProcessingState.readAuthorizationHashesByPath = { + [path]: getContentHash(diskContent), + } + fileProcessingState.editRereadRequirementsByPath = { + [path]: { reason: 'context_compacted', sourceTool: 'compaction' }, + } + fileProcessingState.failedEditRequiresReadByPath[path] = true - // --- Turn 2: str_replace on the fresh state B must succeed without - // requiring the agent to re-read the file. --- - let applyCount = 0 - const result = await handleStrReplace({ + const failResult = await handleEditTransaction({ previousToolCallFinished: Promise.resolve(), toolCall: { - toolCallId: 'cross-turn-edit', - toolName: 'str_replace', + toolCallId: 'tx-autoreread-fail-compacted', + toolName: 'edit_transaction', input: { - path, - replacements: [ + edits: [ { - oldString: 'export const value = 1', - newString: 'export const value = 2', - allowMultiple: false, + type: 'str_replace', + path, + replacements: [ + { + oldString: 'export const missing = 99', + newString: 'export const missing = 100', + allowMultiple: false, + }, + ], }, ], }, }, - fileProcessingState: stateB, + fileProcessingState, + fileContext: mockFileContext, + runId: 'tx-autoreread-compacted-run', logger, - requestOptionalFile: async ({ filePath }: { filePath: string }) => - filePath === path ? diskContent : null, - requestClientToolCall: async (toolCall: any) => { - applyCount += 1 - return confirmedMutationOutput(toolCall) + requestOptionalFile: async () => diskContent, + requestClientToolCall: async () => { + throw new Error('must not apply failed unique match') }, - writeToClient: () => {}, } as any) - expect(applyCount).toBe(1) - const output = result.output[0] - expect(output.type).toBe('json') - if (output.type === 'json') { - expect(output.value).not.toHaveProperty('errorMessage') + expect(failResult.output[0]?.type).toBe('json') + if (failResult.output[0]?.type === 'json') { + const value = failResult.output[0].value as { + errorMessage?: string + failures?: Array<{ basedOnRead?: string; errorMessage?: string }> + } + expect(value).toHaveProperty('errorMessage') + // Residual process failure should echo basedOnRead when content known. + expect(value.failures?.[0]?.basedOnRead).toMatch(/^cap\.v3\./) + expect(String(value.failures?.[0]?.errorMessage)).toContain( + 'basedOnRead=', + ) } - // The fix must keep the auth alive after the cross-turn edit so a - // third turn's edit (or a follow-up edit_transaction) also succeeds - // without re-reading. - expect(stateB.readAuthorizationsByPath?.[path]).toBe(true) - }) - - it('strict str_replace on a fresh fileProcessingState without hydrated auth blocks (proves the cross-turn bug exists without the fix)', async () => { - // Companion to the test above: this one models the BUG. It builds a - // fresh state B without hydrating auth from state A, then confirms the - // strict gate blocks the edit. This is the exact user-reported failure - // mode: read in turn N, edit in turn N+1, first edit is blocked. - const path = 'src/helper.ts' - const diskContent = 'export const value = 1\n' + // Marker must survive failed auto-reread preflight (no pre-apply clear). + expect( + fileProcessingState.editRereadRequirementsByPath?.[path]?.reason, + ).toBe('context_compacted') - // Turn 1: read_files populates auth on state A. - const stateA = createFileProcessingState() - stateA.strictReadBeforeEdit = true - await handleReadFiles({ + let writeApplied = false + const writeResult = await handleWriteFile({ previousToolCallFinished: Promise.resolve(), toolCall: { - toolCallId: 'bug-demo-read', - toolName: 'read_files', - input: { paths: [path] }, + toolCallId: 'write-after-failed-tx-autoreread', + toolName: 'write_file', + input: { path, content: 'export const value = 999\n' }, }, - fileContext: mockFileContext, - fileProcessingState: stateA, - requestFiles: async ({ filePaths }: { filePaths: string[] }) => - buildWholeFileReadResultV1(filePaths, (filePath) => - filePath === path ? diskContent : null, - ), + agentState: { messageHistory: [] }, + clientSessionId: 'test-session', + fileProcessingState, + fingerprintId: 'test-fingerprint', logger, + prompt: undefined, + userId: undefined, + userInputId: 'test-input', + requestOptionalFile: async () => diskContent, + requestClientToolCall: async () => { + writeApplied = true + return [] + }, + writeToClient: () => {}, } as any) - expect(stateA.readAuthorizationsByPath?.[path]).toBe(true) - // Turn 2: a FRESH state B with no hydration (the current bug). The - // strict gate must block the first edit, forcing a redundant read. - const stateB = createFileProcessingState() - stateB.strictReadBeforeEdit = true - expect(stateB.readAuthorizationsByPath?.[path]).toBeUndefined() + expect(writeApplied).toBe(false) + expect(writeResult.output[0]?.type).toBe('json') + if (writeResult.output[0]?.type === 'json') { + expect( + String((writeResult.output[0].value as any).errorMessage), + ).toMatch(/context compaction|read_files|basedOnRead/i) + } + expect( + fileProcessingState.editRereadRequirementsByPath?.[path]?.reason, + ).toBe('context_compacted') + }) - let applyCount = 0 - const result = await handleStrReplace({ + it('process residual failure path includes basedOnRead on failures when content known', async () => { + const path = 'src/process-residual-basedonread.ts' + const diskContent = 'export const value = 1\n' + const fileProcessingState = createFileProcessingState() + fileProcessingState.strictReadBeforeEdit = true + fileProcessingState.readAuthorizationsByPath = { [path]: true } + fileProcessingState.readAuthorizationHashesByPath = { + [path]: getContentHash(diskContent), + } + + const result = await handleEditTransaction({ previousToolCallFinished: Promise.resolve(), toolCall: { - toolCallId: 'bug-demo-edit-blocked', - toolName: 'str_replace', + toolCallId: 'process-residual-basedonread', + toolName: 'edit_transaction', input: { - path, - replacements: [ + edits: [ { - oldString: 'export const value = 1', - newString: 'export const value = 2', - allowMultiple: false, + type: 'str_replace', + path, + replacements: [ + { + oldString: 'export const missing = 1', + newString: 'export const missing = 2', + allowMultiple: false, + }, + ], }, ], }, }, - fileProcessingState: stateB, + fileProcessingState, + fileContext: mockFileContext, + runId: 'process-residual-basedonread-run', logger, - requestOptionalFile: async ({ filePath }: { filePath: string }) => - filePath === path ? diskContent : null, - requestClientToolCall: async (toolCall: any) => { - applyCount += 1 - return [ - { - type: 'json' as const, - value: { file: toolCall.input.path, message: 'applied' }, - }, - ] + requestOptionalFile: async () => diskContent, + requestClientToolCall: async () => { + throw new Error('must not apply residual no-match') }, - writeToClient: () => {}, } as any) - // Without hydration, the strict gate blocks the edit. The client is - // never invoked and the error message points at the missing read. - expect(applyCount).toBe(0) const output = result.output[0] expect(output.type).toBe('json') if (output.type === 'json') { - const value = output.value as { file?: string; errorMessage?: string } - expect(value.file).toBe(path) - expect(String(value.errorMessage)).toContain( - 'strict read-before-edit is enabled', + const value = output.value as { + errorMessage?: string + failures?: Array<{ basedOnRead?: string; errorMessage?: string }> + } + expect(value).toHaveProperty('errorMessage') + expect(value.failures?.[0]?.basedOnRead).toMatch(/^cap\.v3\./) + expect(String(value.failures?.[0]?.errorMessage)).toContain( + 'basedOnRead=', ) } }) @@ -3844,7 +5133,7 @@ describe('processStream cross-turn read-before-edit', () => { stepPrompt: 'Test step prompt', } - it('defers same-response read auth until the next model step', async () => { + it('auto-rereads once so a same-response unique str_replace can apply without model-visible sticky auth', async () => { const sessionState = getInitialSessionState(mockFileContext) const agentState = sessionState.mainAgentState const targetPath = 'src/example.ts' @@ -3887,61 +5176,11 @@ describe('processStream cross-turn read-before-edit', () => { }, } as AgentRuntimeDeps & AgentRuntimeScopedDeps - // Turn 1: the edit arguments were authored before the model could observe - // the read result, so the same-response edit must not consume that newly - // minted implicit authorization. + // Same-response read does not mint model-visible sticky auth for the edit, + // but server auto-reread-once still recovers unique str_replace safely. const stream1 = createMockStreamWithToolCalls([ 'Reading the file now.', { toolName: 'read_files', input: { paths: [targetPath] } }, - { - toolName: 'str_replace', - input: { - path: targetPath, - replacements: [{ oldString: 'value = 1', newString: 'value = 2' }], - }, - }, - { toolName: 'end_turn', input: {} }, - ]) - - await processStream({ - ...agentRuntimeImpl, - agentContext: {}, - agentState, - agentStepId: 'turn-1', - agentTemplate: testAgentTemplate, - ancestorRunIds: [], - clientSessionId: 'test-session', - fileContext: mockFileContext, - fingerprintId: 'test-fingerprint', - fullResponse: '', - localAgentTemplates: { 'test-agent': testAgentTemplate }, - messages: [], - prompt: 'test prompt', - repoId: undefined, - repoUrl: undefined, - runId: 'test-run-id', - signal: new AbortController().signal, - stream: stream1, - system: 'test system', - tools: {}, - userId: 'test-user', - userInputId: 'test-input-id', - onCostCalculated: async () => {}, - onResponseChunk: () => {}, - }) - - expect(appliedPatches).toHaveLength(0) - - // After turn 1: the completed read becomes visible and usable on the next - // provider generation. - expect(agentState.readAuthorizationsByPath?.[targetPath]).toBe(true) - expect(agentState.readAuthorizationHashesByPath?.[targetPath]).toBe( - getContentHash(diskContent), - ) - - // Turn 2: str_replace on the same path succeeds without a redundant read. - const stream2 = createMockStreamWithToolCalls([ - 'Editing the file now.', { toolName: 'str_replace', input: { @@ -3962,7 +5201,7 @@ describe('processStream cross-turn read-before-edit', () => { ...agentRuntimeImpl, agentContext: {}, agentState, - agentStepId: 'turn-2', + agentStepId: 'turn-1', agentTemplate: testAgentTemplate, ancestorRunIds: [], clientSessionId: 'test-session', @@ -3970,13 +5209,13 @@ describe('processStream cross-turn read-before-edit', () => { fingerprintId: 'test-fingerprint', fullResponse: '', localAgentTemplates: { 'test-agent': testAgentTemplate }, - messages: agentState.messageHistory, + messages: [], prompt: 'test prompt', repoId: undefined, repoUrl: undefined, runId: 'test-run-id', signal: new AbortController().signal, - stream: stream2, + stream: stream1, system: 'test system', tools: {}, userId: 'test-user', @@ -3985,9 +5224,11 @@ describe('processStream cross-turn read-before-edit', () => { onResponseChunk: () => {}, }) - // The edit must have been applied (no strict-gate block, no re-read needed). expect(appliedPatches).toHaveLength(1) expect(appliedPatches[0]).toContain('export const value = 2') + // Sticky after success is from post-edit observed content (and/or same-response + // read write-back), not from auto-reread pre-edit grant alone. + expect(agentState.readAuthorizationsByPath?.[targetPath]).toBe(true) expect(agentState.readAuthorizationHashesByPath?.[targetPath]).toBe( getContentHash('export const value = 2\n'), ) diff --git a/packages/agent-runtime/src/__tests__/rewrite-symbol.test.ts b/packages/agent-runtime/src/__tests__/rewrite-symbol.test.ts index 28316e3571..d746aed61a 100644 --- a/packages/agent-runtime/src/__tests__/rewrite-symbol.test.ts +++ b/packages/agent-runtime/src/__tests__/rewrite-symbol.test.ts @@ -205,7 +205,9 @@ describe('rewrite_symbol handler', () => { expect(outputJson(result).errorMessage).toContain('client rejected patch') expect(state.consecutiveStrReplaceFailuresByPath['svc.ts']).toBe(3) - expect(state.failedEditRequiresReadByPath['svc.ts']).toBe(true) + // An explicit client rejection is deterministic: no prepared mutation was + // applied, so it preserves read authorization and does not force a re-read. + expect(state.failedEditRequiresReadByPath['svc.ts']).toBeUndefined() }) test('errors clearly when the symbol is not found', async () => { diff --git a/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts b/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts index 481939311f..20a53d1c4b 100644 --- a/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts +++ b/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts @@ -611,6 +611,21 @@ describe('runAgentStep - set_output tool', () => { ), }), ) + // Pin the reworded gate-block guidance: the message frames the block as + // normal ordering (the gate re-arms per edit and clears automatically), + // not a failure, so this behavior-changing wording stays covered. + expect(chunks).toContainEqual( + expect.objectContaining({ + type: 'error', + message: expect.stringContaining('normal ordering, not a failure'), + }), + ) + expect(chunks).toContainEqual( + expect.objectContaining({ + type: 'error', + message: expect.stringContaining('clears automatically'), + }), + ) expect(chunks).not.toContainEqual( expect.objectContaining({ type: 'tool_call', @@ -929,6 +944,20 @@ describe('runAgentStep - set_output tool', () => { ), }), ) + // Pin the reworded uncommitted-unvalidated gate-block guidance: normal + // ordering framing plus the auto-clearing explanation. + expect(chunks).toContainEqual( + expect.objectContaining({ + type: 'error', + message: expect.stringContaining('normal ordering, not a failure'), + }), + ) + expect(chunks).toContainEqual( + expect.objectContaining({ + type: 'error', + message: expect.stringContaining('clears automatically'), + }), + ) expect(chunks).not.toContainEqual( expect.objectContaining({ type: 'tool_call', diff --git a/packages/agent-runtime/src/__tests__/tool-validation-error.test.ts b/packages/agent-runtime/src/__tests__/tool-validation-error.test.ts index cd707350d9..abd0f2e5db 100644 --- a/packages/agent-runtime/src/__tests__/tool-validation-error.test.ts +++ b/packages/agent-runtime/src/__tests__/tool-validation-error.test.ts @@ -561,6 +561,33 @@ describe('tool validation error handling', () => { } }) + it('gives actionable recovery when get_build_targets receives no changed files', () => { + const result = parseRawToolCall({ + rawToolCall: { + toolName: 'get_build_targets', + toolCallId: 'get-build-targets-empty-files-tool-call-id', + input: { files: [] }, + }, + }) + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.error).toContain('Raw validation issues:') + expect(result.error).toContain('"code": "too_small"') + expect(result.error).toContain( + '`files` must be a non-empty array of changed project-relative file paths', + ) + expect(result.error).toContain( + '{ "files": ["packages/agent-runtime/src/tools/tool-executor.ts"] }', + ) + expect(result.error).toContain( + 'do not call `get_build_targets`; skip build-target discovery until a concrete changed-file list exists', + ) + expect(result.formattedInput).toContain(':') + expect(result.formattedInput).toContain('"files": []') + } + }) + it('should parse stringified params for spawn_agents entries', () => { const result = parseRawToolCall({ rawToolCall: { @@ -735,6 +762,133 @@ describe('tool validation error handling', () => { } }) + it('rejects mis-braced serialized spawn payloads with field-placement guidance', () => { + const result = parseRawToolCall({ + rawToolCall: { + toolName: 'spawn_agents', + toolCallId: 'spawn-agents-misbraced-tool-call-id', + // Two agent entries with a stray top-level `prompt`: it is ambiguous + // which agent the sibling belongs to, so this stays fail-closed and + // is not auto-repaired (unlike the single-agent fold case below). + input: + '{"agents":[{"agent_type":"thinker"},{"agent_type":"file-picker"}],"prompt":"outside-agent"}', + }, + }) + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.error).toContain( + '`prompt`, `params`, and `handoff` must be inside each agent object', + ) + expect(result.error).toContain('check every brace and bracket') + expect(result.error).toContain('ambiguous brace nesting') + } + }) + + it('repairs a single-agent stray-sibling spawn payload by folding prompt into the entry', () => { + const result = parseRawToolCall({ + rawToolCall: { + toolName: 'spawn_agents', + toolCallId: 'spawn-agents-single-agent-fold-tool-call-id', + input: { + agents: [ + { + agent_type: 'code-searcher', + params: { searchQueries: [{ pattern: 'x' }] }, + }, + ], + prompt: 'find x', + }, + }, + }) + + expect('error' in result).toBe(false) + if (!('error' in result)) { + expect(result.input.agents[0].prompt).toBe('find x') + expect(result.input.agents[0].params).toEqual({ + searchQueries: [{ pattern: 'x' }], + }) + } + }) + + it('still rejects a multi-entry spawn array with stray sibling fields', () => { + const result = parseRawToolCall({ + rawToolCall: { + toolName: 'spawn_agents', + toolCallId: 'spawn-agents-multi-entry-stray-sibling-tool-call-id', + input: { + agents: [{ agent_type: 'a' }, { agent_type: 'b' }], + prompt: 'x', + }, + }, + }) + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.error).toContain( + '`prompt`, `params`, and `handoff` must be inside each agent object', + ) + } + }) + + it('does not overwrite an existing in-entry prompt when folding a single-agent stray sibling', () => { + const result = parseRawToolCall({ + rawToolCall: { + toolName: 'spawn_agents', + toolCallId: 'spawn-agents-single-agent-no-overwrite-tool-call-id', + input: { + agents: [{ agent_type: 'code-searcher', prompt: 'inner' }], + prompt: 'outer', + }, + }, + }) + + expect('error' in result).toBe(false) + if (!('error' in result)) { + expect(result.input.agents[0].prompt).toBe('inner') + } + }) + + it('shows the corrected spawn shape inline when agents is an invalid JSON string', () => { + const result = parseRawToolCall({ + rawToolCall: { + toolName: 'spawn_agents', + toolCallId: 'spawn-agents-corrected-shape-hint-tool-call-id', + input: { agents: 'not valid json {' }, + }, + }) + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.error).toContain('Corrected example:') + expect(result.error).toContain('INSIDE each agent object') + } + }) + + it('rejects the original stringified agents array with a mis-braced prompt and points at the corrected shape', () => { + // Reproduces the exact real-world failure: the whole `agents` value was + // emitted as a JSON string, and inside it a stray `"prompt": "..."` pair + // floats as a sibling *array element* rather than a key inside the first + // agent object. That inner text is not valid JSON, so it cannot be + // auto-repaired and must fail closed with corrected-shape guidance. + const result = parseRawToolCall({ + rawToolCall: { + toolName: 'spawn_agents', + toolCallId: 'spawn-agents-stringified-misbraced-prompt-tool-call-id', + input: { + agents: + '[{"agent_type": "code-searcher", "params": {"searchQueries": [{"pattern": "serialized handleSteps", "flags": "-g *.ts"}]}}, "prompt": "Find the test in the agents test suite."}]', + }, + }, + }) + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.error).toContain('Corrected example:') + expect(result.error).toContain('INSIDE each agent object') + } + }) + it('gives spawn-specific recovery for truncated agent JSON', () => { const result = parseRawToolCall({ rawToolCall: { @@ -1699,6 +1853,94 @@ describe('tool validation error handling', () => { expect(message).toContain('Preserve params field names exactly.') }) + it('gives reliability-reviewer snapshot_id recovery with its normalized ID', async () => { + const { validateAgentInput } = + await import('../tools/handlers/tool/spawn-agent-utils') + const reliabilityReviewer = { + ...testAgentTemplate, + id: 'reliability-reviewer', + inputSchema: { + params: z.object({ snapshot_id: z.string() }), + }, + } + + let message = '' + try { + validateAgentInput( + reliabilityReviewer, + 'reliability-reviewer', + undefined, + {}, + ) + } catch (error) { + message = error instanceof Error ? error.message : String(error) + } + + expect(message).toContain('Missing required: snapshot_id') + expect(message).toContain('params.snapshot_id') + expect(message).toContain('"agent_type": "reliability-reviewer"') + expect(message).toContain('"snapshot_id": ""') + expect(message).toContain('exact current snapshot fingerprint') + expect(message).toContain('get_change_review_bundle') + }) + + it('gives dependency-manager canonical manager and operation recovery', async () => { + const { validateAgentInput } = + await import('../tools/handlers/tool/spawn-agent-utils') + const dependencyManager = { + ...testAgentTemplate, + id: 'dependency-manager', + inputSchema: { + params: z.object({ + manager: z.string(), + operation: z.enum(['add', 'remove', 'sync', 'restore', 'update']), + }), + }, + } + + let message = '' + try { + validateAgentInput(dependencyManager, 'dependency-manager', undefined, {}) + } catch (error) { + message = error instanceof Error ? error.message : String(error) + } + + expect(message).toContain('manager:') + expect(message).toContain('operation:') + expect(message).toContain('"required":["manager","operation"]') + expect(message).toContain('"manager": "npm"') + expect(message).toContain('"operation": "add"') + expect(message).toContain('repository manifest/environment evidence') + expect(message).toContain('add, remove, sync, restore, or update') + expect(message).toContain( + 'Do not infer dependency mutation authorization from a validation failure.', + ) + }) + + it('gives librarian canonical repoUrl recovery on empty params', async () => { + const { validateAgentInput } = + await import('../tools/handlers/tool/spawn-agent-utils') + const librarian = { + ...testAgentTemplate, + id: 'librarian', + inputSchema: { + params: z.object({ repoUrl: z.string().url() }), + }, + } + + let message = '' + try { + validateAgentInput(librarian, 'librarian', undefined, {}) + } catch (error) { + message = error instanceof Error ? error.message : String(error) + } + + expect(message).toContain('Missing required: repoUrl') + expect(message).toContain('params.repoUrl') + expect(message).toContain('"repoUrl": "https://github.com//"') + expect(message).toContain('a URL only in prompt prose is not used') + }) + it('rejects security-reviewer snapshot_id with canonical params recovery', async () => { const { validateAgentInput } = await import('../tools/handlers/tool/spawn-agent-utils') @@ -1735,6 +1977,33 @@ describe('tool validation error handling', () => { expect(message).toContain('Preserve params field names exactly.') }) + it('gives code-searcher a searchQueries recovery hint on empty params', async () => { + const { validateAgentInput } = + await import('../tools/handlers/tool/spawn-agent-utils') + const codeSearcher = { + ...testAgentTemplate, + id: 'code-searcher', + inputSchema: { + params: z.object({ + searchQueries: z.array(z.object({ pattern: z.string() })), + }), + }, + } + + let message = '' + try { + validateAgentInput(codeSearcher, 'code-searcher', undefined, {}) + } catch (error) { + message = error instanceof Error ? error.message : String(error) + } + + expect(message).toContain('Missing required: searchQueries') + expect(message).toContain('spawn code-searcher with') + expect(message).toContain('"searchQueries"') + expect(message).toContain('required array of objects') + expect(message).toContain('Preserve params field names exactly.') + }) + it('publishes a structured failure result when Basher is missing command', async () => { const parent: AgentTemplate = { ...testAgentTemplate, @@ -1819,6 +2088,75 @@ describe('tool validation error handling', () => { }) }) + it('repairs a single-agent mis-braced spawn payload and publishes it to the handler', async () => { + const parent: AgentTemplate = { + ...testAgentTemplate, + toolNames: ['spawn_agents', 'end_turn'], + spawnableAgents: ['basher'], + } + const basher: AgentTemplate = { + ...testAgentTemplate, + id: 'basher', + inputSchema: { params: z.object({ command: z.string().min(1) }) }, + toolNames: ['run_terminal_command'], + spawnableAgents: [], + } + const misbracedSpawn: StreamChunk = { + type: 'tool-call', + toolName: 'spawn_agents', + toolCallId: 'basher-misbraced-fold-tool-call-id', + input: { + agents: [{ agent_type: 'basher', params: { command: 'bun test' } }], + prompt: 'Run the tests', + }, + } + async function* mockStream() { + yield misbracedSpawn + return promptSuccess('mock-message-id') + } + const responseChunks: (string | PrintModeEvent)[] = [] + const sessionState = getInitialSessionState(mockFileContext) + + await processStream({ + ...agentRuntimeImpl, + agentContext: {}, + agentState: sessionState.mainAgentState, + agentStepId: 'test-step-id', + agentTemplate: parent, + ancestorRunIds: [], + clientSessionId: 'test-session', + fileContext: mockFileContext, + fingerprintId: 'test-fingerprint', + fullResponse: '', + localAgentTemplates: { 'test-agent': parent, basher }, + messages: [], + prompt: 'test prompt', + repoId: undefined, + repoUrl: undefined, + runId: 'test-run-id', + signal: new AbortController().signal, + stream: mockStream(), + system: 'test system', + tools: {}, + userId: 'test-user', + userInputId: 'test-input-id', + onCostCalculated: async () => {}, + onResponseChunk: (chunk) => responseChunks.push(chunk), + }) + + const events = responseChunks.filter( + (chunk): chunk is PrintModeEvent => typeof chunk !== 'string', + ) + // The payload was repaired (folded), not rejected, so no error event. + expect(events.some((event) => event.type === 'error')).toBe(false) + // A tool_call for spawn_agents was published to the handler. + expect(events.find((event) => event.type === 'tool_call')).toMatchObject({ + type: 'tool_call', + toolName: 'spawn_agents', + toolCallId: 'basher-misbraced-fold-tool-call-id', + }) + }) + it('should still emit tool_call and tool_result for valid tool calls', async () => { // Create an agent that has read_files tool const agentWithReadFiles: AgentTemplate = { @@ -2295,7 +2633,7 @@ describe('tool validation error handling', () => { describe('buildUnavailableToolMessage', () => { it('explains a known-but-ungranted tool without suggesting a near match', () => { const message = buildUnavailableToolMessage({ - toolName: 'code_search', + toolName: 'run_terminal_command', agentId: 'base2', availableTools: ['query_index', 'read_files', 'glob'], }) @@ -2304,6 +2642,19 @@ describe('buildUnavailableToolMessage', () => { expect(message).toContain('is not available for agent `base2`') }) + it('gives concrete code-searcher recovery for ungranted content-search tools', () => { + for (const toolName of ['code_search', 'find_files_matching_content']) { + const message = buildUnavailableToolMessage({ + toolName, + agentId: 'base2', + availableTools: ['read_files'], + }) + + expect(message).toContain('code-searcher') + expect(message).toContain('searchQueries') + } + }) + it('suggests the closest granted tool for a likely typo', () => { const message = buildUnavailableToolMessage({ toolName: 'read_file', diff --git a/packages/agent-runtime/src/process-edit-transaction.ts b/packages/agent-runtime/src/process-edit-transaction.ts index 51aad0f90a..1176c7df55 100644 --- a/packages/agent-runtime/src/process-edit-transaction.ts +++ b/packages/agent-runtime/src/process-edit-transaction.ts @@ -57,13 +57,21 @@ type TransactionEdit = occurrence?: number } | { id?: string; type: 'patch'; path: string; diff: string } - | { id?: string; type: 'write_file'; path: string; content: string } + | { + id?: string + type: 'write_file' + path: string + content: string + basedOnRead?: string + } type TransactionFailure = { editIndex: number id?: string path: string errorMessage: string + /** Optional whole-file capability echoed by the handler for residual recovery. */ + basedOnRead?: string } type TransactionFileChange = { @@ -105,6 +113,7 @@ export async function processEditTransaction(params: { { startLine: number; endLine: number; lineDelta: number }[] >() const failures: TransactionFailure[] = [] + const pathsWithNonRangeEdit = new Set() for (let editIndex = 0; editIndex < edits.length; editIndex++) { const edit = edits[editIndex] if (!edit) continue @@ -136,6 +145,25 @@ export async function processEditTransaction(params: { break } + // A replace_range authenticates against ORIGINAL-snapshot coordinates, but a + // prior non-replace_range content edit on the same path changes the working + // line count without being reflected in those coordinates. Rather than try to + // compute str_replace/structured/patch line deltas, block the replace_range so + // it can never silently splice the wrong lines while its capability hash check + // (validated against the original snapshot) still passes. + if ( + effectiveEdit.type === 'replace_range' && + pathsWithNonRangeEdit.has(effectiveEdit.path) + ) { + failures.push({ + editIndex, + ...(effectiveEdit.id && { id: effectiveEdit.id }), + path: effectiveEdit.path, + errorMessage: `replace_range blocked for ${effectiveEdit.path}: a prior non-replace_range edit changed this file earlier in the transaction, so replace_range's original-snapshot line coordinates can no longer be safely applied. Split this into a separate transaction or use str_replace/rewrite_symbol for this path.`, + }) + break + } + const currentContent = workingContentByPath.get(effectiveEdit.path) const result = await processTransactionEdit({ edit: rangeAdjustment.edit, @@ -167,6 +195,9 @@ export async function processEditTransaction(params: { } workingContentByPath.set(effectiveEdit.path, result.content) + if (rangeAdjustment.edit.type !== 'replace_range') { + pathsWithNonRangeEdit.add(effectiveEdit.path) + } if (rangeAdjustment.edit.type === 'replace_range') { const originalRange = rangeAdjustment.edit.originalRange ?? { startLine: rangeAdjustment.edit.startLine, @@ -196,7 +227,7 @@ export async function processEditTransaction(params: { tool: 'edit_transaction', error: [ `edit_transaction aborted during preflight at edit ${firstFailure.editIndex + 1} of ${edits.length}, so NO files were changed.`, - 'The detailed cause is listed once in failures below. Re-read only when that failure explicitly requires it, then retry the whole related transaction from one current snapshot.', + 'The detailed cause is listed once in failures below. If that failure requires fresh read/capability state, re-read EVERY target in this transaction in the current run, then rebuild and retry the whole transaction from that one current snapshot. Do not refresh only the first failed path while replaying stale tokens for the remaining targets.', ].join('\n\n'), failures, } @@ -356,21 +387,6 @@ async function processTransactionEdit(params: { switch (edit.type) { case 'str_replace': { const initialContent = await initialContentPromise - if (typeof initialContent === 'string') { - return processStrReplace({ - path: edit.path, - replacements: edit.replacements, - atomic: true, - transactionContext: true, - requireFreshReadCapability, - readCapabilityScope: readCapabilityIssuer - ? { ...readCapabilityIssuer, path: edit.path } - : undefined, - initialContentPromise: Promise.resolve(initialContent), - logger, - }) - } - return processStrReplace({ path: edit.path, replacements: edit.replacements, @@ -409,11 +425,21 @@ async function processTransactionEdit(params: { : lines.at(-1) === '' ? lines.length - 1 : lines.length + const authorizationTarget = edit.originalRange ?? { + startLine: edit.startLine, + endLine: edit.endLine, + } { const decoded = decodeReadCapabilityToken(edit.readCapability) if (typeof decoded === 'string') { return { error: decoded } } + // Invariant: the runtime always supplies readCapabilityIssuer in + // production, so project/path/run scope is enforced by the scope check + // below. Do not hard-require the issuer here: non-runtime/test callers + // may omit it. When it is absent the capability metadata match plus the + // observed-content-hash freshness check still gate the edit, but + // cross-scope replay is not detectable in that case. const scope = readCapabilityIssuer ? { ...readCapabilityIssuer, path: edit.path } : undefined @@ -443,10 +469,6 @@ async function processTransactionEdit(params: { error: `replace_range blocked for ${edit.path}: the readCapability-covered content is stale. Re-read lines ${edit.capabilityStartLine}-${edit.capabilityEndLine} and retry with the fresh token.`, } } - const authorizationTarget = edit.originalRange ?? { - startLine: edit.startLine, - endLine: edit.endLine, - } if ( authorizationTarget.startLine < edit.capabilityStartLine || authorizationTarget.endLine > edit.capabilityEndLine @@ -465,13 +487,6 @@ async function processTransactionEdit(params: { error: `replace_range ${edit.startLine}-${edit.endLine} is outside ${edit.path} (${visibleLineCount} lines).`, } } - const currentRange = lines - .slice(edit.startLine - 1, edit.endLine) - .join('\n') - const authorizationTarget = edit.originalRange ?? { - startLine: edit.startLine, - endLine: edit.endLine, - } const narrowedTarget = authorizationTarget.startLine !== edit.capabilityStartLine || authorizationTarget.endLine !== edit.capabilityEndLine diff --git a/packages/agent-runtime/src/process-str-replace.ts b/packages/agent-runtime/src/process-str-replace.ts index 72170d725a..9270408fdf 100644 --- a/packages/agent-runtime/src/process-str-replace.ts +++ b/packages/agent-runtime/src/process-str-replace.ts @@ -95,6 +95,16 @@ const FAILED_EDIT_INLINE_RECOVERY_GUIDANCE = [ ].join('\n') function addFailedEditRecoveryGuidance(error: string): string { + // Scope mismatches are authenticity failures, not evidence that file content + // changed or disappeared. Keep their recovery precise while preserving the + // cap.v3 project/path/run anti-replay boundary. + if ( + error.includes( + 'read capability belongs to a different project, path, or agent run', + ) + ) { + return error + } return `${error}\n\n${ error.includes('Recovery capability for candidate 1:') ? FAILED_EDIT_INLINE_RECOVERY_GUIDANCE @@ -180,6 +190,7 @@ export async function processStrReplace(params: { const validatedReadRanges = new Map() const readCapabilityWarnings: string[] = [] const preflightErrors: string[] = [] + const capabilityAuthorityErrors: string[] = [] let hadNoOpSkip = false // Decode any token-form basedOnRead up front so the rest of the pipeline only @@ -207,7 +218,7 @@ export async function processStrReplace(params: { requireBoundCapability: requireFreshReadCapability, }) if (authorityError) { - preflightErrors.push( + capabilityAuthorityErrors.push( `Invalid basedOnRead for replacement ${i + 1}: ${authorityError}`, ) } @@ -226,6 +237,14 @@ export async function processStrReplace(params: { } } + if (capabilityAuthorityErrors.length > 0) { + return { + tool: 'str_replace' as const, + path, + error: capabilityAuthorityErrors.join('\n\n'), + } + } + // Reject obviously-bogus string anchors (stubs like "dummy", or anything that // does not decode) on EVERY file, large or small. This is the only basedOnRead // check that runs on small files; valid "cap...." tokens decode to objects and diff --git a/packages/agent-runtime/src/tools/handlers/tool/__tests__/edit-application-coordinator.test.ts b/packages/agent-runtime/src/tools/handlers/tool/__tests__/edit-application-coordinator.test.ts index f0f7bd84ea..437208eadf 100644 --- a/packages/agent-runtime/src/tools/handlers/tool/__tests__/edit-application-coordinator.test.ts +++ b/packages/agent-runtime/src/tools/handlers/tool/__tests__/edit-application-coordinator.test.ts @@ -108,6 +108,73 @@ describe('edit application coordinator', () => { ) }) + it('confirms an applied transaction when a no-op path is excluded from confirmationPaths', async () => { + const clientOutput = [ + { + type: 'json', + value: { + kind: 'file_mutation_result', + version: 1, + operationId: 'operation', + outcome: 'applied', + actions: [ + { + actionId: 'operation:0', + index: 0, + action: 'update', + path: 'a.ts', + outcome: 'applied', + beforeHash: 'before', + afterHash: 'after', + }, + ], + authorityTier: 'portable_path', + receiptId: 'operation', + errors: [], + freshCapabilities: [], + }, + }, + ] as any + + // b.ts resolved to a no-op and was excluded from client changes, so the + // client never emits an `applied` action for it. Scoping confirmation to + // only the paths that actually changed lets the transaction be confirmed. + const state = getFileProcessingValues({ + promisesByPath: { 'a.ts': [], 'b.ts': [] }, + }) + let committed = false + const result = await coordinateEditApplication({ + toolName: 'edit_transaction', + fileProcessingState: state, + paths: ['a.ts', 'b.ts'], + confirmationPaths: ['a.ts'], + rejectionRequiresRead: false, + apply: async () => clientOutput, + onApplied: () => { + committed = true + }, + }) + + expect(result.status).toBe('applied') + expect(committed).toBe(true) + + // Without confirmationPaths the default requires BOTH paths, so the same + // output is wrongly reported as rejected — this locks in that the scoping + // is what fixes the false-negative. + const defaultState = getFileProcessingValues({ + promisesByPath: { 'a.ts': [], 'b.ts': [] }, + }) + const defaultResult = await coordinateEditApplication({ + toolName: 'edit_transaction', + fileProcessingState: defaultState, + paths: ['a.ts', 'b.ts'], + rejectionRequiresRead: false, + apply: async () => clientOutput, + }) + + expect(defaultResult.status).toBe('rejected') + }) + it('drops syntax-rejected prepared state without revoking fresh authorization', async () => { const state = getFileProcessingValues({ promisesByPath: { 'a.ts': [] }, diff --git a/packages/agent-runtime/src/tools/handlers/tool/edit-application-coordinator.ts b/packages/agent-runtime/src/tools/handlers/tool/edit-application-coordinator.ts index d55a0979fe..105577a1a4 100644 --- a/packages/agent-runtime/src/tools/handlers/tool/edit-application-coordinator.ts +++ b/packages/agent-runtime/src/tools/handlers/tool/edit-application-coordinator.ts @@ -118,6 +118,41 @@ export function editOutputHasError( return hasExplicitError(output) } +function outputIndicatesStaleSnapshot(value: unknown, depth = 0): boolean { + if (depth > 6 || value === null || value === undefined) return false + if (Array.isArray(value)) { + return value.some((item) => outputIndicatesStaleSnapshot(item, depth + 1)) + } + if (typeof value === 'string') { + return /(?:stale\s+(?:snapshot|hash|range)|content\s+(?:changed|mismatch)|expected\s+hash)/i.test( + value, + ) + } + if (typeof value !== 'object') return false + return Object.values(value as Record).some((nested) => + outputIndicatesStaleSnapshot(nested, depth + 1), + ) +} + +function outputIndicatesUnconfirmedApplication( + value: unknown, + depth = 0, +): boolean { + if (depth > 6 || value === null || value === undefined) return false + if (Array.isArray(value)) { + return value.some((item) => + outputIndicatesUnconfirmedApplication(item, depth + 1), + ) + } + if (typeof value === 'string') { + return /could not confirm/i.test(value) + } + if (typeof value !== 'object') return false + return Object.values(value as Record).some((nested) => + outputIndicatesUnconfirmedApplication(nested, depth + 1), + ) +} + export function invalidatePreparedEditPaths(params: { fileProcessingState: FileProcessingState paths: Iterable @@ -177,11 +212,16 @@ export async function coordinateEditApplication(params: { fileProcessingState: FileProcessingState paths: Iterable apply: () => Promise> - rejectionRequiresRead?: boolean wholeFileContentByPath?: ReadonlyMap onApplied?: () => void + rejectionRequiresRead?: boolean + confirmationPaths?: Iterable }): Promise> { const paths = [...new Set(params.paths)] + const confirmationPaths = + params.confirmationPaths === undefined + ? new Set(paths) + : new Set(params.confirmationPaths) let output: CodebuffToolOutput try { output = await params.apply() @@ -209,22 +249,39 @@ export async function coordinateEditApplication(params: { } if (editOutputHasError(output)) { - if (params.rejectionRequiresRead !== false) { + if (outputIndicatesStaleSnapshot(output)) { invalidatePreparedEditPaths({ fileProcessingState: params.fileProcessingState, paths, - reason: 'application_rejected', + reason: 'stale_snapshot', + sourceTool: params.toolName, + }) + } else if (outputIndicatesUnconfirmedApplication(output)) { + // An unconfirmed-application wrapper (e.g. an empty client result wrapped + // into a 'could not confirm' error) may reflect a partial write, so it + // always requires a re-read regardless of `rejectionRequiresRead`. + invalidatePreparedEditPaths({ + fileProcessingState: params.fileProcessingState, + paths, + reason: 'application_unconfirmed', sourceTool: params.toolName, }) } else { - for (const path of paths) { - delete params.fileProcessingState.promisesByPath[path] - } + // An explicit client rejection confirms that no prepared mutation was + // applied. Drop speculative prepared state, but preserve valid read + // authorization; unlike unconfirmed output or a throw, this outcome is + // deterministic and does not require a re-read. + invalidatePreparedEditPaths({ + fileProcessingState: params.fileProcessingState, + paths, + revokeReadAuthorization: params.rejectionRequiresRead ?? true, + requiresFreshRead: params.rejectionRequiresRead ?? true, + }) } return { status: 'rejected', output } } - if (!hasPositiveApplicationEvidence(output, new Set(paths))) { + if (!hasPositiveApplicationEvidence(output, confirmationPaths)) { invalidatePreparedEditPaths({ fileProcessingState: params.fileProcessingState, paths, diff --git a/packages/agent-runtime/src/tools/handlers/tool/edit-read-state.ts b/packages/agent-runtime/src/tools/handlers/tool/edit-read-state.ts index e49803c7bf..ff292070e2 100644 --- a/packages/agent-runtime/src/tools/handlers/tool/edit-read-state.ts +++ b/packages/agent-runtime/src/tools/handlers/tool/edit-read-state.ts @@ -55,11 +55,17 @@ export function strictEditAuthorizationError(params: { allowScopedCapability?: boolean authorizationWasStale?: boolean wholeFileRequired?: boolean + /** Optional whole-file capability token to echo for recovery without a re-read round-trip. */ + freshReadCapability?: string }): | { errorMessage: string errorCode: 'fresh_read_required' - recovery: { tool: 'read_files'; input: { paths: string[] } } + recovery: { + tool: 'read_files' + input: { paths: string[] } + basedOnRead?: string + } } | undefined { const { @@ -71,6 +77,7 @@ export function strictEditAuthorizationError(params: { allowScopedCapability = true, authorizationWasStale = false, wholeFileRequired = false, + freshReadCapability, } = params const prior = getEditRereadRequirement(fileProcessingState, path) const recoveringFromFailedEdit = Boolean( @@ -83,6 +90,9 @@ export function strictEditAuthorizationError(params: { ) { return undefined } + // Fresh hash match authorizes even when a prior context_compacted (or other) + // reread requirement is still recorded — callers clear it after a successful + // hash-fresh check. if (hasFreshWholeFileAuthorization) return undefined if (allowScopedCapability && hasScopedCapability) return undefined @@ -100,10 +110,19 @@ export function strictEditAuthorizationError(params: { const scopeNote = wholeFileRequired ? ' A prior range-anchored edit or scoped range capability cannot authorize a whole-file overwrite.' : ' A scoped edit may instead provide the fresh capability/hash returned by read_files.' + // Prefer capability-retry when a whole-file token is already available; keep + // read_files only as the secondary path when no capability can be echoed. + const nextLine = freshReadCapability + ? `Next: retry with basedOnRead set to the capability below on the next edit (write_file basedOnRead, or basedOnRead on every str_replace replacement). Do not exploratory re-read first when basedOnRead is provided.\nbasedOnRead="${freshReadCapability}"` + : `Next: call read_files with paths: [${JSON.stringify(path)}].` return { - errorMessage: `${firstLine}\nNext: call read_files with paths: [${JSON.stringify(path)}].${scopeNote}`, + errorMessage: `${firstLine}\n${nextLine}${scopeNote}`, errorCode: 'fresh_read_required', - recovery: { tool: 'read_files', input: { paths: [path] } }, + recovery: { + tool: 'read_files', + input: { paths: [path] }, + ...(freshReadCapability ? { basedOnRead: freshReadCapability } : {}), + }, } } diff --git a/packages/agent-runtime/src/tools/handlers/tool/edit-transaction.ts b/packages/agent-runtime/src/tools/handlers/tool/edit-transaction.ts index 2da12d2df1..ffa3f7665f 100644 --- a/packages/agent-runtime/src/tools/handlers/tool/edit-transaction.ts +++ b/packages/agent-runtime/src/tools/handlers/tool/edit-transaction.ts @@ -1,4 +1,10 @@ -import { getContentHash } from '@codebuff/common/util/content-hash' +import { + decodeReadCapabilityToken, + encodeReadCapabilityToken, + getContentHash, + normalizeLineEndings, + readCapabilityMatchesScope, +} from '@codebuff/common/util/content-hash' import { MAX_FILE_CHANGES_PER_TRANSACTION, MAX_TRANSACTION_FILE_BYTES, @@ -15,6 +21,10 @@ import { normalizeToolPath, revokeWholeFileReadAuthorization, } from './write-file' +import { + clearEditRereadRequirement, + getEditRereadRequirement, +} from './edit-read-state' import { coordinateEditApplication, invalidatePreparedEditPaths, @@ -225,6 +235,55 @@ export const handleEditTransaction = (async ( } } + const projectId = params.fileContext?.projectRoot ?? '' + const runId = params.runId ?? '' + const mintWholeFileCapability = (path: string, content: string) => + encodeReadCapabilityToken({ + startLine: 1, + endLine: normalizeLineEndings(content).split('\n').length, + hash: getContentHash(content), + scope: { + projectId, + path, + runId, + }, + }) + + /** + * Validate write_file basedOnRead: must be whole-file covering, scope-bound, + * and hash-fresh against current content. Partial ranges never authorize. + */ + const validateWriteFileBasedOnRead = ( + path: string, + content: string, + token: string, + ): { ok: true } | { ok: false; error: string } => { + const decoded = decodeReadCapabilityToken(token) + if (typeof decoded === 'string') { + return { ok: false, error: decoded } + } + if (!readCapabilityMatchesScope(decoded, { projectId, path, runId })) { + return { + ok: false, + error: `write_file basedOnRead for ${path} belongs to a different project, path, or agent run. Re-read the whole file and pass the fresh capability.`, + } + } + const lineCount = normalizeLineEndings(content).split('\n').length + if (decoded.startLine !== 1 || decoded.endLine !== lineCount) { + return { + ok: false, + error: `a range capability cannot authorize a whole-file overwrite for ${path} (capability covers lines ${decoded.startLine}-${decoded.endLine}; file has ${lineCount} lines). Pass only a whole-file-covering cap.v3 from a complete paths or full-file range read.`, + } + } + if (decoded.hash !== getContentHash(content)) { + return { + ok: false, + error: `write_file basedOnRead for ${path} did not match the current file content (stale hash). Re-read the whole file and retry with the fresh capability.`, + } + } + return { ok: true } + } + const freshWholeFileAuthorizationPaths = new Set() const staleWholeFileAuthorizationPaths = new Set() for (const path of uniquePaths) { @@ -242,18 +301,121 @@ export const handleEditTransaction = (async ( ) if (isFresh) { freshWholeFileAuthorizationPaths.add(path) + // Do not clear context_compacted on mere hash-fresh: write_file must stay + // blocked until a real re-read or explicit whole-file basedOnRead. str_replace + // clears the marker when it proceeds below (unique oldString is the safety bound). + const rereadReq = getEditRereadRequirement(fileProcessingState, path) + if (rereadReq?.reason !== 'context_compacted') { + clearEditRereadRequirement(fileProcessingState, path) + } } else if (hasStoredAuthorization) { staleWholeFileAuthorizationPaths.add(path) revokeWholeFileReadAuthorization(fileProcessingState, path) } } + // Explicit write_file basedOnRead: whole-file covering + hash-fresh authorizes + // this path for the transaction (including clearing context_compacted). + for (const edit of edits) { + if (edit.type !== 'write_file' || !edit.basedOnRead) continue + if (freshWholeFileAuthorizationPaths.has(edit.path)) { + // Still honor explicit capability for context_compacted: sticky alone is not enough. + const rereadReq = getEditRereadRequirement(fileProcessingState, edit.path) + if (rereadReq?.reason === 'context_compacted') { + const content = initialContentByPath.get(edit.path) + if (typeof content === 'string') { + const auth = validateWriteFileBasedOnRead( + edit.path, + content, + edit.basedOnRead, + ) + if (auth.ok) { + grantWholeFileReadAuthorization( + fileProcessingState, + edit.path, + content, + ) + clearEditRereadRequirement(fileProcessingState, edit.path) + } + } + } + continue + } + const content = initialContentByPath.get(edit.path) + if (typeof content !== 'string') continue + const auth = validateWriteFileBasedOnRead( + edit.path, + content, + edit.basedOnRead, + ) + if (auth.ok) { + freshWholeFileAuthorizationPaths.add(edit.path) + grantWholeFileReadAuthorization(fileProcessingState, edit.path, content) + clearEditRereadRequirement(fileProcessingState, edit.path) + } + } + + // Auto-reread-once per unique path for str_replace auth misses only. + // write_file whole-file overwrites must keep the standalone floor: prior + // complete whole-file sticky hash match (or explicit capability). Snapshots + // already loaded content via requestOptionalFile — authorize *this* + // transaction's str_replace edits from that content without minting durable + // sticky (so a later write_file cannot chain off a blind server re-read). + // Post-apply grant of *new* content after successful application may still + // refresh sticky from observed post-edit bytes (onApplied below). + const autoRereadAuthorizedPaths = new Set() + if (fileProcessingState.strictReadBeforeEdit) { + // Unique-only contract: any allowMultiple:true replacement on a path + // skips auto-reread authorization for that path (fail closed). + const pathsWithAllowMultiple = new Set() + for (const edit of edits) { + if (edit.type !== 'str_replace') continue + if ( + Array.isArray(edit.replacements) && + edit.replacements.some( + (replacement) => replacement.allowMultiple === true, + ) + ) { + pathsWithAllowMultiple.add(edit.path) + } + } + const pathsNeedingAuth = new Set() + for (const edit of edits) { + if (edit.type !== 'str_replace') continue + if (freshWholeFileAuthorizationPaths.has(edit.path)) continue + if (pathsWithAllowMultiple.has(edit.path)) continue + if ( + Array.isArray(edit.replacements) && + edit.replacements.length > 0 && + edit.replacements.every((replacement) => Boolean(replacement.basedOnRead)) + ) { + continue + } + pathsNeedingAuth.add(edit.path) + } + for (const path of pathsNeedingAuth) { + if (freshWholeFileAuthorizationPaths.has(path)) continue + if (staleWholeFileAuthorizationPaths.has(path)) continue + const content = initialContentByPath.get(path) + if (typeof content !== 'string') continue + // Transaction-local only: do not call grantWholeFileReadAuthorization and + // do not add to freshWholeFileAuthorizationPaths (would authorize write_file). + // Do not clear reread markers here — auto-reread only authorizes this + // transaction's str_replace preflight. Markers (context_compacted / + // failed-edit) remain until successful unique apply (onApplied) so a + // failed unique/no-match still keeps write_file blocked under + // context_compacted. + autoRereadAuthorizedPaths.add(path) + } + } + const requireFreshReadCapabilityForPaths = new Set() if (fileProcessingState.strictReadBeforeEdit) { const failures: Array<{ editIndex: number path: string errorMessage: string + basedOnRead?: string }> = [] edits.forEach((edit, editIndex) => { if ( @@ -262,7 +424,109 @@ export const handleEditTransaction = (async ( ) { return } - if (freshWholeFileAuthorizationPaths.has(edit.path)) return + // write_file: context_compacted blocks overwrite even when sticky hash matches, + // unless an explicit whole-file-covering basedOnRead authorizes (handled above). + if ( + edit.type === 'write_file' && + getEditRereadRequirement(fileProcessingState, edit.path)?.reason === + 'context_compacted' + ) { + // Retry validation when basedOnRead was present but failed earlier, or absent. + if (edit.basedOnRead) { + const content = initialContentByPath.get(edit.path) + if (typeof content === 'string') { + const auth = validateWriteFileBasedOnRead( + edit.path, + content, + edit.basedOnRead, + ) + if (auth.ok) { + freshWholeFileAuthorizationPaths.add(edit.path) + grantWholeFileReadAuthorization( + fileProcessingState, + edit.path, + content, + ) + clearEditRereadRequirement(fileProcessingState, edit.path) + return + } + const recoveryCap = mintWholeFileCapability(edit.path, content) + failures.push({ + editIndex, + path: edit.path, + errorMessage: `Edit blocked: ${edit.path} had its exact read content removed from the active model context (context compaction). ${auth.error} basedOnRead="${recoveryCap}"`, + basedOnRead: recoveryCap, + }) + return + } + } + const content = initialContentByPath.get(edit.path) + const basedOnRead = + typeof content === 'string' + ? mintWholeFileCapability(edit.path, content) + : undefined + const compactionRecovery = basedOnRead + ? ` Retry with write_file basedOnRead set to the capability below (whole-file overwrite). Do not exploratory re-read first when basedOnRead is provided; sticky hash match alone is not sufficient for write_file. basedOnRead="${basedOnRead}"` + : ` Call read_files with paths: ["${edit.path}"] before a whole-file overwrite, or pass a whole-file-covering basedOnRead on the write_file edit; sticky hash match alone is not sufficient for write_file.` + failures.push({ + editIndex, + path: edit.path, + errorMessage: `Edit blocked: ${edit.path} had its exact read content removed from the active model context (context compaction).${compactionRecovery}`, + ...(basedOnRead ? { basedOnRead } : {}), + }) + return + } + if (freshWholeFileAuthorizationPaths.has(edit.path)) { + // Hash-fresh sticky authorizes this edit, but do not clear + // context_compacted here for str_replace — only after successful apply + // in onApplied. write_file stays blocked above while the marker remains + // unless basedOnRead already cleared it. + if ( + edit.type !== 'write_file' && + edit.type !== 'str_replace' && + getEditRereadRequirement(fileProcessingState, edit.path)?.reason !== + 'context_compacted' + ) { + clearEditRereadRequirement(fileProcessingState, edit.path) + } + return + } + // Auto-reread authorizes str_replace only in this transaction. + if ( + edit.type === 'str_replace' && + autoRereadAuthorizedPaths.has(edit.path) + ) { + return + } + // write_file basedOnRead that failed validation must surface a clear error. + if (edit.type === 'write_file' && edit.basedOnRead) { + const content = initialContentByPath.get(edit.path) + if (typeof content === 'string') { + const auth = validateWriteFileBasedOnRead( + edit.path, + content, + edit.basedOnRead, + ) + if (auth.ok) { + freshWholeFileAuthorizationPaths.add(edit.path) + grantWholeFileReadAuthorization( + fileProcessingState, + edit.path, + content, + ) + clearEditRereadRequirement(fileProcessingState, edit.path) + return + } + const recoveryCap = mintWholeFileCapability(edit.path, content) + failures.push({ + editIndex, + path: edit.path, + errorMessage: `Edit blocked: ${auth.error} basedOnRead="${recoveryCap}"`, + basedOnRead: recoveryCap, + }) + return + } + } // Per-edit basedOnRead anchors satisfy strict mode without a prior read, // but every replacement must carry its own scoped capability. const hasBasedOnRead = @@ -282,19 +546,40 @@ export const handleEditTransaction = (async ( requireFreshReadCapabilityForPaths.add(edit.path) return } + const content = initialContentByPath.get(edit.path) + const basedOnRead = + typeof content === 'string' + ? mintWholeFileCapability(edit.path, content) + : undefined const rangeRecovery = edit.type === 'replace_range' ? ` Call read_files with ranges: [{ "path": "${edit.path}", "startLine": ${edit.startLine}, "endLine": ${edit.endLine} }] and retry with only its readCapability plus newContent.` : '' + // Prefer capability-retry when a whole-file token is already minted; + // exploratory re-read is only the fallback when no capability exists. + const writeFileRecovery = + edit.type === 'write_file' + ? basedOnRead + ? ` Retry with write_file basedOnRead set to the capability below (whole-file overwrite). Do not exploratory re-read first when basedOnRead is provided.` + : ` Call read_files with paths: ["${edit.path}"] before retrying, or pass a whole-file-covering basedOnRead on the write_file edit.` + : '' + const defaultRecovery = basedOnRead + ? ` Retry with basedOnRead set to the capability below (write_file basedOnRead, or basedOnRead on every str_replace replacement). Do not exploratory re-read first when basedOnRead is provided.` + : ` Call read_files with paths: ["${edit.path}"] before retrying, or include a matching fresh basedOnRead capability on every replacement.` + const capabilityHint = basedOnRead + ? ` basedOnRead="${basedOnRead}"` + : '' failures.push({ editIndex, path: edit.path, errorMessage: staleWholeFileAuthorizationPaths.has(edit.path) - ? `Edit blocked: ${edit.path} changed after its last whole-file read, so the stored authorization was revoked.${rangeRecovery || ` Call read_files with paths: ["${edit.path}"] before retrying, or include a matching fresh basedOnRead capability on every replacement.`}` - : `Edit blocked: strict read-before-edit is enabled and no fresh read authorization exists for ${edit.path}.${rangeRecovery || ` Call read_files with paths: ["${edit.path}"] before retrying, or include a matching fresh basedOnRead capability on every replacement.`} Only a complete whole-file read registers reusable authorization for ${edit.path}; a range read only yields a scoped capability that must be passed explicitly as basedOnRead/readCapability on the edit. If you already read ${edit.path} this session, that authorization may have been dropped: a read and edit emitted in the same step do not authorize until the next step, and context compaction revokes earlier implicit reads — re-read before retrying.`, + ? `Edit blocked: ${edit.path} changed after its last whole-file read, so the stored authorization was revoked.${rangeRecovery || writeFileRecovery || defaultRecovery}${capabilityHint}` + : `Edit blocked: strict read-before-edit is enabled and no fresh read authorization exists for ${edit.path}.${rangeRecovery || writeFileRecovery || defaultRecovery}${basedOnRead ? '' : ` Only a complete whole-file read registers reusable authorization for ${edit.path}; a range read only yields a scoped capability that must be passed explicitly as basedOnRead/readCapability on the edit. If you already read ${edit.path} this session, that authorization may have been dropped: a read and edit emitted in the same step do not authorize until the next step, and context compaction may mark earlier implicit reads for re-check — re-read before retrying when the file changed.`}${capabilityHint}`, + ...(basedOnRead ? { basedOnRead } : {}), }) }) if (failures.length > 0) { + const anyBasedOnRead = failures.some((failure) => failure.basedOnRead) return { output: [ { @@ -302,7 +587,9 @@ export const handleEditTransaction = (async ( value: { errorMessage: [ 'edit_transaction blocked: strict read-before-edit is enabled and one or more paths have no read authorization.', - "Follow each failure's exact recovery selector. For replace_range, re-read that range and use only its readCapability; for str_replace, use a whole-file read or matching basedOnRead on every replacement.", + anyBasedOnRead + ? "Follow each failure's exact recovery selector. When a failure includes basedOnRead, retry with that capability first (write_file basedOnRead, or basedOnRead on every str_replace replacement) without an exploratory re-read; for replace_range without a capability, re-read that range and use only its readCapability." + : "Follow each failure's exact recovery selector. For replace_range, re-read that range and use only its readCapability; for str_replace, use a whole-file read or matching basedOnRead on every replacement.", ].join('\n'), failures, }, @@ -315,11 +602,16 @@ export const handleEditTransaction = (async ( const lifecycleFailures = edits.flatMap((edit, editIndex) => { const source = initialContentByPath.get(edit.path) if (edit.type === 'create' && source !== null) { + const basedOnRead = + typeof source === 'string' + ? mintWholeFileCapability(edit.path, source) + : undefined return [ { editIndex, path: edit.path, - errorMessage: 'Create destination already exists.', + errorMessage: `Create destination already exists at ${edit.path}. Retry with type "write_file" and basedOnRead set to the capability below (whole-file overwrite), or "str_replace" for an in-place edit. Do not exploratory re-read first when basedOnRead is provided.${basedOnRead ? ` basedOnRead="${basedOnRead}"` : ` Call read_files paths: ["${edit.path}"] first if no capability is available.`}`, + ...(basedOnRead ? { basedOnRead } : {}), }, ] } @@ -394,27 +686,42 @@ export const handleEditTransaction = (async ( } if ('error' in transactionResult) { - const failedPaths = new Set( - transactionResult.failures.length > 0 - ? transactionResult.failures.map((failure) => failure.path) - : uniquePaths, - ) - const preserveAuthorizedPaths = [...failedPaths].filter((path) => - freshWholeFileAuthorizationPaths.has(path), - ) - const requireFreshReadPaths = [...failedPaths].filter( - (path) => !freshWholeFileAuthorizationPaths.has(path), - ) - invalidatePreparedEditPaths({ - fileProcessingState, - paths: preserveAuthorizedPaths, - requiresFreshRead: false, + // Echo whole-file basedOnRead on residual process/preflight failures when + // snapshot content is known, matching standalone str_replace recovery + // (capability retry without exploratory re-read). + const enrichedFailures = transactionResult.failures.map((failure) => { + if (failure.basedOnRead) return failure + const content = initialContentByPath.get(failure.path) + if (typeof content !== 'string') return failure + const basedOnRead = mintWholeFileCapability(failure.path, content) + const errorMessage = failure.errorMessage.includes('basedOnRead=') + ? failure.errorMessage + : `${failure.errorMessage} basedOnRead="${basedOnRead}"` + return { + ...failure, + errorMessage, + basedOnRead, + } }) + const failureText = enrichedFailures + .map((failure) => failure.errorMessage) + .join('\n') + const requiresFreshCapability = + /different project, path, or agent run|Invalid basedOnRead|readCapability-covered content is stale|normalized capability metadata does not match/i.test( + failureText, + ) + // Deterministic preflight failures made no writes and preserve existing + // authorization. Capability freshness/scope failures are different: retrying + // the atomic transaction requires one new snapshot for every target so no + // stale token from another path is replayed alongside the refreshed failure. invalidatePreparedEditPaths({ fileProcessingState, - paths: requireFreshReadPaths, - reason: 'preflight_failed', - sourceTool: 'edit_transaction', + paths: uniquePaths, + revokeReadAuthorization: requiresFreshCapability, + requiresFreshRead: requiresFreshCapability, + ...(requiresFreshCapability + ? { reason: 'stale_capability' as const, sourceTool: 'edit_transaction' } + : {}), }) return { @@ -422,8 +729,13 @@ export const handleEditTransaction = (async ( { type: 'json', value: { - errorMessage: transactionResult.error, - failures: transactionResult.failures, + errorMessage: requiresFreshCapability + ? [ + transactionResult.error, + `Atomic recovery requires fresh read state for every transaction target in this run: ${uniquePaths.join(', ')}. Re-read all targets and rebuild the complete transaction with only those fresh capabilities; do not refresh only the first failed path or replay any other stale token.`, + ].join('\n') + : transactionResult.error, + failures: enrichedFailures, }, }, ], @@ -563,6 +875,18 @@ export const handleEditTransaction = (async ( }) clientChanges.sort((a, b) => a.index - b.index) + // Only paths that produced an actual client change can emit an `applied` + // action, so scope the positive-evidence confirmation set to those paths. + // A content edit that resolved to a no-op is excluded from `clientChanges` + // and must not be required for confirmation. + const confirmationPaths = new Set() + for (const { change } of clientChanges) { + confirmationPaths.add(change.path) + if (change.type === 'move' && typeof change.destinationPath === 'string') { + confirmationPaths.add(change.destinationPath) + } + } + const appliedFiles: { path: string patch: string @@ -572,6 +896,8 @@ export const handleEditTransaction = (async ( toolName: 'edit_transaction', fileProcessingState, paths: uniquePaths, + confirmationPaths, + rejectionRequiresRead: false, apply: () => requestClientToolCall({ toolCallId: toolCall.toolCallId, @@ -579,14 +905,42 @@ export const handleEditTransaction = (async ( input: clientChanges.map(({ change }) => change), }), onApplied: () => { + // Paths that successfully applied a str_replace in this transaction may + // clear context_compacted (unique oldString safety bound, post-apply only). + // write_file authorized via whole-file basedOnRead already cleared markers + // pre-apply and refreshes sticky from post-edit content below. + const appliedStrReplacePaths = new Set( + edits + .filter((edit) => edit.type === 'str_replace') + .map((edit) => edit.path), + ) + const appliedWriteFilePaths = new Set( + edits + .filter((edit) => edit.type === 'write_file') + .map((edit) => edit.path), + ) for (const file of transactionResult.files) { - if (freshWholeFileAuthorizationPaths.has(file.path)) { + // Refresh sticky from observed post-edit content when this transaction + // had whole-file auth (real sticky hash-fresh, write_file basedOnRead, + // or successful unique str_replace after auto-reread). Auto-reread alone + // never mints durable sticky before apply; only post-edit observed bytes + // get a durable grant. + if ( + freshWholeFileAuthorizationPaths.has(file.path) || + autoRereadAuthorizedPaths.has(file.path) + ) { grantWholeFileReadAuthorization( fileProcessingState, file.path, file.content, ) } + if (appliedStrReplacePaths.has(file.path)) { + clearEditRereadRequirement(fileProcessingState, file.path) + } + if (appliedWriteFilePaths.has(file.path)) { + clearEditRereadRequirement(fileProcessingState, file.path) + } const fileProcessingResult = Promise.resolve({ tool: 'edit_transaction' as const, path: file.path, diff --git a/packages/agent-runtime/src/tools/handlers/tool/read-files.ts b/packages/agent-runtime/src/tools/handlers/tool/read-files.ts index 5756177bc2..180be0582a 100644 --- a/packages/agent-runtime/src/tools/handlers/tool/read-files.ts +++ b/packages/agent-runtime/src/tools/handlers/tool/read-files.ts @@ -14,7 +14,10 @@ import { grantWholeFileReadAuthorization, normalizeToolPath, } from './write-file' -import { clearEditRereadRequirement } from './edit-read-state' +import { + clearEditRereadRequirement, + getEditRereadRequirement, +} from './edit-read-state' import { getFileReadingUpdates } from '../../../get-file-reading-updates' import { extractSlices } from '../../../structural-read' @@ -195,19 +198,21 @@ export const handleReadFiles = (async ( .map((result) => result.path), ) - for (const path of successfulReadPaths) { - clearEditRereadRequirement(fileProcessingState, path) - delete fileProcessingState.promisesByPath[path] - } + // Paths that receive a complete whole-file sticky grant (or the same complete + // whole-file result that would mint one) may clear context_compacted. + // Partial/range-subset/symbol success may still clear other reread reasons + // (failed_edit gates) but must not drop context_compacted. + const wholeFileGrantPaths = new Set() - if (fileProcessingState.strictReadBeforeEdit) { - for (const result of fileResults) { - if ( - result.selector === 'file' && - result.status === 'ok' && - result.complete && - typeof result.content === 'string' - ) { + for (const result of fileResults) { + if ( + result.selector === 'file' && + result.status === 'ok' && + result.complete && + typeof result.content === 'string' + ) { + wholeFileGrantPaths.add(result.path) + if (fileProcessingState.strictReadBeforeEdit) { grantWholeFileReadAuthorization( fileProcessingState, result.path, @@ -215,6 +220,57 @@ export const handleReadFiles = (async ( ) } } + // Promote complete full-file range reads (1..totalLines) to sticky whole-file + // auth — same as paths reads. Truncated/partial ranges never grant. + if ( + result.selector === 'range' && + result.status === 'ok' && + result.complete === true && + typeof result.content === 'string' + ) { + const totalLines = + 'totalLines' in result && typeof result.totalLines === 'number' + ? result.totalLines + : undefined + const isFullFileCoverage = + result.startLine === 1 && + totalLines !== undefined && + result.endLine === totalLines + if (isFullFileCoverage) { + // Prefer undecorated sourceContent (hash-stable whole-file bytes). + // Numbered display content alone is not used for granting. + const wholeFileContent = + 'sourceContent' in result && + typeof result.sourceContent === 'string' + ? result.sourceContent + : null + if (wholeFileContent !== null) { + wholeFileGrantPaths.add(result.path) + if (fileProcessingState.strictReadBeforeEdit) { + grantWholeFileReadAuthorization( + fileProcessingState, + result.path, + wholeFileContent, + ) + } + } + } + } + } + + for (const path of successfulReadPaths) { + const rereadReq = getEditRereadRequirement(fileProcessingState, path) + if ( + rereadReq?.reason === 'context_compacted' && + !wholeFileGrantPaths.has(path) + ) { + // Preserve context_compacted until a complete whole-file grant; still + // drop stale per-path edit content so later anchors use current disk. + delete fileProcessingState.promisesByPath[path] + continue + } + clearEditRereadRequirement(fileProcessingState, path) + delete fileProcessingState.promisesByPath[path] } const renderedFileResults = fileResults.map((result) => { @@ -322,7 +378,15 @@ export const handleReadFiles = (async ( } successfulReadPaths.add(request.path) - clearEditRereadRequirement(fileProcessingState, request.path) + // Symbol-only success may clear failed_edit gates but never mints whole-file + // auth, so it must not drop context_compacted. + const symbolRereadReq = getEditRereadRequirement( + fileProcessingState, + request.path, + ) + if (symbolRereadReq?.reason !== 'context_compacted') { + clearEditRereadRequirement(fileProcessingState, request.path) + } delete fileProcessingState.promisesByPath[request.path] const slicesTooLarge = slices.reduce((total, slice) => total + slice.content.length, 0) > 100_000 diff --git a/packages/agent-runtime/src/tools/handlers/tool/spawn-agent-utils.ts b/packages/agent-runtime/src/tools/handlers/tool/spawn-agent-utils.ts index 2ca4f82fd9..8a73d40a38 100644 --- a/packages/agent-runtime/src/tools/handlers/tool/spawn-agent-utils.ts +++ b/packages/agent-runtime/src/tools/handlers/tool/spawn-agent-utils.ts @@ -9,6 +9,7 @@ import { } from '@codebuff/common/util/agent-id-parsing' import { withTimeout } from '@codebuff/common/util/promise' import { generateCompactId } from '@codebuff/common/util/string' +import { containsStructuralAuditReceipt } from '@codebuff/common/util/audit-receipt' import { agentHandoffSchema, agentReceiptSchema, @@ -1122,36 +1123,6 @@ function containsToolCall(value: unknown, toolName: string): boolean { return found } -function containsStructuralAuditReceipt( - value: unknown, - expectedSnapshotId?: string, -): boolean { - let found = false - const visit = (item: unknown, depth = 0): void => { - if (found || !item || depth > 12) return - if (Array.isArray(item)) { - for (const nested of item) visit(nested, depth + 1) - return - } - if (typeof item !== 'object') return - const record = item as Record - const receipt = record.structuralReceipt - if (receipt && typeof receipt === 'object' && !Array.isArray(receipt)) { - const snapshotId = (receipt as Record).snapshot_id - if ( - typeof snapshotId === 'string' && - (!expectedSnapshotId || snapshotId === expectedSnapshotId) - ) { - found = true - return - } - } - for (const nested of Object.values(record)) visit(nested, depth + 1) - } - visit(value) - return found -} - function extractReceiptEvidence(params: { output: unknown agentType: string @@ -1651,6 +1622,17 @@ export function validateAgentInput( ), ) const normalizedAgentType = normalizeAgentIdForLookup(agentType) + const reviewerFamilyRequiredSnapshotIds = new Set([ + 'product-reviewer', + 'performance-specialist', + 'reliability-reviewer', + 'migration-reviewer', + 'compatibility-reviewer', + 'accessibility-reviewer', + 'ux-visual-reviewer', + 'dependency-reviewer', + 'evaluator', + ]) const paramsRecord = params && typeof params === 'object' && !Array.isArray(params) ? (params as Record) @@ -1658,14 +1640,23 @@ export function validateAgentInput( const recoveryHint = normalizedAgentType === 'basher' && issuePaths.has('command') ? '\n\nRecovery: spawn Basher with { "agent_type": "basher", "params": { "command": "" } }. A command mentioned only in prompt prose is never executed.' - : normalizedAgentType === 'compatibility-reviewer' && + : reviewerFamilyRequiredSnapshotIds.has(normalizedAgentType) && issuePaths.has('snapshot_id') - ? '\n\nRecovery: set params.snapshot_id to the exact current snapshot fingerprint from get_change_review_bundle, for example { "agent_type": "compatibility-reviewer", "params": { "snapshot_id": "" } }. Do not invent or reuse a stale fingerprint.' + ? `\n\nRecovery: set params.snapshot_id to the exact current snapshot fingerprint from get_change_review_bundle, for example { "agent_type": "${normalizedAgentType}", "params": { "snapshot_id": "" } }. Do not invent or reuse a stale fingerprint.` : normalizedAgentType === 'security-reviewer' && (issuePaths.has('snapshot_fingerprint') || Object.hasOwn(paramsRecord ?? {}, 'snapshot_id')) ? '\n\nRecovery: replace params.snapshot_id with params.snapshot_fingerprint, or add params.snapshot_fingerprint when it is missing. Retain params.changed_files and preserve both canonical field names exactly.' - : '' + : normalizedAgentType === 'dependency-manager' && + (issuePaths.has('manager') || issuePaths.has('operation')) + ? '\n\nRecovery: place both canonical keys in params, for example { "agent_type": "dependency-manager", "params": { "manager": "npm", "operation": "add" } }. manager must come from repository manifest/environment evidence. operation must be one of add, remove, sync, restore, or update. Do not infer dependency mutation authorization from a validation failure.' + : normalizedAgentType === 'librarian' && + issuePaths.has('repoUrl') + ? '\n\nRecovery: set params.repoUrl to a GitHub URL, for example { "agent_type": "librarian", "params": { "repoUrl": "https://github.com//" } }. params.repoUrl must have the form https://github.com//; a URL only in prompt prose is not used.' + : normalizedAgentType === 'code-searcher' && + issuePaths.has('searchQueries') + ? '\n\nRecovery: spawn code-searcher with { "agent_type": "code-searcher", "params": { "searchQueries": [{ "pattern": "", "flags": "-g *.ts" }] } }. searchQueries is a required array of objects each with a non-empty string "pattern"; queries mentioned only in prompt prose are never executed.' + : '' const paramsContract = formatAgentParamsContract(inputSchema.params) throw new Error( `Invalid params for agent ${agentType}: ${formatValidationIssues({ issues: result.error.issues })}\n\nExact params contract (from the child agent schema): ${paramsContract}\nPreserve params field names exactly.${recoveryHint}\n\nOriginal params value:\n${formatValueForError(params ?? {})}`, diff --git a/packages/agent-runtime/src/tools/handlers/tool/str-replace.ts b/packages/agent-runtime/src/tools/handlers/tool/str-replace.ts index 3cc967ab6f..eb7d9ea99f 100644 --- a/packages/agent-runtime/src/tools/handlers/tool/str-replace.ts +++ b/packages/agent-runtime/src/tools/handlers/tool/str-replace.ts @@ -1,3 +1,9 @@ +import { + encodeReadCapabilityToken, + getContentHash, + normalizeLineEndings, +} from '@codebuff/common/util/content-hash' + import { formatUnsafeToolPathError, hasWholeFileReadAuthorization, @@ -8,6 +14,8 @@ import { } from './write-file' import { coordinateEditApplication } from './edit-application-coordinator' import { + clearEditRereadRequirement, + getEditRereadRequirement, markEditRequiresFreshRead, strictEditAuthorizationError, } from './edit-read-state' @@ -126,37 +134,6 @@ export const handleStrReplace = (async ( } } - if ( - recoveringFromFailedEdit && - !hasAnyReadCapability && - !structuralRecovery - ) { - const authorizationError = strictEditAuthorizationError({ - fileProcessingState, - path, - toolName: 'str_replace', - hasFreshWholeFileAuthorization: false, - }) - return { - output: [ - { - type: 'json', - value: { - file: path, - errorMessage: - authorizationError?.errorMessage ?? - `str_replace blocked for ${path}: read_files must refresh the file before retrying.`, - errorCode: 'fresh_read_required', - recovery: { - tool: 'read_files', - input: { paths: [path] }, - }, - }, - }, - ], - } - } - const hasStoredWholeFileAuthorization = hasWholeFileReadAuthorization( fileProcessingState, path, @@ -170,36 +147,6 @@ export const handleStrReplace = (async ( ) { revokeWholeFileReadAuthorization(fileProcessingState, path) } - if ( - fileProcessingState.strictReadBeforeEdit && - !hasStoredWholeFileAuthorization && - !hasAnyReadCapability - ) { - const authorizationError = strictEditAuthorizationError({ - fileProcessingState, - path, - toolName: 'str_replace', - hasFreshWholeFileAuthorization: false, - }) - return { - output: [ - { - type: 'json', - value: { - file: path, - errorMessage: - authorizationError?.errorMessage ?? - `str_replace blocked for ${path}: read_files must authorize the file before editing.`, - errorCode: 'fresh_read_required', - recovery: { - tool: 'read_files', - input: { paths: [path] }, - }, - }, - }, - ], - } - } if (!fileProcessingState.promisesByPath[path]) { fileProcessingState.promisesByPath[path] = [] @@ -211,7 +158,8 @@ export const handleStrReplace = (async ( // Same-turn committed edits are the current base even when the client's // filesystem stub does not immediately reflect them. Across turns there is // no prior promise, so the disk read below is the external-change boundary. - const latestContent = hasAnyReadCapability + // Auto-reread-once for strict auth miss also uses this load (one attempt). + let latestContent = hasAnyReadCapability ? await requestOptionalFile({ ...params, filePath: path }) : previousEdit ? await previousEdit.then((maybeResult) => @@ -221,10 +169,73 @@ export const handleStrReplace = (async ( ) : await requestOptionalFile({ ...params, filePath: path }) - const hadFreshWholeFileAuthorization = + let hadFreshWholeFileAuthorization = typeof latestContent === 'string' && isWholeFileReadAuthorizationFresh(fileProcessingState, path, latestContent) + // Auto-reread-once: when strict auth would block because there is *no* stored + // sticky auth and no basedOnRead, authorize *this* unique str_replace from the + // just-loaded disk content only (in-process). Do NOT mint durable sticky via + // grantWholeFileReadAuthorization — that would let a later write_file whole-file + // overwrite chain off a blind server re-read. Post-apply grant of *new* content + // after a successful unique edit still refreshes sticky (observed post-edit + // bytes). Never auto-regrant over a *stale* sticky hash (external change): + // that path must revoke and fail closed with a freshness error. Fail closed if + // the file is missing. Circuit breaker already returned above. + // Unique-only contract: any allowMultiple:true replacement skips auto-reread + // entirely so multi-match calls fail closed (require sticky or basedOnRead). + let autoRereadAttempted = false + const replacementsAreUniqueOnly = replacements.every( + (replacement) => replacement.allowMultiple !== true, + ) + const needsAuthWithoutCapability = + (fileProcessingState.strictReadBeforeEdit === true || + recoveringFromFailedEdit) && + !hadFreshWholeFileAuthorization && + !hasAnyReadCapability && + !hasStoredWholeFileAuthorization && + !structuralRecovery + if (needsAuthWithoutCapability && replacementsAreUniqueOnly) { + autoRereadAttempted = true + if (typeof latestContent !== 'string') { + // Prefer a fresh disk load when previous-edit chain had no content. + latestContent = await requestOptionalFile({ ...params, filePath: path }) + } + if (typeof latestContent === 'string') { + // In-process only: authorize this str_replace call; no durable sticky mint. + // Auto-reread runs only when there is no stored sticky, so clearing + // context_compacted here cannot open a hash-fresh write_file chain. + clearEditRereadRequirement(fileProcessingState, path) + hadFreshWholeFileAuthorization = true + } else { + const authorizationError = strictEditAuthorizationError({ + fileProcessingState, + path, + toolName: 'str_replace', + hasFreshWholeFileAuthorization: false, + authorizationWasStale: hasStoredWholeFileAuthorization, + }) + return { + output: [ + { + type: 'json', + value: { + file: path, + errorMessage: + authorizationError?.errorMessage ?? + `str_replace blocked for ${path}: read_files must authorize the file before editing.`, + errorCode: 'fresh_read_required', + recovery: { + tool: 'read_files', + input: { paths: [path] }, + }, + }, + }, + ], + } + } + } + if (hasStoredWholeFileAuthorization && !hadFreshWholeFileAuthorization) { markEditRequiresFreshRead({ fileProcessingState, @@ -234,17 +245,42 @@ export const handleStrReplace = (async ( }) } + // Hash-fresh authorization may clear prior reread markers for UX, but must + // preserve context_compacted until a successful unique apply (or a complete + // whole-file read_files grant). Otherwise a failed no-match attempt would + // drop the marker and let a later write_file overwrite on sticky alone. + if (hadFreshWholeFileAuthorization) { + const rereadReq = getEditRereadRequirement(fileProcessingState, path) + if (rereadReq?.reason !== 'context_compacted') { + clearEditRereadRequirement(fileProcessingState, path) + } + } + const requireFreshReadCapability = fileProcessingState.strictReadBeforeEdit === true && !hadFreshWholeFileAuthorization if (requireFreshReadCapability && !hasAnyReadCapability) { + const freshReadCapability = + typeof latestContent === 'string' + ? encodeReadCapabilityToken({ + startLine: 1, + endLine: normalizeLineEndings(latestContent).split('\n').length, + hash: getContentHash(latestContent), + scope: { + projectId: params.fileContext?.projectRoot ?? '', + path, + runId: params.runId ?? '', + }, + }) + : undefined const authorizationError = strictEditAuthorizationError({ fileProcessingState, path, toolName: 'str_replace', hasFreshWholeFileAuthorization: false, authorizationWasStale: hasStoredWholeFileAuthorization, + freshReadCapability, }) return { output: [ @@ -256,7 +292,7 @@ export const handleStrReplace = (async ( authorizationError?.errorMessage ?? `str_replace blocked for ${path}: read_files must refresh the file before retrying.`, errorCode: 'fresh_read_required', - recovery: { + recovery: authorizationError?.recovery ?? { tool: 'read_files', input: { paths: [path] }, }, @@ -327,14 +363,46 @@ export const handleStrReplace = (async ( // the agent only needs to fix the syntax, not re-read the file or switch // tools. (Fix C circuit breaker only counts real processing failures.) if (!strReplaceResult.preflightSyntaxError) { - if (!hadFreshWholeFileAuthorization) { + const requiresFreshCapability = + /(?:readCapability|basedOnRead).*(?:stale|different project, path, or agent run)|(?:stale|different project, path, or agent run).*(?:readCapability|basedOnRead)/is.test( + strReplaceResult.error, + ) + if (requiresFreshCapability) { markEditRequiresFreshRead({ fileProcessingState, path, - reason: 'preflight_failed', + reason: 'stale_capability', sourceTool: 'str_replace', }) } + // After auto-reread-once, still-failed unique/ambiguous matches mint a + // whole-file basedOnRead so the agent can retry without another disk thrash. + if (autoRereadAttempted && typeof latestContent === 'string') { + const basedOnRead = encodeReadCapabilityToken({ + startLine: 1, + endLine: normalizeLineEndings(latestContent).split('\n').length, + hash: getContentHash(latestContent), + scope: { + projectId: params.fileContext?.projectRoot ?? '', + path, + runId: params.runId ?? '', + }, + }) + strReplaceResult.error = [ + strReplaceResult.error, + 'Auto-re-read once failed to apply; retry with basedOnRead below.', + `basedOnRead="${basedOnRead}"`, + JSON.stringify({ + recovery: { + tool: 'read_files', + input: { paths: [path] }, + basedOnRead, + }, + }), + ].join('\n') + } + // Deterministic no-match/ambiguity preflight failures do not mutate the + // client and therefore preserve any valid read authorization. // Fix C: a hard error consumes the per-path failure budget. fileProcessingState.consecutiveStrReplaceFailuresByPath[path] = (fileProcessingState.consecutiveStrReplaceFailuresByPath[path] ?? 0) + 1 @@ -372,11 +440,10 @@ export const handleStrReplace = (async ( toolName: 'str_replace', fileProcessingState, paths: [path], - // Processing/preflight failures never reached the client. Preserve a - // verified whole-file authorization; scoped/stale paths were explicitly - // marked above. Client-side rejection after a prepared edit still requires - // a fresh read through the coordinator's default path. - rejectionRequiresRead: !('error' in strReplaceResult), + rejectionRequiresRead: false, + // Deterministic processing failures and explicit client rejections preserve + // valid read authorization. Stale capability and uncertain application + // outcomes are marked separately and still require a fresh read. wholeFileContentByPath: 'content' in strReplaceResult ? new Map([[path, strReplaceResult.content]]) @@ -390,6 +457,15 @@ export const handleStrReplace = (async ( ) { delete fileProcessingState.consecutiveStrReplaceFailuresByPath[path] } + // Clear context_compacted only after a successful unique apply. Unique + // oldString is the safety bound that authorizes dropping the marker. + if ( + !hadAutoCorrect && + 'content' in strReplaceResult && + (strReplaceResult.failedReplacementCount ?? 0) === 0 + ) { + clearEditRereadRequirement(fileProcessingState, path) + } }, apply: () => postStreamProcessing<'str_replace'>( diff --git a/packages/agent-runtime/src/tools/handlers/tool/write-file.ts b/packages/agent-runtime/src/tools/handlers/tool/write-file.ts index febc87ced0..eb54338b11 100644 --- a/packages/agent-runtime/src/tools/handlers/tool/write-file.ts +++ b/packages/agent-runtime/src/tools/handlers/tool/write-file.ts @@ -1,8 +1,16 @@ import { AbortError } from '@codebuff/common/util/error' -import { getContentHash } from '@codebuff/common/util/content-hash' +import { + decodeReadCapabilityToken, + encodeReadCapabilityToken, + getContentHash, + normalizeLineEndings, + readCapabilityMatchesScope, +} from '@codebuff/common/util/content-hash' import { coordinateEditApplication } from './edit-application-coordinator' import { + clearEditRereadRequirement, + getEditRereadRequirement, markEditRequiresFreshRead, strictEditAuthorizationError, } from './edit-read-state' @@ -270,7 +278,7 @@ export const handleWriteFile = (async ( writeToClient, } = params const path = normalizeToolPath(toolCall.input.path) - const { content } = toolCall.input + const { content, basedOnRead } = toolCall.input if (!path) { return { @@ -339,23 +347,161 @@ export const handleWriteFile = (async ( initialContent = await getExistingDiskContent() } + const projectId = + (params as { fileContext?: { projectRoot?: string } }).fileContext + ?.projectRoot ?? '' + const runId = (params as { runId?: string }).runId ?? '' + const mintWholeFileCapability = (fileContent: string) => + encodeReadCapabilityToken({ + startLine: 1, + endLine: normalizeLineEndings(fileContent).split('\n').length, + hash: getContentHash(fileContent), + scope: { + projectId, + path, + runId, + }, + }) + + /** + * Validate an explicit whole-file-covering basedOnRead for overwrite. + * Partial ranges never authorize; hash/scope must match current content. + * On success: grant sticky + clear reread markers (including context_compacted). + * Never auto-rereads — capability or sticky only. + */ + const tryAuthorizeFromBasedOnRead = ( + fileContent: string, + ): { ok: true } | { ok: false; error: string } => { + if (!basedOnRead) { + return { ok: false, error: 'missing basedOnRead' } + } + const decoded = decodeReadCapabilityToken(basedOnRead) + if (typeof decoded === 'string') { + const freshReadCapability = mintWholeFileCapability(fileContent) + return { + ok: false, + error: `${decoded}\nbasedOnRead="${freshReadCapability}"`, + } + } + const scope = { projectId, path, runId } + if (!readCapabilityMatchesScope(decoded, scope)) { + const freshReadCapability = mintWholeFileCapability(fileContent) + return { + ok: false, + error: `write_file blocked for ${path}: basedOnRead belongs to a different project, path, or agent run. Re-read the whole file and pass the fresh capability.\nbasedOnRead="${freshReadCapability}"`, + } + } + const lineCount = normalizeLineEndings(fileContent).split('\n').length + if (decoded.startLine !== 1 || decoded.endLine !== lineCount) { + const freshReadCapability = mintWholeFileCapability(fileContent) + return { + ok: false, + error: `write_file blocked for ${path}: a range capability cannot authorize a whole-file overwrite (capability covers lines ${decoded.startLine}-${decoded.endLine}; file has ${lineCount} lines). Pass only a whole-file-covering cap.v3 from a complete paths or full-file range read.\nbasedOnRead="${freshReadCapability}"`, + } + } + if (decoded.hash !== getContentHash(fileContent)) { + const freshReadCapability = mintWholeFileCapability(fileContent) + return { + ok: false, + error: `write_file blocked for ${path}: basedOnRead did not match the current file content (stale hash). Re-read the whole file and retry with the fresh capability.\nbasedOnRead="${freshReadCapability}"`, + } + } + // Explicit whole-file capability is the real re-read substitute, including + // clearing context_compacted without an exploratory auto-reread. + grantWholeFileReadAuthorization(fileProcessingState, path, fileContent) + clearEditRereadRequirement(fileProcessingState, path) + return { ok: true } + } + + // Compaction keeps sticky hashes but clears model-visible bytes. Hash-fresh + // alone must not authorize a whole-file overwrite or clear context_compacted; + // only a complete whole-file read_files grant, explicit whole-file basedOnRead, + // or equivalent real re-read clears that marker for write_file. str_replace may + // still proceed on hash-fresh unique edits (unique oldString is the safety bound). + const rereadRequirement = getEditRereadRequirement( + fileProcessingState, + path, + ) + let isHashFresh = + initialContent !== null && + isWholeFileReadAuthorizationFresh( + fileProcessingState, + path, + initialContent, + ) + if ( + initialContent !== null && + rereadRequirement?.reason === 'context_compacted' + ) { + if (basedOnRead) { + const auth = tryAuthorizeFromBasedOnRead(initialContent) + if (auth.ok) { + isHashFresh = true + } else { + return { + tool: 'write_file' as const, + path, + error: auth.error, + } + } + } else { + const freshReadCapability = mintWholeFileCapability(initialContent) + const authorizationError = strictEditAuthorizationError({ + fileProcessingState, + path, + toolName: 'write_file', + hasFreshWholeFileAuthorization: false, + wholeFileRequired: true, + freshReadCapability, + }) + return { + tool: 'write_file' as const, + path, + error: + authorizationError?.errorMessage ?? + `write_file blocked for ${path}: context compaction removed the exact read content from the active model context; re-read the whole file before overwriting.\nbasedOnRead="${freshReadCapability}"`, + } + } + } + + if (isHashFresh) { + clearEditRereadRequirement(fileProcessingState, path) + } + if ( initialContent !== null && fileProcessingState.failedEditRequiresReadByPath[path] ) { - const authorizationError = strictEditAuthorizationError({ - fileProcessingState, - path, - toolName: 'write_file', - hasFreshWholeFileAuthorization: false, - wholeFileRequired: true, - }) - return { - tool: 'write_file' as const, - path, - error: - authorizationError?.errorMessage ?? - `write_file blocked for ${path}: read_files must refresh the current whole-file content before retrying.`, + if (isHashFresh) { + clearEditRereadRequirement(fileProcessingState, path) + } else if (basedOnRead) { + const auth = tryAuthorizeFromBasedOnRead(initialContent) + if (auth.ok) { + isHashFresh = true + } else { + return { + tool: 'write_file' as const, + path, + error: auth.error, + } + } + } else { + const freshReadCapability = mintWholeFileCapability(initialContent) + const authorizationError = strictEditAuthorizationError({ + fileProcessingState, + path, + toolName: 'write_file', + hasFreshWholeFileAuthorization: false, + wholeFileRequired: true, + freshReadCapability, + }) + return { + tool: 'write_file' as const, + path, + error: + authorizationError?.errorMessage ?? + `write_file blocked for ${path}: read_files must refresh the current whole-file content before retrying.\nbasedOnRead="${freshReadCapability}"`, + } } } if ( @@ -368,34 +514,47 @@ export const handleWriteFile = (async ( initialContent, ) ) { - const authorizationWasStale = hasWholeFileReadAuthorization( - fileProcessingState, - path, - ) - if (authorizationWasStale) { - markEditRequiresFreshRead({ + if (basedOnRead) { + const auth = tryAuthorizeFromBasedOnRead(initialContent) + if (!auth.ok) { + return { + tool: 'write_file' as const, + path, + error: auth.error, + } + } + } else { + const authorizationWasStale = hasWholeFileReadAuthorization( fileProcessingState, path, - reason: 'stale_snapshot', - sourceTool: 'write_file', + ) + if (authorizationWasStale) { + markEditRequiresFreshRead({ + fileProcessingState, + path, + reason: 'stale_snapshot', + sourceTool: 'write_file', + }) + } else { + revokeWholeFileReadAuthorization(fileProcessingState, path) + } + const freshReadCapability = mintWholeFileCapability(initialContent) + const authorizationError = strictEditAuthorizationError({ + fileProcessingState, + path, + toolName: 'write_file', + hasFreshWholeFileAuthorization: false, + authorizationWasStale, + wholeFileRequired: true, + freshReadCapability, }) - } else { - revokeWholeFileReadAuthorization(fileProcessingState, path) - } - const authorizationError = strictEditAuthorizationError({ - fileProcessingState, - path, - toolName: 'write_file', - hasFreshWholeFileAuthorization: false, - authorizationWasStale, - wholeFileRequired: true, - }) - return { - tool: 'write_file' as const, - path, - error: - authorizationError?.errorMessage ?? - `write_file blocked for ${path}: read_files must authorize the current whole-file content before overwriting it.`, + return { + tool: 'write_file' as const, + path, + error: + authorizationError?.errorMessage ?? + `write_file blocked for ${path}: read_files must authorize the current whole-file content before overwriting it.\nbasedOnRead="${freshReadCapability}"`, + } } } @@ -462,7 +621,7 @@ export const handleWriteFile = (async ( toolName: 'write_file', fileProcessingState, paths: [path], - rejectionRequiresRead: !('error' in writeFileResult), + rejectionRequiresRead: false, wholeFileContentByPath: 'content' in writeFileResult ? new Map([[path, writeFileResult.content]]) diff --git a/packages/agent-runtime/src/tools/tool-executor.ts b/packages/agent-runtime/src/tools/tool-executor.ts index f28a3612f3..7e57b37d83 100644 --- a/packages/agent-runtime/src/tools/tool-executor.ts +++ b/packages/agent-runtime/src/tools/tool-executor.ts @@ -628,6 +628,13 @@ export function buildUnavailableToolMessage(params: { // granted. Point the model at the granted tools / spawnable agents instead // of letting it guess another unavailable name. if ((toolNames as readonly string[]).includes(toolName)) { + // Concrete recovery for the two content-search tools the root orchestrator + // is intentionally not granted: point the model at the code-searcher agent + // with a copyable params shape instead of the generic sentence. This stays + // message-only; the tool remains fail-closed and nothing is auto-spawned. + if (toolName === 'code_search' || toolName === 'find_files_matching_content') { + return `${base} \`${toolName}\` is a registered tool the root orchestrator is not granted; spawn the code-searcher agent instead: { "agent_type": "code-searcher", "params": { "searchQueries": [{ "pattern": "", "flags": "-g *.ts" }] } }.` + } return `${base} \`${toolName}\` is a registered tool but is not granted to this agent; use one of the available tools above, or spawn an agent that provides that capability.` } // Case 2: likely a typo/near-miss of a granted tool. @@ -705,6 +712,22 @@ function getToolValidationHint( ): string | undefined { const fieldHint = issues ? getFieldSpecificHint(toolName, issues) : undefined + if ( + toolName === 'get_build_targets' && + (issues ?? []).some( + (issue) => + issue.code === 'too_small' && + issue.path?.length === 1 && + issue.path[0] === 'files', + ) + ) { + return [ + '`files` must be a non-empty array of changed project-relative file paths.', + 'Example: { "files": ["packages/agent-runtime/src/tools/tool-executor.ts"] }', + 'When there are no changed files, do not call `get_build_targets`; skip build-target discovery until a concrete changed-file list exists.', + ].join('\n') + } + if (toolName === 'str_replace') { const base = [ 'Expected shape: { "path": string, "replacements": [{ "oldString": string, "newString": string, "allowMultiple"?: boolean }] }.', @@ -732,7 +755,8 @@ function getToolValidationHint( if (toolName === 'spawn_agents') { const base = [ 'Expected shape: { "agents": [{ "agent_type": string, "prompt"?: string, "params"?: object, "handoff"?: object }] }.', - 'Pass agents as an array of objects. Valid stringified or double-stringified JSON is repaired automatically, but truncated JSON and non-object entries are rejected. Do not stringify each agent entry.', + 'Pass agents as an array of objects. `prompt`, `params`, and `handoff` must be inside each agent object; check every brace and bracket when a field appears misplaced. Valid stringified or double-stringified JSON is repaired automatically, but ambiguous brace nesting, truncated JSON, and non-object entries are rejected without guessing or auto-repair. Do not stringify each agent entry.', + 'Corrected example: { "agents": [{ "agent_type": "code-searcher", "prompt": "", "params": { "searchQueries": [{ "pattern": "" }] } }] } — note prompt/params live INSIDE each agent object, and agents is a real array, not a JSON string.', ].join('\n') const hasHandoffIssue = (issues ?? []).some(isSpawnAgentHandoffIssue) if (!hasHandoffIssue) return base @@ -874,6 +898,89 @@ function formatInvalidInputExcerpts( return excerpts.join('\n\n') || formatValueForError(input, 2_000) } +// Handle a mis-braced spawn_agents payload where `prompt`, `params`, or +// `handoff` appear as siblings of `agents` at the top level. These fields +// belong INSIDE each agent object; the non-strict top-level schema would +// otherwise silently drop the stray key, hiding the mistake. In the +// unambiguous single-agent case we fold the stray siblings into that one +// entry and return a repaired input for safeParse. Every ambiguous case +// (multi-entry arrays, unexpected sibling keys) still fails closed with the +// base spawn_agents guidance. +type DetectMisbracedSpawnResult = + | { repairedInput: Record } + | { error: ToolCallError } + +function detectMisbracedSpawnPayload(args: { + input: unknown + toolCallId: string + rawInput: unknown + logger?: Logger +}): DetectMisbracedSpawnResult | undefined { + const { input, toolCallId, rawInput, logger } = args + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return undefined + } + const record = input as Record + if (!Array.isArray(record.agents)) return undefined + const foldableKeys = ['prompt', 'params', 'handoff'] as const + const misplacedKeys = foldableKeys.filter((key) => Object.hasOwn(record, key)) + if (misplacedKeys.length === 0) return undefined + + // Unambiguous single-agent repair: fold the stray prompt/params/handoff + // siblings INTO the single agent entry without overwriting existing in-entry + // values, then hand the corrected shape back for safeParse (which reruns the + // per-entry normalize preprocess). Never fold into a multi-entry array (it is + // ambiguous which agent the sibling belongs to). Any sibling key outside + // {prompt, params, handoff} other than the legitimate end-step param makes + // the intent ambiguous, so those fall through to the fail-closed error below. + const allowedSiblingKeys = new Set([ + ...foldableKeys, + endsAgentStepParam, + ]) + const hasUnexpectedSibling = Object.keys(record).some( + (key) => key !== 'agents' && !allowedSiblingKeys.has(key), + ) + if (record.agents.length === 1 && !hasUnexpectedSibling) { + const entry = parseJsonBounded(record.agents[0]) + if (entry !== null && typeof entry === 'object' && !Array.isArray(entry)) { + const entryRecord = entry as Record + const merged: Record = { ...entryRecord } + for (const key of foldableKeys) { + if (Object.hasOwn(record, key) && !Object.hasOwn(entryRecord, key)) { + merged[key] = record[key] + } + } + const repairedInput: Record = { agents: [merged] } + // Preserve any legitimate non-agent top-level key the schema still + // accepts (e.g. the end-step param); drop the folded stray siblings. + for (const [key, value] of Object.entries(record)) { + if (key === 'agents') continue + if ((foldableKeys as readonly string[]).includes(key)) continue + repairedInput[key] = value + } + // Log only the folded key names and the toolCallId; never the payload + // values themselves, to avoid leaking prompt/params/handoff content. + logger?.debug( + { toolCallId, misplacedKeys }, + 'spawn_agents: auto-repaired mis-braced single-agent payload by folding stray sibling field(s) into the agent entry', + ) + return { repairedInput } + } + } + + const hint = getToolValidationHint('spawn_agents', undefined, input) + const summary = `misplaced top-level field(s) ${misplacedKeys.join(', ')} alongside \`agents\`` + return { + error: { + toolName: 'spawn_agents', + toolCallId, + input: rawInput, + error: `Invalid parameters for spawn_agents: ${summary}.${hint ? `\n\n${hint}` : ''}`, + formattedInput: formatInvalidInputExcerpts(input, []), + }, + } +} + function isFileChangingTool(toolName: string): boolean { return ( toolName === 'apply_patch' || @@ -1029,8 +1136,9 @@ export function parseRawToolCall(params: { input: unknown providerOptions?: ProviderMetadata } + logger?: Logger }): CodebuffToolCall | ToolCallError { - const { rawToolCall } = params + const { rawToolCall, logger } = params const toolName = rawToolCall.toolName const processedParameters = parseStringifiedToolInput( @@ -1048,10 +1156,24 @@ export function parseRawToolCall(params: { ) } - const repairedInput = repairTerminalCommandScalars( + let repairedInput = repairTerminalCommandScalars( toolName, repairSetOutputData(toolName, processedParameters.input), ) + if (toolName === 'spawn_agents') { + const misbraced = detectMisbracedSpawnPayload({ + input: repairedInput, + toolCallId: rawToolCall.toolCallId, + rawInput: rawToolCall.input, + logger, + }) + if (misbraced) { + if ('error' in misbraced) { + return misbraced.error + } + repairedInput = misbraced.repairedInput + } + } const result = paramsSchema.safeParse(repairedInput) if (!result.success) { @@ -1192,6 +1314,7 @@ export async function executeToolCall( input, providerOptions: params.providerOptions, }, + logger, }) // Filter out restricted tools - emit error instead of tool call/result @@ -1406,7 +1529,7 @@ export async function executeToolCall( onResponseChunk({ type: 'error', message: - 'Spawning `git-committer` is not available yet. The validation/reviewer gate must pass before committing. Wait for the automated gate to complete, then commit.', + 'Spawning `git-committer` is not available yet. This is normal ordering, not a failure: the validation/reviewer gate re-arms whenever code changes, so a fresh edit sets it back to pending and the committer is withheld until that gate passes for the changed files. The gate runs and clears automatically — do not retry the spawn now. Wait for the gate to report passed for the pending files, then spawn git-committer; the commit will succeed on the next attempt.', }) if (filteredAgents.length === 0) { return abortablePreviousToolCallFinished @@ -1567,7 +1690,7 @@ export async function executeToolCall( onResponseChunk({ type: 'error', message: - 'Spawning `git-committer` is not available yet. The requested commit would stage changes that have not passed the validation/reviewer gate. Validate the working-tree changes first, then commit.', + 'Spawning `git-committer` is not available yet. This is normal ordering, not a failure: the requested commit would stage changes that have not passed the validation/reviewer gate (a recent edit left working-tree files pending). The gate runs and clears automatically for those files — do not retry the spawn now. Wait for it to report passed, then spawn git-committer; the commit will succeed on the next attempt.', }) if (filteredAgents.length === 0) { return abortablePreviousToolCallFinished diff --git a/packages/agent-runtime/src/util/__tests__/read-authorization.test.ts b/packages/agent-runtime/src/util/__tests__/read-authorization.test.ts index 17818bdd8e..f9bfb814b5 100644 --- a/packages/agent-runtime/src/util/__tests__/read-authorization.test.ts +++ b/packages/agent-runtime/src/util/__tests__/read-authorization.test.ts @@ -5,7 +5,7 @@ import { revokeImplicitReadAuthorizationsAfterCompaction } from '../read-authori import type { AgentState } from '@codebuff/common/types/session-state' describe('revokeImplicitReadAuthorizationsAfterCompaction', () => { - it('revokes implicit whole-file authority and records a typed reread reason', () => { + it('keeps sticky whole-file authority and records a typed reread reason', () => { const state = { readAuthorizationsByPath: { 'src/a.ts': true }, readAuthorizationHashesByPath: { @@ -22,8 +22,12 @@ describe('revokeImplicitReadAuthorizationsAfterCompaction', () => { revokeImplicitReadAuthorizationsAfterCompaction(state) - expect(state.readAuthorizationsByPath).toEqual({}) - expect(state.readAuthorizationHashesByPath).toEqual({}) + // Sticky maps are preserved; edit-time hash freshness still gates edits. + expect(state.readAuthorizationsByPath).toEqual({ 'src/a.ts': true }) + expect(state.readAuthorizationHashesByPath).toEqual({ + 'src/a.ts': 'sha256:a', + 'src/hash-only.ts': 'sha256:b', + }) expect(state.editRereadRequirementsByPath).toEqual({ 'src/a.ts': { reason: 'context_compacted', @@ -33,6 +37,7 @@ describe('revokeImplicitReadAuthorizationsAfterCompaction', () => { reason: 'context_compacted', sourceTool: 'context compaction', }, + // Non-context_compacted requirements are preserved. 'src/existing.ts': { reason: 'stale_snapshot', sourceTool: 'str_replace', diff --git a/packages/agent-runtime/src/util/read-authorization.ts b/packages/agent-runtime/src/util/read-authorization.ts index 90d40dba09..09d02e8efa 100644 --- a/packages/agent-runtime/src/util/read-authorization.ts +++ b/packages/agent-runtime/src/util/read-authorization.ts @@ -1,6 +1,11 @@ import type { AgentState } from '@codebuff/common/types/session-state' -/** Revoke implicit edit authority when compaction removes exact read bodies. */ +/** + * After context compaction removes exact read bodies from model-visible context, + * record a typed reread reason for telemetry/guidance — but keep sticky whole-file + * authorizations and hashes. Edit-time `isWholeFileReadAuthorizationFresh` still + * fails closed when disk content has drifted from the stored hash. + */ export function revokeImplicitReadAuthorizationsAfterCompaction( agentState: AgentState, ): void { @@ -17,6 +22,5 @@ export function revokeImplicitReadAuthorizationsAfterCompaction( sourceTool: 'context compaction', } } - agentState.readAuthorizationsByPath = {} - agentState.readAuthorizationHashesByPath = {} + // Sticky maps intentionally preserved: hash freshness is enforced at edit time. }