Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
3 changes: 3 additions & 0 deletions packages/acp-bridge/src/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1052,6 +1052,9 @@ export const IDLE_HOOK_EVENTS: Record<HookEventName, ServeHookEventMeta> = {
description: 'When a new session is started',
matcherKind: 'sessionTrigger',
},
CwdChanged: {
description: 'After the session changes its working directory',
},
MessageDisplay: {
description: 'Repeatedly, as the assistant reply streams',
},
Expand Down
24 changes: 23 additions & 1 deletion packages/cli/src/acp-integration/acpAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2075,6 +2075,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
installPendingManagedConversationBinding: ReturnType<typeof vi.fn>;
commitManagedConversationBinding: ReturnType<typeof vi.fn>;
releaseManagedConversationBinding: ReturnType<typeof vi.fn>;
startCronScheduler: ReturnType<typeof vi.fn>;
appendLiveConversationTranscript: ReturnType<typeof vi.fn>;
collectActiveWorkHolds: ReturnType<typeof vi.fn>;
hasStandaloneRelocationBlockers: ReturnType<typeof vi.fn>;
Expand Down Expand Up @@ -4360,6 +4361,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
getCreatedAt: vi.fn().mockReturnValue(1_700_000_000_000),
getTurnCount: vi.fn().mockReturnValue(3),
prompt: vi.fn().mockResolvedValue({ stopReason: 'end_turn' }),
refreshSkillsFromSettings: vi.fn().mockResolvedValue(undefined),
};
lastSessionMock = sessionMock;
return sessionMock as unknown as InstanceType<typeof Session>;
Expand Down Expand Up @@ -5078,6 +5080,19 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
expect(
lastSessionMock?.hardSuspendTodoStopGuard.mock.invocationCallOrder[0],
).toBeLessThan(relocateWorkingDirectory.mock.invocationCallOrder[0]!);
expect(lastSessionMock?.startCronScheduler).toHaveBeenCalledTimes(2);
// The daemon owns the settings watcher for non-managed sessions, so
// this is the only push of `available_commands_update` after a move.
expect(
(
lastSessionMock as unknown as {
refreshSkillsFromSettings: ReturnType<typeof vi.fn>;
}
).refreshSkillsFromSettings,
).toHaveBeenCalledWith({
reloadSettings: false,
notifyConfigChanged: false,
});
} finally {
await fs.rm(targetDir, { recursive: true, force: true });
}
Expand Down Expand Up @@ -5361,7 +5376,11 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
expect(innerConfig.relocateWorkingDirectory).toHaveBeenCalledWith(
expectation.child.canonicalPath,
expectation.child.canonicalPath,
{ skipProcessChdir: true, skipArtifactMigration: true },
{
skipProcessChdir: true,
skipArtifactMigration: true,
trustedFolder: true,
},
);
expect(
lastSessionMock?.installPendingManagedConversationBinding,
Expand Down Expand Up @@ -10936,6 +10955,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
expect.objectContaining({
toolInvocationGuard: expect.any(Function),
}),
expect.anything(),
);

mockConnectionState.resolve();
Expand Down Expand Up @@ -19699,8 +19719,10 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => {
}

const sessionSettings = vi.mocked(loadCliConfig).mock.calls[0]?.[0];
const hostPolicy = vi.mocked(loadCliConfig).mock.calls[0]?.[9];
expect(sessionSettings?.experimental?.cron).toBe(false);
expect(requestSettings.merged.experimental?.cron).toBe(true);
expect(hostPolicy?.projectRuntimeCronEnabled).toBe(false);

