Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions packages/core/src/core/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,68 @@ describe('Gemini Client (client.ts)', () => {
expect(resumedClient.getChat().getLastPromptTokenCount()).toBe(123_456);
});

it('seeds recently completed tools from resumed history', async () => {
vi.mocked(mockConfig.getResumedSessionData).mockReturnValue({
conversation: {
sessionId: 'resumed-session-id',
projectHash: 'project-hash',
startTime: new Date(0).toISOString(),
lastUpdated: new Date(0).toISOString(),
messages: [
{
message: {
role: 'model',
parts: [
{
functionCall: {
id: 'call_read',
name: 'read_file',
args: {},
},
},
],
},
},
{
message: {
role: 'user',
parts: [
{
functionResponse: {
id: 'call_read',
name: 'read_file',
response: { ok: true },
},
},
],
},
},
{
message: {
role: 'model',
parts: [
{
functionCall: {
id: 'call_pending',
name: 'write_file',
args: {},
},
},
],
},
},
],
},
filePath: '/test/session.jsonl',
lastCompletedUuid: null,
} as unknown as ReturnType<Config['getResumedSessionData']>);

const resumedClient = new GeminiClient(mockConfig);
await resumedClient.initialize();

expect(resumedClient['recentCompletedToolNames']).toEqual(['read_file']);
});

