Skip to content

Commit 7beeb9c

Browse files
Merge pull request #1304 from universe-hcy/fix/cache-safe-params-slot
fix: 修复 lastCacheSafeParams 跨会话残留导致 post-turn fork 上下文污染
2 parents 52554d7 + 73a07c6 commit 7beeb9c

9 files changed

Lines changed: 147 additions & 22 deletions

File tree

biome.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"includes": [
1010
"**",
1111
"!!**/dist",
12+
"!!**/dist-stable",
1213
"!!**/.claude/workflows",
1314
"!!**/*.workflow.mjs"
1415
]

src/cli/print.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ import {
185185
logSuggestionSuppressed,
186186
type PromptVariant,
187187
} from 'src/services/PromptSuggestion/promptSuggestion.js'
188-
import { getLastCacheSafeParams } from 'src/utils/forkedAgent.js'
188+
import { getLastCacheSafeParams } from 'src/utils/cacheSafeParamsSlot.js'
189189
import { getAccountInformation } from 'src/utils/auth.js'
190190
import { OAuthService } from 'src/services/oauth/index.js'
191191
import { installOAuthTokens } from 'src/cli/handlers/auth.js'

src/commands/btw/btw.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ import type { Message } from '../../types/message.js';
1616
import { createAbortController } from '../../utils/abortController.js';
1717
import { saveGlobalConfig } from '../../utils/config.js';
1818
import { errorMessage } from '../../utils/errors.js';
19-
import { type CacheSafeParams, getLastCacheSafeParams } from '../../utils/forkedAgent.js';
19+
import { getLastCacheSafeParams } from '../../utils/cacheSafeParamsSlot.js';
20+
import type { CacheSafeParams } from '../../utils/forkedAgent.js';
2021
import { getMessagesAfterCompactBoundary } from '../../utils/messages.js';
2122
import type { ProcessUserInputContext } from '../../utils/processUserInput/processUserInput.js';
2223
import { runSideQuestion } from '../../utils/sideQuestion.js';

