perf: stop busting the prompt cache and slim per-turn context (#2142)

* perf: stop busting the prompt cache and slim per-turn context

Benchmarked against a comparable harness on grok-4.6 (identical one-shot
coding task), OpenClaude used 84k total tokens per task with near-zero
cache reuse. Root causes and fixes:

- Auto-memory now defaults off in non-interactive (-p) sessions
  (src/memdir/paths.ts). The conversation-arc append gated behind it
  rewrote the system prompt every request (Date.now()-relative durations,
  running token counters, per-turn RAG retrieval), which invalidates
  implicit prefix caches from byte one on chat-completions providers.
  An explicit settings opt-in (autoMemoryEnabled / memory.autoWrite)
  still enables it; also drops the ~3.2k-token memory protocol section
  from one-shot runs.

- The OpenAI shim no longer runs compressToolHistory for providers with
  implicit prefix caching (OpenAI, xAI, DeepSeek, Kimi/Moonshot, Codex)
  (requestPreparation.ts, both call sites). Its end-relative window
  retro-edits already-sent tool results each turn, mutating the middle
  of the request prefix — the native Anthropic transport already guards
  against exactly this (claude.ts shouldCompressNativeToolHistory).

- Remove the wall-clock-relative "Ns ago" line from the multi-turn
  tracking block (conversationArc.ts) — it changed on every request.

- Ship the ~1.7k-token git commit/PR protocol in the Bash tool
  description only when the session is inside a git repository
  (gitSettings.ts). The probe is cached per cwd, not per process, since
  worktree tools and daemon/SDK processes change directories mid-life.

- Add a code-robustness bullet to the Doing-tasks system prompt section:
  derive timing-sensitive logic from elapsed time, and wire up every
  element introduced (prompts.ts).

Measured on the same benchmark (8 runs): 84k -> ~46.6k total tokens per
task (-45%), 89s -> ~60s wall clock, per-call cache reads up from a
constant 128 tokens to 12k-29k, baseline context 16.8k -> 11.7k tokens.

Tests: 573 targeted tests pass, including new coverage for the
non-interactive memory default and the per-cwd git probe; tsc --noEmit
clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: address review findings on the prompt-cache changes

- Correct the prefix-caching route ids ('moonshot'/'kimi-code', not
  'kimi'/'codex' — the latter never matched a real routeId) and replace
  the unanchored host regex with parsed-hostname comparison so
  path-routed gateways are not misclassified.

- gitSettings: an explicit includeGitInstructions settings value now
  always wins over the repo probe (recourse for bare-repo/GIT_DIR
  layouts), and the probe reuses the existing LRU-memoized findGitRoot
  on getCwd() instead of a second process.cwd()-keyed implementation —
  fixing stale results after Bash `cd`, `git init`, and in daemon/SDK
  processes serving multiple directories.

- Memory gate: env-provisioned memory (CLAUDE_COWORK_MEMORY_PATH_OVERRIDE,
  CLAUDE_CODE_REMOTE with a mounted memory dir) counts as explicit opt-in,
  so Cowork/remote sessions keep extraction and indexing.

- Multi-turn tracking block: render only completed turns and drop the
  running token totals — the in-progress turn's tool-call list and the
  aggregate counters changed between model requests, still rewriting the
  system prompt mid-turn.

- Update the Kimi K3 compression test to assert the new policy (history
  kept uncompressed on implicit-prefix-caching hosts) and extend
  gitSettings tests to cover settings overrides and session-cwd tracking.

569 tests pass, tsc --noEmit clean, benchmark re-run confirms metrics
hold (45.1k total tokens, 62.8s, 3 calls).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: green CI and address CodeRabbit review on the prompt-cache changes

CI: the conversation-arc suites assumed the multi-turn tracking block
renders with only an in-progress turn, and that auto-memory is on in the
(non-interactive) test process. Both now seed a completed prior turn,
mark the session interactive where they exercise interactive behavior,
and assert the new cache-stability invariants directly: the in-progress
turn is never rendered, and no Duration/token-total lines appear.

CodeRabbit findings:

- Codex transport now skips tool-history compression too: Codex talks to
  OpenAI Responses backends with implicit prefix caching, and the
  end-relative compression window rewrites already-sent tool results,
  busting the cache (mirrors the openaiShim/requestPreparation skip).
  Its compression test now pins the uncompressed behavior.

- New regression tests for both compression decision paths: an
  implicit-prefix-caching host skips compression on chat-completions and
  Responses requests, while a non-caching custom endpoint still
  compresses.

- New byte-stability test: the same turn rendered twice with an advanced
  clock and a grown in-progress tool-call list produces an identical
  system prompt.

- gitSettings: cover the CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS=0
  defined-falsy override outside a repository.

- Focused system-prompt test asserting the new timing/wiring guidance
  without snapshotting the full prompt.

bun run check (smoke, deadcode, full suite) passes; tsc --noEmit clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: isolate CLAUDE_CODE_SIMPLE in the doing-tasks prompt test

getSystemPrompt short-circuits to a minimal prompt when CLAUDE_CODE_SIMPLE
is truthy; save, unset, and restore it around the test so the full prompt
path is always exercised regardless of process-level state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Codex
2026-08-19 22:49:55 +08:00
committed by GitHub
co-authored by Claude Fable 5
parent ef1a462faf
commit 31ac8a6eca
13 changed files with 445 additions and 28 deletions
+35
View File
@@ -0,0 +1,35 @@
import { afterEach, beforeEach, expect, test } from 'bun:test'
import { withMockMacro } from 'src/test/mockMacro.js'
import { getSystemPrompt } from './prompts.js'
// getSystemPrompt returns a minimal prompt without the doing-tasks section
// when CLAUDE_CODE_SIMPLE is truthy — unset it so the test always exercises
// the full prompt path regardless of process-level state.
let originalSimple: string | undefined
beforeEach(() => {
originalSimple = process.env.CLAUDE_CODE_SIMPLE
delete process.env.CLAUDE_CODE_SIMPLE
})
afterEach(() => {
if (originalSimple === undefined) {
delete process.env.CLAUDE_CODE_SIMPLE
} else {
process.env.CLAUDE_CODE_SIMPLE = originalSimple
}
})
test('coding system prompt includes the timing and wiring robustness guidance', async () => {
const prompt = await withMockMacro(
{ ISSUES_EXPLAINER: 'report the issue at the tracker', VERSION: '0.0.0-test' },
async () => (await getSystemPrompt([], 'test-model')).join('\n'),
)
// Focused assertions on the new "Doing tasks" guidance — not a full-prompt
// snapshot, so unrelated prompt edits don't churn this test.
expect(prompt).toContain(
'derive timing-sensitive logic (animation, physics, timers) from actual elapsed time',
)
expect(prompt).toContain('Every element you introduce must be wired up')
})
+1
View File
@@ -226,6 +226,7 @@ function getSimpleDoingTasksSection(): string {
`Avoid giving time estimates or predictions for how long tasks will take, whether for your own work or for users planning projects. Focus on what needs to be done, not how long it might take.`,
`If an approach fails, diagnose why before switching tactics—read the error, check your assumptions, try a focused fix. Don't retry the identical action blindly, but don't abandon a viable approach after a single failure either. Escalate to the user with ${ASK_USER_QUESTION_TOOL_NAME} only when you're genuinely stuck after investigation, not as a first response to friction.`,
`Be careful not to introduce security vulnerabilities such as command injection, XSS, SQL injection, and other OWASP top 10 vulnerabilities. If you notice that you wrote insecure code, immediately fix it. Prioritize writing safe, secure, and correct code.`,
`Make behavior explicit rather than environment-dependent: derive timing-sensitive logic (animation, physics, timers) from actual elapsed time instead of assuming a fixed frame or tick rate. Every element you introduce must be wired up — a UI element, state variable, or parameter that nothing ever updates or reads is a bug, not a placeholder.`,
...codeStyleSubitems,
`Avoid backwards-compatibility hacks like renaming unused _vars, re-exporting types, adding // removed comments for removed code, etc. If you are certain that something is unused, you can delete it completely.`,
// @[MODEL LAUNCH]: False-claims mitigation for Capybara v8 (29-30% FC rate vs v4's 16.7%)
+25 -1
View File
@@ -1,5 +1,9 @@
import { afterEach, beforeEach, expect, test, mock } from 'bun:test'
import { setAllowedSettingSources } from '../bootstrap/state.js'
import {
getIsInteractive,
setAllowedSettingSources,
setIsInteractive,
} from '../bootstrap/state.js'
import { SETTING_SOURCES } from '../utils/settings/constants.js'
import { isAutoMemoryEnabled } from './paths.ts'
@@ -15,6 +19,7 @@ const realSettings = (await import(
// opt-out can't be silently re-enabled by a narrower scope flipping the key.
let _originalEnv: Record<string, string | undefined> = {}
let _originalInteractive = false
type SourceFixture = { source: string; settings: Record<string, unknown> }
let _sources: SourceFixture[] = []
@@ -41,6 +46,10 @@ beforeEach(() => {
delete process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR
_sources = []
// Auto-memory defaults off for non-interactive (-p) sessions; these tests
// exercise the interactive default unless a test overrides this.
_originalInteractive = getIsInteractive()
setIsInteractive(true)
// Enable every source so getEnabledSettingSources() returns the full set in
// priority order; the fixtures decide which of them carry a value.
setAllowedSettingSources([...SETTING_SOURCES])
@@ -61,6 +70,7 @@ afterEach(() => {
process.env[k] = v
}
}
setIsInteractive(_originalInteractive)
setAllowedSettingSources([...SETTING_SOURCES])
// mock.restore() undoes spies but NOT mock.module() registrations, which
// otherwise leak into later test files in the same (serial) run. Re-register
@@ -74,6 +84,20 @@ test('defaults to enabled when no source sets the key and no env override', () =
expect(isAutoMemoryEnabled()).toBe(true)
})
test('defaults to disabled in non-interactive (-p) sessions', () => {
setIsInteractive(false)
mockSources([{ source: 'userSettings', settings: {} }])
expect(isAutoMemoryEnabled()).toBe(false)
})
test('an explicit settings opt-in overrides the non-interactive default', () => {
setIsInteractive(false)
mockSources([
{ source: 'userSettings', settings: { memory: { autoWrite: true } } },
])
expect(isAutoMemoryEnabled()).toBe(true)
})
test('memory.autoWrite: false opts out via the new discoverable alias (#1326)', () => {
mockSources([
{ source: 'projectSettings', settings: { memory: { autoWrite: false } } },
+21
View File
@@ -57,6 +57,7 @@ export function isAutoMemoryEnabled(): boolean {
// a parent-scope opt-out cannot be re-enabled by a narrower scope (#1326).
// Per-source reads are cached (getSettingsForSource), so this stays cheap on
// the hot path.
let explicitOptIn = false
for (const source of getEnabledSettingSources()) {
const sourceSettings = getSettingsForSource(source)
if (
@@ -65,6 +66,26 @@ export function isAutoMemoryEnabled(): boolean {
) {
return false
}
if (
sourceSettings?.autoMemoryEnabled === true ||
sourceSettings?.memory?.autoWrite === true
) {
explicitOptIn = true
}
}
// One-shot non-interactive (-p) runs have no future session to build memory
// for: default off to skip the ~3.2k-token memory protocol section, the
// per-request arc/RAG system-prompt append (which busts the prompt cache),
// and turn-end extraction forks. Still enabled by any explicit provisioning:
// a settings opt-in, CLAUDE_CODE_DISABLE_AUTO_MEMORY=0 (handled above), a
// Cowork memory-path override, or a mounted remote memory dir — those
// sessions are non-interactive but deliberately memory-backed.
const envProvisionedMemory =
hasAutoMemPathOverride() ||
(isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) &&
Boolean(process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR))
if (!explicitOptIn && !envProvisionedMemory && getIsNonInteractiveSession()) {
return false
}
return true
}
+24
View File
@@ -16,6 +16,11 @@ import {
updateGoalStatus,
} from './utils/conversationArc.js'
import { resetGlobalGraph } from './utils/knowledgeGraph.js'
import {
addToolCallToTurn,
resetMultiTurnState,
startNewTurn,
} from './utils/multiTurnContext.js'
import { setClaudeConfigHomeDirForTesting } from './utils/envUtils.js'
import { getAutoMemPath } from './memdir/paths.js'
import { setGovernancePolicySettingsForSourceForTesting } from './utils/governancePolicy.js'
@@ -38,11 +43,13 @@ beforeEach(async () => {
memory: { requireApprovalBeforeWrite: false },
}))
resetArc()
resetMultiTurnState()
})
afterEach(() => {
try {
resetArc()
resetMultiTurnState()
resetGlobalGraph()
setGovernancePolicySettingsForSourceForTesting(null)
setClaudeConfigHomeDirForTesting(undefined)
@@ -109,6 +116,18 @@ productionArcTest('query appends arc memory to the model system prompt without m
updateGoalStatus(goal.id, 'completed')
await finalizeArcTurn()
// Seed a prior COMPLETED turn: the multi-turn tracking block renders only
// completed turns (the in-progress turn's tool-call list grows between
// model requests and would bust the prompt cache). query() starts a fresh
// turn, which completes this one.
startNewTurn()
addToolCallToTurn({
id: 'call_prior',
name: 'read_file',
input: { path: '/prior.ts' },
timestamp: Date.now(),
})
const userMessage = createUserMessage({ content: 'review query integration' })
let observedSystemPrompt: readonly string[] = []
const deps: QueryDeps = {
@@ -141,5 +160,10 @@ productionArcTest('query appends arc memory to the model system prompt without m
expect(prompt).toContain('PERSISTENT PROJECT MEMORY')
expect(prompt).toContain('Ship query integration')
expect(prompt).toContain('MULTI-TURN CONTEXT TRACKING')
expect(prompt).toContain('read_file')
// No per-request-varying content: wall-clock durations and running token
// totals would rewrite the system prompt every request and bust the cache.
expect(prompt).not.toContain('Duration:')
expect(prompt).not.toContain('Total Tokens:')
expect(userMessage.message.content).toBe('review query integration')
})
+10 -2
View File
@@ -782,7 +782,11 @@ describe('Codex request translation', () => {
expect(output?.output).toBe('first block\nsecond block')
})
test('compresses structured tool results with the Codex Responses separator', async () => {
test('keeps tool history uncompressed on the Codex transport (implicit prefix caching)', async () => {
// Codex talks to OpenAI Responses backends, which do implicit prefix
// caching: compressToolHistory's end-relative window would rewrite
// already-sent tool results each turn and bust the cache, so the Codex
// path must send history verbatim even with compression enabled.
setToolHistoryCompressionEnabledOverrideForTest(true)
try {
mock.restore()
@@ -828,8 +832,12 @@ describe('Codex request translation', () => {
const outputs = (body?.input as Array<{ type?: string; output?: string }>)
.filter(item => item.type === 'function_call_output')
expect(outputs[16]?.output).toBe(
`${'a'.repeat(1_000)}\n${'b'.repeat(999)}\n[…truncated 501 chars from tool history]`,
`${'a'.repeat(1_000)}\n${'b'.repeat(1_500)}`,
)
for (const item of outputs) {
expect(item.output).not.toContain('truncated')
expect(item.output).not.toContain('chars omitted')
}
} finally {
setToolHistoryCompressionEnabledOverrideForTest(undefined)
}
+14 -13
View File
@@ -1,6 +1,5 @@
import { APIError } from '@anthropic-ai/sdk'
import { buildAnthropicUsageFromRawUsage } from './cacheMetrics.js'
import { compressToolHistory } from './compressToolHistory.js'
import { fetchWithProxyRetry } from './fetchWithProxyRetry.js'
import { stableStringifyJson } from '../../utils/stableStringify.js'
import type {
@@ -578,18 +577,20 @@ export async function performCodexRequest(options: {
signal?: AbortSignal
fetcher?: typeof fetchWithProxyRetry
}): Promise<Response> {
const compressedMessages = compressToolHistory(
options.params.messages as Array<{
role?: string
message?: { role?: string; content?: unknown }
content?: unknown
}>,
options.request.resolvedModel,
// Codex Responses flattens structured tool-result text with a single
// newline, unlike Chat's double-newline message serialization.
{ textBlockSeparator: '\n' },
)
const input = convertAnthropicMessagesToResponsesInput(compressedMessages)
// No tool-history compression on the Codex transport: Codex talks to
// OpenAI Responses backends, which do implicit prefix caching, and
// compressToolHistory's end-relative window rewrites already-sent tool
// results each turn — mutating the request prefix and forfeiting the
// cache, which costs more than the compression saves. This mirrors the
// prefix-caching skip in openaiShim/requestPreparation.ts; context
// pressure is handled by the compaction machinery, as on the native
// Anthropic transport.
const rawMessages = options.params.messages as Array<{
role?: string
message?: { role?: string; content?: unknown }
content?: unknown
}>
const input = convertAnthropicMessagesToResponsesInput(rawMessages)
const body: Record<string, unknown> = {
model: options.request.resolvedModel,
input: input.length > 0
@@ -485,7 +485,7 @@ test('FIX: 1M context model with 30 exchanges → only first 5 mid-truncated', a
}
})
test('Kimi K3 256K selection uses its own compression window while sending the k3 API name', async () => {
test('Kimi K3 256K selection keeps history uncompressed (implicit prefix caching) while sending the k3 API name', async () => {
mockState.enabled = true
process.env.OPENAI_BASE_URL = 'https://api.kimi.com/coding/v1'
const messages = buildLongConversation(50, 5_000)
@@ -497,9 +497,65 @@ test('Kimi K3 256K selection uses its own compression window while sending the k
expect(body.model).toBe('k3')
expect(toolMessages).toHaveLength(50)
// api.kimi.com does implicit prefix caching: compressToolHistory's
// end-relative window would rewrite already-sent tool results each turn
// and bust the cache, so compression is skipped for this host.
for (const m of toolMessages) {
expect(m.content).not.toContain('chars omitted')
expect(m.content).not.toContain('[…truncated')
}
})
test('implicit-prefix-caching host (api.deepseek.com) skips tool-history compression', async () => {
mockState.enabled = true
mockState.effectiveWindow = 100_000 // small window: would compress on a custom endpoint
process.env.OPENAI_BASE_URL = 'https://api.deepseek.com/v1'
const messages = buildLongConversation(30, 5_000)
const body = await captureRequestBody(messages, 'deepseek-chat')
const toolMessages = getToolMessages(body)
expect(toolMessages).toHaveLength(30)
for (const m of toolMessages) {
expect(m.content.length).toBe(5_000)
expect(m.content).not.toContain('chars omitted')
expect(m.content).not.toContain('[…truncated')
}
})
test('non-caching custom endpoint still compresses tool history', async () => {
// Guard against the inverse regression: the prefix-caching skip must not
// disable compression for endpoints with no implicit caching. The default
// harness base URL (http://example.test/v1) is such an endpoint.
mockState.enabled = true
mockState.effectiveWindow = 100_000 // recent=12, mid=25
const messages = buildLongConversation(30, 5_000)
const body = await captureRequestBody(messages, 'gpt-4o')
const toolMessages = getToolMessages(body)
expect(toolMessages).toHaveLength(30)
expect(toolMessages[0].content).toContain('chars omitted')
})
test('implicit-prefix-caching host skips compression on the Responses path too', async () => {
mockState.enabled = true
mockState.effectiveWindow = 100_000
const messages = buildLongConversation(30, 5_000)
const body = await captureResponsesRequestBody(messages, 'gpt-4o', {
baseUrl: 'https://api.openai.com/v1',
})
const outputs = (body.input as Array<{ type?: string; output?: string }>)
.filter(item => item.type === 'function_call_output')
expect(outputs).toHaveLength(30)
for (const item of outputs) {
expect(item.output).not.toContain('chars omitted')
expect(item.output).not.toContain('[…truncated')
}
})
// ============================================================================
// FIX: stub preserves tool name and args — model can re-invoke if needed
// ============================================================================
@@ -69,6 +69,46 @@ type Dependencies = {
): boolean
}
// Providers documented to do implicit prefix caching on OpenAI-compatible
// endpoints (see the stableStringifyJson rationale in utils/stableStringify.ts:
// OpenAI, Kimi/Moonshot, DeepSeek — plus xAI). For these, a stable request
// prefix is worth more than tool-history compression.
const PREFIX_CACHING_ROUTE_IDS = new Set([
'openai',
'xai',
'deepseek',
'moonshot',
'kimi-code',
])
const PREFIX_CACHING_HOSTNAMES = new Set([
'api.openai.com',
'api.x.ai',
'api.deepseek.com',
'api.moonshot.ai',
'api.moonshot.cn',
'api.kimi.com',
])
function providerUsesImplicitPrefixCaching(
routeId: string | null | undefined,
baseUrl: string | undefined,
): boolean {
if (routeId && PREFIX_CACHING_ROUTE_IDS.has(routeId)) {
return true
}
if (!baseUrl) {
return false
}
// Parse and compare hostnames rather than substring-matching the raw URL —
// a path-routed gateway like https://proxy.example/api.openai.com/v1 is not
// the provider itself and gets no implicit caching.
try {
return PREFIX_CACHING_HOSTNAMES.has(new URL(baseUrl).hostname.toLowerCase())
} catch {
return false
}
}
export function prepareOpenAIRequest({
request,
params,
@@ -114,11 +154,21 @@ export function prepareOpenAIRequest({
: shimConfig.endpointPath?.startsWith('/models/gemini-')
? 'gemini'
: request.transport
// Mirror the native-transport guard (shouldCompressNativeToolHistory):
// compressToolHistory's window is measured from the end of the conversation,
// so each turn rewrites tool results that were already sent verbatim. On
// providers with implicit prefix caching that mutates the middle of the
// request prefix and forfeits the entire cache downstream — costing far more
// than the compression saves.
const skipCompressionForPrefixCache = providerUsesImplicitPrefixCaching(
runtimeShimContext.routeId,
request.baseUrl,
)
const compressedMessages =
effectiveTransport === 'chat_completions' ||
effectiveTransport === 'responses' ||
effectiveTransport === 'responses_compat'
? fastPath.skipToolHistoryCompression
? fastPath.skipToolHistoryCompression || skipCompressionForPrefixCache
? rawMessages
: compressToolHistory(rawMessages, runtimeModel, {
textBlockSeparator:
@@ -301,7 +351,7 @@ export function prepareOpenAIRequest({
let responsesMessages: typeof compressedMessages | undefined
const getResponsesInput = () => {
responsesMessages ??= effectiveTransport === 'chat_completions'
? fastPath.skipToolHistoryCompression
? fastPath.skipToolHistoryCompression || skipCompressionForPrefixCache
? rawMessages
: compressToolHistory(rawMessages, request.resolvedModel, {
textBlockSeparator: '\n',
+70 -2
View File
@@ -18,6 +18,7 @@ import {
} from './conversationArc.js'
import { resetGlobalGraph } from './knowledgeGraph.js'
import { setClaudeConfigHomeDirForTesting } from './envUtils.js'
import { getIsInteractive, setIsInteractive } from '../bootstrap/state.js'
import {
acquireSharedMutationLock,
releaseSharedMutationLock,
@@ -39,6 +40,7 @@ const ARC_FILENAME = '.arc.json'
describe('conversationArc', () => {
let memDir: string
let configDir: string | undefined
let originalInteractive = false
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
beforeEach(async () => {
@@ -51,6 +53,10 @@ describe('conversationArc', () => {
resetGlobalGraph()
delete process.env.CLAUDE_CODE_DISABLE_AUTO_MEMORY
delete process.env.CLAUDE_CODE_SIMPLE
// Auto-memory defaults off in non-interactive sessions (which includes
// the test process); these tests exercise interactive-session behavior.
originalInteractive = getIsInteractive()
setIsInteractive(true)
// Disable memory-write approval for tests so arc persistence, fact
// extraction, and vector-index writes behave as they do in production
// when the policy is set to require-approval=false.
@@ -63,6 +69,7 @@ describe('conversationArc', () => {
try {
resetArc()
resetGlobalGraph()
setIsInteractive(originalInteractive)
setGovernancePolicySettingsForSourceForTesting(null)
if (originalConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
@@ -509,12 +516,13 @@ describe('conversationArc', () => {
}
})
it('regression: appends multi-turn context information when MULTI_TURN_CONTEXT feature is enabled', async () => {
it('regression: appends multi-turn context for completed turns when MULTI_TURN_CONTEXT feature is enabled', async () => {
process.env.MULTI_TURN_CONTEXT = 'true'
try {
const { startNewTurn, addMessageToTurn, addToolCallToTurn, resetMultiTurnState } = await import('./multiTurnContext.js')
resetMultiTurnState()
// First turn completes (a second turn starts), so it is renderable.
startNewTurn()
addMessageToTurn(createMessage('assistant', 'Running checks'))
addToolCallToTurn({
@@ -523,6 +531,13 @@ describe('conversationArc', () => {
input: { path: '/test.ts' },
timestamp: Date.now()
})
startNewTurn()
addToolCallToTurn({
id: 'call_current',
name: 'write_file',
input: { path: '/current.ts' },
timestamp: Date.now()
})
const autoMemDir = getAutoMemPath()
clearArcArtifacts(autoMemDir)
@@ -538,11 +553,64 @@ describe('conversationArc', () => {
expect(promptWithArc.length).toBe(mockSystemPrompt.length + 1)
const promptText = promptWithArc.join('\n')
expect(promptText).toContain('MULTI-TURN CONTEXT TRACKING')
expect(promptText).toContain('Total Turns: 1')
expect(promptText).toContain('Total Turns: 2')
expect(promptText).toContain('read_file')
// The in-progress turn is never rendered: its tool-call list grows
// between model requests and would bust the prompt cache.
expect(promptText).not.toContain('write_file')
expect(promptText).not.toContain('Duration:')
expect(promptText).not.toContain('Total Tokens:')
} finally {
delete process.env.MULTI_TURN_CONTEXT
}
})
it('regression: multi-turn context block is byte-stable across model requests within a turn', async () => {
process.env.MULTI_TURN_CONTEXT = 'true'
const realDateNow = Date.now
try {
const { startNewTurn, addToolCallToTurn, resetMultiTurnState } = await import('./multiTurnContext.js')
resetMultiTurnState()
startNewTurn()
addToolCallToTurn({
id: 'call_done',
name: 'read_file',
input: { path: '/done.ts' },
timestamp: Date.now()
})
startNewTurn()
const autoMemDir = getAutoMemPath()
clearArcArtifacts(autoMemDir)
mkdirSync(autoMemDir, { recursive: true })
initializeArc(autoMemDir)
const lastMessage = createMessage('user', 'continue')
const mockSystemPrompt = ['# System Instructions']
const { appendArcToSystemPrompt } = await import('./conversationArc.js')
Date.now = () => 1_000_000
const first = await appendArcToSystemPrompt(mockSystemPrompt, [lastMessage])
// A later model request in the SAME turn: the clock has advanced and
// the in-progress turn has picked up a tool call. The rendered system
// prompt must be byte-identical, or the prompt cache is busted.
Date.now = () => 9_000_000
addToolCallToTurn({
id: 'call_in_progress',
name: 'write_file',
input: { path: '/wip.ts' },
timestamp: Date.now()
})
const second = await appendArcToSystemPrompt(mockSystemPrompt, [lastMessage])
expect(second.join('\n')).toBe(first.join('\n'))
expect(first.join('\n')).not.toContain('Duration:')
} finally {
Date.now = realDateNow
delete process.env.MULTI_TURN_CONTEXT
}
})
})
})
+15 -6
View File
@@ -647,14 +647,21 @@ export async function appendArcToSystemPrompt(
let multiTurnContent = ''
if (feature('MULTI_TURN_CONTEXT') || (typeof process !== 'undefined' && process.env.MULTI_TURN_CONTEXT === 'true')) {
const { getMultiTurnStats, getRecentTurns } = await import('./multiTurnContext.js')
const { getMultiTurnStats, getRecentTurns, getCurrentTurn } = await import('./multiTurnContext.js')
const stats = getMultiTurnStats()
if (stats.totalTurns > 0) {
// Render only COMPLETED turns and no running token totals: the current
// turn's tool-call list grows with every model request, and the
// aggregate token counters change per request too. This block lands in
// the system prompt, so any per-request variation rewrites the prefix
// and busts the prompt cache. Total Turns changes once per turn, which
// keeps the prompt stable across the requests within a turn.
const currentTurn = getCurrentTurn()
const recent = getRecentTurns(4)
.filter(turn => turn !== currentTurn)
.slice(-3)
if (stats.totalTurns > 0 && recent.length > 0) {
multiTurnContent = '\n--- BEGIN MULTI-TURN CONTEXT TRACKING ---\n'
+ `Total Turns: ${stats.totalTurns}\n`
+ `Total Tokens: ${stats.totalTokens}\n`
+ `Average Tokens Per Turn: ${stats.avgTokensPerTurn}\n`
const recent = getRecentTurns(3)
const MAX_TOOL_INPUT_BYTES = 2000
const MAX_AGGREGATE_BYTES = 10000
let trimmedTurns = 0
@@ -667,8 +674,10 @@ export async function appendArcToSystemPrompt(
: redacted
return `${tc.name}(${truncated})`
}).join(', ') || 'None'
// No wall-clock-relative values here: "Ns ago" changes on every
// request, which rewrites the system prompt and busts the prompt
// cache upstream of the entire message history.
const turnStr = `- Turn ID: ${turn.turnId}\n`
+ ` Duration: ${Math.round((Date.now() - turn.startTime) / 1000)}s ago\n`
+ ` Tool Calls: ${toolCallsStr}\n`
if (Buffer.byteLength(multiTurnContent, 'utf8') + Buffer.byteLength(turnStr, 'utf8') > MAX_AGGREGATE_BYTES) {
trimmedTurns++
+107
View File
@@ -0,0 +1,107 @@
import { afterEach, beforeEach, expect, mock, test } from 'bun:test'
import { mkdirSync, mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { getCwdState, setCwdState } from '../bootstrap/state.js'
import { shouldIncludeGitInstructions } from './gitSettings.js'
const realSettings = (await import(
`./settings/settings.js?gitSettingsTestReal=${Date.now()}-${Math.random()}`
)) as typeof import('./settings/settings.js')
let originalCwdState: string
let originalEnv: string | undefined
let tempRoot: string
let settingsFixture: Record<string, unknown> = {}
beforeEach(() => {
originalCwdState = getCwdState()
originalEnv = process.env.CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS
delete process.env.CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS
tempRoot = mkdtempSync(join(tmpdir(), 'gitsettings-test-'))
settingsFixture = {}
mock.module('./settings/settings.js', () => ({
...realSettings,
getInitialSettings: () => settingsFixture,
}))
})
afterEach(() => {
setCwdState(originalCwdState)
if (originalEnv === undefined) {
delete process.env.CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS
} else {
process.env.CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS = originalEnv
}
rmSync(tempRoot, { recursive: true, force: true })
mock.module('./settings/settings.js', () => ({ ...realSettings }))
mock.restore()
})
test('omits git instructions outside a git repository', () => {
const plainDir = join(tempRoot, 'plain')
mkdirSync(plainDir)
setCwdState(plainDir)
expect(shouldIncludeGitInstructions()).toBe(false)
})
test('includes git instructions inside a git repository', () => {
const repoDir = join(tempRoot, 'repo')
mkdirSync(join(repoDir, '.git'), { recursive: true })
setCwdState(repoDir)
expect(shouldIncludeGitInstructions()).toBe(true)
})
test('includes git instructions in a subdirectory of a git repository', () => {
const repoDir = join(tempRoot, 'repo2')
mkdirSync(join(repoDir, '.git'), { recursive: true })
const subDir = join(repoDir, 'src', 'nested')
mkdirSync(subDir, { recursive: true })
setCwdState(subDir)
expect(shouldIncludeGitInstructions()).toBe(true)
})
test('follows the session cwd, not the process cwd (Bash cd / daemon safety)', () => {
const plainDir = join(tempRoot, 'plain-then-repo')
mkdirSync(plainDir)
const repoDir = join(tempRoot, 'repo3')
mkdirSync(join(repoDir, '.git'), { recursive: true })
setCwdState(plainDir)
expect(shouldIncludeGitInstructions()).toBe(false)
setCwdState(repoDir)
expect(shouldIncludeGitInstructions()).toBe(true)
})
test('explicit includeGitInstructions: true wins over the repo probe', () => {
const plainDir = join(tempRoot, 'plain-forced-on')
mkdirSync(plainDir)
setCwdState(plainDir)
settingsFixture = { includeGitInstructions: true }
expect(shouldIncludeGitInstructions()).toBe(true)
})
test('explicit includeGitInstructions: false wins inside a repository', () => {
const repoDir = join(tempRoot, 'repo-forced-off')
mkdirSync(join(repoDir, '.git'), { recursive: true })
setCwdState(repoDir)
settingsFixture = { includeGitInstructions: false }
expect(shouldIncludeGitInstructions()).toBe(false)
})
test('env kill switch wins even inside a git repository', () => {
const repoDir = join(tempRoot, 'repo4')
mkdirSync(join(repoDir, '.git'), { recursive: true })
setCwdState(repoDir)
process.env.CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS = '1'
expect(shouldIncludeGitInstructions()).toBe(false)
})
test('explicit CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS=0 forces instructions on outside a repository', () => {
const plainDir = join(tempRoot, 'plain-env-forced-on')
mkdirSync(plainDir)
setCwdState(plainDir)
process.env.CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS = '0'
// The defined-falsy env value short-circuits before repository detection.
expect(shouldIncludeGitInstructions()).toBe(true)
})
+14 -1
View File
@@ -6,13 +6,26 @@
// settings.ts → git/gitignore.ts → git.ts, so git.ts → settings.ts loops.
//
// If you're tempted to add `import settings` to git.ts — don't. Put it here.
// (Importing git.ts from here is fine — the dependency only flows one way.)
import { getCwd } from './cwd.js'
import { isEnvDefinedFalsy, isEnvTruthy } from './envUtils.js'
import { findGitRoot } from './git.js'
import { getInitialSettings } from './settings/settings.js'
export function shouldIncludeGitInstructions(): boolean {
const envVal = process.env.CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS
if (isEnvTruthy(envVal)) return false
if (isEnvDefinedFalsy(envVal)) return true
return getInitialSettings().includeGitInstructions ?? true
// An explicit settings value always wins — it is the recourse for layouts
// the repo probe can't see (bare-repo checkouts with GIT_DIR/GIT_WORK_TREE,
// --separate-git-dir outside the cwd ancestry).
const configured = getInitialSettings().includeGitInstructions
if (configured !== undefined) return configured
// Default: ship the ~1.7k-token commit/PR protocol in the Bash tool
// description only when the session is inside a git repository — it is
// pure waste elsewhere. findGitRoot handles worktree/submodule .git files
// and is LRU-memoized; getCwd() tracks the session cwd (Bash `cd`,
// daemon/SDK sessions), which process.cwd() does not.
return findGitRoot(getCwd()) !== null
}