Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d6d7343
feat(daemon): support scoped workspace memory tasks
qqqys Aug 24, 2026
712ec7d
Merge branch 'main' into feat/workspace-remember-project-scope
qwen-code-dev-bot Aug 24, 2026
79248c1
Merge branch 'main' into feat/workspace-remember-project-scope
qwen-code-dev-bot Aug 24, 2026
a37b60f
Merge branch 'main' into feat/workspace-remember-project-scope
qwen-code-dev-bot Aug 24, 2026
a6dfe90
fix(workspace): repair scoped remember/forget review findings (#9895)
qwen-code-dev-bot Aug 24, 2026
fbf2f5b
fix(workspace): hide project tier from user-scoped remember prompt (#…
qwen-code-dev-bot Aug 25, 2026
aa1ef0e
fix(workspace): rebuild indexes for every remember write, incl. MEMOR…
qwen-code-dev-bot Aug 25, 2026
3bd321a
fix(workspace): repair indexes on failed remember runs, pre-flight SD…
qwen-code-dev-bot Aug 25, 2026
0ce7b1a
Merge branch 'main' into feat/workspace-remember-project-scope
qqqys Aug 26, 2026
e7e7dfd
Merge branch 'main' into feat/workspace-remember-project-scope
qqqys Aug 26, 2026
be17c48
Merge branch 'main' into feat/workspace-remember-project-scope
qwen-code-dev-bot Aug 26, 2026
970dc43
Merge branch 'main' into feat/workspace-remember-project-scope
qwen-code-dev-bot Aug 26, 2026
edefa8d
fix(core): defer early-completion abort until batch emission complete…
qwen-code-dev-bot Aug 26, 2026
4676f94
fix(core): complete self-aborted remember runs and repair indexes on …
qwen-code-dev-bot Aug 26, 2026
983489a
Merge branch 'main' into feat/workspace-remember-project-scope
qwen-code-dev-bot Aug 27, 2026
9e01d07
fix(core): close the round-13 review findings on scoped remember
qqqys Aug 28, 2026
46c242c
Merge remote-tracking branch 'upstream/main' into feat/workspace-reme…
qqqys Aug 28, 2026
b0a1149
fix(core): report an external abort that resolves after the early write
qqqys Aug 28, 2026
1203087
Merge remote-tracking branch 'upstream/main' into feat/workspace-reme…
qqqys Aug 28, 2026
ac8ab16
Merge branch 'main' into feat/workspace-remember-project-scope
qwen-code-dev-bot Aug 28, 2026
47387cd
test(memory): pin the scoped remember/forget branches the review kept…
qqqys Aug 28, 2026
a81cf78
test(memory): make the fork abort probes real and pin the remaining s…
qqqys Aug 28, 2026
e36d3ad
Merge branch 'main' into feat/workspace-remember-project-scope
qwen-code-dev-bot Aug 29, 2026
b632f51
Merge branch 'main' into feat/workspace-remember-project-scope
qwen-code-dev-bot Aug 29, 2026
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
3 changes: 3 additions & 0 deletions integration-tests/cli/qwen-serve-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,10 @@ describe('qwen serve — capabilities envelope', () => {
'auth_provider_install',
'workspace_memory',
'workspace_memory_remember',
'workspace_memory_remember_project_scope',
'workspace_memory_remember_user_scope',
'workspace_memory_forget',
'workspace_memory_forget_scope',
'workspace_memory_dream',
'workspace_agents',
'workspace_agent_generate',
Expand Down
3 changes: 3 additions & 0 deletions packages/acp-bridge/src/bridgeTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,7 @@ export interface ChangeSessionCwdResult {
}

export type BridgeWorkspaceMemoryRememberContextMode = 'workspace' | 'clean';
export type BridgeWorkspaceMemoryRememberTargetScope = 'project' | 'user';
export type BridgeAutoMemoryTopic =
| 'user'
| 'feedback'
Expand All @@ -605,6 +606,7 @@ export type BridgeAutoMemoryTopic =
export interface BridgeWorkspaceMemoryRememberRequest {
content: string;
contextMode: BridgeWorkspaceMemoryRememberContextMode;
scope?: BridgeWorkspaceMemoryRememberTargetScope;
}

export interface BridgeWorkspaceMemoryRememberResult {
Expand All @@ -615,6 +617,7 @@ export interface BridgeWorkspaceMemoryRememberResult {

export interface BridgeWorkspaceMemoryForgetRequest {
query: string;
scope?: BridgeWorkspaceMemoryRememberTargetScope;
}

export interface BridgeWorkspaceMemoryForgetMatch {
Expand Down
180 changes: 180 additions & 0 deletions packages/cli/src/acp-integration/acpAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9392,6 +9392,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, {
content: ' Remember the workspace uses vitest. ',
contextMode: 'clean',
scope: 'project',
}),
).resolves.toEqual({
summary: 'saved',
Expand All @@ -9404,6 +9405,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
projectRoot: '/workspace',
content: 'Remember the workspace uses vitest.',
contextMode: 'clean',
scope: 'project',
abortSignal: expect.any(AbortSignal),
}),
);
Expand All @@ -9414,6 +9416,63 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
await agentPromise;
});

it('omits scope entirely from an unscoped workspace memory remember', async () => {
const refreshHierarchicalMemory = vi.fn().mockResolvedValue(undefined);
const refreshSystemInstruction = vi.fn().mockResolvedValue(undefined);
Object.assign(mockConfig, {
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
refreshHierarchicalMemory,
getLlmClient: vi.fn().mockReturnValue({
refreshSystemInstruction,
}),
});
mockRunManagedRememberByAgent.mockResolvedValue({
summary: 'saved',
filesTouched: ['/mem/MEMORY.md'],
touchedScopes: ['project'],
});

const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;

await expect(
agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, {
content: 'Remember the workspace uses vitest.',
}),
).resolves.toEqual({
summary: 'saved',
filesTouched: ['/mem/MEMORY.md'],
touchedScopes: ['project'],
});
// The scoped twin above asserts through `objectContaining`, which cannot
// see an absent key — so the omitted arm of
// `...(rawScope ? { scope: rawScope } : {})` had no pin at all. An
// explicit `scope: undefined` reaching the helper reads as a requested
// scope to `params.scope &&` further down, which is what selects the
// mismatch guard over automatic scope routing.
const [args] = mockRunManagedRememberByAgent.mock.calls.at(-1)!;
expect(args).not.toHaveProperty('scope');
expect(args).toMatchObject({
config: mockConfig,
projectRoot: '/workspace',
content: 'Remember the workspace uses vitest.',
contextMode: 'workspace',
});

mockConnectionState.resolve();
await agentPromise;
});

it('refreshes live sessions after workspace memory remember', async () => {
const sessionRefreshHierarchicalMemory = vi
.fn()
Expand Down Expand Up @@ -9683,6 +9742,38 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
}
});