src/commands/clear/conversation.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
import { isLocalShellTask } from '../../tasks/LocalShellTask/guards.js'
3030
import { asAgentId } from '../../types/ids.js'
3131
import type { Message } from '../../types/message.js'
32+
import { saveCacheSafeParams } from '../../utils/cacheSafeParamsSlot.js'
3233
import { createEmptyAttributionState } from '../../utils/commitAttribution.js'
3334
import type { FileStateCache } from '../../utils/fileStateCache.js'
3435
import {
@@ -156,6 +157,12 @@ export async function clearConversation({
156157
setLastClassifierRequests(null)
157158
resetCostState()
158159

160+
// Drop the post-turn CacheSafeParams snapshot: it holds the pre-clear
161+
// conversation's full message history. The session-id check in
162+
// getLastCacheSafeParams would reject it anyway after regenerateSessionId
163+
// below, but clearing here releases the memory immediately.
164+
saveCacheSafeParams(null)
165+
159166
setCwd(getOriginalCwd())
160167
readFileState.clear()
161168
discoveredSkillNames?.clear()

src/commands/recap/generateRecap.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,8 @@
1212

1313
import { APIUserAbortError } from '@anthropic-ai/sdk'
1414
import { logForDebugging } from '../../utils/debug.js'
15-
import {
16-
getLastCacheSafeParams,
17-
runForkedAgent,
18-
} from '../../utils/forkedAgent.js'
15+
import { getLastCacheSafeParams } from '../../utils/cacheSafeParamsSlot.js'
16+
import { runForkedAgent } from '../../utils/forkedAgent.js'
1917
import {
2018
createUserMessage,
2119
getAssistantMessageText,

src/query/stopHooks.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,8 @@ import type { QuerySource } from '../constants/querySource.js'
4949
import { executeAutoDream } from '../services/autoDream/autoDream.js'
5050
import { executePromptSuggestion } from '../services/PromptSuggestion/promptSuggestion.js'
5151
import { isBareMode, isEnvDefinedFalsy } from '../utils/envUtils.js'
52-
import {
53-
createCacheSafeParams,
54-
saveCacheSafeParams,
55-
} from '../utils/forkedAgent.js'
52+
import { saveCacheSafeParams } from '../utils/cacheSafeParamsSlot.js'
53+
import { createCacheSafeParams } from '../utils/forkedAgent.js'
5654

5755
type StopHookResult = {
5856
blockingErrors: Message[]
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* The slot holds the last main-session turn's CacheSafeParams (including the
3+
* full conversation history in forkContextMessages). It must never survive a
4+
* conversation boundary: /clear regenerates the session id and in-process
5+
* /resume switches it — in both cases a post-turn fork (/recap, SDK
6+
* side_question, SDK promptSuggestion) reading a stale snapshot would send
7+
* the previous conversation's history to the API.
8+
*
9+
* Uses the real bootstrap/state module (no mocks): regenerateSessionId and
10+
* switchSession are exactly what /clear and /resume call in production.
11+
*/
12+
import { beforeEach, describe, expect, test } from 'bun:test'
13+
import {
14+
getSessionId,
15+
regenerateSessionId,
16+
switchSession,
17+
} from 'src/bootstrap/state.js'
18+
import { asSessionId } from 'src/types/ids.js'
19+
import {
20+
getLastCacheSafeParams,
21+
saveCacheSafeParams,
22+
} from 'src/utils/cacheSafeParamsSlot.js'
23+
import type { CacheSafeParams } from 'src/utils/forkedAgent.js'
24+
25+
function makeParams(tag: string): CacheSafeParams {
26+
// Only identity matters for the slot; the shape is opaque to it.
27+
return { forkContextMessages: [tag] } as unknown as CacheSafeParams
28+
}
29+
30+
beforeEach(() => {
31+
saveCacheSafeParams(null)
32+
})
33+
34+
describe('saveCacheSafeParams / getLastCacheSafeParams', () => {
35+
test('returns the saved params within the same session', () => {
36+
const params = makeParams('same-session')
37+
saveCacheSafeParams(params)
38+
expect(getLastCacheSafeParams()).toBe(params)
39+
// Repeated reads keep returning it
40+
expect(getLastCacheSafeParams()).toBe(params)
41+
})
42+
43+
test('returns null when nothing was saved', () => {
44+
expect(getLastCacheSafeParams()).toBeNull()
45+
})
46+
47+
test('saving null clears the slot', () => {
48+
saveCacheSafeParams(makeParams('to-clear'))
49+
saveCacheSafeParams(null)
50+
expect(getLastCacheSafeParams()).toBeNull()
51+
})
52+
53+
test('a later save overwrites an earlier one', () => {
54+
saveCacheSafeParams(makeParams('first'))
55+
const second = makeParams('second')
56+
saveCacheSafeParams(second)
57+
expect(getLastCacheSafeParams()).toBe(second)
58+
})
59+
60+
test('snapshot is invalidated by /clear (regenerateSessionId)', () => {
61+
saveCacheSafeParams(makeParams('pre-clear'))
62+
regenerateSessionId()
63+
// Without the session-id check this returned the pre-clear conversation's
64+
// history, which /recap and SDK side_question then sent to the API.
65+
expect(getLastCacheSafeParams()).toBeNull()
66+
})
67+
68+
test('snapshot is invalidated by in-process /resume (switchSession)', () => {
69+
saveCacheSafeParams(makeParams('session-a'))
70+
switchSession(asSessionId('00000000-0000-4000-8000-00000000resu'))
71+
expect(getLastCacheSafeParams()).toBeNull()
72+
})
73+
74+
test('stale snapshot stays dropped even if the original session id returns', () => {
75+
const original = getSessionId()
76+
saveCacheSafeParams(makeParams('original-session'))
77+
switchSession(asSessionId('00000000-0000-4000-8000-0000000other'))
78+
expect(getLastCacheSafeParams()).toBeNull()
79+
// Switching back does not resurrect it — the read already released it.
80+
switchSession(original)
81+
expect(getLastCacheSafeParams()).toBeNull()
82+
})
83+
84+
test('a save after the session switch is valid for the new session', () => {
85+
saveCacheSafeParams(makeParams('old'))
86+
regenerateSessionId()
87+
const fresh = makeParams('new-session')
88+
saveCacheSafeParams(fresh)
89+
expect(getLastCacheSafeParams()).toBe(fresh)
90+
})
91+
})

src/utils/cacheSafeParamsSlot.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* Process-wide snapshot of the main loop's cache-safe params, written by
3+
* handleStopHooks after each main-session turn so post-turn forks
4+
* (/recap, SDK side_question, SDK promptSuggestion, /btw) can share the
5+
* parent's prompt cache without threading params through every caller.
6+
*
7+
* The snapshot is only valid for the conversation that wrote it. The session
8+
* id is captured at save time and checked at read time: /clear
9+
* (regenerateSessionId) and in-process /resume (switchSession) both change
10+
* the current session id, which invalidates the snapshot. Without the check,
11+
* a post-turn fork started after /clear or /resume would prepend the
12+
* previous conversation's full history (forkContextMessages) to its API
13+
* request and answer based on a conversation the user no longer sees.
14+
*
15+
* Kept in its own module (rather than forkedAgent.ts) so the slot can be
16+
* unit-tested without forkedAgent's heavy query-loop dependency chain.
17+
*/
18+
import { getSessionId } from '../bootstrap/state.js'
19+
import type { SessionId } from '../types/ids.js'
20+
import type { CacheSafeParams } from './forkedAgent.js'
21+
22+
let lastCacheSafeParams: CacheSafeParams | null = null
23+
let savedSessionId: SessionId | null = null
24+
25+
export function saveCacheSafeParams(params: CacheSafeParams | null): void {
26+
lastCacheSafeParams = params
27+
savedSessionId = params === null ? null : getSessionId()
28+
}
29+
30+
export function getLastCacheSafeParams(): CacheSafeParams | null {
31+
if (lastCacheSafeParams !== null && savedSessionId !== getSessionId()) {
32+
// Stale snapshot from a previous conversation — drop it eagerly so the
33+
// old message array can be GC'd instead of lingering until the next turn.
34+
lastCacheSafeParams = null
35+
savedSessionId = null
36+
}
37+
return lastCacheSafeParams
38+
}

src/utils/forkedAgent.ts

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -71,18 +71,9 @@ export type CacheSafeParams = {
7171
forkContextMessages: Message[]
7272
}
7373

74-
// Slot written by handleStopHooks after each turn so post-turn forks
75-
// (promptSuggestion, postTurnSummary, /btw) can share the main loop's
76-
// prompt cache without each caller threading params through.
77-
let lastCacheSafeParams: CacheSafeParams | null = null
78-
79-
export function saveCacheSafeParams(params: CacheSafeParams | null): void {
80-
lastCacheSafeParams = params
81-
}
82-
83-
export function getLastCacheSafeParams(): CacheSafeParams | null {
84-
return lastCacheSafeParams
85-
}
74+
// The post-turn snapshot slot (saveCacheSafeParams/getLastCacheSafeParams)
75+
// lives in cacheSafeParamsSlot.ts — it is session-scoped and kept separate
76+
// so it can be unit-tested without this module's query-loop dependencies.
8677

8778
export type ForkedAgentParams = {
8879
/** Messages to start the forked query loop with */

0 commit comments

Comments
 (0)