From 951ca13db809e3d65276d3e2fc0a947202c65d45 Mon Sep 17 00:00:00 2001 From: paisley <8197966+su8su@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:54:31 +0800 Subject: [PATCH] fix: keep @agent first send on target workspace Prevent reactive ACP loads from cancelling new-agent prompts by binding the target workspace on session switch and creating the target main session when missing. (#1184) --- .../chat-workspace-and-navigation.md | 4 +- .../rules/session-workspace-authority.md | 2 + .../chat-workspace-and-navigation.md | 6 +- .../tasks/fix-target-agent-first-send.md | 52 ++++++++ shared/chat/types.ts | 2 +- src/pages/Chat/index.tsx | 4 +- src/stores/chat.ts | 34 +++-- src/stores/chat/session-actions.ts | 19 ++- tests/e2e/chat-acp-inline-timeline.spec.ts | 125 ++++++++++++++++++ tests/unit/chat-acp-page.test.tsx | 64 ++++++++- 10 files changed, 288 insertions(+), 24 deletions(-) create mode 100644 harness/specs/tasks/fix-target-agent-first-send.md diff --git a/harness/reference/chat-workspace-and-navigation.md b/harness/reference/chat-workspace-and-navigation.md index 21f2a553..64b96675 100644 --- a/harness/reference/chat-workspace-and-navigation.md +++ b/harness/reference/chat-workspace-and-navigation.md @@ -18,7 +18,7 @@ OpenClaw's persisted ACP `cwd` is authoritative for a bound session. The global The effective workspace is shared by ACP load/prompt, the composer, sidebar grouping, the right-side workspace browser, and tool-derived file activity. A bound session is read-only in the composer and is not moved when the global selection changes. Missing or unreadable bound paths show unavailable/error state instead of silently changing roots. -ClawX persists global and recent workspace selections plus custom display labels through Main-owned settings APIs. Custom labels are keyed by canonical path and never replace path identity or ACP cwd authority. Renderer session state may mirror the bound path for UI coordination, but must not become a competing persistent session-to-path authority. Targeted `@agent` sends may intentionally use the target agent workspace and should remain an explicit branch. +ClawX persists global and recent workspace selections plus custom display labels through Main-owned settings APIs. Custom labels are keyed by canonical path and never replace path identity or ACP cwd authority. Renderer session state may mirror the bound path for UI coordination, but must not become a competing persistent session-to-path authority. Targeted `@agent` sends intentionally use the target agent workspace and remain an explicit branch. Navigation records that workspace on the target session placeholder before reactive loading; a newly targeted agent's first send creates its main ACP session and shares one load identity with the prompt so navigation cannot supersede delivery. ## First Send And Titles @@ -51,6 +51,6 @@ The Chat question directory belongs to the active ACP timeline rather than works ## Validation Anchors -Key tests include `tests/unit/workspace-context.test.ts`, `tests/unit/session-title.test.ts`, `tests/unit/session-buckets.test.ts`, `tests/unit/sidebar-session-buckets.test.ts`, `tests/unit/workspace-browser-body.test.tsx`, `tests/e2e/chat-workspace-context.spec.ts`, and `tests/e2e/chat-question-directory.spec.ts`. +Key tests include `tests/unit/workspace-context.test.ts`, `tests/unit/session-title.test.ts`, `tests/unit/session-buckets.test.ts`, `tests/unit/sidebar-session-buckets.test.ts`, `tests/unit/workspace-browser-body.test.tsx`, `tests/unit/chat-acp-page.test.tsx`, `tests/e2e/chat-workspace-context.spec.ts`, `tests/e2e/chat-acp-inline-timeline.spec.ts`, and `tests/e2e/chat-question-directory.spec.ts`. This reference consolidates the former workspace sidebar, chat workspace context, sidebar workspace UI, and ACP working-directory title designs. The later flat activity-sorted sidebar supersedes the earlier recency buckets. diff --git a/harness/specs/rules/session-workspace-authority.md b/harness/specs/rules/session-workspace-authority.md index 6a6886b9..d1371a39 100644 --- a/harness/specs/rules/session-workspace-authority.md +++ b/harness/specs/rules/session-workspace-authority.md @@ -12,6 +12,8 @@ OpenClaw ACP cwd is authoritative for a bound Chat session. Global workspace sel Custom workspace names are display-only aliases keyed by canonical workspace path. They may change sidebar and composer labels, but never path-based grouping, ACP cwd, browser roots, attachment authority, or session binding. +Targeted `@agent` sends establish the target session placeholder with the target agent workspace before navigation can trigger reactive loading. A target session and its first prompt must share one session-and-workspace load identity; reactive navigation must not supersede that load or silently cancel delivery. If the target main session does not exist yet, the first targeted send creates it before prompting. + The ACP load or new-session operation is the only boundary that establishes session workspace context. Main canonicalizes the workspace root and execution cwd, registers them only after a successful load, restores the prior context after failure, and validates later attachment operations by exact session key and generation. Attachment resolve, read, preview, and open requests cannot provide or replace the execution cwd and must be revalidated in Main on every operation. Local attachment references may resolve outside the workspace; the workspace remains authoritative for relative-path resolution and the separate workspace browser and tool-derived file boundaries. Session or generation replacement revokes the prior context; attachment refs and prior resolution are not authority. Keep `_meta.prefixCwd: true`. Remove the leading working-directory envelope only from automatic titles and narrowly defined turn matching; never alter explicit user labels, user-authored content, or user-visible transcript content. diff --git a/harness/specs/scenarios/chat-workspace-and-navigation.md b/harness/specs/scenarios/chat-workspace-and-navigation.md index 7af6c0f6..544427d9 100644 --- a/harness/specs/scenarios/chat-workspace-and-navigation.md +++ b/harness/specs/scenarios/chat-workspace-and-navigation.md @@ -8,11 +8,14 @@ ownedPaths: - electron/services/sessions-api.ts - src/lib/workspace-context.ts - src/stores/settings.ts + - src/stores/chat.ts + - src/stores/chat/session-actions.ts - src/components/layout/Sidebar.tsx - src/components/layout/session-buckets.ts - src/components/file-preview/ArtifactPanel.tsx - src/components/file-preview/WorkspaceBrowserBody.tsx - src/pages/Chat/ChatInput.tsx + - src/pages/Chat/index.tsx - src/pages/Chat/ChatToolbar.tsx - shared/host-api/contract.ts - electron/utils/store.ts @@ -21,6 +24,7 @@ ownedPaths: - tests/unit/session-title.test.ts - tests/unit/session-buckets.test.ts - tests/e2e/chat-workspace-context.spec.ts + - tests/e2e/chat-acp-inline-timeline.spec.ts - tests/e2e/chat-question-directory.spec.ts requiredProfiles: - fast @@ -34,6 +38,6 @@ requiredRules: - docs-sync --- -This scenario covers selecting a workspace for a new Chat, binding it through OpenClaw ACP cwd, restoring historical workspace context, renaming imported workspace display labels, navigating workspace-grouped sessions, browsing the effective workspace, and jumping among user questions. +This scenario covers selecting a workspace for a new Chat, binding it through OpenClaw ACP cwd, targeting another agent without losing that agent's workspace or first prompt, restoring historical workspace context, renaming imported workspace display labels, navigating workspace-grouped sessions, browsing the effective workspace, and jumping among user questions. The current resolution, ordering, title normalization, and browser behavior are documented in `harness/reference/chat-workspace-and-navigation.md`. diff --git a/harness/specs/tasks/fix-target-agent-first-send.md b/harness/specs/tasks/fix-target-agent-first-send.md new file mode 100644 index 00000000..01528247 --- /dev/null +++ b/harness/specs/tasks/fix-target-agent-first-send.md @@ -0,0 +1,52 @@ +--- +id: fix-target-agent-first-send +title: Keep targeted agent first sends atomic +scenario: gateway-backend-communication +taskType: runtime-bridge +intent: Ensure the first prompt sent to a newly created agent uses that agent's workspace and is not cancelled by a competing ACP session load. +touchedAreas: + - harness/specs/tasks/fix-target-agent-first-send.md + - harness/reference/chat-workspace-and-navigation.md + - harness/specs/scenarios/chat-workspace-and-navigation.md + - harness/specs/rules/session-workspace-authority.md + - src/pages/Chat/index.tsx + - shared/chat/types.ts + - src/stores/chat/session-actions.ts + - src/stores/chat.ts + - tests/unit/chat-acp-page.test.tsx + - tests/e2e/chat-acp-inline-timeline.spec.ts +expectedUserBehavior: + - Selecting a newly created agent in the Chat composer and sending the first prompt creates and binds that agent's main ACP session. + - The prompt uses the target agent workspace instead of the current or global workspace. + - Session navigation cannot start a competing load that silently cancels the targeted prompt. +requiredProfiles: + - fast + - comms +requiredRules: + - renderer-main-boundary + - backend-communication-boundary + - host-api-fallback-policy + - host-events-fallback-policy + - session-workspace-authority + - comms-regression + - docs-sync +requiredTests: + - pnpm harness validate --spec harness/specs/tasks/fix-target-agent-first-send.md + - pnpm exec vitest run tests/unit/chat-acp-page.test.tsx + - pnpm exec playwright test tests/e2e/chat-acp-inline-timeline.spec.ts + - pnpm run typecheck + - pnpm run comms:replay + - pnpm run comms:compare +acceptance: + - Target-agent navigation records the explicit target workspace on the renderer session placeholder before reactive ACP loading can run. + - The first targeted send requests ACP session creation when the target main session is not present. + - The targeted send and the reactive loader share one session-and-workspace identity, preventing a second load from superseding the prompt path. + - Existing target sessions continue to load without creating duplicates. + - Renderer continues to use host-api and does not add direct IPC or Gateway HTTP calls. +docs: + required: true +--- + +## Scope + +This task fixes the renderer coordination between target-agent navigation, workspace resolution, ACP load identity, and first-prompt delivery. It does not change the Main-owned ACP transport or OpenClaw runtime. diff --git a/shared/chat/types.ts b/shared/chat/types.ts index f043602f..5ca60389 100644 --- a/shared/chat/types.ts +++ b/shared/chat/types.ts @@ -149,7 +149,7 @@ export interface ChatState { // Actions loadSessions: () => Promise; switchSession: (key: string) => void; - selectAcpSession: (key: string) => void; + selectAcpSession: (key: string, workspacePath?: string) => void; newSession: () => void; acknowledgeAcpSessionCreated: (key: string, workspacePath?: string) => void; deleteSession: (key: string) => Promise; diff --git a/src/pages/Chat/index.tsx b/src/pages/Chat/index.tsx index 96f6d4bc..6256351e 100644 --- a/src/pages/Chat/index.tsx +++ b/src/pages/Chat/index.tsx @@ -399,11 +399,11 @@ export function Chat() { mimeType: file.mimeType, })); if (targetAgent) { - selectAcpSession(sessionKey); + selectAcpSession(sessionKey, promptCwd); } void (async () => { const existingSession = sessions.find((session) => session.key === sessionKey); - const createIfMissing = !targetAgent && (!existingSession || !!existingSession.createdLocally); + const createIfMissing = !existingSession || !!existingSession.createdLocally; if ( createIfMissing || acpActiveSessionKey !== sessionKey diff --git a/src/stores/chat.ts b/src/stores/chat.ts index 3dd4e893..96f4db71 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -2010,11 +2010,24 @@ function resolveMainSessionKeyForAgent(agentId: string | undefined | null): stri return summary?.mainSessionKey || buildFallbackMainSessionKey(normalizedAgentId); } -function ensureSessionEntry(sessions: ChatSession[], sessionKey: string, createdLocally = false): ChatSession[] { - if (sessions.some((session) => session.key === sessionKey)) { - return sessions; +function ensureSessionEntry( + sessions: ChatSession[], + sessionKey: string, + options: { createdLocally?: boolean; workspacePath?: string } = {}, +): ChatSession[] { + const existingSession = sessions.find((session) => session.key === sessionKey); + if (existingSession) { + if (!options.workspacePath || existingSession.workspacePath === options.workspacePath) return sessions; + return sessions.map((session) => ( + session.key === sessionKey ? { ...session, workspacePath: options.workspacePath } : session + )); } - return [...sessions, { key: sessionKey, displayName: sessionKey, ...(createdLocally ? { createdLocally: true } : {}) }]; + return [...sessions, { + key: sessionKey, + displayName: sessionKey, + ...(options.createdLocally ? { createdLocally: true } : {}), + ...(options.workspacePath ? { workspacePath: options.workspacePath } : {}), + }]; } function clearSessionEntryFromMap>(entries: T, sessionKey: string): T { @@ -2040,7 +2053,7 @@ function buildSessionSwitchPatch( | 'pendingToolImages' >, nextSessionKey: string, - options: { createdLocally?: boolean } = {}, + options: { createdLocally?: boolean; workspacePath?: string } = {}, ): Partial { captureSessionRunState(state.currentSessionKey, state); if (state.messages.length > 0) { @@ -2069,7 +2082,7 @@ function buildSessionSwitchPatch( return { currentSessionKey: nextSessionKey, currentAgentId: getAgentIdFromSessionKey(nextSessionKey), - sessions: ensureSessionEntry(nextSessions, nextSessionKey, !!options.createdLocally), + sessions: ensureSessionEntry(nextSessions, nextSessionKey, options), sessionLabels: leavingEmpty ? clearSessionEntryFromMap(state.sessionLabels, state.currentSessionKey) : state.sessionLabels, @@ -3022,11 +3035,14 @@ export const useChatStore = create((set, get) => ({ get().loadHistory(); }, - selectAcpSession: (key: string) => { - if (key === get().currentSessionKey) return; + selectAcpSession: (key: string, workspacePath?: string) => { + if ( + key === get().currentSessionKey + && (!workspacePath || get().sessions.find((session) => session.key === key)?.workspacePath === workspacePath) + ) return; clearHistoryPoll(); clearBaselines(); - set((s) => buildSessionSwitchPatch(s, key)); + set((s) => buildSessionSwitchPatch(s, key, { workspacePath })); }, // ── Delete session ── diff --git a/src/stores/chat/session-actions.ts b/src/stores/chat/session-actions.ts index de4c5fba..45568763 100644 --- a/src/stores/chat/session-actions.ts +++ b/src/stores/chat/session-actions.ts @@ -348,7 +348,7 @@ export function createSessionActions( get().loadHistory(); }, - selectAcpSession: (key: string) => { + selectAcpSession: (key: string, workspacePath?: string) => { const { currentSessionKey, messages, sessionLastActivity, sessionLabels } = get(); const leavingEmpty = !currentSessionKey.endsWith(':main') && messages.length === 0 @@ -366,10 +366,19 @@ export function createSessionActions( pendingFinal: false, lastUserMessageAt: null, pendingToolImages: [], - sessions: [ - ...(leavingEmpty ? s.sessions.filter((session) => session.key !== currentSessionKey) : s.sessions), - ...(s.sessions.some((session) => session.key === key) ? [] : [{ key, displayName: key }]), - ], + sessions: (() => { + const sessions = leavingEmpty + ? s.sessions.filter((session) => session.key !== currentSessionKey) + : s.sessions; + const existingSession = sessions.find((session) => session.key === key); + if (!existingSession) { + return [...sessions, { key, displayName: key, ...(workspacePath ? { workspacePath } : {}) }]; + } + if (!workspacePath || existingSession.workspacePath === workspacePath) return sessions; + return sessions.map((session) => ( + session.key === key ? { ...session, workspacePath } : session + )); + })(), ...(leavingEmpty ? { sessionLabels: Object.fromEntries( Object.entries(s.sessionLabels).filter(([k]) => k !== currentSessionKey), diff --git a/tests/e2e/chat-acp-inline-timeline.spec.ts b/tests/e2e/chat-acp-inline-timeline.spec.ts index df406bda..094561f4 100644 --- a/tests/e2e/chat-acp-inline-timeline.spec.ts +++ b/tests/e2e/chat-acp-inline-timeline.spec.ts @@ -219,6 +219,50 @@ async function installAcpPromptFailureMock(app: ElectronApplication, error: stri }, error); } +async function installTargetAgentRequestRecorder(app: ElectronApplication) { + await app.evaluate(async ({ app: _app }, targetSessionKey) => { + const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron'); + type HostInvokeRequest = { + id?: string; + module?: string; + action?: string; + payload?: Record; + args?: unknown[]; + }; + type RecordedRequest = { action: string; payload: Record }; + type IpcInvokeHandler = (event: unknown, request: HostInvokeRequest) => Promise; + const handlers = (ipcMain as unknown as { _invokeHandlers?: Map })._invokeHandlers; + const originalHostInvoke = handlers?.get('host:invoke'); + const globals = globalThis as unknown as { __targetAgentRequests?: RecordedRequest[] }; + globals.__targetAgentRequests = []; + + ipcMain.removeHandler('host:invoke'); + ipcMain.handle('host:invoke', async (event: unknown, request: HostInvokeRequest) => { + const requestPayload = request.payload ?? (Array.isArray(request.args) ? request.args[0] : undefined); + if ( + request?.module === 'chat' + && (request.action === 'loadAcpSession' || request.action === 'sendAcpPrompt') + && requestPayload?.sessionKey === targetSessionKey + ) { + globals.__targetAgentRequests?.push({ + action: request.action, + payload: requestPayload, + }); + return { id: request.id, ok: true, data: { success: true, generation: 1 } }; + } + return originalHostInvoke?.(event, request) ?? { id: request?.id, ok: true, data: {} }; + }); + }, REVIEWER_SESSION_KEY); +} + +async function getTargetAgentRequests(app: ElectronApplication) { + return await app.evaluate(async ({ app: _app }) => { + return (globalThis as unknown as { + __targetAgentRequests?: Array<{ action: string; payload: Record }>; + }).__targetAgentRequests ?? []; + }); +} + async function installAcpPromptDeferredMock(app: ElectronApplication) { await app.evaluate(async ({ app: _app }) => { const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron'); @@ -939,6 +983,87 @@ test.describe('ClawX ACP inline timeline', () => { } }); + test('creates and sends the first prompt to a newly targeted agent workspace', async ({ launchElectronApp }) => { + const app = await launchElectronApp({ skipSetup: true }); + + try { + await installIpcMocks(app, { + gatewayStatus: { state: 'running', gatewayReady: true, port: 18789, pid: 12345 }, + gatewayRpc: { + [stableStringify(['sessions.list', {}])]: { + success: true, + result: { + sessions: [ + { key: MAIN_SESSION_KEY, displayName: 'main', workspacePath: MAIN_WORKSPACE, updatedAt: new Date().toISOString() }, + ], + }, + }, + }, + hostApi: { + ...baseHostApiMocks(), + [stableStringify(['/api/agents', 'GET'])]: { + ok: true, + data: { + status: 200, + ok: true, + json: { + success: true, + agents: [ + { + id: 'main', + name: 'main', + workspace: MAIN_WORKSPACE, + mainSessionKey: MAIN_SESSION_KEY, + }, + { + id: 'reviewer', + name: 'reviewer', + workspace: REVIEWER_WORKSPACE, + mainSessionKey: REVIEWER_SESSION_KEY, + modelDisplay: 'mock-model', + }, + ], + }, + }, + }, + }, + }); + await installTargetAgentRequestRecorder(app); + + const page = await openChat(app); + await expect(page.getByTestId('acp-chat-empty-state')).toBeVisible({ timeout: 30_000 }); + + await page.getByTestId('chat-composer-agent').click(); + await page.getByRole('button', { name: 'reviewer mock-model' }).click(); + await page.getByTestId('chat-composer-input').fill('Hello reviewer'); + await page.getByTestId('chat-composer-send').click(); + + await expect.poll(async () => { + const requests = await getTargetAgentRequests(app); + return requests.some((request) => request.action === 'sendAcpPrompt'); + }).toBe(true); + + const requests = await getTargetAgentRequests(app); + expect(requests.filter((request) => request.action === 'loadAcpSession')).toEqual([{ + action: 'loadAcpSession', + payload: { + sessionKey: REVIEWER_SESSION_KEY, + workspaceRoot: REVIEWER_WORKSPACE, + cwd: REVIEWER_WORKSPACE, + createIfMissing: true, + }, + }]); + expect(requests.some((request) => ( + request.action === 'sendAcpPrompt' + && request.payload.sessionKey === REVIEWER_SESSION_KEY + && request.payload.cwd === REVIEWER_WORKSPACE + && request.payload.message === 'Hello reviewer' + ))).toBe(true); + } finally { + await closeElectronApp(app); + } + }); + test('keeps recoverable target-agent prompt failures visible after switching sessions', async ({ launchElectronApp }) => { const app = await launchElectronApp({ skipSetup: true }); const error = "Error invoking remote method 'host:invoke': reply was never sent"; diff --git a/tests/unit/chat-acp-page.test.tsx b/tests/unit/chat-acp-page.test.tsx index 148a009b..a017836f 100644 --- a/tests/unit/chat-acp-page.test.tsx +++ b/tests/unit/chat-acp-page.test.tsx @@ -346,9 +346,19 @@ describe('ACP Chat page', () => { chatState.loadSessions.mockReset(); chatState.loadSessions.mockResolvedValue(undefined); chatState.selectAcpSession.mockReset(); - chatState.selectAcpSession.mockImplementation((sessionKey: string) => { + chatState.selectAcpSession.mockImplementation((sessionKey: string, workspacePath?: string) => { chatState.currentSessionKey = sessionKey; chatState.currentAgentId = sessionKey.split(':')[1] || 'main'; + const existingSession = chatState.sessions.find((session) => session.key === sessionKey); + if (existingSession) { + chatState.sessions = chatState.sessions.map((session) => ( + session.key === sessionKey + ? { ...session, workspacePath: workspacePath ?? session.workspacePath } + : session + )); + } else { + chatState.sessions = [...chatState.sessions, { key: sessionKey, workspacePath }]; + } }); chatState.acknowledgeAcpSessionCreated.mockReset(); settingsState.chatWorkspacePath = '/workspace'; @@ -664,6 +674,10 @@ describe('ACP Chat page', () => { { id: 'main', name: 'Main', workspace: '/workspace', mainSessionKey: 'agent:main:main' }, { id: 'research', name: 'Research', workspace: '/research-workspace', mainSessionKey: 'agent:research:desk' }, ]; + chatState.sessions = [ + { key: 'agent:main:main', workspacePath: '/workspace' }, + { key: 'agent:research:desk', workspacePath: '/research-workspace' }, + ]; render(); @@ -675,7 +689,7 @@ describe('ACP Chat page', () => { expect(acpState.loadSession).toHaveBeenCalledWith({ sessionKey: 'agent:research:desk', workspaceRoot: '/research-workspace', cwd: '/research-workspace', }); - expect(chatState.selectAcpSession).toHaveBeenCalledWith('agent:research:desk'); + expect(chatState.selectAcpSession).toHaveBeenCalledWith('agent:research:desk', '/research-workspace'); expect(acpState.sendPrompt).toHaveBeenCalledWith({ sessionKey: 'agent:research:desk', cwd: '/research-workspace', @@ -690,6 +704,41 @@ describe('ACP Chat page', () => { ); }); + it('creates a new target agent session in its workspace before the first prompt', async () => { + agentsState.agents = [ + { id: 'main', name: 'Main', workspace: '/workspace', mainSessionKey: 'agent:main:main' }, + { id: 'research', name: 'Research', workspace: '/research-workspace', mainSessionKey: 'agent:research:main' }, + ]; + + render(); + + fireEvent.click(screen.getByTestId('mock-send-target')); + + await waitFor(() => { + expect(acpState.acceptedPromptSessionKeys).toContain('agent:research:main'); + }); + expect(chatState.selectAcpSession).toHaveBeenCalledWith( + 'agent:research:main', + '/research-workspace', + ); + expect(acpState.loadSession).toHaveBeenCalledWith({ + sessionKey: 'agent:research:main', + workspaceRoot: '/research-workspace', + cwd: '/research-workspace', + createIfMissing: true, + }); + expect(chatState.acknowledgeAcpSessionCreated).toHaveBeenCalledWith( + 'agent:research:main', + '/research-workspace', + ); + expect(acpState.sendPrompt).toHaveBeenCalledWith({ + sessionKey: 'agent:research:main', + cwd: '/research-workspace', + message: 'Ask research', + media: undefined, + }); + }); + it('reloads an active target ACP session before sending when its cwd is stale', async () => { const sessionKey = 'agent:research:desk'; agentsState.agents = [ @@ -711,7 +760,10 @@ describe('ACP Chat page', () => { await waitFor(() => { expect(acpState.loadSession).toHaveBeenCalledWith({ - sessionKey, workspaceRoot: '/research-workspace', cwd: '/research-workspace', + sessionKey, + workspaceRoot: '/research-workspace', + cwd: '/research-workspace', + createIfMissing: true, }); }); expect(acpState.sendPrompt).toHaveBeenCalledWith({ @@ -730,6 +782,10 @@ describe('ACP Chat page', () => { { id: 'main', name: 'Main', workspace: '/workspace', mainSessionKey: 'agent:main:main' }, { id: 'research', name: 'Research', workspace: '/research-workspace', mainSessionKey: 'agent:research:desk' }, ]; + chatState.sessions = [ + { key: 'agent:main:main', workspacePath: '/workspace' }, + { key: 'agent:research:desk', workspacePath: '/research-workspace' }, + ]; acpState.loadSession.mockImplementation(async (input: { sessionKey: string }) => { if (input.sessionKey === 'agent:research:desk') return false; acpState.activeSessionKey = input.sessionKey; @@ -745,7 +801,7 @@ describe('ACP Chat page', () => { sessionKey: 'agent:research:desk', workspaceRoot: '/research-workspace', cwd: '/research-workspace', }); }); - expect(chatState.selectAcpSession).toHaveBeenCalledWith('agent:research:desk'); + expect(chatState.selectAcpSession).toHaveBeenCalledWith('agent:research:desk', '/research-workspace'); expect(acpState.sendPrompt).not.toHaveBeenCalled(); });