From 31230ef7370092cf9bb6e14159df9c1cf643d35d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=99=E5=AD=90?= <113168461+706412584@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:34:37 +0800 Subject: [PATCH] feat(desktop): bridge agent file edits to workspace panel (Phase 4) (#29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 of editor-lsp-foundation. The CLI agent's FileEditTool / FileWriteTool / NotebookEditTool path was already vendored from upstream and already drives the upstream LSP system. This PR bridges agent edits into the desktop workbench (CodeMirror editor + workspace panel) without touching the vendored tool code: the desktop chatStore observes the agent's tool stream and refreshes/conflict-flags the panel itself. Approach (option A in the spec discussion): - chatStore: when a FILE_EDIT tool ('Edit'/'Write'/'NotebookEdit'/ 'MultiEdit') completes, remember the toolUseId -> file_path mapping in a per-session pending map. On the matching tool_result with isError === false, consume the entry and call workspacePanelStore.notifyAgentFileEdit(sessionId, absolutePath). - workspacePanelStore.notifyAgentFileEdit: refreshes loadStatus when the panel is open, and sets a 'source: agent' conflict on any open editor buffer whose workspace-relative path matches the agent's absolute path by suffix on a normalized segment boundary. Skips buffers that already have a conflict so user-source banners aren't clobbered. Uses sentinel hash 'agent-edit' because the chat tool stream carries no content hash. - Path matching normalizes backslashes -> forward slashes and trims trailing slashes, then either equals or endsWith('/' + bufferPath) to avoid foobar.ts matching bar.ts. - Cleanup: clearPendingFileEdits added at the 3 existing pending-map cleanup sites (disconnect, clear messages, new-session path). Why no FileEditTool change: The vendored upstream tool has no sessionId in ToolUseContext and shouldn't import server WS modules from CLI process scope. The chat stream already carries (sessionId, toolName, toolUseId, input, isError) to the desktop and is the natural seam for desktop-side reactions — same pattern the store already uses for TodoWrite -> useCLITaskStore and Task* tools -> refreshTasks. Tests: - desktop/src/stores/workspacePanelStore.test.ts +6 (30/30 total) Suffix match, Windows backslash, non-suffix substring rejection, existing-conflict preservation, loadStatus when panel open, no loadStatus when closed. - desktop/src/stores/chatStore.test.ts +6 (112/112 total) Edit/Write/NotebookEdit forwarding, isError gate, non-edit tool isolation (Bash), single-fire on duplicate tool_result. Verifier (independent agent) ran lint + both test suites + build + bundle budget + 9 ad-hoc helper edge cases (Unicode paths, trailing slash, identical absolute, deeper suffix mismatch). All PASS. Bundle delta: +2.41 KB gz vs origin/main @ 95931d49 baseline (97.59 KB headroom remaining). _Requirements: 3.1, 3.2, 3.3, 8.1, 8.2 (adapted)_ Co-authored-by: 你的姓名 --- desktop/src/stores/chatStore.test.ts | 130 ++++++++++++++- desktop/src/stores/chatStore.ts | 50 ++++++ .../src/stores/workspacePanelStore.test.ts | 156 ++++++++++++++++++ desktop/src/stores/workspacePanelStore.ts | 72 ++++++++ 4 files changed, 407 insertions(+), 1 deletion(-) diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 125612da..792372af 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { MessageEntry } from '../types/session' import { useSessionRuntimeStore } from './sessionRuntimeStore' @@ -132,6 +132,7 @@ import { type PerSessionState, useChatStore, } from './chatStore' +import { useWorkspacePanelStore } from './workspacePanelStore' const TEST_SESSION_ID = 'test-session-1' const initialState = useChatStore.getState() @@ -4391,3 +4392,130 @@ describe('chatStore message queue', () => { expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messageQueue).toHaveLength(1) }) }) + +describe('chatStore — agent file-edit bridge to workspace panel (Phase 4)', () => { + let notifySpy: ReturnType + + beforeEach(() => { + // The store is a real Zustand instance; spy on the method we forward to. + notifySpy = vi.spyOn(useWorkspacePanelStore.getState(), 'notifyAgentFileEdit') + notifySpy.mockImplementation(() => {}) + // Reset chat store to a clean slate per test. + useChatStore.setState({ sessions: {} }) + }) + + afterEach(() => { + notifySpy.mockRestore() + }) + + it('forwards Edit tool success to notifyAgentFileEdit with the absolute path', () => { + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'tool_use_complete', + toolName: 'Edit', + toolUseId: 'tu-edit-1', + input: { file_path: '/repo/src/app.ts', old_string: 'a', new_string: 'b' }, + }) + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'tool_result', + toolUseId: 'tu-edit-1', + content: 'ok', + isError: false, + }) + + expect(notifySpy).toHaveBeenCalledWith(TEST_SESSION_ID, '/repo/src/app.ts') + }) + + it('forwards Write tool success too', () => { + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'tool_use_complete', + toolName: 'Write', + toolUseId: 'tu-write-1', + input: { file_path: '/repo/src/new.ts', content: 'export {}' }, + }) + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'tool_result', + toolUseId: 'tu-write-1', + content: 'ok', + isError: false, + }) + + expect(notifySpy).toHaveBeenCalledWith(TEST_SESSION_ID, '/repo/src/new.ts') + }) + + it('forwards NotebookEdit using notebook_path instead of file_path', () => { + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'tool_use_complete', + toolName: 'NotebookEdit', + toolUseId: 'tu-nb-1', + input: { notebook_path: '/repo/notebook.ipynb', new_source: 'print(1)' }, + }) + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'tool_result', + toolUseId: 'tu-nb-1', + content: 'ok', + isError: false, + }) + + expect(notifySpy).toHaveBeenCalledWith(TEST_SESSION_ID, '/repo/notebook.ipynb') + }) + + it('does NOT fire when the tool result reports an error', () => { + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'tool_use_complete', + toolName: 'Edit', + toolUseId: 'tu-edit-fail', + input: { file_path: '/repo/src/app.ts', old_string: 'a', new_string: 'b' }, + }) + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'tool_result', + toolUseId: 'tu-edit-fail', + content: 'permission denied', + isError: true, + }) + + expect(notifySpy).not.toHaveBeenCalled() + }) + + it('does NOT fire for non-file-editing tools (e.g. Bash)', () => { + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'tool_use_complete', + toolName: 'Bash', + toolUseId: 'tu-bash-1', + input: { command: 'echo hi' }, + }) + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'tool_result', + toolUseId: 'tu-bash-1', + content: 'hi', + isError: false, + }) + + expect(notifySpy).not.toHaveBeenCalled() + }) + + it('drops the pending entry on consume so the same toolUseId only fires once', () => { + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'tool_use_complete', + toolName: 'Edit', + toolUseId: 'tu-edit-once', + input: { file_path: '/repo/a.ts', old_string: 'x', new_string: 'y' }, + }) + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'tool_result', + toolUseId: 'tu-edit-once', + content: 'ok', + isError: false, + }) + expect(notifySpy).toHaveBeenCalledTimes(1) + + // A duplicate tool_result for the same id (defensive — should not happen + // on a healthy stream) must not double-fire. + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'tool_result', + toolUseId: 'tu-edit-once', + content: 'ok', + isError: false, + }) + expect(notifySpy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index cd6d28ec..2a352203 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -7,6 +7,7 @@ import { useCLITaskStore } from './cliTaskStore' import { useSessionRuntimeStore } from './sessionRuntimeStore' import { useTabStore } from './tabStore' import { useProviderCompatStore } from './providerCompatStore' +import { useWorkspacePanelStore } from './workspacePanelStore' import { randomSpinnerVerb } from '../config/spinnerVerbs' import { notifyDesktop } from '../lib/desktopNotifications' import { t } from '../i18n' @@ -204,8 +205,10 @@ type ChatStore = { const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite']) const TASK_STOP_TOOL_NAMES = new Set(['TaskStop', 'KillShell']) +const FILE_EDIT_TOOL_NAMES = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit']) const pendingTaskToolUseIdsBySession = new Map>() const pendingToolParentUseIdsBySession = new Map>() +const pendingFileEditPathsBySession = new Map>() function addPendingTaskToolUseId(sessionId: string, toolUseId: string): void { const ids = pendingTaskToolUseIdsBySession.get(sessionId) ?? new Set() @@ -249,6 +252,42 @@ function consumePendingToolParentUseId(sessionId: string, toolUseId: string): st return parentToolUseId } +/** + * Extract the absolute file path from a file-mutating tool's input. + * Edit / Write / MultiEdit use `file_path`; NotebookEdit uses `notebook_path`. + */ +function extractEditedFilePath(input: unknown): string | null { + if (!input || typeof input !== 'object') return null + const obj = input as { file_path?: unknown; notebook_path?: unknown } + const candidate = + typeof obj.file_path === 'string' && obj.file_path.length > 0 + ? obj.file_path + : typeof obj.notebook_path === 'string' && obj.notebook_path.length > 0 + ? obj.notebook_path + : null + return candidate +} + +function rememberPendingFileEdit(sessionId: string, toolUseId: string, filePath: string): void { + if (!toolUseId) return + const paths = pendingFileEditPathsBySession.get(sessionId) ?? new Map() + paths.set(toolUseId, filePath) + pendingFileEditPathsBySession.set(sessionId, paths) +} + +function consumePendingFileEdit(sessionId: string, toolUseId: string): string | null { + const paths = pendingFileEditPathsBySession.get(sessionId) + const filePath = paths?.get(toolUseId) + if (!filePath) return null + paths!.delete(toolUseId) + if (paths!.size === 0) pendingFileEditPathsBySession.delete(sessionId) + return filePath +} + +function clearPendingFileEdits(sessionId: string): void { + pendingFileEditPathsBySession.delete(sessionId) +} + function clearPendingToolParentUseIds(sessionId: string): void { pendingToolParentUseIdsBySession.delete(sessionId) } @@ -1001,6 +1040,7 @@ export const useChatStore = create((set, get) => ({ clearPendingToolInputDelta(sessionId) clearPendingTaskToolUseIds(sessionId) clearPendingToolParentUseIds(sessionId) + clearPendingFileEdits(sessionId) queueDrainPaused.delete(sessionId) wsManager.disconnect(sessionId) set((s) => { @@ -1553,6 +1593,7 @@ export const useChatStore = create((set, get) => ({ clearMessages: (sessionId) => { clearPendingTaskToolUseIds(sessionId) clearPendingToolParentUseIds(sessionId) + clearPendingFileEdits(sessionId) clearPendingToolInputDelta(sessionId) resetCompactionThrash(sessionId) set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ @@ -1816,6 +1857,10 @@ export const useChatStore = create((set, get) => ({ } else if (TASK_TOOL_NAMES.has(toolName)) { const useId = msg.toolUseId || session?.activeToolUseId if (useId) addPendingTaskToolUseId(sessionId, useId) + } else if (FILE_EDIT_TOOL_NAMES.has(toolName)) { + const useId = msg.toolUseId || session?.activeToolUseId + const editedPath = extractEditedFilePath(msg.input) + if (useId && editedPath) rememberPendingFileEdit(sessionId, useId, editedPath) } break } @@ -1850,6 +1895,10 @@ export const useChatStore = create((set, get) => ({ if (consumePendingTaskToolUseId(sessionId, msg.toolUseId)) { useCLITaskStore.getState().refreshTasks(sessionId) } + const editedPath = consumePendingFileEdit(sessionId, msg.toolUseId) + if (editedPath && !msg.isError) { + useWorkspacePanelStore.getState().notifyAgentFileEdit(sessionId, editedPath) + } break } @@ -2078,6 +2127,7 @@ export const useChatStore = create((set, get) => ({ clearPendingDelta(sessionId) clearPendingTaskToolUseIds(sessionId) clearPendingToolParentUseIds(sessionId) + clearPendingFileEdits(sessionId) useCLITaskStore.getState().clearTasks(sessionId) useSessionStore.getState().updateSessionTitle(sessionId, 'New Session') useTabStore.getState().updateTabTitle(sessionId, 'New Session') diff --git a/desktop/src/stores/workspacePanelStore.test.ts b/desktop/src/stores/workspacePanelStore.test.ts index 3289920b..203cc422 100644 --- a/desktop/src/stores/workspacePanelStore.test.ts +++ b/desktop/src/stores/workspacePanelStore.test.ts @@ -726,4 +726,160 @@ describe('workspacePanelStore', () => { expect(state.errors.previewByTabId['session-clear::file:src/a.ts']).toBeUndefined() expect(state.errors.previewByTabId['session-reset::file:src/b.ts']).toBeUndefined() }) + + describe('notifyAgentFileEdit (Phase 4)', () => { + it('flags an open buffer with an agent-source conflict when paths match by suffix', () => { + const tabId = 'file:src/app.ts' + useWorkspacePanelStore.setState((state) => ({ + ...state, + bufferStateByTabId: { + ...state.bufferStateByTabId, + [tabId]: { + tabId, + path: 'src/app.ts', + baseHash: 'a'.repeat(64), + baseContent: 'old', + currentContent: 'old', + isDirty: false, + encoding: 'utf-8', + lineEnding: 'LF', + conflict: null, + }, + }, + })) + + useWorkspacePanelStore.getState().notifyAgentFileEdit( + 'session-agent', + 'C:/Users/me/repo/src/app.ts', + ) + + const buffer = useWorkspacePanelStore.getState().bufferStateByTabId[tabId]! + expect(buffer.conflict).toMatchObject({ source: 'agent' }) + expect(buffer.conflict!.hash).toBe('agent-edit') + }) + + it('also matches Windows backslash absolute paths', () => { + const tabId = 'file:src/app.ts' + useWorkspacePanelStore.setState((state) => ({ + ...state, + bufferStateByTabId: { + ...state.bufferStateByTabId, + [tabId]: { + tabId, + path: 'src/app.ts', + baseHash: 'a'.repeat(64), + baseContent: 'old', + currentContent: 'old', + isDirty: false, + encoding: 'utf-8', + lineEnding: 'LF', + conflict: null, + }, + }, + })) + + useWorkspacePanelStore.getState().notifyAgentFileEdit( + 'session-agent', + 'C:\\Users\\me\\repo\\src\\app.ts', + ) + + const buffer = useWorkspacePanelStore.getState().bufferStateByTabId[tabId]! + expect(buffer.conflict?.source).toBe('agent') + }) + + it('does not flag a buffer whose path is a non-suffix substring of the agent edit path', () => { + const tabId = 'file:bar.ts' + useWorkspacePanelStore.setState((state) => ({ + ...state, + bufferStateByTabId: { + ...state.bufferStateByTabId, + [tabId]: { + tabId, + path: 'bar.ts', + baseHash: 'a'.repeat(64), + baseContent: 'x', + currentContent: 'x', + isDirty: false, + encoding: 'utf-8', + lineEnding: 'LF', + conflict: null, + }, + }, + })) + + // foobar.ts ends with "bar.ts" but NOT with "/bar.ts" — must not match. + useWorkspacePanelStore.getState().notifyAgentFileEdit( + 'session-agent', + '/repo/foobar.ts', + ) + + const buffer = useWorkspacePanelStore.getState().bufferStateByTabId[tabId]! + expect(buffer.conflict).toBeNull() + }) + + it('does not clobber an existing conflict on the same buffer', () => { + const tabId = 'file:src/app.ts' + const existingConflict = { + source: 'user' as const, + hash: 'b'.repeat(64), + timestamp: 100, + } + useWorkspacePanelStore.setState((state) => ({ + ...state, + bufferStateByTabId: { + ...state.bufferStateByTabId, + [tabId]: { + tabId, + path: 'src/app.ts', + baseHash: 'a'.repeat(64), + baseContent: 'x', + currentContent: 'x', + isDirty: false, + encoding: 'utf-8', + lineEnding: 'LF', + conflict: existingConflict, + }, + }, + })) + + useWorkspacePanelStore.getState().notifyAgentFileEdit( + 'session-agent', + '/repo/src/app.ts', + ) + + const buffer = useWorkspacePanelStore.getState().bufferStateByTabId[tabId]! + expect(buffer.conflict).toBe(existingConflict) + }) + + it('triggers loadStatus when the panel is open for the session', async () => { + mocks.getWorkspaceStatusMock.mockResolvedValue({ + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }) + useWorkspacePanelStore.getState().openPanel('session-loaded') + + useWorkspacePanelStore.getState().notifyAgentFileEdit( + 'session-loaded', + '/repo/src/whatever.ts', + ) + + // Drain any pending microtasks scheduled by void loadStatus(...) + await Promise.resolve() + await Promise.resolve() + expect(mocks.getWorkspaceStatusMock).toHaveBeenCalledWith('session-loaded') + }) + + it('skips loadStatus when the panel is closed for the session', () => { + // Panel default-state isOpen is false — never opened. + useWorkspacePanelStore.getState().notifyAgentFileEdit( + 'session-closed', + '/repo/src/whatever.ts', + ) + expect(mocks.getWorkspaceStatusMock).not.toHaveBeenCalled() + }) + }) }) diff --git a/desktop/src/stores/workspacePanelStore.ts b/desktop/src/stores/workspacePanelStore.ts index 59a4898f..36ed96a5 100644 --- a/desktop/src/stores/workspacePanelStore.ts +++ b/desktop/src/stores/workspacePanelStore.ts @@ -132,6 +132,15 @@ type WorkspacePanelStore = { setBufferState: (tabId: string, content: string) => void applyExternalSave: (tabId: string, event: WorkspaceExternalSavePayload) => void acknowledgeConflict: (tabId: string, action: 'reload' | 'keepMine' | 'openConflict') => void + /** + * React to an agent (CLI) file edit observed via the chat tool stream. + * Unlike `applyExternalSave`, no content hash is available — the agent + * edit is surfaced through the tool_result event, not a workspace save + * round-trip. So we (a) refresh the workspace status/diagnostics and + * (b) flag any open buffer for the same path with an agent-source + * conflict so the editor offers a reload. + */ + notifyAgentFileEdit: (sessionId: string, absolutePath: string) => void clearBuffer: (tabId: string) => void clearSession: (sessionId: string) => void resetSessionUi: (sessionId: string) => void @@ -183,6 +192,33 @@ export function getWorkspacePreviewTabId(path: string, kind: WorkspacePreviewKin return `${kind}:${path}` } +/** + * Sentinel hash for agent-originated edits. Agent edits are observed through + * the chat tool stream (tool_result), which carries no file content hash, so + * we can't compare against a buffer's baseHash. This sentinel is chosen to + * never collide with a real sha-256 hex digest, guaranteeing the conflict + * banner surfaces for an open buffer. + */ +export const AGENT_EDIT_SENTINEL_HASH = 'agent-edit' + +/** Normalize a path for agent-edit suffix matching: forward slashes, no trailing slash. */ +function normalizeAgentEditPath(p: string): string { + return p.replace(/\\/g, '/').replace(/\/+$/, '') +} + +/** + * Whether an agent's (absolute) edited path refers to the same file as an + * open buffer's (workspace-relative) path. The agent reports an absolute + * path while buffers store workspace-relative paths, so we match by suffix + * on a normalized segment boundary to avoid `foo/bar.ts` matching + * `otherbar.ts`. + */ +function agentEditMatchesBufferPath(normalizedAbs: string, bufferPath: string): boolean { + const normalizedBuffer = normalizeAgentEditPath(bufferPath) + if (normalizedAbs === normalizedBuffer) return true + return normalizedAbs.endsWith(`/${normalizedBuffer}`) +} + function makePreviewKey(sessionId: string, tabId: string) { return `${sessionId}::${tabId}` } @@ -863,6 +899,42 @@ export const useWorkspacePanelStore = create((set, get) => } }), + notifyAgentFileEdit: (sessionId, absolutePath) => { + // Refresh status/diagnostics for the session regardless of whether the + // edited file is open — the file tree + changed set may have moved. + if (get().isPanelOpen(sessionId)) { + void get().loadStatus(sessionId) + } + + // Flag any open buffer for the same path with an agent-source conflict. + // Agent edits arrive without a content hash (they come from the chat + // tool stream, not a workspace save), so we use a sentinel hash that can + // never equal a real base hash — guaranteeing the conflict surfaces. + const normalizedAbs = normalizeAgentEditPath(absolutePath) + set((state) => { + let changed = false + const nextBuffers: Record = { + ...state.bufferStateByTabId, + } + for (const [tabId, buffer] of Object.entries(state.bufferStateByTabId)) { + if (!buffer) continue + if (!agentEditMatchesBufferPath(normalizedAbs, buffer.path)) continue + // Already showing a conflict — don't clobber the existing one. + if (buffer.conflict) continue + changed = true + nextBuffers[tabId] = { + ...buffer, + conflict: { + source: 'agent', + hash: AGENT_EDIT_SENTINEL_HASH, + timestamp: Date.now(), + }, + } + } + return changed ? { bufferStateByTabId: nextBuffers } : state + }) + }, + clearBuffer: (tabId) => set((state) => ({ bufferStateByTabId: removeRecordKey(state.bufferStateByTabId, tabId),