mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 00:48:10 +00:00
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)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
@@ -149,7 +149,7 @@ export interface ChatState {
|
||||
// Actions
|
||||
loadSessions: () => Promise<void>;
|
||||
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<void>;
|
||||
|
||||
@@ -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
|
||||
|
||||
+25
-9
@@ -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<T extends Record<string, unknown>>(entries: T, sessionKey: string): T {
|
||||
@@ -2040,7 +2053,7 @@ function buildSessionSwitchPatch(
|
||||
| 'pendingToolImages'
|
||||
>,
|
||||
nextSessionKey: string,
|
||||
options: { createdLocally?: boolean } = {},
|
||||
options: { createdLocally?: boolean; workspacePath?: string } = {},
|
||||
): Partial<ChatState> {
|
||||
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<ChatState>((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 ──
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
args?: unknown[];
|
||||
};
|
||||
type RecordedRequest = { action: string; payload: Record<string, unknown> };
|
||||
type IpcInvokeHandler = (event: unknown, request: HostInvokeRequest) => Promise<unknown>;
|
||||
const handlers = (ipcMain as unknown as { _invokeHandlers?: Map<string, IpcInvokeHandler> })._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<string, unknown> }>;
|
||||
}).__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";
|
||||
|
||||
@@ -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(<Chat />);
|
||||
|
||||
@@ -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(<Chat />);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user