it('uses Startup SessionStart source for non-resumed initialize without explicit source', async () => {
const hookSystem = {
fireSessionStartEvent: vi.fn().mockResolvedValue(
Expand Down Expand Up @@ -1645,6 +1707,14 @@ describe('Gemini Client (client.ts)', () => {

expect(client['lastHookMicrocompactionTimestamp']).toBeNull();
});

it('clears recently completed tools', async () => {
client.recordCompletedToolCall('read_file');

await client.resetChat();

expect(client['recentCompletedToolNames']).toEqual([]);
});
});

describe('history mutation invalidates FileReadCache', () => {
Expand Down Expand Up @@ -3630,6 +3700,7 @@ hello
getHistory: vi.fn().mockReturnValue([]),
};
client['chat'] = mockChat as GeminiChat;
client.recordCompletedToolCall('mcp__ata__article-list-query');

const stream = client.sendMessageStream(
[{ text: 'Please answer tersely' }],
Expand All @@ -3646,6 +3717,7 @@ hello
expect.objectContaining({
config: mockConfig,
excludedFilePaths: expect.any(Set),
recentTools: ['mcp__ata__article-list-query'],
}),
);
expect(mockTurnRunFn).toHaveBeenCalledWith(
Expand Down
51 changes: 50 additions & 1 deletion packages/core/src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ import { type File, type IdeContext } from '../ide/types.js';
import { PermissionMode, type StopHookOutput } from '../hooks/types.js';

const MAX_TURNS = 100;
const MAX_RECENT_TOOL_NAMES_FOR_MEMORY = 20;

export enum SendMessageType {
UserQuery = 'userQuery',
Expand Down Expand Up @@ -211,6 +212,7 @@ export class GeminiClient {
private lastPromptId: string | undefined = undefined;
private lastSentIdeContext: IdeContext | undefined;
private forceFullIdeContext = true;
private recentCompletedToolNames: string[] = [];
private pendingMemoryPrefetch: MemoryPrefetchHandle | undefined;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] resetChat() (line ~628) does not clear recentCompletedToolNames. Every other piece of per-conversation state is explicitly reset there (surfacedRelevantAutoMemoryPaths, cachedGitStatus, lastApiCompletionTimestamp, file read cache, deferred tools, etc.), but this new field was omitted.

After a /clear, stale tool names from the previous conversation persist and continue to drive the isActiveToolUsageMemory filter — ephemeral tool-schema memories for tools used in the old conversation will be incorrectly suppressed in the new one.

Suggested change
private pendingMemoryPrefetch: MemoryPrefetchHandle | undefined;
private recentCompletedToolNames: string[] = [];
// NOTE: also reset in resetChat()

— qwen3.7-max via Qwen Code /review

private lastSessionStartContext: string | undefined;
private lastSessionStartSource: SessionStartSource | undefined;
Expand Down Expand Up @@ -286,12 +288,16 @@ export class GeminiClient {
// Check if we're resuming from a previous session
const resumedSessionData = this.config.getResumedSessionData();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] When a session is resumed (resumedSessionData is truthy), the code replays UI telemetry and rebuilds API history but never extracts tool-call names to seed recentCompletedToolNames. The field stays at [] until the first new tool call completes, so the active-tool filter is inert during the resumed session's first query — which is the query most likely to benefit from memory recall.

Consider walking resumedSessionData.conversation.messages after buildApiHistoryFromConversation to extract the last N assistant functionCall.name values and assign them to this.recentCompletedToolNames.

— qwen3.7-max via Qwen Code /review

if (resumedSessionData) {
replayUiTelemetryFromConversation(resumedSessionData.conversation, this.config.getSessionId());
replayUiTelemetryFromConversation(
resumedSessionData.conversation,
this.config.getSessionId(),
);
// Convert resumed session to API history format
// Each ChatRecord's message field is already a Content object
const resumedHistory = buildApiHistoryFromConversation(
resumedSessionData.conversation,
);
this.seedRecentCompletedToolNamesFromHistory(resumedHistory);
await this.startChat(
resumedHistory,
sessionStartSource ?? SessionStartSource.Resume,
Expand Down Expand Up @@ -636,6 +642,7 @@ export class GeminiClient {
this.cachedGitStatus = undefined;
this.lastApiCompletionTimestamp = null;
this.lastHookMicrocompactionTimestamp = null;
this.recentCompletedToolNames = [];
// startChat() rewrites the chat to its initial state. Any prior
// read_file tool results the FileReadCache still tracks are no
// longer in history, so a follow-up Read would serve a placeholder
Expand Down Expand Up @@ -1494,6 +1501,8 @@ export class GeminiClient {
toolName: string,
args?: Record<string, unknown>,
): void {
this.rememberCompletedToolName(toolName);

if (args && SKILL_WRITE_TOOL_NAMES.has(toolName)) {
const filePath = args['file_path'] ?? args['path'] ?? args['target_file'];
if (
Expand All @@ -1506,6 +1515,45 @@ export class GeminiClient {
this.toolCallCount += 1;
}

private rememberCompletedToolName(toolName: string): void {
const normalizedToolName = toolName.trim();
if (!normalizedToolName) {
return;
}
this.recentCompletedToolNames = [
...this.recentCompletedToolNames.filter(
(name) => name !== normalizedToolName,
),
normalizedToolName,
].slice(-MAX_RECENT_TOOL_NAMES_FOR_MEMORY);
}

private seedRecentCompletedToolNamesFromHistory(history: Content[]): void {
const completedCallIds = new Set<string>();
for (const message of history) {
for (const part of message.parts ?? []) {
const responseId = part.functionResponse?.id;
if (responseId) {
completedCallIds.add(responseId);
}
}
}

this.recentCompletedToolNames = [];
for (const message of history) {
for (const part of message.parts ?? []) {
const call = part.functionCall;
if (!call?.name) {
continue;
}
if (call.id && !completedCallIds.has(call.id)) {
continue;
}
this.rememberCompletedToolName(call.name);
}
}
}

private async microcompactIdleHistory(
lastCompletionTimestamp: number | null,
): Promise<boolean> {
Expand Down Expand Up @@ -1692,6 +1740,7 @@ export class GeminiClient {
.recall(this.config.getProjectRoot(), partToString(request), {
config: this.config,
excludedFilePaths: this.surfacedRelevantAutoMemoryPaths,
recentTools: [...this.recentCompletedToolNames],
abortSignal: controller.signal,
})
.catch((error: unknown) => {
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/memory/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ describe('managed auto-memory prompt helpers', () => {
expect(prompt).toContain('User prefers terse responses.');
});

it('warns extraction not to save MCP tool schemas or failed calls', () => {
const prompt = buildManagedAutoMemoryPrompt('/tmp/project/.qwen/memory');

expect(prompt).toContain(
'MCP tool names, parameter schemas, field mappings, guessed tool-call formats, or raw failed tool-call transcripts',
);
expect(prompt).toContain('confirmed durable workaround');
expect(prompt).toContain('live tool definitions are authoritative');
});

it('appends managed auto-memory after existing hierarchical memory', () => {
const result = appendManagedAutoMemoryToUserMemory(
'--- Context from: QWEN.md ---\nProject rules',
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/memory/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export const WHAT_NOT_TO_SAVE_SECTION: readonly string[] = [
'- Code patterns, conventions, architecture, file paths, or project structure — these can be derived by reading the current project state.',
'- Git history, recent changes, or who-changed-what — `git log` / `git blame` are authoritative.',
'- Debugging solutions or fix recipes — the fix is in the code; the commit message has the context.',
'- MCP tool names, parameter schemas, field mappings, guessed tool-call formats, or raw failed tool-call transcripts — live tool definitions are authoritative and may change. Save a tool-related note only when it captures a confirmed durable workaround, warning, owner, or escalation path.',
'- Anything already documented in QWEN.md or AGENTS.md files.',
'- Ephemeral task details: in-progress work, temporary state, current conversation context.',
'',
Expand Down
61 changes: 61 additions & 0 deletions packages/core/src/memory/recall.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,40 @@ const docs: ScannedAutoMemoryDocument[] = [
},
];

const activeToolDocs: ScannedAutoMemoryDocument[] = [
{
type: 'reference',
filePath: '/tmp/ata-tool.md',
relativePath: 'ata-tool.md',
filename: 'ata-tool.md',
title: 'ATA tool schema notes',
description:
'article-list-query parameter schema and failed tool-call attempts',
body: '# ATA tool schema notes\n\n- ata::article-list-query failed with guessed field mappings.',
mtimeMs: 4,
},
{
type: 'reference',
filePath: '/tmp/ata-gotcha.md',
relativePath: 'ata-gotcha.md',
filename: 'ata-gotcha.md',
title: 'ATA tool gotcha',
description: 'article-list-query known workaround for transient failures',
body: '# ATA tool gotcha\n\n- mcp__ata__article-list-query can return systemError during index rotation; retry after checking the ATA oncall note.',
mtimeMs: 6,
},
{
type: 'reference',
filePath: '/tmp/ata-owner.md',
relativePath: 'ata-owner.md',
filename: 'ata-owner.md',
title: 'ATA escalation',
description: 'ATA service owner and escalation path',
body: '# ATA escalation\n\n- Ask the ATA oncall when the service returns systemError.',
mtimeMs: 5,
},
];

describe('auto-memory relevant recall', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down Expand Up @@ -134,4 +168,31 @@ describe('auto-memory relevant recall', () => {
'/tmp/user.md',
);
});

it('keeps active tool schemas out of heuristic fallback', async () => {
vi.mocked(scanAutoMemoryTopicDocuments).mockResolvedValue(activeToolDocs);
vi.mocked(selectRelevantAutoMemoryDocumentsByModel).mockRejectedValue(
new Error('selector failed'),
);

const result = await resolveRelevantAutoMemoryPromptForQuery(
'/tmp/project',
'read the ATA article with article-list-query',
{
config: {} as Config,
recentTools: ['mcp__ata__article-list-query'],
},
);

expect(result.strategy).toBe('heuristic');
expect(result.selectedDocs.map((doc) => doc.filePath)).not.toContain(
'/tmp/ata-tool.md',
);
expect(result.selectedDocs.map((doc) => doc.filePath)).toContain(
'/tmp/ata-gotcha.md',
);
expect(result.selectedDocs.map((doc) => doc.filePath)).toContain(
'/tmp/ata-owner.md',
);
});
});
Loading
Loading