From 9af449219b579fc198cb71065cf2ef560a3cb7af Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Wed, 16 Sep 2026 23:43:23 +0800 Subject: [PATCH] feat(context): announce external context directories in the turn text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent could not tell which external context directories were attached: the CLI's own environment block is not part of Qoderian's system prompt, so its list of additional working directories never reached the model. Send an tag with the turn text instead — appended only when the selection changed, so the system prompt stays untouched and cache-friendly. Announcements cover additions and removals ("(none)"), skip /compact, and replay once after a session switch, which never saw the current directories. Co-authored-by: QoderAI (Qwen 3.8 Max) --- CHANGELOG.md | 6 +++ src/core/runtime/types.ts | 2 + src/qoder/prompt/context/prompt-context.ts | 20 +++++++++- src/qoder/prompt/qoder-turn-encoder.ts | 6 ++- src/qoder/runtime/qoder-chat-runtime.ts | 23 ++++++++++- .../qoder/prompt/qoder-turn-encoder.test.ts | 38 +++++++++++++++++++ .../qoder/runtime/qoder-chat-runtime.test.ts | 29 ++++++++++++++ 7 files changed, 120 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fae79d..663a8b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ version with its date and start a fresh empty `[Unreleased]` above it. ## [Unreleased] +### Added + +- The agent now learns which external context directories are attached: the + message you send right after adding or removing one carries the current + list, so the agent can use those folders without you referencing every file. + ## [1.0.11] - 2026-09-16 ### Added diff --git a/src/core/runtime/types.ts b/src/core/runtime/types.ts index f656456..8a5c061 100644 --- a/src/core/runtime/types.ts +++ b/src/core/runtime/types.ts @@ -49,6 +49,8 @@ export interface ChatTurnRequest { browserSelection?: BrowserSelectionContext | null; canvasSelection?: CanvasSelectionContext | null; externalContextPaths?: string[]; + /** External context list to announce in the turn text when it just changed. */ + externalContextsNotice?: string[]; enabledMcpServers?: Set; } diff --git a/src/qoder/prompt/context/prompt-context.ts b/src/qoder/prompt/context/prompt-context.ts index a0d1250..4877c7d 100644 --- a/src/qoder/prompt/context/prompt-context.ts +++ b/src/qoder/prompt/context/prompt-context.ts @@ -8,6 +8,7 @@ import { escapeXmlClosingTag } from './xml-context'; const LINKED_NOTE_TAG = 'linked_note'; const NOTE_CONTEXT_TAG_PATTERN = '(linked_note|current_note)'; +const EXTERNAL_CONTEXT_TAG = 'external_context'; // Matches note context at the START of prompt (legacy placement) const NOTE_CONTEXT_PREFIX_REGEX = new RegExp(`^<${NOTE_CONTEXT_TAG_PATTERN}>\\n[\\s\\S]*?<\\/\\1>\\n\\n`); @@ -18,9 +19,9 @@ const NOTE_CONTEXT_SUFFIX_REGEX = new RegExp(`\\n\\n<${NOTE_CONTEXT_TAG_PATTERN} * Pattern to match XML context tags appended to prompts. * These tags are always preceded by \n\n separator. * Matches: linked_note/current_note, editor_selection (with attributes), editor_cursor (with attributes), - * context_files, canvas_selection, browser_selection + * context_files, canvas_selection, browser_selection, external_context */ -export const XML_CONTEXT_PATTERN = /\n\n<(?:linked_note|current_note|editor_selection|editor_cursor|context_files|canvas_selection|browser_selection)[\s>]/; +export const XML_CONTEXT_PATTERN = /\n\n<(?:linked_note|current_note|editor_selection|editor_cursor|context_files|canvas_selection|browser_selection|external_context)[\s>]/; const BRACKET_CONTEXT_PATTERN = /\n\[(?:Current note|Editor selection from|Browser selection from|Canvas selection from)\b/; export function formatCurrentNote(notePath: string): string { @@ -109,6 +110,7 @@ export function extractUserQuery(prompt: string): string { .replace(/[\s\S]*?<\/context_files>\s*/g, '') .replace(/\s*/g, '') .replace(/\s*/g, '') + .replace(/[\s\S]*?<\/external_context>\s*/g, '') .trim(); } @@ -120,3 +122,17 @@ function formatContextFilesLine(files: string[]): string { export function appendContextFiles(prompt: string, files: string[]): string { return `${prompt}\n\n${formatContextFilesLine(files)}`; } + +/** + * Formats the external context directory list for a turn. Appended only when + * the selection just changed, so the model learns about additions and + * removals once instead of re-reading the same list every turn. + */ +export function formatExternalContexts(paths: readonly string[]): string { + const body = paths.length > 0 ? paths.join('\n') : '(none)'; + return `<${EXTERNAL_CONTEXT_TAG}>\n${escapeXmlClosingTag(body, EXTERNAL_CONTEXT_TAG)}\n`; +} + +export function appendExternalContexts(prompt: string, paths: readonly string[]): string { + return `${prompt}\n\n${formatExternalContexts(paths)}`; +} diff --git a/src/qoder/prompt/qoder-turn-encoder.ts b/src/qoder/prompt/qoder-turn-encoder.ts index 6783386..cc4e845 100644 --- a/src/qoder/prompt/qoder-turn-encoder.ts +++ b/src/qoder/prompt/qoder-turn-encoder.ts @@ -3,7 +3,7 @@ import type { McpServerManager } from '../mcp/mcp-server-manager'; import { appendBrowserContext } from './context/browser-context'; import { appendCanvasContext } from './context/canvas-context'; import { appendEditorContext } from './context/editor-context'; -import { appendCurrentNote } from './context/prompt-context'; +import { appendCurrentNote, appendExternalContexts } from './context/prompt-context'; function isCompactCommand(text: string): boolean { return /^\/compact(\s|$)/i.test(text); @@ -32,6 +32,10 @@ export function encodeQoderTurn( if (request.canvasSelection) { persistedContent = appendCanvasContext(persistedContent, request.canvasSelection); } + + if (request.externalContextsNotice) { + persistedContent = appendExternalContexts(persistedContent, request.externalContextsNotice); + } } const mcpMentions = mcpManager.extractMentions(persistedContent); diff --git a/src/qoder/runtime/qoder-chat-runtime.ts b/src/qoder/runtime/qoder-chat-runtime.ts index bc6fc3b..14072fe 100644 --- a/src/qoder/runtime/qoder-chat-runtime.ts +++ b/src/qoder/runtime/qoder-chat-runtime.ts @@ -135,6 +135,7 @@ export class QoderChatRuntime implements ChatRuntime { private permissionModeSyncCallback: ((sdkMode: string) => void) | null = null; private vaultPath: string | null = null; private currentExternalContextPaths: string[] = []; + private announcedExternalContextPaths: string[] = []; private currentMcpServers: Record = {}; private readyStateListeners = new Set<(ready: boolean) => void>(); @@ -216,7 +217,25 @@ export class QoderChatRuntime implements ChatRuntime { } prepareTurn(request: ChatTurnRequest): PreparedChatTurn { - return encodeQoderTurn(request, this.mcpManager); + const notice = this.consumeExternalContextsNotice(request.externalContextPaths); + return encodeQoderTurn( + notice === undefined ? request : { ...request, externalContextsNotice: notice }, + this.mcpManager, + ); + } + + /** + * The agent cannot see the workspace roots otherwise: the CLI's own + * environment block is not part of Qoderian's system prompt. Announce the + * current list once per change (empty means every directory was removed). + */ + private consumeExternalContextsNotice(paths: string[] | undefined): string[] | undefined { + const current = paths ?? []; + const announced = this.announcedExternalContextPaths; + this.announcedExternalContextPaths = [...current]; + const unchanged = current.length === announced.length + && current.every((path) => announced.includes(path)); + return unchanged ? undefined : current; } consumeTurnMetadata(): ChatTurnMetadata { @@ -1275,6 +1294,8 @@ export class QoderChatRuntime implements ChatRuntime { if (sessionChanged) { void this.closePersistentQuery('session switch'); this.crashRecoveryAttempted = false; + // A restored session never saw the current directories; announce them. + this.announcedExternalContextPaths = []; } this.sessionManager.setSessionId(id, this.getScopedSettings().model); diff --git a/tests/unit/qoder/prompt/qoder-turn-encoder.test.ts b/tests/unit/qoder/prompt/qoder-turn-encoder.test.ts index 2349857..bf1915c 100644 --- a/tests/unit/qoder/prompt/qoder-turn-encoder.test.ts +++ b/tests/unit/qoder/prompt/qoder-turn-encoder.test.ts @@ -113,6 +113,44 @@ describe('encodeQoderTurn', () => { expect(result.persistedContent).toContain('node-2'); }); + it('should append the external context notice when provided', () => { + const request: ChatTurnRequest = { + text: 'hello', + externalContextsNotice: ['/Users/me/Workspace', '/tmp/other'], + }; + const result = encodeQoderTurn(request, mcpManager); + + expect(result.persistedContent).toContain(''); + expect(result.persistedContent).toContain('/Users/me/Workspace'); + expect(result.persistedContent).toContain('/tmp/other'); + }); + + it('should not append the external context tag from the paths alone', () => { + const request: ChatTurnRequest = { + text: 'hello', + externalContextPaths: ['/Users/me/Workspace'], + }; + const result = encodeQoderTurn(request, mcpManager); + + expect(result.persistedContent).toBe('hello'); + }); + + it('should announce an emptied external context list', () => { + const result = encodeQoderTurn({ text: 'hello', externalContextsNotice: [] }, mcpManager); + + expect(result.persistedContent).toContain(''); + expect(result.persistedContent).toContain('(none)'); + }); + + it('should skip the external context notice for /compact', () => { + const result = encodeQoderTurn( + { text: '/compact', externalContextsNotice: ['/Users/me/Workspace'] }, + mcpManager, + ); + + expect(result.persistedContent).toBe('/compact'); + }); + it('should extract and transform MCP mentions', () => { const mentions = new Set(['server-a']); mcpManager.extractMentions.mockReturnValue(mentions); diff --git a/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts b/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts index 1ca32d8..ea1200f 100644 --- a/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts +++ b/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts @@ -141,6 +141,35 @@ describe('QoderChatRuntime', () => { const result = service.prepareTurn({ text: '@server-a hello' }); expect(result.mcpMentions).toEqual(new Set(['server-a'])); }); + + it('should announce external context paths only when they change', () => { + const first = service.prepareTurn({ text: 'hi', externalContextPaths: ['/tmp/a'] }); + expect(first.persistedContent).toContain(''); + expect(first.persistedContent).toContain('/tmp/a'); + + const unchanged = service.prepareTurn({ text: 'again', externalContextPaths: ['/tmp/a'] }); + expect(unchanged.persistedContent).not.toContain(''); + + const added = service.prepareTurn({ + text: 'more', + externalContextPaths: ['/tmp/b', '/tmp/a'], + }); + expect(added.persistedContent).toContain(''); + expect(added.persistedContent).toContain('/tmp/b'); + + const cleared = service.prepareTurn({ text: 'done' }); + expect(cleared.persistedContent).toContain('(none)'); + }); + + it('should announce external context paths again after a session switch', () => { + service.prepareTurn({ text: 'hi', externalContextPaths: ['/tmp/a'] }); + service.prepareTurn({ text: 'again', externalContextPaths: ['/tmp/a'] }); + + service.setSessionId('session-with-contexts', ['/tmp/a']); + + const afterSwitch = service.prepareTurn({ text: 'resumed', externalContextPaths: ['/tmp/a'] }); + expect(afterSwitch.persistedContent).toContain(''); + }); }); describe('query with PreparedChatTurn', () => {