mockConnectionState.resolve();
await agentPromise;
Expand Down
79 changes: 74 additions & 5 deletions packages/cli/src/acp-integration/acpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import type { LoadedSettings } from '../config/settings.js';
import { formatCronRelocationNotice } from '../config/cron-relocation-notice.js';
import {
loadSettings,
reloadEnvironment,
Expand Down Expand Up @@ -2304,8 +2305,9 @@ function readScopeSettings(
async function resolvePreferredMemoryFile(
dir: string,
fallbackFilename: string,
contextFileNames: readonly string[],
): Promise<string> {
for (const filename of getAllMemoryFilenames()) {
for (const filename of contextFileNames) {
const filePath = path.join(dir, filename);
try {
await fs.access(filePath);
Expand All @@ -2321,15 +2323,25 @@ async function resolvePreferredMemoryFile(
async function resolveQwenMemoryPaths(params: {
cwd: string;
projectRoot: string;
/**
* The session's context-file names. `/cd` makes these session-scoped
* and never updates the process-global list, so a host asking for the
* memory paths after a move must be answered from the session, or it
* is handed `QWEN.md` for a project whose file is `CONTEXT.md`.
*/
contextFileNames?: readonly string[];
}): Promise<QwenMemoryPaths> {
const fallbackFilename = getAllMemoryFilenames()[0] ?? 'QWEN.md';
const contextFileNames = params.contextFileNames ?? getAllMemoryFilenames();
const fallbackFilename = contextFileNames[0] ?? 'QWEN.md';
const userMemoryFile = await resolvePreferredMemoryFile(
Storage.getGlobalQwenDir(),
fallbackFilename,
contextFileNames,
);
const projectMemoryFile = await resolvePreferredMemoryFile(
params.cwd,
fallbackFilename,
contextFileNames,
);
const autoMemoryDir = getAutoMemoryRoot(params.projectRoot);

Expand Down Expand Up @@ -8148,7 +8160,11 @@ class QwenAgent implements Agent {
? params['projectRoot']
: cwd;
return {
paths: await resolveQwenMemoryPaths({ cwd, projectRoot }),
paths: await resolveQwenMemoryPaths({
cwd,
projectRoot,
contextFileNames: this.contextFileNamesForCwd(cwd),
}),
};
}
case SERVE_STATUS_EXT_METHODS.workspaceMcp:
Expand Down Expand Up @@ -10194,7 +10210,11 @@ class QwenAgent implements Agent {
const relocation = await config.relocateWorkingDirectory(
canonicalPath,
canonicalPath,
{ skipProcessChdir: true, skipArtifactMigration: true },
{
skipProcessChdir: true,
skipArtifactMigration: true,
trustedFolder: true,
},
);
if (conversationDirectoryExpectation !== undefined) {
await assertManagedConversationDirectoryIdentity(
Expand Down Expand Up @@ -10226,6 +10246,32 @@ class QwenAgent implements Agent {
}`,
);
}
for (const error of relocation.projectRuntimeRefreshErrors ?? []) {
warnings.push(
`Project runtime refresh failed: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
if (relocation.cronExitSummary) {
warnings.push(
formatCronRelocationNotice(relocation.cronExitSummary),
);
}

try {
await session.refreshSkillsFromSettings({
reloadSettings: false,
notifyConfigChanged: false,
});
} catch (error) {
warnings.push(
`Available commands refresh failed: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
session.startCronScheduler();

try {
await config
Expand Down Expand Up @@ -12493,14 +12539,20 @@ class QwenAgent implements Agent {
// not process.exit(1) the shared ACP child and every session on its
// channel. newSessionConfig maps the throw to a RequestError.
true,
this.managedToolInvocationGuard || restoreOptions || provisionalWorkspace
this.managedToolInvocationGuard ||
restoreOptions ||
provisionalWorkspace ||
sessionSource?.sourceType === 'channel'
? {
...(provisionalWorkspace
? { provisionalWorkspace: true as const }
: {}),
...(this.managedToolInvocationGuard
? { toolInvocationGuard: this.managedToolInvocationGuard }
: {}),
...(sessionSource?.sourceType === 'channel'
? { projectRuntimeCronEnabled: false }
: {}),
...(restoreOptions && sessionId
? {
sessionRestore: {
Expand All @@ -12516,6 +12568,7 @@ class QwenAgent implements Agent {
: {}),
}
: undefined,
settings,
);
if (sessionSource) {
config.setSessionSource(sessionSource.sourceType, sessionSource.sourceId);
Expand Down Expand Up @@ -12696,6 +12749,22 @@ class QwenAgent implements Agent {
config.setFileSystemService(acpFileSystemService);
}

/**
* Context-file names for a host request about `cwd`. `/cd` scopes the
* names to the session and leaves the process-global list untouched, so
* an agent-level request has to be answered from the session that owns
* the directory; the global list is only right when no session does.
*/
private contextFileNamesForCwd(cwd: string): readonly string[] {
Comment thread
qqqys marked this conversation as resolved.
Comment thread
qqqys marked this conversation as resolved.
for (const session of this.sessions.values()) {
const config = session.getConfig();
if (config.getWorkingDir() === cwd) {
Comment thread
qqqys marked this conversation as resolved.
Outdated
Comment thread
qqqys marked this conversation as resolved.
Outdated
return config.getContextFileNames();
}
}
return getAllMemoryFilenames();
}

private async createAndStoreSession(
config: Config,
settings: LoadedSettings,
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/acp-integration/session/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,9 @@ describe('Session', () => {
getTool: ReturnType<typeof vi.fn>;
ensureTool: ReturnType<typeof vi.fn>;
registerTool: ReturnType<typeof vi.fn>;
registerSessionTool: ReturnType<typeof vi.fn>;
registerPermissionDeferredFactory: ReturnType<typeof vi.fn>;
registerSessionPermissionDeferredFactory: ReturnType<typeof vi.fn>;
revealDeferredTool: ReturnType<typeof vi.fn>;
pinDeferredToolReveal: ReturnType<typeof vi.fn>;
warmAll: ReturnType<typeof vi.fn>;
Expand Down Expand Up @@ -782,14 +784,23 @@ describe('Session', () => {
getTool: vi.fn(),
ensureTool: vi.fn().mockResolvedValue(true),
registerTool: vi.fn(),
registerSessionTool: vi.fn(),
registerPermissionDeferredFactory: vi.fn(),
registerSessionPermissionDeferredFactory: vi.fn(),
revealDeferredTool: vi.fn(),
pinDeferredToolReveal: vi.fn(),
warmAll: vi.fn().mockResolvedValue(undefined),
getFunctionDeclarationsFiltered: vi.fn((names: string[]) =>
names.map((name) => ({ name })),
),
};
mockToolRegistry.registerSessionTool = vi.fn((tool) =>
mockToolRegistry.registerTool(tool),
);
mockToolRegistry.registerSessionPermissionDeferredFactory = vi.fn(
(name, factory) =>
mockToolRegistry.registerPermissionDeferredFactory(name, factory),
);
const fileService = {
shouldGitIgnoreFile: vi.fn().mockReturnValue(false),
shouldIgnoreFile: vi.fn().mockReturnValue(false),
Expand Down Expand Up @@ -823,6 +834,7 @@ describe('Session', () => {
assertCanStartTurn: vi.fn().mockResolvedValue(undefined),
getWorkingDir: vi.fn().mockReturnValue(process.cwd()),
getProjectRoot: vi.fn().mockReturnValue('/repo'),
getContextFileNames: vi.fn().mockReturnValue(['QWEN.md', 'AGENTS.md']),
// Folder trust gates the project `.qwen/loop.md`; default trusted (the
// production default). Untrusted-folder tests override to false.
isTrustedFolder: vi.fn().mockReturnValue(true),
Expand Down Expand Up @@ -2016,6 +2028,11 @@ describe('Session', () => {
await session.enableLiveScreenContext();
const screenTool = registered.get(CAPTURE_SCREEN_CONTEXT_TOOL_NAME);
expect(screenTool?.name).toBe('capture_screen_context');
// Session-owned, not merely registered: a plain `registerTool` here
// would let the next `/cd` dispose the live channel tool.
expect(mockToolRegistry.registerSessionTool).toHaveBeenCalledWith(
expect.objectContaining({ name: CAPTURE_SCREEN_CONTEXT_TOOL_NAME }),
);
const invocation = screenTool?.build({});
expect(invocation).toBeDefined();
await expect(invocation?.getDefaultPermission()).resolves.toBe('allow');
Expand Down
17 changes: 11 additions & 6 deletions packages/cli/src/acp-integration/session/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1691,14 +1691,14 @@ export async function registerCreateSubSessionTool(
}
const toolRegistry = config.getToolRegistry();
if (registrationStatus === 'deferred') {
toolRegistry.registerPermissionDeferredFactory(
toolRegistry.registerSessionPermissionDeferredFactory(
ToolNames.CREATE_SUB_SESSION,
Comment thread
qqqys marked this conversation as resolved.
async () => new CreateSubSessionTool(config),
);
await config.getLlmClient().setTools();
return;
}
toolRegistry.registerTool(new CreateSubSessionTool(config));
toolRegistry.registerSessionTool(new CreateSubSessionTool(config));
Comment thread
qqqys marked this conversation as resolved.
// The registration lands after `config.initialize()` → `startChat()` already
// snapshotted the chat's tool declarations, and the tool is deferred — so it
// stays filtered out of the declarations until revealed. Reveal it and
Expand Down Expand Up @@ -3207,7 +3207,7 @@ export class Session implements SessionContext {
screenshotPath,
};
});
registry.registerTool(tool);
registry.registerSessionTool(tool);
if (registry.getTool(CAPTURE_SCREEN_CONTEXT_TOOL_NAME) !== tool) {
Comment thread
qqqys marked this conversation as resolved.
throw new Error(
'capture_screen_context is required for Live Voice but is disabled.',
Expand All @@ -3231,7 +3231,7 @@ export class Session implements SessionContext {
);
}
}
for (const tool of tools) registry.registerTool(tool);
for (const tool of tools) registry.registerSessionTool(tool);
for (const tool of tools) {
if (registry.getTool(tool.name) !== tool) {
throw new Error(
Expand All @@ -3258,7 +3258,7 @@ export class Session implements SessionContext {
message,
});
});
registry.registerTool(tool);
registry.registerSessionTool(tool);
if (registry.getTool(SPEAK_TO_USER_TOOL_NAME) !== tool) {
throw new Error(
'speak_to_user is required for Live Voice but is disabled.',
Expand Down Expand Up @@ -10094,6 +10094,7 @@ export class Session implements SessionContext {
const matchedContextFileWrite = didWriteProjectContextFile(
memoryWriteCandidates,
this.config.getProjectRoot(),
this.config.getContextFileNames(),
);
debugLogger.debug(
`ACP session ${this.sessionId} checked marked context-file memory tool batch; matched=${matchedContextFileWrite}`,
Expand Down Expand Up @@ -10971,7 +10972,11 @@ export class Session implements SessionContext {
// prompt right after an allow-rule call just worked.
const forceAutoReviewForAllow =
approvalMode === ApprovalMode.AUTO &&
(shouldForceAutoModeReviewForAllow(pmCtx, this.config.getCwd()) ||
(shouldForceAutoModeReviewForAllow(
pmCtx,
this.config.getCwd(),
this.config.getContextFileNames(),
) ||
shouldClassifyAllShellForAutoMode(policyToolName, this.config));
const confirmationPermission = getEffectivePermissionForConfirmation(
finalPermission,
Expand Down
Loading
Loading