fix(core): close the round-2 review findings on the deferred-tool rules PR

- session-switch.ts: re-initialize the client in the opentui /resume
  rollback, mirroring the other three rollback routes, so the restored
  session's reviewed-schema evidence is rebuilt (R2-14).
- tool-registry.ts: syncReviewedDeclarations keeps a reviewed tool armed
  when its tool_search block is present but mid-JSON truncated — the name
  is recovered from the block head and the in-memory review carries over;
  blocks absent from history entirely are still forgotten (qqqys R2-2/R2-4).
- extract.ts: back the pending-window cut off to a real turn boundary so a
  model functionCall is never split from its functionResponse (R2-12).
- extractionAgentPlanner.ts: curate the pending window through
  extractCuratedHistory (dropping degraded placeholder turns), and stamp a
  historical window with windowAsOf so the prompt drops the recency wording
  and the anchor-to-today date claim (R2-5).

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
易良 2026-10-01 20:22:58 +08:00
parent 778610a092
commit ffd9df8928
10 changed files with 415 additions and 19 deletions

View file

@ -249,6 +249,30 @@ describe('handleResumeSession', () => {
);
});
it('re-initializes the client when rolling back after the core swap', async () => {
// The forward initialize succeeds; the failure lands at the transcript
// reset (after the core swap, before the UI commit). The rollback's
// startNewSession clears the reviewed-schema evidence, so the client
// must be re-initialized against the restored session — otherwise the
// next hidden deferred call is refused even though its tool_search
// block is still in context. Removing the rollback's initialize() reds
// this: the count stays at the forward call alone.
const { config, calls } = createFakeConfig();
const host = createFakeHost(config);
vi.mocked(host.resetTranscript).mockImplementation(() => {
throw new Error('transcript boom');
});
await handleResumeSession(host, 'target-session');
expect(calls.startNewSession.map(([id]) => id)).toEqual([
'target-session',
'old-session',
]);
expect(calls.clientInitialize).toBe(2);
expect(calls.swapAbort).toBe(1);
});
it('blocks the switch while background work is running', async () => {
const { config, calls } = createFakeConfig();
const registry = config.getBackgroundTaskRegistry() as unknown as {

View file

@ -207,6 +207,17 @@ export async function handleResumeSession(
resetBackgroundStateForSessionSwitch(config);
config.startNewSession(oldSessionId, undefined);
await config.loadPausedBackgroundAgents(oldSessionId).catch(() => {});
// Re-hydrate the client against the restored session, mirroring the
// other three rollback routes (useResumeCommand, useBranchCommand,
// the TUI session-switch): startNewSession cleared the reviewed-schema
// evidence, and without a fresh startChat the chat keeps serving the
// abandoned session's replayed history, so the next hidden deferred
// call is refused as un-reviewed even though its tool_search block is
// still in context. Must run BEFORE abortTelemetrySwap below — the
// re-initialize replays the old session's history on top of the
// abandoned session's replay, and restore overwrites rather than
// subtracts, so the final state is exactly pre-swap (#9833).
await config.getGeminiClient()?.initialize?.();
} catch (rollbackErr) {
config
.getDebugLogger()

View file

@ -1595,7 +1595,9 @@ function validateHistory(history: Content[]) {
* filters or recitation). Extracting valid turns from the history
* ensures that subsequent requests could be accepted by the model.
*/
function extractCuratedHistory(comprehensiveHistory: Content[]): Content[] {
export function extractCuratedHistory(
comprehensiveHistory: Content[],
): Content[] {
if (comprehensiveHistory === undefined || comprehensiveHistory.length === 0) {
return [];
}

View file

@ -259,22 +259,113 @@ describe('auto-memory extraction', () => {
const first = await runAutoMemoryExtract({ ...params, history });
const boundary = prefix.length + CACHE_SAFE_HISTORY_TAIL_ENTRIES;
// The naive boundary lands on the open functionCall at i=38 (the skipped
// fact shifted the alternating pairs by one), so the cut retreats one
// entry: the window ends before the open call and the cursor stops
// there, keeping the call and its response together for the next run.
const aligned = boundary - 1;
// The aligned window does not reach the end of history, so the run is
// stamped as a historical segment for the planner prompt.
expect(runAutoMemoryExtractionByAgent).toHaveBeenLastCalledWith(
mockConfig,
projectRoot,
history.slice(prefix.length, boundary),
history.slice(prefix.length, aligned),
{ windowAsOf: expect.any(String) },
);
expect(first.cursor.processedOffset).toBe(boundary);
expect(first.cursor.processedOffset).toBe(aligned);
const persisted = JSON.parse(
await fs.readFile(getAutoMemoryExtractCursorPath(projectRoot), 'utf-8'),
);
expect(persisted.processedOffset).toBe(boundary);
expect(persisted.processedOffset).toBe(aligned);
const second = await runAutoMemoryExtract({ ...params, history });
expect(runAutoMemoryExtractionByAgent).toHaveBeenLastCalledWith(
mockConfig,
projectRoot,
history.slice(boundary),
history.slice(aligned),
undefined,
);
expect(second.cursor.processedOffset).toBe(history.length);
});
it('backs the pending-window cut off a model functionCall so the response is never orphaned', async () => {
// A plain-index cut can land between a model functionCall and its
// functionResponse: the trailing repair would fabricate a response
// reusing the real call's id, and the next window would open on the
// orphaned true output, which no run ever sees. The cut must retreat to
// the turn boundary: the window ends before the open call, the cursor
// stops there, and the next run's window starts on it.
vi.mocked(runAutoMemoryExtractionByAgent).mockResolvedValue({
touchedTopics: [],
touchedProjectScope: false,
touchedUserScope: false,
hasToolActivity: true,
});
const params = {
projectRoot,
sessionId: 'session-1',
config: mockConfig,
preserveUnprocessedHistory: true,
};
const prefix: Content[] = [
{ role: 'user', parts: [{ text: 'Already processed.' }] },
];
await runAutoMemoryExtract({ ...params, history: prefix });
// Entry at the naive cut (startOffset + CACHE_SAFE_HISTORY_TAIL_ENTRIES)
// is a model functionCall; its response follows it.
const boundary = prefix.length + CACHE_SAFE_HISTORY_TAIL_ENTRIES;
const history: Content[] = [
...prefix,
{ role: 'user', parts: [{ text: 'Pending user fact.' }] },
...Array.from(
{ length: CACHE_SAFE_HISTORY_TAIL_ENTRIES - 2 },
(_, i): Content => ({
role: 'model',
parts: [{ text: `Filler turn ${i}.` }],
}),
),
{
role: 'model',
parts: [
{ functionCall: { id: 'call-open', name: 'read_file', args: {} } },
],
},
{
role: 'user',
parts: [
{
functionResponse: {
id: 'call-open',
name: 'read_file',
response: { output: 'the real file bytes' },
},
},
],
},
{ role: 'user', parts: [{ text: 'A durable fact in the remainder.' }] },
];
expect(history[boundary - 1]?.parts?.[0]).toHaveProperty('functionCall');
const first = await runAutoMemoryExtract({ ...params, history });
// The cut retreated one entry: the window ends before the open call.
// That window is a historical segment, so the planner gets windowAsOf.
expect(runAutoMemoryExtractionByAgent).toHaveBeenLastCalledWith(
mockConfig,
projectRoot,
history.slice(prefix.length, boundary - 1),
{ windowAsOf: expect.any(String) },
);
expect(first.cursor.processedOffset).toBe(boundary - 1);
const second = await runAutoMemoryExtract({ ...params, history });
// The next window opens on the call, so call and response are extracted
// together — and it reaches the end of history, so no segment stamp.
expect(runAutoMemoryExtractionByAgent).toHaveBeenLastCalledWith(
mockConfig,
projectRoot,
history.slice(boundary - 1),
undefined,
);
expect(second.cursor.processedOffset).toBe(history.length);
});
@ -332,10 +423,13 @@ describe('auto-memory extraction', () => {
);
const first = await runAutoMemoryExtract({ ...params, history });
// The first window does not reach the end of history, so it is stamped
// as a historical segment for the planner prompt.
expect(runAutoMemoryExtractionByAgent).toHaveBeenLastCalledWith(
mockConfig,
projectRoot,
history.slice(0, CACHE_SAFE_HISTORY_TAIL_ENTRIES),
{ windowAsOf: expect.any(String) },
);
// No genuine progress, but the windowed arm must still advance: holding
// the cursor at startOffset recomputes a byte-identical slice next turn

View file

@ -75,6 +75,38 @@ function getSessionMismatchResult(
};
}
/**
* Cut-safety predicates for the pending-window boundary. A cut is unsafe when
* the last included entry is a model entry carrying a functionCall (its
* response falls outside the window and the trailing repair would fabricate
* one reusing the real call's id), or when the next entry is a user entry
* that is all functionResponse parts (the next window would open on an
* orphaned response whose call sits in already-processed history, and no
* extraction run would ever see it — the cursor only advances).
*/
function endsOnOpenCall(
history: readonly Content[],
endOffset: number,
): boolean {
const last = history[endOffset - 1];
return (
last?.role === 'model' &&
(last.parts ?? []).some((part) => part.functionCall)
);
}
function opensOnOrphanedResponse(
history: readonly Content[],
endOffset: number,
): boolean {
const next = history[endOffset];
return (
next?.role === 'user' &&
(next.parts ?? []).length > 0 &&
(next.parts ?? []).every((part) => part.functionResponse)
);
}
async function readExtractCursor(
projectRoot: string,
): Promise<AutoMemoryExtractCursor> {
@ -182,12 +214,30 @@ export async function runAutoMemoryExtract(params: {
// With turn-skipping enabled, a large ending turn can evict skipped facts
// from the usual tail. Process the oldest pending window instead, and never
// mark its unseen remainder as processed.
const endOffset = params.preserveUnprocessedHistory
let endOffset = params.preserveUnprocessedHistory
? Math.min(
params.history.length,
startOffset + CACHE_SAFE_HISTORY_TAIL_ENTRIES,
)
: params.history.length;
// A plain index can split a model functionCall from its functionResponse:
// the trailing repair would fabricate a response reusing the real call's
// id, and the next window would open on the orphaned true output, which no
// extraction run ever sees (the cursor only advances). Back the cut off to
// a real turn boundary — but only while the window does not already reach
// the end of history (a session ending on an open call must still be able
// to arm the no-op cooldown at processedOffset === history.length), and
// never past startOffset (a zero-length window still writes the cursor).
if (params.preserveUnprocessedHistory) {
while (
endOffset < params.history.length &&
endOffset > startOffset &&
(endsOnOpenCall(params.history, endOffset) ||
opensOnOrphanedResponse(params.history, endOffset))
) {
endOffset--;
}
}
const pendingHistory = params.history.slice(startOffset, endOffset);
// Skip if there are no new, non-empty user messages in the unprocessed
@ -218,6 +268,14 @@ export async function runAutoMemoryExtract(params: {
params.config,
params.projectRoot,
params.preserveUnprocessedHistory ? pendingHistory : undefined,
// A pending window that does not reach the end of history is a
// historical segment: the planner drops the recency wording and the
// anchor-to-today date claim, which would otherwise convert the
// segment's relative dates against the drain day. The previous cursor's
// write time is the nearest proxy for the segment's own date.
endOffset < params.history.length
? { windowAsOf: currentCursor.updatedAt }
: undefined,
);
if (agentResult.touchedTopics.length > 0) {

View file

@ -87,10 +87,13 @@ describe('auto-memory extraction with agent planner', () => {
});
expect(result.touchedTopics).toEqual(['user']);
// The cached-tail path passes no window stamp: history stays undefined
// and the planner keeps the current-tail prompt wording.
expect(runAutoMemoryExtractionByAgent).toHaveBeenCalledWith(
mockConfig,
projectRoot,
undefined,
undefined,
);
const docs = await scanAutoMemoryTopicDocuments(projectRoot);

View file

@ -171,6 +171,56 @@ describe('runAutoMemoryExtractionByAgent', () => {
]);
});
it('curates degraded placeholder turns out of the pending window', async () => {
// The window comes from raw history; the cached tail was curated. Without
// the same curation the extractor is told the assistant said
// "(request timeout)" and may write a durable memory from it.
vi.mocked(runForkedAgent).mockResolvedValue({
status: 'completed',
finalText: '',
filesTouched: [],
filesWritten: [],
});
const pending: Content[] = [
{ role: 'user', parts: [{ text: 'Remember: I prefer pnpm.' }] },
{ role: 'model', parts: [{ text: '(request timeout)' }] },
{ role: 'model', parts: [{ text: 'Noted.' }] },
];
await runAutoMemoryExtractionByAgent(mockConfig, '/tmp', pending);
const extraHistory =
vi.mocked(runForkedAgent).mock.calls[0]?.[0].extraHistory;
expect(JSON.stringify(extraHistory)).not.toContain('(request timeout)');
expect(JSON.stringify(extraHistory)).toContain('Noted.');
});
it('drops the recency and today-anchoring claims for a historical window', async () => {
// While the cursor is behind, the window is a historical segment: the
// prompt must not claim it is the recent tail nor anchor its relative
// dates to today (nothing on this path carries the segment's own date).
vi.mocked(runForkedAgent).mockResolvedValue({
status: 'completed',
finalText: '',
filesTouched: [],
filesWritten: [],
});
const pending: Content[] = [
{ role: 'user', parts: [{ text: 'We hit this yesterday.' }] },
{ role: 'model', parts: [{ text: 'Noted.' }] },
];
await runAutoMemoryExtractionByAgent(mockConfig, '/tmp', pending, {
windowAsOf: '2026-09-28T10:00:00.000Z',
});
const prompt = vi.mocked(runForkedAgent).mock.calls[0]?.[0].taskPrompt;
expect(prompt).not.toContain("Today's date is");
expect(prompt).not.toContain('recent conversation history');
expect(prompt).toContain('earlier part of the session');
expect(prompt).toContain('2026-09-28T10:00:00.000Z');
});
it('strips runtime reminders and hidden reasoning from inherited history', async () => {
vi.mocked(getCacheSafeParams).mockReturnValue({
generationConfig: {},

View file

@ -8,6 +8,7 @@ import type { Config } from '../config/config.js';
import { createDebugLogger } from '../utils/debugLogger.js';
import { runForkedAgent, getCacheSafeParams } from '../agents/forkedAgent.js';
import { slimCompactionInput } from '../services/compactionInputSlimming.js';
import { extractCuratedHistory } from '../core/llm-chat.js';
import { buildFunctionResponseParts } from '../tools/agent/fork-subagent.js';
import type { Content } from '@google/genai';
import {
@ -43,7 +44,7 @@ const debugLogger = createDebugLogger('AUTO_MEMORY_EXTRACTION_AGENT');
const EXTRACTION_AGENT_SYSTEM_PROMPT = [
'You are now acting as the managed memory extraction subagent for an AI coding assistant.',
'',
'The recent conversation history is already in your context. Analyze only that recent conversation and use it to update persistent managed memory.',
'The conversation history is already in your context. Analyze only that conversation and use it to update persistent managed memory.',
'',
'Rules:',
'- Read existing memory files first to avoid creating duplicates.',
@ -188,25 +189,35 @@ function buildTaskPrompt(
userMemoryRoot: string,
topicSummaries: string,
keywordVocabularySnapshot: string,
windowAsOf?: string,
): string {
return [
'Managed memory has TWO directories. Choose which one to write each memory into using the per-type `<scope>` guidance in your system instructions:',
`- USER memory (cross-project, durable knowledge about who the user is): \`${userMemoryRoot}\``,
`- PROJECT memory (this project only): \`${projectMemoryRoot}\``,
'',
'Scan the recent conversation history in your context and update durable managed memory in whichever directory each memory belongs.',
windowAsOf
? 'Scan the conversation history segment in your context and update durable managed memory in whichever directory each memory belongs.'
: 'Scan the recent conversation history in your context and update durable managed memory in whichever directory each memory belongs.',
'',
// Inherited history is scrubbed of `<system-reminder>` blocks above, and
// those are the only place a date reaches the model (the startup prelude
// and the per-turn refresh). Passing extraHistory also suppresses the
// fork's own env bootstrap, so the date has to be stated here or the
// "convert relative dates to absolute ones" instruction is unanswerable.
`Today's date is ${formatDateForContext()} — use it to turn any relative date in the history into an absolute one before saving.`,
// A historical segment (the pending window while the cursor is behind)
// must NOT be anchored to today: nothing on this path carries the
// window's own date, and `windowAsOf` is the previous cursor's write
// time — only a proxy, so the instruction drops the conversion claim
// rather than re-anchoring to it.
windowAsOf
? `This segment is from an earlier part of the session (last processed around ${windowAsOf}) — do not convert its relative dates against today; keep them relative or omit them.`
: `Today's date is ${formatDateForContext()} — use it to turn any relative date in the history into an absolute one before saving.`,
'',
'Available tools in this run: `read_file`, `grep_search`, `glob`, and `write_file`/`edit` for paths inside EITHER managed memory directory above.',
'- Do not use any other tools.',
'- You have a limited turn budget. `edit` requires a prior `read_file` of the same file, so the efficient strategy is: first issue all reads in parallel for every file you might update; then issue all `write_file`/`edit` calls in parallel. Do not interleave reads and writes across multiple turns.',
'- You MUST only use content from the recent conversation history in your context plus the current managed memory files.',
'- You MUST only use content from the conversation history in your context plus the current managed memory files.',
'- Do not inspect repository code, git history, or unrelated files.',
`- Treat files under the top-level \`${AUTO_MEMORY_PINNED_DIRNAME}/\` directory in either managed memory root as protected read-only records. You may read them to avoid duplicates, but never modify, overwrite, rename, merge into, or delete them, and do not intentionally remove their valid entries from \`${AUTO_MEMORY_INDEX_FILENAME}\`.`,
'- Prefer updating an existing writable memory file over creating a duplicate. Check both directories for an existing entry before creating a new one.',
@ -300,6 +311,14 @@ export async function runAutoMemoryExtractionByAgent(
config: Config,
projectRoot: string,
history?: Content[],
opts?: {
/**
* Set when `history` is the pending window and the cursor is behind: the
* previous cursor's write time, the nearest proxy for the window's own
* date. Drops the recency wording and the anchor-to-today date claim.
*/
windowAsOf?: string;
},
): Promise<AutoMemoryExtractionExecutionResult> {
const cacheSafe = getCacheSafeParams(config.getSessionId());
if (!cacheSafe) {
@ -308,9 +327,14 @@ export async function runAutoMemoryExtractionByAgent(
'extraction must run after a completed main turn.',
);
}
// The pending window comes from raw history: curate it the way the cached
// tail is curated, or degraded placeholder turns (e.g. "(request
// timeout)") reach the extractor as something the assistant said.
const inputHistory = history
? slimCompactionInput(history, config.getEffectiveInputModalities())
.slimmedHistory
? slimCompactionInput(
extractCuratedHistory(history),
config.getEffectiveInputModalities(),
).slimmedHistory
: cacheSafe.history;
const extraHistory = buildAgentHistory(inputHistory);
@ -338,6 +362,7 @@ export async function runAutoMemoryExtractionByAgent(
userMemoryRoot,
topicSummaries,
keywordVocabularySnapshot,
opts?.windowAsOf,
),
systemPrompt: EXTRACTION_AGENT_SYSTEM_PROMPT,
maxTurns: config.getMemoryAgentMaxTurns() ?? 5,

View file

@ -1036,15 +1036,36 @@ export class ToolRegistry {
this.reviewedDeferredDeclarations.clear();
}
/** Rebuilds reviews from schemas actually retained in the primary history. */
/**
* Rebuilds reviews from schemas actually retained in the primary history.
*
* Replacement is the contract for history-replacement events: when a
* compaction or truncation removes the tool_search block entirely, the
* model can no longer see the schema, so the review must be forgotten.
* The one exception is a block that is still present but unreadable —
* model-facing truncation can cut a tool_search response mid-JSON, which
* fails the parse without meaning the review never happened: the model
* did receive the full schema when the response arrived. Such a name is
* recovered from the block's intact head and its in-memory review carries
* over; a name absent from every tool_search block in history is still
* forgotten.
*/
syncReviewedDeclarations(history: readonly Content[]): void {
const reviewed = new Map<string, string>();
const derived = new Map<string, string>();
// Names mentioned by a tool_search block, recoverable from the block's
// head even when truncation cut the JSON tail.
const mentioned = new Set<string>();
for (const entry of history) {
for (const part of entry.parts ?? []) {
const response = part.functionResponse;
if (response?.name !== ToolNames.TOOL_SEARCH) continue;
const output = response.response?.['output'];
if (typeof output !== 'string') continue;
for (const mention of output.matchAll(
/<function>\s*\{\s*"name"\s*:\s*"([^"]+)"/gs,
)) {
mentioned.add(mention[1]!);
}
for (const match of output.matchAll(/<function>(.*?)<\/function>/gs)) {
try {
const { name, parametersJsonSchema, serverName } = JSON.parse(
@ -1054,10 +1075,10 @@ export class ToolRegistry {
const suffix = `\u0000${name}\u0000${JSON.stringify(parametersJsonSchema)}`;
const previous = this.reviewedDeferredDeclarations.get(name);
if (typeof serverName === 'string') {
reviewed.set(name, `${serverName}${suffix}`);
derived.set(name, `${serverName}${suffix}`);
} else if (previous?.endsWith(suffix)) {
// Old transcripts did not serialize the MCP server identity.
reviewed.set(name, previous);
derived.set(name, previous);
} else {
// Legacy block without server provenance, and no in-memory
// review to re-adopt (fresh process, or a cleared map).
@ -1076,10 +1097,10 @@ export class ToolRegistry {
// changed schema must keep refusing.
const liveFingerprint = deferredDeclarationFingerprint(live);
if (liveFingerprint === `${live.serverName}${suffix}`) {
reviewed.set(name, liveFingerprint);
derived.set(name, liveFingerprint);
}
} else {
reviewed.set(name, suffix);
derived.set(name, suffix);
}
}
} catch {
@ -1088,7 +1109,16 @@ export class ToolRegistry {
}
}
}
this.reviewedDeferredDeclarations = reviewed;
for (const name of mentioned) {
if (derived.has(name)) continue;
// Present but unreadable (truncated mid-block): keep the review the
// model genuinely received rather than disarming the tool.
const existing = this.reviewedDeferredDeclarations.get(name);
if (existing !== undefined) {
derived.set(name, existing);
}
}
this.reviewedDeferredDeclarations = derived;
}
/**

View file

@ -832,6 +832,105 @@ describe('ToolSearchTool', () => {
).resolves.toMatchObject({ tool: mcpTool });
});
it('keeps a reviewed tool armed when its tool_search block is truncated mid-JSON', async () => {
// Model-facing truncation can cut a tool_search response inside a
// <function> block: the entry stays in history but the JSON no longer
// parses. The review did happen — the model received the full schema
// when the response arrived — so the tool must stay callable. Red when
// the sync goes back to deriving only from parseable blocks.
const hidden = new MockTool({
name: 'cron_list',
shouldDefer: true,
// A real schema so the serialized block carries parametersJsonSchema
// for the truncation cut to land inside the JSON payload.
params: { type: 'object', properties: { target: { type: 'string' } } },
});
registry.registerTool(hidden);
const history = await searchHistory(hidden.name);
registry.syncReviewedDeclarations(history);
expect(registry.getReviewedDeclaration(hidden.name)).toBe(
deferredDeclarationFingerprint(hidden),
);
// Cut the response output inside the JSON payload: the <function> head
// (and the tool's name) survives, the schema tail is gone.
const truncated = history.map((entry) => ({
...entry,
parts: (entry.parts ?? []).map((part) => {
if (!part.functionResponse) return part;
const output = part.functionResponse.response?.['output'];
if (typeof output !== 'string') return part;
const cut = output.indexOf('"parametersJsonSchema"');
return {
...part,
functionResponse: {
...part.functionResponse,
response: { output: output.slice(0, cut) },
},
};
}),
}));
registry.syncReviewedDeclarations(truncated);
expect(registry.getReviewedDeclaration(hidden.name)).toBe(
deferredDeclarationFingerprint(hidden),
);
await expect(
resolveDeferredToolCall(registry, {
name: hidden.name,
arguments: {},
}),
).resolves.toMatchObject({ tool: hidden });
});
it('still forgets a review when its tool_search block leaves the history entirely', async () => {
// The carry-over is for present-but-unreadable evidence only: an empty
// retained window means the model can no longer see the schema at all,
// so the review is forgotten (the truncate/orphan/clear pins' contract).
const hidden = new MockTool({ name: 'cron_list', shouldDefer: true });
registry.registerTool(hidden);
registry.syncReviewedDeclarations(await searchHistory(hidden.name));
expect(registry.getReviewedDeclaration(hidden.name)).toBeDefined();
registry.syncReviewedDeclarations([]);
expect(registry.getReviewedDeclaration(hidden.name)).toBeUndefined();
await expect(
resolveDeferredToolCall(registry, {
name: hidden.name,
arguments: {},
}),
).resolves.toMatchObject({ errorType: ToolErrorType.INVALID_TOOL_PARAMS });
});
it('overwrites a review when a fresher tool_search block re-derives one', async () => {
// The carry-over is not accumulation of stale entries: a new review of
// the same name re-derives and replaces the old fingerprint.
const hidden = new MockTool({
name: 'cron_list',
shouldDefer: true,
});
registry.registerTool(hidden);
registry.recordReviewedDeclaration(hidden);
const stale = registry.getReviewedDeclaration(hidden.name);
const changed = new MockTool({
name: 'cron_list',
shouldDefer: true,
description: 'a changed schema',
params: {
type: 'object',
properties: { extra: { type: 'string' } },
},
});
registry.registerTool(changed);
registry.syncReviewedDeclarations(await searchHistory(changed.name));
const fresh = registry.getReviewedDeclaration(changed.name);
expect(fresh).toBe(deferredDeclarationFingerprint(changed));
expect(fresh).not.toBe(stale);
});
it('leaves a legacy serverName-less block unreviewed while its tool is not registered', () => {
// Progressive MCP discovery registers tools after a resumed chat's first
// sync. Recording the bare suffix here would refuse every later call as