it('rejects workspace memory remember with an invalid scope', async () => {
Object.assign(mockConfig, {
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
});

const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;

try {
await expect(
agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, {
content: 'Remember me.',
scope: 'global',
}),
).rejects.toThrow('Invalid scope');
expect(mockRunManagedRememberByAgent).not.toHaveBeenCalled();
} finally {
mockConnectionState.resolve();
await agentPromise;
}
});

it('rejects workspace memory remember when managed memory is unavailable', async () => {
Object.assign(mockConfig, {
isManagedMemoryAvailable: vi.fn().mockReturnValue(false),
Expand Down Expand Up @@ -10126,6 +10217,95 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
await agentPromise;
});

it('forwards a scoped workspace memory forget to the memory manager', async () => {
const forget = vi.fn().mockResolvedValue({
systemMessage: 'Forgot 1 entry.',
removedEntries: [
{
topic: 'user',
summary: 'old preference',
filePath: '/mem/user/shared.md',
},
],
touchedTopics: ['user'],
touchedScopes: ['user'],
});
Object.assign(mockConfig, {
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
getMemoryManager: vi.fn().mockReturnValue({ forget }),
getChatRecordingService: vi.fn().mockReturnValue({
recordUiTelemetryEvent: vi.fn(),
}),
getTranscriptPath: vi.fn().mockReturnValue('/tmp/transcript.jsonl'),
});

const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;

await expect(
agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryForget, {
query: 'old preference',
scope: 'user',
}),
).resolves.toMatchObject({
summary: 'Forgot 1 entry.',
touchedScopes: ['user'],
});
expect(forget).toHaveBeenCalledWith('/workspace', 'old preference', {
config: expect.objectContaining({
getChatRecordingService: expect.any(Function),
getTranscriptPath: expect.any(Function),
}),
abortSignal: expect.any(AbortSignal),
scope: 'user',
});

mockConnectionState.resolve();
await agentPromise;
});

it('rejects workspace memory forget with an invalid scope', async () => {
const forget = vi.fn();
Object.assign(mockConfig, {
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
getMemoryManager: vi.fn().mockReturnValue({ forget }),
});

const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;

await expect(
agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryForget, {
query: 'old preference',
scope: 'global',
}),
).rejects.toThrow('Invalid scope');
expect(forget).not.toHaveBeenCalled();

mockConnectionState.resolve();
await agentPromise;
});

