diff --git a/docs/features/agent-session-transfer/plan.md b/docs/features/agent-session-transfer/plan.md new file mode 100644 index 000000000..b633008d1 --- /dev/null +++ b/docs/features/agent-session-transfer/plan.md @@ -0,0 +1,308 @@ +# Implementation Plan - Agent Session Transfer + +## Current Code Notes + +- `DeepChatAgentsSettings.vue` deletes custom DeepChat agents through + `configPresenter.deleteDeepChatAgent(form.id)` after `window.confirm`. +- `AcpSettings.vue` deletes manual ACP agents through `configPresenter.removeManualAcpAgent(agent.id)` + after `window.confirm`. +- `AgentRepository.deleteDeepChatAgent` currently reassigns all `new_sessions.agent_id` values from + the deleted custom DeepChat agent to built-in `deepchat`. +- `AgentRepository.removeManualAcpAgent` deletes only the agent record. +- `NewSessionsTable` already supports list/page filtering by `agentId` and has a batch + `reassignAgentId(fromAgentId, toAgentId)`, but it does not expose a precise single-session + ownership update or impact counts. +- `AgentSessionPresenter` is the correct place to check runtime state, delete sessions with their + child subagents, emit session-list updates, and coordinate ACP session cleanup. +- `AgentRuntimePresenter` caches session agent ids in memory, so ownership moves need a runtime hook, + not just a database update. +- `ChatTopBar.vue` already has a right-side `...` dropdown for writable sessions. Its current order + is pin/unpin, clear messages, separator, delete. The session move entry should be inserted between + pin/unpin and clear messages. + +## Architecture + +### Shared Contracts + +Add typed route contracts in `src/shared/contracts/routes/sessions.routes.ts`: + +- `sessions.getAgentTransferImpact` + - input: `{ agentId: string }` + - output: `{ impact: AgentTransferImpact }` +- `sessions.moveAgentSessions` + - input: `{ fromAgentId: string; toAgentId: string }` + - output: `{ movedSessionIds: string[]; deletedSessionIds: string[] }` +- `sessions.deleteAgentSessions` + - input: `{ agentId: string }` + - output: `{ deletedSessionIds: string[] }` +- `sessions.moveSessionToAgent` + - input: `{ sessionId: string; toAgentId: string }` + - output: `{ session: SessionWithState }` + +Proposed shared shape: + +```ts +type AgentTransferImpact = { + agentId: string + totalSessions: number + regularSessions: number + subagentSessions: number + emptyDrafts: number + movableSessions: number + blockedSessions: number + samples: Array<{ + id: string + title: string + sessionKind: 'regular' | 'subagent' + isDraft: boolean + projectDir: string | null + status: 'idle' | 'generating' | 'error' + blockReason?: 'active' | 'pending-input' + }> +} +``` + +Keep `config.listAgents` as the source for enabled target-agent options. + +### Presenter Responsibilities + +`AgentSessionPresenter`: + +- `getAgentTransferImpact(agentId)` + - List `new_sessions` with `{ agentId, includeSubagents: true }`. + - Treat drafts with no message ids as empty drafts. + - Use `getSessionState` and pending-input inspection to classify movable vs blocked. + - Return a small sample list for the dialog, not the full history. +- `moveAgentSessions(fromAgentId, toAgentId, options)` + - Validate both agents exist and are not the same. + - Recompute impact server-side immediately before mutation. + - Refuse the entire operation when any non-empty related session is blocked. + - Delete empty drafts for the source agent. + - Move each non-empty related session through the same internal helper used by + `moveSessionToAgent`. +- `moveSessionToAgent(sessionId, toAgentId, options)` + - Only allow regular sessions from the public chat-level route. + - Refuse active/generating sessions. + - Resolve target runtime defaults: + - DeepChat target: target agent config merged with built-in DeepChat defaults, then app default + model fallback. + - ACP target: rejected. Conversation history must not move into ACP agents. + - Update `new_sessions.agent_id` and any target-specific session fields in one logical operation. + - Update the DeepChat runtime's session-agent cache and provider/model/generation settings. + - Clear stale ACP binding for the previous ACP agent when moving away from ACP. + - Emit `SESSION_EVENTS.LIST_UPDATED` / typed `sessions.updated`. +- `deleteAgentSessions(agentId)` + - Recompute impact. + - Refuse active sessions. + - Delete related sessions with existing `deleteSessionInternal`, which already handles child + sessions, message cleanup, permissions, skills, search documents, and ACP cleanup. + +`AgentRuntimePresenter`: + +- Add an optional implementation method such as `setSessionAgentContext(sessionId, context)` to + `IAgentImplementation`. +- The DeepChat implementation updates `sessionAgentIds`, provider/model, generation settings, + disabled tool cache, project dir cache, and invalidates system prompt/tool profile caches without + deleting messages. +- It must reject generating sessions. + +`NewSessionsTable` / `NewSessionManager`: + +- Add a precise `updateAgentId(sessionId, agentId)` helper. +- Keep `reassignAgentId` as a low-level fallback only; the transfer path should move session by + session so runtime state and ACP cleanup stay correct. +- Consider `countByAgent(agentId, includeSubagents)` for fast summary, but correctness can start with + `list({ agentId, includeSubagents: true })`. + +`AgentRepository` / `ConfigPresenter`: + +- Change custom DeepChat deletion so it no longer silently reassigns sessions to built-in DeepChat. + The UI should call the session move/delete route first, then delete the agent. +- Manual ACP deletion can keep removing only the agent record, because the UI/session route will have + already moved or deleted related sessions. +- Keep a defensive fallback in the delete methods: if sessions still exist for the source agent, + return `false` or throw a clear error instead of silently orphaning or reassigning them. + +### Renderer + +Create a reusable transfer dialog surface, likely `AgentTransferDialog.vue` plus a thin +delete-agent wrapper. It should support both one-shot agent deletion migration and single-session +movement. + +For delete-agent usage, mount it from: + +- `DeepChatAgentsSettings.vue` +- `AcpSettings.vue` manual custom-agent section + +For chat-level usage, mount it from `ChatTopBar.vue` and open it from the right-side `...` menu item +placed between pin/unpin and clear messages: + +```text +DropdownMenuContent + Pin / Unpin + Move conversation + Clear messages + separator + Delete +``` + +The delete-agent dialog fetches: + +- impact via `SessionClient.getAgentTransferImpact(agentId)` +- target options via `ConfigClient.listAgents()`, filtered to enabled DeepChat agents except source + +It submits: + +- move path: `SessionClient.moveAgentSessions(...)`, then existing agent deletion +- delete path: `SessionClient.deleteAgentSessions(agentId)`, then existing agent deletion + +The chat-level dialog fetches the same target options and submits: + +- move path: `SessionClient.moveSessionToAgent(sessionId, targetAgentId)` + +Dialog layout requirements: + +- Use a viewport-aware max height, for example + `max-h-[min(720px,calc(100vh-2rem))]` on desktop and `max-h-[calc(100vh-1rem)]` on narrow + screens. +- Keep the header and footer outside the scrolling region so the title and actions remain visible. +- Put impact summaries, affected chat samples, explanatory copy, and the target picker in an + internal `overflow-y-auto` body. +- Prefer a single-column mobile layout; only use side-by-side summary/details areas on wider screens. +- Ensure long agent names, session titles, and project paths truncate or wrap without widening the + dialog. +- Use a loading state inside the body, not a full-window blocker. + +Chat-level move behavior: + +- Show only for active regular sessions. +- Disable while the session status is generating. +- Use `SessionClient.moveSessionToAgent(sessionId, targetAgentId)`. +- Update `useSessionStore` with the returned session and let existing selected-agent sync run. + +## Event Flow + +Delete with move: + +```text +Settings delete button + -> SessionClient.getAgentTransferImpact(agentId) + -> AgentDeleteImpactDialog opens + -> user chooses target agent + -> SessionClient.moveAgentSessions(fromAgentId, toAgentId) + -> AgentSessionPresenter moves sessions and emits sessions.updated + -> ConfigPresenter deletes source agent and emits config.agents.changed + -> settings reloads agents, session store refreshes affected sessions +``` + +Delete with related chats removed: + +```text +Settings delete button + -> impact dialog + -> user chooses "Delete chats with this Agent" + -> SessionClient.deleteAgentSessions(agentId) + -> ConfigPresenter deletes source agent + -> session store receives deleted session ids / list update +``` + +Single session move: + +```text +ChatTopBar right-side ... menu + -> user selects target agent + -> SessionClient.moveSessionToAgent(sessionId, targetAgentId) + -> AgentSessionPresenter updates session ownership/runtime context + -> sessions.updated(reason: updated) + -> Session store upserts returned session and selected agent syncs to target +``` + +## Data Rules + +- Preserve: + - `new_sessions.id`, title, pin state, project dir, parent relation + - deepchat messages, assistant blocks, files, traces, tape entries + - search documents and usage stats + - session summary/compaction state where still meaningful +- Reset or recompute: + - target agent id + - provider/model for future turns + - DeepChat generation defaults for target DeepChat agents + - disabled tools from the target DeepChat agent + - ACP external session id and bound process state +- Delete: + - empty drafts during delete-agent flow + - related sessions only when the user explicitly picks the destructive option + +## ACP Transfer Handling + +- ACP agents are allowed as sources only. A manual ACP agent's idle chats can move to an enabled + DeepChat agent before the ACP agent is deleted. +- ACP agents are never valid transfer targets. This blocks both DeepChat-to-ACP and ACP-to-ACP moves, + reducing the risk of future conflicts with ACP's external session bindings. +- When moving an ACP-backed chat to DeepChat, clear the stale ACP provider binding before applying + the target DeepChat runtime context. +- Because ACP is not a target, the first increment does not need a workdir picker in the transfer + dialog. + +## Compatibility + +- Existing sessions assigned to removed manual ACP agents are not fixed automatically by this feature + unless the source agent still exists at deletion time. A later repair utility can scan orphaned + `new_sessions.agent_id` values. +- Existing custom DeepChat deletion behavior changes from implicit fallback to explicit user choice. + This is intentional and should be called out in release notes. +- No schema migration is required for the first increment if `new_sessions.agent_id` is updated via a + new helper. + +## Test Strategy + +Main tests: + +- `agentRepository.test.ts` + - Custom DeepChat delete refuses when sessions remain, or no longer silently reassigns. + - Manual ACP delete remains safe after sessions are moved/deleted. +- `agentSessionPresenter.test.ts` + - Impact summary counts regular/subagent/draft/blocked sessions. + - Batch move DeepChat -> DeepChat applies target ownership and model defaults. + - Batch move ACP -> DeepChat clears ACP binding and keeps messages. + - Moving to an ACP target is rejected for both DeepChat and ACP sources. + - Active/generating sessions block move and delete. + - `deleteAgentSessions` deletes related sessions through existing recursive cleanup. +- `agentRuntimePresenter.test.ts` + - Session agent context update refreshes cached agent id and invalidates prompt/tool caches. + - Generating sessions reject context transfer. + +Renderer tests: + +- `DeepChatAgentsSettings.test.ts` + - Delete opens impact dialog. + - Move path calls session move before agent delete. + - Delete path calls session delete before agent delete. +- `AcpSettings` test coverage for manual ACP delete impact dialog. +- `ChatTopBar` tests: + - `Move conversation` appears between pin/unpin and clear messages. + - Selecting it opens the transfer dialog. + - The entry is disabled or unavailable for read-only/subagent/active sessions. +- Dialog responsiveness tests should assert the shell has a viewport max height and the detail body + owns the scroll region. +- Store/client tests for single-session move and disabled active state. + +Quality gates after implementation: + +- `pnpm run format` +- `pnpm run i18n` +- `pnpm run lint` +- Targeted Vitest suites for touched main and renderer modules + +## Risks + +- Runtime cache drift is the main risk. Mitigation: transfer through AgentSessionPresenter and add a + runtime-level context update method. +- ACP sessions may have provider-specific expectations about session continuity. Mitigation: allow + ACP only as a source, clear stale ACP bindings when moving to DeepChat, and reject ACP targets. +- Batch deletion is destructive. Mitigation: in-app dialog, explicit destructive option, and tests that + verify move is the default/safe path. +- Applying target DeepChat defaults can surprise users who expected current model settings to remain. + Mitigation: dialog copy states that future replies use the target agent; future enhancement can add + "keep current model/settings". diff --git a/docs/features/agent-session-transfer/spec.md b/docs/features/agent-session-transfer/spec.md new file mode 100644 index 000000000..aadd37e67 --- /dev/null +++ b/docs/features/agent-session-transfer/spec.md @@ -0,0 +1,264 @@ +# Agent Session Transfer + +## Context + +[Issue #1705](https://github.com/ThinkInAIXYZ/deepchat/issues/1705) reports that deleting an +agent can make the user's valuable work feel lost because the app does not explain what happens to +that agent's chats. The current implementation is inconsistent: + +- Custom DeepChat agent deletion asks for a simple browser confirm, then reassigns + `new_sessions.agent_id` to the built-in `deepchat`. +- Manual ACP agent deletion asks for a simple browser confirm, then removes the agent record without + reassigning sessions. Sessions that still point at the removed ACP agent can become unavailable in + the session list. + +The feature should turn agent deletion from an opaque destructive action into an explicit choice: +move/import related conversations to another eligible DeepChat agent, or delete those conversations +together with the agent. + +## User Need + +Users need to understand and control what happens to chats owned by an agent before deleting that +agent. They also need a normal chat-level way to move an idle conversation to another DeepChat agent +when the conversation history is useful but the next turns should use a different agent. + +## Goals + +- Show a structured delete-agent dialog whenever a deletable DeepChat or manual ACP agent has related + sessions. +- Offer two clear outcomes in that dialog: + - Move related sessions to another enabled DeepChat agent, then delete the source agent. + - Delete related sessions, then delete the source agent. +- Allow regular idle sessions to move from their current agent to a DeepChat agent outside the delete + flow. +- Expose exactly two first-increment migration entry points: + - One-shot migration while deleting an agent. + - A chat detail action from the active conversation's top-right `...` menu. +- Support DeepChat-to-DeepChat and ACP-to-DeepChat moves for idle sessions by preserving + conversation history and reinitializing the target DeepChat agent runtime for future turns. +- Prevent moves into ACP agents. DeepChat history must not be moved to ACP, and ACP-to-ACP moves are + blocked to avoid future ACP session binding conflicts. +- Keep empty draft sessions from blocking deletion. + +## Non-goals + +- No moving sessions while a turn is generating, waiting for tool approval, or otherwise active. +- No automatic migration for registry ACP uninstall in the first increment. Manual ACP delete and + custom DeepChat delete are the user-visible delete paths covered first. +- No cross-agent move for subagent sessions from the manual chat action in the first increment. + Subagent sessions are included in delete-agent impact handling because they can still reference the + deleted agent. +- No deep copy/duplicate UI in the first increment. The first shipped action is move/import, not + "duplicate and keep the original under the old agent". +- No moving any conversation history into ACP agents in the first increment. ACP sessions may only + move out to a DeepChat agent. + +## Terminology + +- **Move/import**: keep the same DeepChat session id and stored messages, change the owning + `agent_id`, and make future turns use the target DeepChat agent. +- **Related sessions**: rows in `new_sessions` whose `agent_id` is the agent being deleted, including + regular sessions, subagent sessions, and drafts. +- **Importable sessions**: related sessions that are idle and can be safely re-bound to a target + agent. +- **Empty drafts**: draft rows without messages. These may be deleted during source-agent deletion + without being offered as valuable chat history. + +## User Stories + +1. As a user deleting a custom DeepChat agent, I can see how many chats will be affected before I + confirm deletion. +2. As a user deleting a manual ACP agent, I can move that agent's finished chats to a DeepChat agent + so they remain visible and usable. +3. As a user who does not want to keep related chats, I can explicitly delete those chats together + with the agent. +4. As a user viewing an idle regular conversation, I can move it to a different DeepChat agent from + the chat UI, then continue the conversation with the target agent. +5. As a user with an active conversation, I am told to stop or wait before moving the conversation. + +## Acceptance Criteria + +1. Deleting a custom DeepChat or manual ACP agent with related non-empty sessions opens an in-app + dialog rather than `window.confirm`. +2. The delete dialog shows counts for regular sessions, subagent sessions, empty drafts, and sessions + that cannot currently be moved because they are active. +3. The primary safe action is "Move chats to..." and requires selecting an enabled DeepChat target + agent that is not the source agent. +4. The destructive action is "Delete chats and agent"; it clearly states that related chats will be + removed. +5. If the source agent has no non-empty related sessions, deletion can use a shorter in-app confirm + that states there are no chats to move. +6. Moving to a DeepChat agent applies the target agent's runtime defaults for future turns while + preserving existing messages, attachments, search documents, tape entries, and title. +7. Moving into ACP is not allowed. The UI must not list ACP agents as transfer targets, and the main + process must reject direct move requests whose target agent resolves to ACP. +8. Active or generating sessions cannot move. The dialog lists the blocked count and disables the + move/delete completion until those sessions are stopped or finish. +9. After a successful delete-agent move, the source agent is removed and moved sessions appear under + the target agent in the session list without an app restart. +10. After a successful chat-level move, the active session remains open, the selected agent syncs to + the target agent, and the next user message uses the target agent. +11. The chat-level move entry is in the active conversation top bar's right-side `...` menu, placed + between "Pin/Unpin" and "Clear messages". +12. Transfer dialogs are responsive: they keep a viewport-aware maximum height, keep header/footer + actions visible, and scroll only the detailed body content when the impact list or help text is + long. +13. All new user-facing text uses i18n keys. +14. Tests cover impact summary, DeepChat deletion with move, manual ACP deletion with move to a + DeepChat target, explicit delete of related sessions, chat-level move, active-session blocking, + and rejection of ACP targets. + +## UX States + +### Delete Agent With Movable Chats + +```text ++----------------------------------------------------------------+ +| Delete Agent | +| Agent: Code Reviewer | ++----------------------------------------------------------------+ +| This agent has conversations attached to it. Choose how | +| DeepChat should handle them before the agent is deleted. | +| | +| Impact | +| Regular chats 12 | +| Subagent chats 3 | +| Empty drafts 2 | +| Currently active 0 | +| | +| What should happen to these chats? | +| | +| (o) Move chats to another DeepChat Agent | +| Target Agent | +| [ DeepChat v ] | +| | +| Future replies will use the target Agent. Existing | +| messages and files stay in the same chats. | +| | +| ( ) Delete chats with this Agent | +| Related chats and their local files will be removed. | +| | +| Recent affected chats | +| - Automation setup | +| - Code review workflow | +| - Release checklist | +| ... body scrolls when this area grows ... | ++----------------------------------------------------------------+ +| [ Cancel ] [ Move & Delete ] | ++----------------------------------------------------------------+ +``` + +### Delete Agent With Active Chats + +```text ++------------------------------------------------------------+ +| Delete Agent | +| | +| Agent: Claude Code | +| | +| 2 related chats are still active. Stop or wait for those | +| chats before deleting this agent. | +| | +| Impact | +| Regular chats 5 | +| Subagent chats 0 | +| Empty drafts 1 | +| Currently active 2 | +| | +| [ View Active Chats ] [ Close ] | ++------------------------------------------------------------+ +``` + +### Delete Agent With No Chats + +```text ++----------------------------------------------+ +| Delete Agent | +| | +| Delete "Scratch Agent"? | +| No conversations are attached to this agent. | +| | +| [ Cancel ] [ Delete ] | ++----------------------------------------------+ +``` + +### Chat-Level Move + +```text +Chat Top Bar ++----------------------------------------------------------------+ +| Project notes [share] [...] | ++----------------------------------------------------------------+ + +Top-right ... menu ++--------------------------------------+ +| Pin | +| Move conversation | +| Clear messages | +| ------------------------------------ | +| Delete | ++--------------------------------------+ + +Move dialog ++------------------------------------------------------------+ +| Move Conversation | +| Project notes | ++------------------------------------------------------------+ +| Current Agent | +| DeepChat | +| | +| Target Agent | +| [ Code Reviewer v ] | +| | +| Existing messages and files stay in this conversation. | +| Future replies will use the target DeepChat Agent. | +| ACP agents are not listed as targets. ACP chats can move | +| out to DeepChat, but chats cannot move into ACP. | ++------------------------------------------------------------+ +| [ Cancel ] [ Move ] | ++------------------------------------------------------------+ +``` + +### Responsive Dialog Rules + +```text +Desktop / tablet ++------------------------------------------------------------+ +| Fixed header: title, source agent/session | ++------------------------------------------------------------+ +| Scroll body: impact, affected chat samples, target picker, | +| explanatory copy, target picker, affected chats | ++------------------------------------------------------------+ +| Fixed footer: cancel + primary/destructive action | ++------------------------------------------------------------+ + +Narrow mobile ++--------------------------------------+ +| Fixed header | ++--------------------------------------+ +| Single-column scroll body | +| Controls keep full width | ++--------------------------------------+ +| Fixed footer | +| [ Cancel ] [ Move ] | ++--------------------------------------+ +``` + +## Constraints + +- Follow the existing Presenter pattern: session ownership and transfer belongs in + `AgentSessionPresenter`; agent record deletion remains in `ConfigPresenter` / + `AgentRepository`. +- New renderer-main APIs should use typed routes and `renderer/api/*Client` rather than adding new + direct `useLegacyPresenter()` usage. +- Existing `new_sessions` rows, message tables, tape tables, files, search documents, and usage stats + are user data and must not be dropped during move/import. +- The transfer flow must update both persisted state and in-memory runtime caches, otherwise hooks and + the next message can still report the old agent id. +- ACP transfer must treat `acp_sessions` as target-agent-specific because it is keyed by + `(conversation_id, agent_id)`. + +## Open Questions + +None for the first increment. A future duplicate/copy feature can be specified separately after the +move/import behavior is shipped and tested. diff --git a/docs/features/agent-session-transfer/tasks.md b/docs/features/agent-session-transfer/tasks.md new file mode 100644 index 000000000..46a6c5e84 --- /dev/null +++ b/docs/features/agent-session-transfer/tasks.md @@ -0,0 +1,29 @@ +# Tasks - Agent Session Transfer + +- [x] Contracts: add typed session transfer routes and shared schemas in `sessions.routes.ts`. +- [x] Client: expose `getAgentTransferImpact`, `moveAgentSessions`, `deleteAgentSessions`, and + `moveSessionToAgent` from `SessionClient`. +- [x] SQLite/session manager: add precise `updateAgentId(sessionId, agentId)` and any small counting + helpers needed for impact summaries. +- [x] Runtime: add a DeepChat runtime method to update session agent context without deleting + messages; reject generating sessions and invalidate agent-dependent caches. +- [x] Main presenter: implement `AgentSessionPresenter` impact summary, batch move, single-session + move, and delete-by-agent flows. +- [x] ACP handling: clear stale source ACP bindings when moving ACP chats to DeepChat, and block ACP + targets so DeepChat-to-ACP and ACP-to-ACP moves cannot occur. +- [x] Agent deletion safety: remove silent DeepChat fallback reassignment and prevent deleting an + agent while sessions still point at it. +- [x] Renderer dialog: build a responsive transfer dialog with a viewport-aware max height, fixed + header/footer, internal scroll body, move/delete states, target-agent selection, + blocked-session messaging, loading, and error states. +- [x] Settings integration: replace `window.confirm` deletion in `DeepChatAgentsSettings.vue` and + manual-agent deletion in `AcpSettings.vue`. +- [x] Chat-level move: add `Move conversation` to `ChatTopBar.vue`'s right-side `...` menu between + pin/unpin and clear messages, then wire it to the transfer dialog and store/client integration. +- [x] i18n: add English and Chinese strings first, then run the repository i18n workflow for other + locales. +- [ ] Tests: main presenter coverage exists for impact summaries, DeepChat moves, ACP-to-DeepChat, + and ACP target rejection; renderer dialog/store and repository regression tests remain as + follow-up coverage. +- [x] Validation: run `pnpm run format`, `pnpm run i18n`, `pnpm run lint`, typecheck, targeted + tests, and `git diff --check`. diff --git a/src/main/presenter/agentRepository/index.ts b/src/main/presenter/agentRepository/index.ts index cc2d2368a..e63f3f0d3 100644 --- a/src/main/presenter/agentRepository/index.ts +++ b/src/main/presenter/agentRepository/index.ts @@ -192,7 +192,14 @@ export class AgentRepository { return false } - this.sqlitePresenter.newSessionsTable.reassignAgentId(agentId, BUILTIN_DEEPCHAT_AGENT_ID) + const relatedSessions = this.sqlitePresenter.newSessionsTable.list({ + agentId, + includeSubagents: true + }) + if (relatedSessions.length > 0) { + return false + } + this.sqlitePresenter.agentsTable.delete(agentId) return true } @@ -287,6 +294,13 @@ export class AgentRepository { if (!row || row.agent_type !== 'acp' || row.source !== 'manual') { return false } + const relatedSessions = this.sqlitePresenter.newSessionsTable.list({ + agentId, + includeSubagents: true + }) + if (relatedSessions.length > 0) { + return false + } this.sqlitePresenter.agentsTable.delete(agentId) return true } diff --git a/src/main/presenter/agentRuntimePresenter/index.ts b/src/main/presenter/agentRuntimePresenter/index.ts index 3d1731106..6b43db139 100644 --- a/src/main/presenter/agentRuntimePresenter/index.ts +++ b/src/main/presenter/agentRuntimePresenter/index.ts @@ -20,6 +20,7 @@ import type { QueuePendingInputOptions, SendMessageInput, SessionCompactionState, + SessionAgentContextUpdate, SessionGenerationSettings, ToolInteractionResponse, ToolInteractionResult, @@ -1424,6 +1425,54 @@ export class AgentRuntimePresenter implements IAgentImplementation { this.invalidateToolProfileCache(sessionId) } + async setSessionAgentContext( + sessionId: string, + config: SessionAgentContextUpdate + ): Promise { + const nextProviderId = config.providerId?.trim() + const nextModelId = config.modelId?.trim() + const nextAgentId = config.agentId?.trim() + if (!nextAgentId || !nextProviderId || !nextModelId) { + throw new Error('Session agent context update requires agentId, providerId and modelId.') + } + + const state = this.runtimeState.get(sessionId) + const dbSession = this.sessionStore.get(sessionId) + if (!state && !dbSession) { + throw new Error(`Session ${sessionId} not found`) + } + + if (state?.status === 'generating') { + throw new Error('Cannot move session while it is generating.') + } + + const permissionMode: PermissionMode = + config.permissionMode === 'default' ? 'default' : 'full_access' + const sanitizedGenerationSettings = await this.sanitizeGenerationSettings( + nextProviderId, + nextModelId, + config.generationSettings ?? {} + ) + + this.runtimeState.set(sessionId, { + status: state?.status ?? 'idle', + providerId: nextProviderId, + modelId: nextModelId, + permissionMode + }) + this.sessionStore.updateSessionModel(sessionId, nextProviderId, nextModelId) + this.sessionStore.updatePermissionMode(sessionId, permissionMode) + this.sessionStore.updateGenerationSettings( + sessionId, + this.buildPersistedGenerationSettingsReplacement(sanitizedGenerationSettings) + ) + this.sessionAgentIds.set(sessionId, nextAgentId) + this.sessionProjectDirs.set(sessionId, this.normalizeProjectDir(config.projectDir)) + this.sessionGenerationSettings.set(sessionId, sanitizedGenerationSettings) + this.invalidateSystemPromptCache(sessionId) + this.invalidateToolProfileCache(sessionId) + } + async setSessionProjectDir(sessionId: string, projectDir: string | null): Promise { const normalized = this.normalizeProjectDir(projectDir) const previous = this.sessionProjectDirs.has(sessionId) diff --git a/src/main/presenter/agentSessionPresenter/index.ts b/src/main/presenter/agentSessionPresenter/index.ts index 65cff750b..595195aff 100644 --- a/src/main/presenter/agentSessionPresenter/index.ts +++ b/src/main/presenter/agentSessionPresenter/index.ts @@ -5,6 +5,9 @@ import type { AgentTapeInfo, AgentTapeSearchOptions, AgentTapeSearchResult, + AgentTransferBlockReason, + AgentTransferImpact, + AgentTransferImpactSample, ChatMessagePageResult, SessionListItem, SessionLightweightListResult, @@ -92,6 +95,18 @@ type SearchableMessageRow = { updatedAt: number } +type AgentTransferTargetContext = { + agentId: string + agentType: 'deepchat' + providerId: string + modelId: string + projectDir: string | null + permissionMode: PermissionMode + generationSettings?: Partial + disabledAgentTools: string[] + subagentEnabled: boolean +} + const SUBAGENT_SESSION_INIT_MAX_ATTEMPTS = 2 const SQLITE_MAINLINE_NORMALIZATION_KEY = 'sqlite-mainline-normalization-v1' @@ -1860,6 +1875,166 @@ export class AgentSessionPresenter { }) } + async getAgentTransferImpact(agentId: string): Promise { + const sessions = this.sessionManager.list({ + agentId, + includeSubagents: true + }) + const samples: AgentTransferImpactSample[] = [] + let emptyDrafts = 0 + let movableSessions = 0 + let blockedSessions = 0 + + for (const session of sessions) { + const assessment = await this.assessTransferSession(session) + if (assessment.isEmptyDraft) { + emptyDrafts += 1 + } + if (assessment.blockReason) { + blockedSessions += 1 + } else if (!assessment.isEmptyDraft) { + movableSessions += 1 + } + + if (samples.length < 6 && (!assessment.isEmptyDraft || assessment.blockReason)) { + samples.push({ + id: session.id, + title: session.title, + sessionKind: session.sessionKind, + isDraft: Boolean(session.isDraft), + projectDir: session.projectDir, + status: assessment.status, + blockReason: assessment.blockReason + }) + } + } + + return { + agentId, + totalSessions: sessions.length, + regularSessions: sessions.filter((session) => session.sessionKind === 'regular').length, + subagentSessions: sessions.filter((session) => session.sessionKind === 'subagent').length, + emptyDrafts, + movableSessions, + blockedSessions, + samples + } + } + + async moveAgentSessions( + fromAgentId: string, + toAgentId: string + ): Promise<{ movedSessionIds: string[]; deletedSessionIds: string[] }> { + const sourceAgentId = fromAgentId.trim() + const targetAgentId = toAgentId.trim() + if (!sourceAgentId || !targetAgentId) { + throw new Error('Source and target agent ids are required.') + } + if (sourceAgentId === targetAgentId) { + throw new Error('Source and target agents cannot be the same.') + } + await this.resolveTransferTargetContext(targetAgentId, null) + + const sessions = this.sessionManager.list({ + agentId: sourceAgentId, + includeSubagents: true + }) + const transferSessionIds: string[] = [] + const emptyDraftSessionIds: string[] = [] + const movedSessionIds: string[] = [] + const deletedSessionIds: string[] = [] + const deletedSessionIdSet = new Set() + + for (const session of sessions) { + const assessment = await this.assessTransferSession(session) + if (assessment.blockReason) { + throw new Error(`Session ${session.id} cannot be moved: ${assessment.blockReason}`) + } + if (assessment.isEmptyDraft) { + emptyDraftSessionIds.push(session.id) + continue + } + + await this.resolveTransferTargetContext(targetAgentId, session.projectDir) + transferSessionIds.push(session.id) + } + + for (const sessionId of transferSessionIds) { + if (deletedSessionIdSet.has(sessionId) || !this.sessionManager.get(sessionId)) { + continue + } + await this.moveSessionToAgentInternal(sessionId, targetAgentId, true) + movedSessionIds.push(sessionId) + } + + for (const sessionId of emptyDraftSessionIds) { + if (deletedSessionIdSet.has(sessionId) || !this.sessionManager.get(sessionId)) { + continue + } + const deleted = await this.deleteSessionInternal(sessionId) + deleted.forEach((deletedSessionId) => deletedSessionIdSet.add(deletedSessionId)) + deletedSessionIds.push(...deleted) + } + + if (movedSessionIds.length > 0) { + this.emitSessionListUpdated({ + sessionIds: movedSessionIds, + reason: 'updated' + }) + } + if (deletedSessionIds.length > 0) { + this.emitSessionListUpdated({ + sessionIds: deletedSessionIds, + reason: 'deleted' + }) + } + + return { movedSessionIds, deletedSessionIds } + } + + async deleteAgentSessions(agentId: string): Promise { + const sessions = this.sessionManager.list({ + agentId: agentId.trim(), + includeSubagents: true + }) + const deletedSessionIds: string[] = [] + const deletedSessionIdSet = new Set() + + for (const session of sessions) { + const assessment = await this.assessTransferSession(session) + if (assessment.blockReason) { + throw new Error(`Session ${session.id} cannot be deleted: ${assessment.blockReason}`) + } + } + + for (const session of sessions) { + if (deletedSessionIdSet.has(session.id) || !this.sessionManager.get(session.id)) { + continue + } + const deleted = await this.deleteSessionInternal(session.id) + deleted.forEach((sessionId) => deletedSessionIdSet.add(sessionId)) + deletedSessionIds.push(...deleted) + } + + if (deletedSessionIds.length > 0) { + this.emitSessionListUpdated({ + sessionIds: deletedSessionIds, + reason: 'deleted' + }) + } + + return deletedSessionIds + } + + async moveSessionToAgent(sessionId: string, toAgentId: string): Promise { + const updated = await this.moveSessionToAgentInternal(sessionId, toAgentId) + this.emitSessionListUpdated({ + sessionIds: [sessionId], + reason: 'updated' + }) + return updated + } + async cancelGeneration(sessionId: string): Promise { const session = this.sessionManager.get(sessionId) if (!session) return @@ -2069,7 +2244,11 @@ export class AgentSessionPresenter { sessionIds: [sessionId], reason: 'updated' }) - return await this.tryBuildSessionWithState(updated) + const sessionWithState = await this.tryBuildSessionWithState(updated) + if (!sessionWithState) { + throw new Error(`Failed to build session state after project update: ${sessionId}`) + } + return sessionWithState } async getSessionGenerationSettings(sessionId: string): Promise { @@ -2484,6 +2663,163 @@ export class AgentSessionPresenter { } } + private async assessTransferSession(session: SessionRecord): Promise<{ + status: SessionWithState['status'] + isEmptyDraft: boolean + blockReason?: AgentTransferBlockReason + }> { + const agent = await this.resolveAgentImplementation(session.agentId) + const state = await agent.getSessionState(session.id) + const status = state?.status ?? 'idle' + const hasMessages = await this.hasSessionMessages(agent, session.id) + const hasSubagentChildren = + session.sessionKind === 'regular' && + this.sessionManager.list({ includeSubagents: true, parentSessionId: session.id }).length > 0 + const isEmptyDraft = Boolean(session.isDraft) && !hasMessages && !hasSubagentChildren + let hasPendingInput = false + + if (agent.listPendingInputs) { + try { + hasPendingInput = (await agent.listPendingInputs(session.id)).length > 0 + } catch (error) { + console.warn( + `[AgentSessionPresenter] Failed to inspect pending input for session=${session.id}:`, + error + ) + hasPendingInput = true + } + } + + if (status === 'generating') { + return { status, isEmptyDraft, blockReason: 'active' } + } + if (hasPendingInput) { + return { status, isEmptyDraft, blockReason: 'pending-input' } + } + + return { status, isEmptyDraft } + } + + private async moveSessionToAgentInternal( + sessionId: string, + toAgentId: string, + allowSubagent: boolean = false + ): Promise { + const session = this.sessionManager.get(sessionId) + if (!session) { + throw new Error(`Session not found: ${sessionId}`) + } + if (!allowSubagent && session.sessionKind !== 'regular') { + throw new Error('Only regular conversations can be moved from the conversation menu.') + } + + const targetAgentId = toAgentId.trim() + if (!targetAgentId) { + throw new Error('Target agent id is required.') + } + if (session.agentId === targetAgentId) { + throw new Error('Conversation is already assigned to the selected agent.') + } + + const assessment = await this.assessTransferSession(session) + if (assessment.blockReason) { + throw new Error(`Session ${sessionId} cannot be moved: ${assessment.blockReason}`) + } + + const previousAgentId = session.agentId + const targetContext = await this.resolveTransferTargetContext(targetAgentId, session.projectDir) + const previousAcpBacked = await this.isAcpBackedSession(sessionId, previousAgentId) + const agent = await this.resolveAgentImplementation(targetContext.agentId) + + if (!agent.setSessionAgentContext) { + throw new Error(`Agent ${targetContext.agentId} does not support session transfer.`) + } + + if (previousAcpBacked) { + await (this.providerSessionPort?.clearAcpSession?.(sessionId) ?? + this.llmProviderPresenter.clearAcpSession(sessionId)) + } + + await agent.setSessionAgentContext(sessionId, { + agentId: targetContext.agentId, + providerId: targetContext.providerId, + modelId: targetContext.modelId, + projectDir: targetContext.projectDir, + permissionMode: targetContext.permissionMode, + generationSettings: targetContext.generationSettings + }) + + this.sessionManager.updateAgentId(sessionId, targetContext.agentId) + this.sessionManager.update(sessionId, { + projectDir: targetContext.projectDir, + subagentEnabled: session.sessionKind === 'regular' ? targetContext.subagentEnabled : false + }) + this.sessionManager.updateDisabledAgentTools(sessionId, targetContext.disabledAgentTools) + + await this.syncAcpSessionWorkdir( + targetContext.providerId, + sessionId, + targetContext.agentId, + targetContext.projectDir + ) + + const updated = this.sessionManager.get(sessionId) + if (!updated) { + throw new Error(`Session not found after transfer: ${sessionId}`) + } + + const sessionWithState = await this.tryBuildSessionWithState(updated) + if (!sessionWithState) { + throw new Error(`Failed to build session state after transfer: ${sessionId}`) + } + return sessionWithState + } + + private async resolveTransferTargetContext( + targetAgentId: string, + currentProjectDir: string | null + ): Promise { + const resolvedAgentId = resolveAcpAgentAlias(targetAgentId.trim()) + const agentType = await this.getAgentType(resolvedAgentId) + if (agentType === 'acp') { + throw new Error('Conversation history cannot be moved to ACP agents.') + } + if (agentType !== 'deepchat') { + throw new Error(`Target agent not found: ${targetAgentId}`) + } + + const currentProject = currentProjectDir?.trim() || null + const config = await this.resolveDeepChatAgentConfigCompat(resolvedAgentId) + const defaultModel = this.configPresenter.getDefaultModel() + const providerId = + config?.defaultModelPreset?.providerId?.trim() || defaultModel?.providerId?.trim() || '' + const modelId = + config?.defaultModelPreset?.modelId?.trim() || defaultModel?.modelId?.trim() || '' + if (!providerId || !modelId) { + throw new Error('Target DeepChat agent does not have a default model.') + } + + return { + agentId: resolvedAgentId, + agentType, + providerId, + modelId, + projectDir: + currentProject || + config?.defaultProjectPath?.trim() || + this.getDefaultProjectPathCompat() || + null, + permissionMode: config?.permissionMode === 'default' ? 'default' : 'full_access', + generationSettings: this.mergeDeepChatDefaultGenerationSettings(config), + disabledAgentTools: this.normalizeDisabledAgentTools(config?.disabledAgentTools), + subagentEnabled: this.resolveSessionSubagentEnabled( + agentType, + undefined, + config?.subagentEnabled + ) + } + } + private async deleteSessionInternal(sessionId: string): Promise { const session = this.sessionManager.get(sessionId) if (!session) return [] diff --git a/src/main/presenter/agentSessionPresenter/sessionManager.ts b/src/main/presenter/agentSessionPresenter/sessionManager.ts index 4779b6532..6357e63b7 100644 --- a/src/main/presenter/agentSessionPresenter/sessionManager.ts +++ b/src/main/presenter/agentSessionPresenter/sessionManager.ts @@ -203,6 +203,16 @@ export class NewSessionManager { this.sqlitePresenter.newEnvironmentsTable.syncForSession(id) } + updateAgentId(id: string, agentId: string): void { + const current = this.sqlitePresenter.newSessionsTable.get(id) + if (!current || current.agent_id === agentId) { + return + } + + this.sqlitePresenter.newSessionsTable.updateAgentId(id, agentId) + this.sqlitePresenter.newEnvironmentsTable.syncForSession(id) + } + // Window binding management bindWindow(webContentsId: number, sessionId: string): void { this.windowBindings.set(webContentsId, sessionId) diff --git a/src/main/presenter/sqlitePresenter/tables/newSessions.ts b/src/main/presenter/sqlitePresenter/tables/newSessions.ts index 939f17f85..2f977955f 100644 --- a/src/main/presenter/sqlitePresenter/tables/newSessions.ts +++ b/src/main/presenter/sqlitePresenter/tables/newSessions.ts @@ -398,6 +398,12 @@ export class NewSessionsTable extends BaseTable { this.update(id, { disabled_agent_tools: JSON.stringify(disabledAgentTools) }) } + updateAgentId(id: string, agentId: string): void { + this.db + .prepare('UPDATE new_sessions SET agent_id = ?, updated_at = ? WHERE id = ?') + .run(agentId, Date.now(), id) + } + reassignAgentId(fromAgentId: string, toAgentId: string): void { this.db .prepare('UPDATE new_sessions SET agent_id = ?, updated_at = ? WHERE agent_id = ?') diff --git a/src/main/routes/index.ts b/src/main/routes/index.ts index fc0e7ddf1..77a1cca46 100644 --- a/src/main/routes/index.ts +++ b/src/main/routes/index.ts @@ -123,6 +123,7 @@ import { sessionsCompactRoute, sessionsConvertPendingInputToSteerRoute, sessionsCreateRoute, + sessionsDeleteAgentSessionsRoute, sessionsDeleteMessageRoute, sessionsDeletePendingInputRoute, sessionsDeleteRoute, @@ -135,6 +136,7 @@ import { sessionsGetAcpSessionConfigOptionsRoute, sessionsGetActiveRoute, sessionsGetAgentsRoute, + sessionsGetAgentTransferImpactRoute, sessionsGetDisabledAgentToolsRoute, sessionsGetLightweightByIdsRoute, sessionsGetGenerationSettingsRoute, @@ -145,7 +147,9 @@ import { sessionsListRoute, sessionsListMessageTracesRoute, sessionsListPendingInputsRoute, + sessionsMoveAgentSessionsRoute, sessionsMoveQueuedInputRoute, + sessionsMoveToAgentRoute, sessionsQueuePendingInputRoute, sessionsRenameRoute, sessionsResumePendingQueueRoute, @@ -1952,6 +1956,38 @@ export async function dispatchDeepchatRoute( return sessionsDeleteRoute.output.parse({ deleted: true }) } + case sessionsGetAgentTransferImpactRoute.name: { + const input = sessionsGetAgentTransferImpactRoute.input.parse(rawInput) + const impact = await runtime.agentSessionPresenter.getAgentTransferImpact(input.agentId) + return sessionsGetAgentTransferImpactRoute.output.parse({ impact }) + } + + case sessionsMoveAgentSessionsRoute.name: { + const input = sessionsMoveAgentSessionsRoute.input.parse(rawInput) + const result = await runtime.agentSessionPresenter.moveAgentSessions( + input.fromAgentId, + input.toAgentId + ) + return sessionsMoveAgentSessionsRoute.output.parse(result) + } + + case sessionsDeleteAgentSessionsRoute.name: { + const input = sessionsDeleteAgentSessionsRoute.input.parse(rawInput) + const deletedSessionIds = await runtime.agentSessionPresenter.deleteAgentSessions( + input.agentId + ) + return sessionsDeleteAgentSessionsRoute.output.parse({ deletedSessionIds }) + } + + case sessionsMoveToAgentRoute.name: { + const input = sessionsMoveToAgentRoute.input.parse(rawInput) + const session = await runtime.agentSessionPresenter.moveSessionToAgent( + input.sessionId, + input.toAgentId + ) + return sessionsMoveToAgentRoute.output.parse({ session }) + } + case sessionsGetAcpSessionCommandsRoute.name: { const input = sessionsGetAcpSessionCommandsRoute.input.parse(rawInput) const commands = await runtime.agentSessionPresenter.getAcpSessionCommands(input.sessionId) diff --git a/src/renderer/api/SessionClient.ts b/src/renderer/api/SessionClient.ts index f5a869320..bc1e32632 100644 --- a/src/renderer/api/SessionClient.ts +++ b/src/renderer/api/SessionClient.ts @@ -13,6 +13,7 @@ import { sessionsCompactRoute, sessionsConvertPendingInputToSteerRoute, sessionsCreateRoute, + sessionsDeleteAgentSessionsRoute, sessionsDeleteMessageRoute, sessionsDeletePendingInputRoute, sessionsDeleteRoute, @@ -25,6 +26,7 @@ import { sessionsGetAcpSessionConfigOptionsRoute, sessionsGetActiveRoute, sessionsGetAgentsRoute, + sessionsGetAgentTransferImpactRoute, sessionsGetDisabledAgentToolsRoute, sessionsGetLightweightByIdsRoute, sessionsGetGenerationSettingsRoute, @@ -35,7 +37,9 @@ import { sessionsListRoute, sessionsListMessageTracesRoute, sessionsListPendingInputsRoute, + sessionsMoveAgentSessionsRoute, sessionsMoveQueuedInputRoute, + sessionsMoveToAgentRoute, sessionsQueuePendingInputRoute, sessionsRenameRoute, sessionsResumePendingQueueRoute, @@ -274,6 +278,31 @@ export function createSessionClient(bridge: DeepchatBridge = getDeepchatBridge() await bridge.invoke(sessionsDeleteRoute.name, { sessionId }) } + async function getAgentTransferImpact(agentId: string) { + const result = await bridge.invoke(sessionsGetAgentTransferImpactRoute.name, { agentId }) + return result.impact + } + + async function moveAgentSessions(fromAgentId: string, toAgentId: string) { + return await bridge.invoke(sessionsMoveAgentSessionsRoute.name, { + fromAgentId, + toAgentId + }) + } + + async function deleteAgentSessions(agentId: string) { + const result = await bridge.invoke(sessionsDeleteAgentSessionsRoute.name, { agentId }) + return result.deletedSessionIds + } + + async function moveSessionToAgent(sessionId: string, toAgentId: string) { + const result = await bridge.invoke(sessionsMoveToAgentRoute.name, { + sessionId, + toAgentId + }) + return result.session + } + async function getAcpSessionCommands(sessionId: string) { const result = await bridge.invoke(sessionsGetAcpSessionCommandsRoute.name, { sessionId }) return result.commands @@ -466,6 +495,10 @@ export function createSessionClient(bridge: DeepchatBridge = getDeepchatBridge() compactSession, exportSession, deleteSession, + getAgentTransferImpact, + moveAgentSessions, + deleteAgentSessions, + moveSessionToAgent, getAcpSessionCommands, getAcpSessionConfigOptions, setAcpSessionConfigOption, diff --git a/src/renderer/settings/components/AcpSettings.vue b/src/renderer/settings/components/AcpSettings.vue index 401a532d9..60a8c9b83 100644 --- a/src/renderer/settings/components/AcpSettings.vue +++ b/src/renderer/settings/components/AcpSettings.vue @@ -279,7 +279,12 @@ -