it('rejects oversized workspace memory forget queries', async () => {
const forget = vi.fn();
Object.assign(mockConfig, {
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/acp-integration/acpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8597,6 +8597,14 @@ class QwenAgent implements Agent {
throw RequestError.invalidParams(undefined, 'Invalid contextMode');
}
const contextMode: WorkspaceRememberContextMode = rawContextMode;
const rawScope = params['scope'];
if (
rawScope !== undefined &&
rawScope !== 'project' &&
rawScope !== 'user'
) {
throw RequestError.invalidParams(undefined, 'Invalid scope');
}
if (!this.config.isManagedMemoryAvailable()) {
throw new RequestError(
-32009,
Expand All @@ -8616,6 +8624,7 @@ class QwenAgent implements Agent {
projectRoot,
content: content.trim(),
contextMode,
...(rawScope ? { scope: rawScope } : {}),
abortSignal: childSignal,
});
if (result.filesTouched.length > 0) {
Expand Down Expand Up @@ -8688,6 +8697,14 @@ class QwenAgent implements Agent {
'Query exceeds maximum size',
);
}
const rawScope = params['scope'];
if (
rawScope !== undefined &&
rawScope !== 'project' &&
rawScope !== 'user'
) {
throw RequestError.invalidParams(undefined, 'Invalid scope');
}
if (!this.config.isManagedMemoryAvailable()) {
throw new RequestError(
-32009,
Expand All @@ -8708,6 +8725,7 @@ class QwenAgent implements Agent {
.forget(projectRoot, trimmedQuery, {
config: hiddenConfig,
abortSignal: childSignal,
...(rawScope ? { scope: rawScope } : {}),
});
return {
summary:
Expand Down
36 changes: 36 additions & 0 deletions packages/cli/src/serve/acp-http/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3918,6 +3918,23 @@ export class AcpDispatcher {
}
return;
}
const rawScope = params['scope'];
if (
rawScope !== undefined &&
rawScope !== 'project' &&
rawScope !== 'user'
) {
if (id !== undefined) {
conn.sendConn(
error(
id,
RPC.INVALID_PARAMS,
'`scope` must be "project", "user", or omitted',
),
);
}
return;
}
try {
const available =
await this.bridge.isWorkspaceMemoryRememberAvailable();
Expand All @@ -3941,6 +3958,7 @@ export class AcpDispatcher {
const task = this.workspaceRememberLane.enqueue({
content: content.trim(),
contextMode: rawContextMode,
...(rawScope ? { scope: rawScope } : {}),
...(conn.clientId ? { originatorClientId: conn.clientId } : {}),
...(assertGenerationOpen ? { assertGenerationOpen } : {}),
});
Expand Down Expand Up @@ -4021,6 +4039,23 @@ export class AcpDispatcher {
}
return;
}
const rawScope = params['scope'];
if (
rawScope !== undefined &&
rawScope !== 'project' &&
rawScope !== 'user'
) {
if (id !== undefined) {
conn.sendConn(
error(
id,
RPC.INVALID_PARAMS,
'`scope` must be "project", "user", or omitted',
),
);
}
return;
}
try {
const available =
await this.bridge.isWorkspaceMemoryRememberAvailable();
Expand All @@ -4043,6 +4078,7 @@ export class AcpDispatcher {
}
const task = this.workspaceRememberLane.enqueueForget({
query: trimmedQuery,
...(rawScope ? { scope: rawScope } : {}),
...(conn.clientId ? { originatorClientId: conn.clientId } : {}),
...(assertGenerationOpen ? { assertGenerationOpen } : {}),
});
Expand Down
Loading
Loading