From e0f0cb1f455dfa4e2f8c5ca84af78f442bf45418 Mon Sep 17 00:00:00 2001 From: zhangmo8 Date: Tue, 14 Jul 2026 10:03:42 +0800 Subject: [PATCH 1/7] fix(agent): enforce multi-agent host isolation Wire per-agent MCP allow-lists into catalog/call paths, stop cross-session workspace allow-list leaks, and reset permissions and skills when rebinding a session to another host agent. --- .../agent-scoped-extensions/spec.md | 2 + .../multi-agent-isolation/plan.md | 29 ++++++++ .../multi-agent-isolation/spec.md | 63 ++++++++++++++++ .../multi-agent-isolation/tasks.md | 9 +++ .../spec.md | 40 +++++++++++ .../spec.md | 31 ++++++++ .../spec.md | 37 ++++++++++ .../agentRuntimePresenter/dispatch.ts | 31 +++++++- .../presenter/agentRuntimePresenter/index.ts | 71 ++++++++++++++++++- .../presenter/agentRuntimePresenter/types.ts | 2 + .../agentTools/agentToolManager.ts | 3 +- .../agentRuntimePresenter.test.ts | 59 ++++++++++++++- .../agentRuntimePresenter/dispatch.test.ts | 66 +++++++++++++++++ 13 files changed, 437 insertions(+), 6 deletions(-) create mode 100644 docs/architecture/multi-agent-isolation/plan.md create mode 100644 docs/architecture/multi-agent-isolation/spec.md create mode 100644 docs/architecture/multi-agent-isolation/tasks.md create mode 100644 docs/issues/agent-mcp-allowlist-runtime-enforce/spec.md create mode 100644 docs/issues/agent-tool-workspace-cross-session-leak/spec.md create mode 100644 docs/issues/agent-transfer-permission-skill-reset/spec.md diff --git a/docs/architecture/agent-scoped-extensions/spec.md b/docs/architecture/agent-scoped-extensions/spec.md index ba0e34472..7ef29d442 100644 --- a/docs/architecture/agent-scoped-extensions/spec.md +++ b/docs/architecture/agent-scoped-extensions/spec.md @@ -21,6 +21,8 @@ global. Keep plugin availability global for DeepChat agents and unavailable for 2. Each DeepChat agent can independently configure its available normal MCP server set. 3. Switching DeepChat agents updates Skills and MCP enablement from that agent's policy. 4. Runtime prompt, skill tools, MCP definitions, and MCP calls use the session agent's policy. + DeepChat runtime passes resolved `enabledMcpServerIds` into tool catalog, fingerprint, and MCP + call/pre-check paths (plugin-owned MCP remains globally available when the plugin is enabled). 5. Existing global skill installation/import and MCP server definitions remain global resources. 6. Existing users retain compatible behavior: the built-in `deepchat` agent defaults to globally available Skills and MCP servers; other DeepChat agents inherit its policy. diff --git a/docs/architecture/multi-agent-isolation/plan.md b/docs/architecture/multi-agent-isolation/plan.md new file mode 100644 index 000000000..06d3b5213 --- /dev/null +++ b/docs/architecture/multi-agent-isolation/plan.md @@ -0,0 +1,29 @@ +# Multi-Agent Isolation — Phase 0 Plan + +## Approach + +1. Treat host-agent allow-lists as runtime contracts, not settings-only metadata. +2. Reuse existing MCP `enabledServerIds` filters in `McpPresenter` / `ToolManager`. +3. Pass policy through the same seams already used for Skills (`AgentExtensionPolicy`). +4. Reset session-scoped security state when `agent_id` changes. + +## Affected Interfaces + +- `AgentExtensionPolicy` gains `enabledMcpServerIds`. +- `ToolDefinitionContext.enabledMcpServerIds` populated for DeepChat sessions. +- `ProcessControlCollaborators` exposes MCP allow-list + agent id for tool execution. +- `setSessionAgentContext` becomes a security boundary for transfer/rebind. + +## Compatibility + +- `null` / omitted allow-list: unrestricted normal MCP (current default after inheritance). +- `[]`: no normal MCP tools/calls; plugin-owned MCP still exempt. +- Plugin enablement remains global; `enabledPluginIds` stays omitted. +- No schema/IPC changes. + +## Test Strategy + +- Rewrite agentRuntimePresenter tool-discovery expectations to enforce MCP policy. +- Keep plugin policy omission assertion. +- Workspace allow-list unit coverage for call workdir only. +- Transfer/rebind clears permissions and refilters skills. diff --git a/docs/architecture/multi-agent-isolation/spec.md b/docs/architecture/multi-agent-isolation/spec.md new file mode 100644 index 000000000..4df30dfa3 --- /dev/null +++ b/docs/architecture/multi-agent-isolation/spec.md @@ -0,0 +1,63 @@ +# Multi-Agent Isolation Contract + +## User Need + +DeepChat is a multi-host-agent application. Users configure independent DeepChat agents with +different Skills, MCP servers, tools, memory, and default permissions. Runtime behavior must match +those host-agent policies when sessions run concurrently or when history is transferred. + +## Goal + +Define the durable isolation matrix for host agents and the enforcement seams that keep Skills, MCP, +tools, memory, permissions, and workspace boundaries aligned with the session's host `agent_id`. + +## Isolation Matrix + +| Resource | Install / process | Policy owner | Runtime enforce | +| --- | --- | --- | --- | +| Skills catalog | global | agent `enabledSkillNames` | prompt / list / view / activate / catalog fingerprint | +| MCP servers | global process | agent `enabledMcpServerIds` | catalog + call + pre-check + fingerprint | +| Plugins | global | none (always global for DeepChat) | plugin lifecycle only | +| Plugin-owned MCP | global | exempt from agent MCP allow-list | always available when plugin enabled | +| Built-in agent tools | process | session `disabledAgentTools` | catalog filter | +| Memory | per-agent storage | agent memory settings | agentId-keyed rows / vectors | +| Permission approvals | session | session `permissionMode` + caches | conversation-scoped; cleared on transfer | +| Workspace | session `project_dir` | session | tool allowed dirs from call workdir only | +| Tape / messages | session | session | session-scoped | +| ACP MCP | ACP process | ACP `agent_mcp_selections` | ACP session/call path | + +## Acceptance Criteria + +1. Two DeepChat agents with different `enabledMcpServerIds` expose different MCP tool catalogs at + runtime and cannot call disallowed servers. +2. Concurrent sessions with different workdirs do not widen each other's filesystem allow lists. +3. Transferring a session to another agent clears permission approvals, refilters active skills to + the target allow-list, clears plan/runtime skill activation, and invalidates tool caches. +4. Skills allow-list enforcement remains intact. +5. Plugins remain globally enabled for DeepChat agents; UI/docs distinguish global plugins from + agent-scoped Skills/MCP. +6. ACP agents continue using ACP MCP selections, not DeepChat `enabledMcpServerIds`. + +## Constraints + +- Do not copy skill files or MCP definitions per agent. +- Do not introduce per-agent plugin process isolation in this goal. +- Keep null/`undefined` allow-list semantics: inherit resolved builtin policy; `[]` disables the + category for normal (non-plugin) MCP servers. +- Preserve Presenter boundaries and existing route/event schemas. + +## Non-goals + +- Subagent target-agent full policy resolution (separate architecture slice). +- Process-level sandboxing between agents. +- Removing the DeepChat + `providerId=acp` compatibility path. +- Changing ACP Memory absence. + +## Decisions + +- DeepChat runtime must pass effective `enabledMcpServerIds` from + `resolveDeepChatAgentConfig(session.agentId)` into tool discovery and MCP call paths. +- Historical `enabledPluginIds` remains omitted from tool discovery forever. +- Tool workspace allow lists trust the conversation workdir argument, not a shared mutable manager + workspace field. +- Transfer is a security boundary: approvals and pinned skills do not silently survive host change. diff --git a/docs/architecture/multi-agent-isolation/tasks.md b/docs/architecture/multi-agent-isolation/tasks.md new file mode 100644 index 000000000..7ab49fe49 --- /dev/null +++ b/docs/architecture/multi-agent-isolation/tasks.md @@ -0,0 +1,9 @@ +# Multi-Agent Isolation — Phase 0 Tasks + +- [x] Write architecture isolation contract +- [x] Write issue specs for MCP allow-list, workspace leak, transfer reset +- [x] Wire `enabledMcpServerIds` into DeepChat extension policy / catalog / fingerprint / call +- [x] Fix AgentToolManager allowed-directories cross-session leak +- [x] Reset permissions / skills / plan on `setSessionAgentContext` +- [x] Block permission-retry success leak; align deferred tool options +- [x] Update tests and run format / i18n / lint / focused vitest diff --git a/docs/issues/agent-mcp-allowlist-runtime-enforce/spec.md b/docs/issues/agent-mcp-allowlist-runtime-enforce/spec.md new file mode 100644 index 000000000..6468e5822 --- /dev/null +++ b/docs/issues/agent-mcp-allowlist-runtime-enforce/spec.md @@ -0,0 +1,40 @@ +# Agent MCP Allow-List Runtime Enforce + +## Issue + +DeepChat agent config and Plugins UI store per-agent `enabledMcpServerIds`, but session tool +discovery does not pass that allow-list into `ToolPresenter` / MCP catalog or call paths. Agents with +`enabledMcpServerIds: []` can still list and invoke globally running MCP servers. + +## Impact + +- Host-agent MCP isolation is a product lie: settings appear agent-scoped, runtime is global. +- Violates `agent-scoped-extensions` AC4. +- Concurrent multi-agent sessions share the full MCP surface. + +## Root Cause + +`resolveAgentExtensionPolicy` only returns `enabledSkillNames`. +`createSessionToolCatalogPort` omits `enabledMcpServerIds` from the tool definition context. +A regression test (`omits historical MCP and plugin policies`) incorrectly locked MCP omission +together with historical plugin policy removal. + +## Fix Plan + +- Extend extension policy with resolved `enabledMcpServerIds`. +- Pass allow-list into catalog context, fingerprint, execute options, and deferred execution. +- Rely on existing MCP `enabledServerIds` filtering for definitions and calls. +- Keep plugin-owned MCP exemption and omit `enabledPluginIds`. +- Replace the omit-MCP expectation with enforce expectations. + +## Tasks + +- [x] Runtime policy + catalog + fingerprint + dispatch/deferred wiring +- [x] Rewrite presenter tests for enforce semantics +- [x] format / i18n / lint / focused tests + +## Validation + +- Agent with `enabledMcpServerIds: []` yields no normal MCP tools in discovery context. +- Agent with a non-empty list passes that list into discovery. +- `enabledPluginIds` remains absent from discovery context. diff --git a/docs/issues/agent-tool-workspace-cross-session-leak/spec.md b/docs/issues/agent-tool-workspace-cross-session-leak/spec.md new file mode 100644 index 000000000..2905e4870 --- /dev/null +++ b/docs/issues/agent-tool-workspace-cross-session-leak/spec.md @@ -0,0 +1,31 @@ +# Agent Tool Workspace Cross-Session Leak + +## Issue + +`AgentToolManager` is a process singleton. `buildAllowedDirectories` adds both the call's workspace +path and the manager's last `syncContext` workspace path. Concurrent sessions with different +project directories can therefore allow filesystem access into another session's workdir. + +## Impact + +Multi-agent / multi-session isolation fails for local file tools even when each session has a +distinct `project_dir`. + +## Root Cause + +Shared mutable `this.agentWorkspacePath` is merged into every call's allow list. + +## Fix Plan + +- Build allow lists from the call's workspace path only (plus skill roots, runtime roots, approvals). +- Do not add the manager's last synced workspace path. + +## Tasks + +- [x] Remove shared workspace merge from `buildAllowedDirectories` +- [x] Covered by toolPresenter suite + multi-agent isolation contract +- [x] format / lint / focused tests + +## Validation + +- Allowed directories for workdir `/a` do not include a previously synced `/b`. diff --git a/docs/issues/agent-transfer-permission-skill-reset/spec.md b/docs/issues/agent-transfer-permission-skill-reset/spec.md new file mode 100644 index 000000000..6f1a51872 --- /dev/null +++ b/docs/issues/agent-transfer-permission-skill-reset/spec.md @@ -0,0 +1,37 @@ +# Agent Transfer Permission / Skill Reset + +## Issue + +Moving a session to another DeepChat agent updates `agent_id`, model, permission mode, and disabled +tools, but keeps conversation-scoped command/file/settings approvals, unfiltered active skill pins, +plan state, and runtime-activated skills from the previous host. + +## Impact + +A session transferred from a permissive agent to a strict agent can retain prior approvals and skill +pins that the target host policy would not grant. + +## Root Cause + +`setSessionAgentContext` rebinds identity and caches but does not clear session security state. + +## Fix Plan + +On agent rebind: + +- clear session permission approvals +- clear agent plan state +- clear runtime-activated skills +- refilter persisted active skills to the target `enabledSkillNames` +- keep existing tool-profile / system-prompt invalidation + +## Tasks + +- [x] Implement reset in `setSessionAgentContext` +- [x] Extend skillPresenter port with `setActiveSkills` where needed +- [x] format / lint / focused tests + +## Validation + +- After transfer, permission caches for the session are empty. +- Active skills are a subset of the target agent's allow-list (or empty when none allowed). diff --git a/src/main/presenter/agentRuntimePresenter/dispatch.ts b/src/main/presenter/agentRuntimePresenter/dispatch.ts index 1e3f359c3..629de6101 100644 --- a/src/main/presenter/agentRuntimePresenter/dispatch.ts +++ b/src/main/presenter/agentRuntimePresenter/dispatch.ts @@ -1345,12 +1345,17 @@ async function runToolCall(params: { toolCallStarted = true onToolCallStarted?.(completedToolCall.id) } + const enabledMcpServerIds = controls?.getEnabledMcpServerIds?.() const result = await toolExecution.execute(toolCall, { onProgress: applyProgressUpdate, signal: io.abortSignal, permissionMode: toolPermissionMode, activeSkillNames: controls?.getActiveSkillNames?.(), - enabledSkillNames: controls?.getEnabledSkillNames?.() + enabledSkillNames: controls?.getEnabledSkillNames?.(), + agentId: controls?.getAgentId?.(), + ...(enabledMcpServerIds === null || enabledMcpServerIds === undefined + ? {} + : { enabledMcpServerIds }) }) return result } @@ -1405,6 +1410,30 @@ async function runToolCall(params: { } } + // Never stage a permission payload as a successful tool result after auto-grant retry. + if (toolRawData?.requiresPermission) { + io.abortSignal.throwIfAborted() + const pendingPermission = normalizePermissionRequest( + toolRawData.permissionRequest as PermissionRequestLike | undefined, + { + toolName: toolContext.name, + serverName: toolContext.serverName, + description: `Permission required for ${toolContext.name}` + } + ) + if (pendingPermission) { + return { + kind: 'permission', + permission: pendingPermission, + toolContext + } + } + return buildToolErrorOutcome( + execution, + new Error(`Tool ${toolContext.name} still requires permission after approval.`) + ) + } + returnedToolResult = toolRawData if (io.abortSignal.aborted) { return buildReturnedToolResultOutcome(execution, toolRawData) diff --git a/src/main/presenter/agentRuntimePresenter/index.ts b/src/main/presenter/agentRuntimePresenter/index.ts index 8c250c02e..1e517ac6c 100644 --- a/src/main/presenter/agentRuntimePresenter/index.ts +++ b/src/main/presenter/agentRuntimePresenter/index.ts @@ -244,6 +244,7 @@ type ResumeBudgetToolCall = { type AgentExtensionPolicy = { enabledSkillNames?: string[] | null + enabledMcpServerIds?: string[] | null } type PackageJsonManifest = { @@ -706,6 +707,7 @@ export class AgentRuntimePresenter { ISkillPresenter, | 'getMetadataList' | 'getActiveSkills' + | 'setActiveSkills' | 'loadSkillContent' | 'viewDraftSkill' | 'installDraftSkill' @@ -731,6 +733,7 @@ export class AgentRuntimePresenter { ISkillPresenter, | 'getMetadataList' | 'getActiveSkills' + | 'setActiveSkills' | 'loadSkillContent' | 'viewDraftSkill' | 'installDraftSkill' @@ -2932,6 +2935,11 @@ export class AgentRuntimePresenter { instance.setAgentId(nextAgentId) instance.setProjectDir(this.normalizeProjectDir(config.projectDir)) instance.setGenerationSettings(sanitizedGenerationSettings) + // Transfer/rebind is a host-agent security boundary: drop prior approvals, plan, and skill pins. + this.sessionPermissionPort?.clearSessionPermissions(sessionId) + this.toolPresenter?.clearAgentPlanState?.(sessionId) + instance.replaceRuntimeActivatedSkills([]) + await this.refilterActiveSkillsForAgentPolicy(sessionId, nextAgentId, instance) this.invalidateSystemPromptCache(sessionId) this.invalidateToolProfileCache(sessionId) } @@ -4201,6 +4209,12 @@ export class AgentRuntimePresenter { getActiveSkillNames: () => getEffectiveRuntimeSkillNames(), getEnabledSkillNames: () => this.normalizeNullablePolicyList(streamExtensionPolicy.enabledSkillNames), + getEnabledMcpServerIds: () => + this.normalizeNullablePolicyList(streamExtensionPolicy.enabledMcpServerIds), + getAgentId: () => + resourceInstance.getAgentId()?.trim() || + this.getSessionAgentId(sessionId) || + 'deepchat', activateSkill: async (skillName) => { const policy = await this.resolveAgentExtensionPolicy(sessionId, resourceInstance) if (this.filterSkillNamesByPolicy([skillName], policy).length === 0) { @@ -7193,11 +7207,22 @@ export class AgentRuntimePresenter { deferredAbortSignal ) this.throwIfAbortRequested(deferredAbortSignal) + const deferredPermissionMode = normalizePermissionMode( + this.getDeepChatRuntimeState(sessionId)?.permissionMode + ) + const deferredActiveSkillNames = await awaitWithAbort( + this.resolveActiveSkillNamesForToolProfile(sessionId), + deferredAbortSignal + ) + this.throwIfAbortRequested(deferredAbortSignal) invoked = true onToolCallStarted?.() const result = await this.toolExecutionPort.execute(request, { agentId: this.getSessionAgentId(sessionId) ?? 'deepchat', + permissionMode: deferredPermissionMode, + activeSkillNames: deferredActiveSkillNames, enabledSkillNames: extensionPolicy.enabledSkillNames ?? undefined, + enabledMcpServerIds: this.toToolDefinitionMcpServerIds(extensionPolicy.enabledMcpServerIds), onProgress: (update) => { if ( update.kind !== 'subagent_orchestrator' || @@ -7366,6 +7391,7 @@ export class AgentRuntimePresenter { resourceInstance ) this.throwIfStaleDeepChatInstance(sessionId, resourceInstance) + const enabledMcpServerIds = this.toToolDefinitionMcpServerIds(policy.enabledMcpServerIds) return { profile: profile.kind, @@ -7377,7 +7403,8 @@ export class AgentRuntimePresenter { chatMode: 'agent', conversationId: sessionId, agentWorkspacePath: projectDir, - activeSkillNames: effectiveActiveSkillNames + activeSkillNames: effectiveActiveSkillNames, + ...(enabledMcpServerIds === undefined ? {} : { enabledMcpServerIds }) } } }, @@ -7445,6 +7472,7 @@ export class AgentRuntimePresenter { left.localeCompare(right) ), enabledSkillNames: this.normalizeNullablePolicyList(policy.enabledSkillNames), + enabledMcpServerIds: this.normalizeNullablePolicyList(policy.enabledMcpServerIds), skillsEnabled, activeSkillNames }) @@ -7487,7 +7515,8 @@ export class AgentRuntimePresenter { try { const config = await this.configPresenter.resolveDeepChatAgentConfig(agentId) return { - enabledSkillNames: config.enabledSkillNames + enabledSkillNames: config.enabledSkillNames, + enabledMcpServerIds: config.enabledMcpServerIds } } catch (error) { console.warn( @@ -7498,6 +7527,44 @@ export class AgentRuntimePresenter { } } + private toToolDefinitionMcpServerIds(value?: string[] | null): string[] | undefined { + if (value === null || value === undefined) { + return undefined + } + return this.normalizeSkillNames(value) + } + + private async refilterActiveSkillsForAgentPolicy( + sessionId: string, + agentId: string, + resourceInstance?: DeepChatAgentInstance + ): Promise { + if (!this.skillPresenter?.getActiveSkills || !this.skillPresenter?.setActiveSkills) { + return + } + try { + // Prefer explicit target agent config so rebind does not depend on session row timing. + const targetConfig = + typeof this.configPresenter.resolveDeepChatAgentConfig === 'function' + ? await this.configPresenter.resolveDeepChatAgentConfig(agentId) + : null + const policy: AgentExtensionPolicy = targetConfig + ? { + enabledSkillNames: targetConfig.enabledSkillNames, + enabledMcpServerIds: targetConfig.enabledMcpServerIds + } + : await this.resolveAgentExtensionPolicy(sessionId, resourceInstance) + const current = await this.skillPresenter.getActiveSkills(sessionId) + const allowed = this.filterSkillNamesByPolicy(current, policy) + await this.skillPresenter.setActiveSkills(sessionId, allowed) + } catch (error) { + console.warn( + `[DeepChatAgent] Failed to refilter active skills after agent rebind for session ${sessionId}:`, + error + ) + } + } + private normalizeNullablePolicyList(value?: string[] | null): string[] | null | undefined { if (value === null || value === undefined) { return value diff --git a/src/main/presenter/agentRuntimePresenter/types.ts b/src/main/presenter/agentRuntimePresenter/types.ts index c918341c4..b3a63a28e 100644 --- a/src/main/presenter/agentRuntimePresenter/types.ts +++ b/src/main/presenter/agentRuntimePresenter/types.ts @@ -102,6 +102,8 @@ export interface ProcessControlCollaborators { ) => void getActiveSkillNames?: () => string[] getEnabledSkillNames?: () => string[] | null | undefined + getEnabledMcpServerIds?: () => string[] | null | undefined + getAgentId?: () => string | undefined activateSkill?: (skillName: string) => Promise cacheImage?: (data: string) => Promise } diff --git a/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts b/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts index 955ad9186..54a721ed2 100644 --- a/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts +++ b/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts @@ -1260,8 +1260,9 @@ export class AgentToolManager { ordered.push(resolved) } + // Only trust the call-scoped workspace path. Do not merge the manager's last + // syncContext workspace — that leaks across concurrent multi-agent sessions. addPath(workspacePath) - addPath(this.agentWorkspacePath) if (conversationId && includeSkillRoots) { const activeSkillRoots = await this.resolveActiveSkillRoots( diff --git a/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts b/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts index 02c6fc4f4..ab3b55d9a 100644 --- a/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts +++ b/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts @@ -76,6 +76,7 @@ vi.mock('@/presenter', () => ({ skillPresenter: { getMetadataList: vi.fn().mockResolvedValue([]), getActiveSkills: vi.fn().mockResolvedValue([]), + setActiveSkills: vi.fn().mockImplementation(async (_id: string, skills: string[]) => skills), loadSkillContent: vi.fn().mockResolvedValue(null), viewDraftSkill: vi.fn(), installDraftSkill: vi.fn(), @@ -172,6 +173,7 @@ function getSkillPresenterMock() { return presenter.skillPresenter as { getMetadataList: ReturnType getActiveSkills: ReturnType + setActiveSkills: ReturnType loadSkillContent: ReturnType viewDraftSkill: ReturnType installDraftSkill: ReturnType @@ -724,6 +726,9 @@ describe('AgentRuntimePresenter', () => { const skillPresenter = getSkillPresenterMock() skillPresenter.getMetadataList.mockResolvedValue([]) skillPresenter.getActiveSkills.mockResolvedValue([]) + skillPresenter.setActiveSkills.mockImplementation( + async (_id: string, skills: string[]) => skills + ) skillPresenter.loadSkillContent.mockResolvedValue(null) skillPresenter.viewDraftSkill.mockResolvedValue({ success: false, action: 'view', draftId: '' }) skillPresenter.installDraftSkill.mockResolvedValue({ @@ -3158,7 +3163,7 @@ describe('AgentRuntimePresenter', () => { expect(replacement.getSystemPromptCache()?.prompt).toBe('replacement prompt') }) - it('omits historical MCP and plugin policies from session tool discovery', async () => { + it('enforces agent MCP allow-list and omits historical plugin policies from tool discovery', async () => { configPresenter.resolveDeepChatAgentConfig.mockResolvedValue({ enabledMcpServerIds: [], enabledSkillNames: ['skill-a'] @@ -3168,7 +3173,21 @@ describe('AgentRuntimePresenter', () => { await agent.processMessage('s1', 'Hello') const toolContext = toolPresenter.getAllToolDefinitions.mock.calls[0][0] - expect(toolContext).not.toHaveProperty('enabledMcpServerIds') + expect(toolContext.enabledMcpServerIds).toEqual([]) + expect(toolContext).not.toHaveProperty('enabledPluginIds') + }) + + it('passes non-empty agent MCP allow-list into session tool discovery', async () => { + configPresenter.resolveDeepChatAgentConfig.mockResolvedValue({ + enabledMcpServerIds: ['server-x', 'server-y'], + enabledSkillNames: null + }) + + await agent.initSession('s1', { providerId: 'openai', modelId: 'gpt-4' }) + await agent.processMessage('s1', 'Hello') + + const toolContext = toolPresenter.getAllToolDefinitions.mock.calls[0][0] + expect(toolContext.enabledMcpServerIds).toEqual(['server-x', 'server-y']) expect(toolContext).not.toHaveProperty('enabledPluginIds') }) @@ -5113,6 +5132,42 @@ describe('AgentRuntimePresenter', () => { expect(invalidateToolProfileCache).not.toHaveBeenCalled() }) + it('clears permissions and refilters active skills when rebinding host agent', async () => { + const skillPresenter = getSkillPresenterMock() + skillPresenter.getActiveSkills.mockResolvedValue(['skill-a', 'skill-b', 'skill-c']) + configPresenter.resolveDeepChatAgentConfig.mockImplementation(async (agentId: string) => { + if (agentId === 'strict-agent') { + return { + enabledSkillNames: ['skill-b'], + enabledMcpServerIds: [] + } + } + return { + enabledSkillNames: null, + enabledMcpServerIds: null + } + }) + + await agent.initSession('s1', { providerId: 'openai', modelId: 'gpt-4' }) + const instance = agent.deepChatRuntime.getOrHydrate(toAppSessionId('s1')) + instance.replaceRuntimeActivatedSkills(['runtime-skill']) + const clearAgentPlanState = vi.spyOn(toolPresenter, 'clearAgentPlanState') + + await agent.setSessionAgentContext('s1', { + agentId: 'strict-agent', + providerId: 'openai', + modelId: 'gpt-4', + projectDir: '/workspace', + permissionMode: 'default' + }) + + expect(sessionPermissionPort.clearSessionPermissions).toHaveBeenCalledWith('s1') + expect(clearAgentPlanState).toHaveBeenCalledWith('s1') + expect(instance.getRuntimeActivatedSkills()).toEqual([]) + expect(instance.getAgentId()).toBe('strict-agent') + expect(skillPresenter.setActiveSkills).toHaveBeenCalledWith('s1', ['skill-b']) + }) + it('drops unsupported reasoning and verbosity settings when switching models', async () => { configPresenter.getModelConfig.mockImplementation((modelId: string, providerId: string) => { if (providerId === 'openai' && modelId === 'gpt-4o-mini') { diff --git a/test/main/presenter/agentRuntimePresenter/dispatch.test.ts b/test/main/presenter/agentRuntimePresenter/dispatch.test.ts index 3cc49a32c..864539380 100644 --- a/test/main/presenter/agentRuntimePresenter/dispatch.test.ts +++ b/test/main/presenter/agentRuntimePresenter/dispatch.test.ts @@ -1345,6 +1345,72 @@ describe('dispatch', () => { expect(result.executed).toBe(1) }) + it('does not stage success when full_access tool still requires permission after grant', async () => { + const tools = [makeAgentTool('write')] + const toolPresenter = { + ...createMockToolPresenter(), + callTool: vi.fn(async () => ({ + content: 'permission required', + rawData: { + content: 'permission required', + isError: true, + requiresPermission: true, + permissionRequest: { + permissionType: 'write', + description: 'Need write permission', + paths: ['/tmp/secret.txt'] + } + } + })) + } as unknown as IToolPresenter + const autoGrantPermission = vi.fn().mockResolvedValue(undefined) + + state.blocks.push({ + type: 'tool_call', + content: '', + status: 'pending', + timestamp: Date.now(), + tool_call: { + id: 'tc-write', + name: 'write', + params: '{"path":"/tmp/secret.txt"}', + response: '' + } + }) + state.completedToolCalls = [ + { id: 'tc-write', name: 'write', arguments: '{"path":"/tmp/secret.txt"}' } + ] + + const result = await executeTools( + state, + [], + 0, + tools, + toolPresenter, + 'gpt-4', + io, + 'full_access', + new ToolOutputGuard(), + 32000, + 1024, + { autoGrantPermission } + ) + + expect(result.type).toBe('paused') + if (result.type !== 'paused') throw new Error('Expected paused tool batch') + expect(autoGrantPermission).toHaveBeenCalled() + expect(toolPresenter.callTool).toHaveBeenCalledTimes(2) + expect(result.interactions).toEqual([ + expect.objectContaining({ + origin: 'post-call-permission', + toolCallId: 'tc-write' + }) + ]) + // Permission payload must not be committed as a successful tool response body. + expect(state.blocks[0].tool_call?.response ?? '').not.toContain('permission required') + expect(result.executionState.committedResultCallIds).not.toContain('tc-write') + }) + it('reviews command-runner Agent tool calls even without path args', async () => { const hooks = { onPermissionRequest: vi.fn(), From fd6f1a51f383016277bf96093f22f6cec117572f Mon Sep 17 00:00:00 2001 From: zhangmo8 Date: Tue, 14 Jul 2026 10:06:34 +0800 Subject: [PATCH 2/7] fix(agent): isolate cross-agent subagent host policy Resolve permission mode, disabled tools, system prompt, and skill allow-lists from the target host agent while keeping parent workdir and model for delegated subagent sessions. --- .../multi-agent-isolation/tasks.md | 1 + .../subagent-host-policy-isolation/plan.md | 16 +++++ .../subagent-host-policy-isolation/spec.md | 46 +++++++++++++ .../subagent-host-policy-isolation/tasks.md | 7 ++ .../agentAssignmentPolicy.ts | 53 +++++++++++++-- .../lifecycleCoordinator.ts | 4 +- .../presenter/sessionApplication/ports.ts | 4 ++ .../agentTools/subagentOrchestratorTool.ts | 1 + .../presenter/toolPresenter/runtimePorts.ts | 1 + .../agentAssignmentPolicy.test.ts | 68 +++++++++++++++++++ .../lifecycleCoordinator.test.ts | 3 + .../subagentOrchestratorTool.test.ts | 3 +- 12 files changed, 201 insertions(+), 6 deletions(-) create mode 100644 docs/architecture/subagent-host-policy-isolation/plan.md create mode 100644 docs/architecture/subagent-host-policy-isolation/spec.md create mode 100644 docs/architecture/subagent-host-policy-isolation/tasks.md diff --git a/docs/architecture/multi-agent-isolation/tasks.md b/docs/architecture/multi-agent-isolation/tasks.md index 7ab49fe49..e5903b630 100644 --- a/docs/architecture/multi-agent-isolation/tasks.md +++ b/docs/architecture/multi-agent-isolation/tasks.md @@ -7,3 +7,4 @@ - [x] Reset permissions / skills / plan on `setSessionAgentContext` - [x] Block permission-retry success leak; align deferred tool options - [x] Update tests and run format / i18n / lint / focused vitest +- [x] Phase 1: subagent target host policy isolation diff --git a/docs/architecture/subagent-host-policy-isolation/plan.md b/docs/architecture/subagent-host-policy-isolation/plan.md new file mode 100644 index 000000000..1c1844944 --- /dev/null +++ b/docs/architecture/subagent-host-policy-isolation/plan.md @@ -0,0 +1,16 @@ +# Subagent Host Policy Isolation — Plan + +## Approach + +1. Pass `parentAgentId` into subagent assignment resolution. +2. Expand `ResolvedSubagentAssignment` with `permissionMode`. +3. In `SessionAgentAssignmentPolicy.resolveSubagentAssignment`: + - self: inherit input surface + - cross-agent DeepChat: load target config for permission/tools/prompt/skill filter +4. Lifecycle initializes runtime with resolved `permissionMode`. +5. Orchestrator supplies `parentAgentId` from the parent session. + +## Tests + +- Policy unit: self vs cross-agent vs ACP +- Lifecycle mock: uses resolved permissionMode diff --git a/docs/architecture/subagent-host-policy-isolation/spec.md b/docs/architecture/subagent-host-policy-isolation/spec.md new file mode 100644 index 000000000..ee747f95b --- /dev/null +++ b/docs/architecture/subagent-host-policy-isolation/spec.md @@ -0,0 +1,46 @@ +# Subagent Host Policy Isolation + +## User Need + +When a parent DeepChat agent delegates work to a configured slot that targets another host agent, +the child session must not silently inherit the parent's full capability surface while writing +Memory and skills under the target agent identity. + +## Goal + +Make cross-agent subagent sessions resolve security-relevant policy from the **target host agent**, +while keeping intentional parent inheritance for workspace and model selection. + +## Inheritance Matrix + +| Field | `self` slot | Other target agent | +| --- | --- | --- | +| `agentId` / Memory | parent | target | +| `projectDir` | parent (same workdir rule) | parent (same workdir rule) | +| `providerId` / `modelId` | parent session | parent session | +| `permissionMode` | parent session | **target agent config** | +| `disabledAgentTools` | parent session | **target agent config** | +| `systemPrompt` | parent generation settings | **target agent config** | +| `activeSkills` | parent session pins | parent pins **∩ target `enabledSkillNames`** | +| MCP / plugins | runtime host policy (agentId) | runtime host policy (target agentId) | + +## Acceptance Criteria + +1. Self-target subagent sessions keep parent permissionMode, disabled tools, skills, and generation + settings. +2. Cross-agent subagent sessions apply target permissionMode, disabled tools, and system prompt. +3. Cross-agent active skills are filtered by the target agent allow-list (`[]` means none). +4. Workdir remains the parent session workdir for both cases. +5. ACP subagent targets remain ACP-shaped (no DeepChat tool/skill inheritance). + +## Constraints + +- No renderer/IPC schema change required beyond existing createSubagentSession fields. +- Do not enable nested subagents. +- Do not change run timeout / concurrency guardrails. + +## Non-goals + +- Bubbling child permission UI into the parent interaction queue (later UX slice). +- Per-child model overrides from target default model. +- Process-level isolation of MCP/plugin runtimes. diff --git a/docs/architecture/subagent-host-policy-isolation/tasks.md b/docs/architecture/subagent-host-policy-isolation/tasks.md new file mode 100644 index 000000000..c9eba1620 --- /dev/null +++ b/docs/architecture/subagent-host-policy-isolation/tasks.md @@ -0,0 +1,7 @@ +# Subagent Host Policy Isolation — Tasks + +- [x] Spec / plan +- [x] Ports + policy resolution +- [x] Lifecycle + orchestrator wiring +- [x] Tests +- [x] format / i18n / lint diff --git a/src/main/presenter/sessionApplication/agentAssignmentPolicy.ts b/src/main/presenter/sessionApplication/agentAssignmentPolicy.ts index a1c4c28c9..45bf1599a 100644 --- a/src/main/presenter/sessionApplication/agentAssignmentPolicy.ts +++ b/src/main/presenter/sessionApplication/agentAssignmentPolicy.ts @@ -109,6 +109,7 @@ export class SessionAgentAssignmentPolicy implements SessionAssignmentPolicyPort targetAgentId: input.targetAgentId?.trim() ? descriptor.id : null, providerId: 'acp', modelId: descriptor.id, + permissionMode: normalizePermissionMode(input.permissionMode), generationSettings: { systemPrompt: '' }, disabledAgentTools: [], activeSkills: [] @@ -117,14 +118,46 @@ export class SessionAgentAssignmentPolicy implements SessionAssignmentPolicyPort this.assertAcpSessionHasWorkdir(input.providerId, input.projectDir) + const parentAgentId = input.parentAgentId?.trim() || null + const isCrossAgent = Boolean(parentAgentId && parentAgentId !== descriptor.id) + const targetAgentId = input.targetAgentId?.trim() ? descriptor.id : null + + if (!isCrossAgent) { + return { + agentId: descriptor.id, + targetAgentId, + providerId: input.providerId, + modelId: input.modelId, + permissionMode: normalizePermissionMode(input.permissionMode), + generationSettings: input.generationSettings, + disabledAgentTools: normalizeDisabledAgentTools(input.disabledAgentTools), + activeSkills: normalizeActiveSkills(input.activeSkills) + } + } + + // Cross-agent child: keep parent workdir/model, apply target host security policy. + const agentConfig = await this.config.resolveDeepChatAgentConfig(descriptor.id) + const parentGeneration = input.generationSettings ?? {} + const generationSettings = this.mergeDefaultGenerationSettings(agentConfig, { + ...parentGeneration, + systemPrompt: + typeof agentConfig?.systemPrompt === 'string' + ? agentConfig.systemPrompt + : parentGeneration.systemPrompt + }) + return { agentId: descriptor.id, - targetAgentId: input.targetAgentId?.trim() ? descriptor.id : null, + targetAgentId, providerId: input.providerId, modelId: input.modelId, - generationSettings: input.generationSettings, - disabledAgentTools: normalizeDisabledAgentTools(input.disabledAgentTools), - activeSkills: normalizeActiveSkills(input.activeSkills) + permissionMode: normalizePermissionMode(agentConfig?.permissionMode), + generationSettings, + disabledAgentTools: normalizeDisabledAgentTools(agentConfig?.disabledAgentTools), + activeSkills: this.filterSkillsByAllowList( + normalizeActiveSkills(input.activeSkills), + agentConfig?.enabledSkillNames + ) } } @@ -199,4 +232,16 @@ export class SessionAgentAssignmentPolicy implements SessionAssignmentPolicyPort const merged = { ...defaults, ...overrides } return Object.keys(merged).length > 0 ? merged : undefined } + + private filterSkillsByAllowList(skills: string[], allowList?: string[] | null): string[] { + if (allowList === null || allowList === undefined) { + return skills + } + const allowed = new Set( + allowList + .map((skillName) => (typeof skillName === 'string' ? skillName.trim() : '')) + .filter((skillName) => skillName.length > 0) + ) + return skills.filter((skillName) => allowed.has(skillName)) + } } diff --git a/src/main/presenter/sessionApplication/lifecycleCoordinator.ts b/src/main/presenter/sessionApplication/lifecycleCoordinator.ts index 464eb1619..50e74d6ce 100644 --- a/src/main/presenter/sessionApplication/lifecycleCoordinator.ts +++ b/src/main/presenter/sessionApplication/lifecycleCoordinator.ts @@ -213,10 +213,12 @@ export class SessionLifecycleCoordinator implements SessionLifecyclePort { const projectDir = input.projectDir?.trim() || null const runtimeConfig = await this.dependencies.assignmentPolicy.resolveSubagentAssignment({ agentId, + parentAgentId: input.parentAgentId, targetAgentId: input.targetAgentId, projectDir, providerId: input.providerId, modelId: input.modelId, + permissionMode: input.permissionMode, generationSettings: input.generationSettings, disabledAgentTools: input.disabledAgentTools, activeSkills: input.activeSkills @@ -249,7 +251,7 @@ export class SessionLifecycleCoordinator implements SessionLifecyclePort { providerId: runtimeConfig.providerId, modelId: runtimeConfig.modelId, projectDir, - permissionMode: input.permissionMode, + permissionMode: runtimeConfig.permissionMode, generationSettings: runtimeConfig.generationSettings }) if (runtimeConfig.activeSkills.length > 0) { diff --git a/src/main/presenter/sessionApplication/ports.ts b/src/main/presenter/sessionApplication/ports.ts index 090e48d84..1b994aa39 100644 --- a/src/main/presenter/sessionApplication/ports.ts +++ b/src/main/presenter/sessionApplication/ports.ts @@ -312,10 +312,12 @@ export interface ResolvedSessionAssignment { export interface SubagentAssignmentInput { agentId: string + parentAgentId?: string | null targetAgentId?: string | null projectDir: string | null providerId: string modelId: string + permissionMode?: PermissionMode generationSettings?: Partial disabledAgentTools?: string[] activeSkills?: string[] @@ -326,6 +328,7 @@ export interface ResolvedSubagentAssignment { targetAgentId: string | null providerId: string modelId: string + permissionMode: PermissionMode generationSettings?: Partial disabledAgentTools: string[] activeSkills: string[] @@ -461,6 +464,7 @@ export type SessionLifecycleProjectionPort = Pick< export interface SessionLifecycleSubagentInput { parentSessionId: string agentId: string + parentAgentId?: string | null slotId: string displayName: string targetAgentId?: string | null diff --git a/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts b/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts index 1dc9c0081..fd3c5534e 100644 --- a/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts +++ b/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts @@ -1125,6 +1125,7 @@ export class SubagentOrchestratorTool { const child = await this.runtimePort.createSubagentSession({ parentSessionId: parent.sessionId, agentId: task.targetAgentId || parent.agentId, + parentAgentId: parent.agentId, slotId: task.slotId, displayName: task.targetAgentName, targetAgentId: task.targetAgentId, diff --git a/src/main/presenter/toolPresenter/runtimePorts.ts b/src/main/presenter/toolPresenter/runtimePorts.ts index bc63ad32c..856f1707d 100644 --- a/src/main/presenter/toolPresenter/runtimePorts.ts +++ b/src/main/presenter/toolPresenter/runtimePorts.ts @@ -56,6 +56,7 @@ export interface ConversationSessionInfo { export interface CreateSubagentSessionInput { parentSessionId: string agentId: string + parentAgentId?: string | null slotId: string displayName: string targetAgentId?: string | null diff --git a/test/main/presenter/sessionApplication/agentAssignmentPolicy.test.ts b/test/main/presenter/sessionApplication/agentAssignmentPolicy.test.ts index c724e49cc..f65f54557 100644 --- a/test/main/presenter/sessionApplication/agentAssignmentPolicy.test.ts +++ b/test/main/presenter/sessionApplication/agentAssignmentPolicy.test.ts @@ -124,6 +124,7 @@ describe('SessionAgentAssignmentPolicy', () => { projectDir: '/repo', providerId: 'openai', modelId: 'gpt-4', + permissionMode: 'full_access', activeSkills: ['ignored'] }) ).resolves.toEqual({ @@ -131,6 +132,7 @@ describe('SessionAgentAssignmentPolicy', () => { targetAgentId: 'claude-acp', providerId: 'acp', modelId: 'claude-acp', + permissionMode: 'full_access', generationSettings: { systemPrompt: '' }, disabledAgentTools: [], activeSkills: [] @@ -141,6 +143,72 @@ describe('SessionAgentAssignmentPolicy', () => { ) }) + it('inherits parent surface for self-target DeepChat subagents', async () => { + const { policy } = createHarness() + + await expect( + policy.resolveSubagentAssignment({ + agentId: 'deepchat', + parentAgentId: 'deepchat', + targetAgentId: 'deepchat', + projectDir: '/repo', + providerId: 'openai', + modelId: 'gpt-4', + permissionMode: 'default', + generationSettings: { systemPrompt: 'parent prompt', temperature: 0.2 }, + disabledAgentTools: ['exec'], + activeSkills: ['skill-a', 'skill-b'] + }) + ).resolves.toEqual({ + agentId: 'deepchat', + targetAgentId: 'deepchat', + providerId: 'openai', + modelId: 'gpt-4', + permissionMode: 'default', + generationSettings: { systemPrompt: 'parent prompt', temperature: 0.2 }, + disabledAgentTools: ['exec'], + activeSkills: ['skill-a', 'skill-b'] + }) + }) + + it('applies target host policy for cross-agent DeepChat subagents', async () => { + const { policy, configs } = createHarness() + configs.set('reviewer', { + defaultModelPreset: { providerId: 'anthropic', modelId: 'claude-review' }, + permissionMode: 'default', + systemPrompt: 'Reviewer prompt', + disabledAgentTools: ['write', 'exec'], + enabledSkillNames: ['skill-b'] + }) + + await expect( + policy.resolveSubagentAssignment({ + agentId: 'reviewer', + parentAgentId: 'deepchat', + targetAgentId: 'reviewer', + projectDir: '/repo', + providerId: 'openai', + modelId: 'gpt-4', + permissionMode: 'full_access', + generationSettings: { systemPrompt: 'parent prompt', temperature: 0.4 }, + disabledAgentTools: [], + activeSkills: ['skill-a', 'skill-b', 'skill-c'] + }) + ).resolves.toEqual({ + agentId: 'reviewer', + targetAgentId: 'reviewer', + providerId: 'openai', + modelId: 'gpt-4', + permissionMode: 'default', + generationSettings: { + systemPrompt: 'Reviewer prompt', + temperature: 0.4 + }, + disabledAgentTools: ['exec', 'write'], + activeSkills: ['skill-b'] + }) + }) + it('rejects DeepChat transfer targets backed by ACP defaults', async () => { const { policy, configs } = createHarness() configs.set('reviewer', { diff --git a/test/main/presenter/sessionApplication/lifecycleCoordinator.test.ts b/test/main/presenter/sessionApplication/lifecycleCoordinator.test.ts index 9191cef9f..16eaa2b57 100644 --- a/test/main/presenter/sessionApplication/lifecycleCoordinator.test.ts +++ b/test/main/presenter/sessionApplication/lifecycleCoordinator.test.ts @@ -169,9 +169,11 @@ function createHarness(initialSessions: SessionRecord[] = []) { resolveSubagentAssignment: vi.fn( async (input: { agentId: string + parentAgentId?: string | null targetAgentId?: string | null providerId: string modelId: string + permissionMode?: DeepChatSessionState['permissionMode'] generationSettings?: Partial disabledAgentTools?: string[] activeSkills?: string[] @@ -180,6 +182,7 @@ function createHarness(initialSessions: SessionRecord[] = []) { targetAgentId: input.targetAgentId ?? null, providerId: input.providerId, modelId: input.modelId, + permissionMode: input.permissionMode ?? 'full_access', generationSettings: input.generationSettings, disabledAgentTools: input.disabledAgentTools ?? [], activeSkills: input.activeSkills ?? [] diff --git a/test/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.test.ts b/test/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.test.ts index 79ffec18c..680ed2333 100644 --- a/test/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.test.ts +++ b/test/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.test.ts @@ -175,7 +175,8 @@ describe('SubagentOrchestratorTool', () => { expect(resolveConversationWorkdir).toHaveBeenCalledWith(parentSession.sessionId) expect(createSubagentSession).toHaveBeenCalledWith( expect.objectContaining({ - projectDir: resolvedWorkdir + projectDir: resolvedWorkdir, + parentAgentId: parentSession.agentId }) ) expect(handoffMessage).toContain('Current Agent Working Directory:') From fe9e387c7ed79ab5b4cc3a88b03691b7776c0d62 Mon Sep 17 00:00:00 2001 From: zhangmo8 Date: Tue, 14 Jul 2026 10:12:29 +0800 Subject: [PATCH 3/7] fix(agent): harden multi-agent UX and session lifecycle Stop ACP tool progress from looking like permissions, clarify Plugins Hub global vs agent policy scope, inherit parent approvals into subagents with clearer waiting guidance, and make chat/session timeouts and deletion best-effort instead of zombie-prone. --- .../acp-tool-progress-permission-ui/spec.md | 16 + .../spec.md | 13 + .../agent/acp/runtime/acpContentMapper.ts | 5 +- src/main/presenter/index.ts | 8 +- .../permission/commandPermissionCache.ts | 16 + .../permission/commandPermissionService.ts | 4 + .../permission/filePermissionService.ts | 16 + .../permission/settingsPermissionService.ts | 13 + src/main/presenter/runtimePorts.ts | 4 + .../lifecycleCoordinator.ts | 4 + .../lifecycleDeletionTransaction.ts | 30 +- .../presenter/sessionApplication/ports.ts | 5 + .../agentTools/subagentOrchestratorTool.ts | 13 +- src/main/routes/chat/chatService.ts | 105 +++---- src/main/routes/index.ts | 1 - src/main/routes/sessions/sessionService.ts | 7 +- src/renderer/src/i18n/da-DK/settings.json | 5 +- src/renderer/src/i18n/de-DE/settings.json | 5 +- src/renderer/src/i18n/en-US/settings.json | 5 +- src/renderer/src/i18n/es-ES/settings.json | 5 +- src/renderer/src/i18n/fa-IR/settings.json | 5 +- src/renderer/src/i18n/fr-FR/settings.json | 5 +- src/renderer/src/i18n/he-IL/settings.json | 5 +- src/renderer/src/i18n/id-ID/settings.json | 5 +- src/renderer/src/i18n/it-IT/settings.json | 5 +- src/renderer/src/i18n/ja-JP/settings.json | 5 +- src/renderer/src/i18n/ko-KR/settings.json | 5 +- src/renderer/src/i18n/ms-MY/settings.json | 5 +- src/renderer/src/i18n/pl-PL/settings.json | 5 +- src/renderer/src/i18n/pt-BR/settings.json | 5 +- src/renderer/src/i18n/ru-RU/settings.json | 5 +- src/renderer/src/i18n/tr-TR/settings.json | 5 +- src/renderer/src/i18n/vi-VN/settings.json | 5 +- src/renderer/src/i18n/zh-CN/settings.json | 5 +- src/renderer/src/i18n/zh-HK/settings.json | 5 +- src/renderer/src/i18n/zh-TW/settings.json | 5 +- .../src/pages/plugins/PluginsHubPage.vue | 18 ++ .../acp/runtime/acpContentMapper.test.ts | 7 + .../agentSessionPresenter.test.ts | 26 +- .../lifecycleDeletionTransaction.test.ts | 7 +- test/main/routes/chatService.test.ts | 278 +++++------------- 41 files changed, 375 insertions(+), 321 deletions(-) create mode 100644 docs/issues/acp-tool-progress-permission-ui/spec.md create mode 100644 docs/issues/chat-generation-lifecycle-timeouts/spec.md diff --git a/docs/issues/acp-tool-progress-permission-ui/spec.md b/docs/issues/acp-tool-progress-permission-ui/spec.md new file mode 100644 index 000000000..23d9d51ef --- /dev/null +++ b/docs/issues/acp-tool-progress-permission-ui/spec.md @@ -0,0 +1,16 @@ +# ACP Tool Progress Pseudo Permission UI + +## Issue + +Ordinary ACP `tool_call` / status progress was projected as `action_type: 'tool_call_permission'`, +which mixed narrative tool progress with real permission interactions. + +## Fix + +Emit reasoning events for tool progress only; reserve permission action blocks for protocol +permission requests. + +## Tasks + +- [x] Remove pseudo permission action blocks from `acpContentMapper` +- [x] Regression test diff --git a/docs/issues/chat-generation-lifecycle-timeouts/spec.md b/docs/issues/chat-generation-lifecycle-timeouts/spec.md new file mode 100644 index 000000000..fa542507e --- /dev/null +++ b/docs/issues/chat-generation-lifecycle-timeouts/spec.md @@ -0,0 +1,13 @@ +# Chat / Session Lifecycle Timeouts and Locks + +## Issue + +ChatService used a fake stream lock and agentType preflight that did not match enqueue-first +generation ownership. Session create used a 5s timeout that could fail ACP cold start. Session +delete aborted row removal when backend cleanup failed, leaving zombie sessions. + +## Fix + +- ChatService: accept-path abort only; no mutual exclusion over runtime generation; honest stop result +- SessionService: longer create/list timeouts +- SessionDeletionTransaction: best-effort stages, always attempt row delete diff --git a/src/main/agent/acp/runtime/acpContentMapper.ts b/src/main/agent/acp/runtime/acpContentMapper.ts index 4a713c8f5..70373fc3c 100644 --- a/src/main/agent/acp/runtime/acpContentMapper.ts +++ b/src/main/agent/acp/runtime/acpContentMapper.ts @@ -235,15 +235,14 @@ export class AcpContentMapper { this.emitToolCallStartIfNeeded(state, payload) + // Tool progress is narrative only. Never emit tool_call_permission action blocks here — + // those are reserved for real session/request_permission interactions. const shouldEmitReasoning = update.sessionUpdate === 'tool_call' || (status && status !== previousStatus) if (shouldEmitReasoning) { const reasoningText = this.buildToolCallReasoning(state.toolName, status) if (reasoningText) { payload.events.push(createStreamEvent.reasoning(reasoningText)) - payload.blocks.push( - this.createBlock('action', reasoningText, { action_type: 'tool_call_permission' }) - ) } } diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index 178175c49..17734ce9d 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -565,6 +565,11 @@ export class Presenter implements IPresenter { this.filePermissionService.clearConversation(sessionId) this.settingsPermissionService.clearConversation(sessionId) }, + cloneSessionPermissions: (sourceSessionId, targetSessionId) => { + this.commandPermissionService.cloneConversation(sourceSessionId, targetSessionId) + this.filePermissionService.cloneConversation(sourceSessionId, targetSessionId) + this.settingsPermissionService.cloneConversation(sourceSessionId, targetSessionId) + }, approvePermission: async (sessionId, permission) => { const permissionType = permission.permissionType const serverName = permission.serverName || '' @@ -923,7 +928,8 @@ export class Presenter implements IPresenter { workdir: this.sessionAgentAssignmentCoordinator, initialTurn: this.sessionTurnCoordinator, projection: this.sessionProjectionCoordinator, - deletion: this.sessionDeletionTransaction + deletion: this.sessionDeletionTransaction, + permissions: sessionPermissionPort }) this.cronJobs.setRunSessionStarter( createCronJobRunSessionStarter({ diff --git a/src/main/presenter/permission/commandPermissionCache.ts b/src/main/presenter/permission/commandPermissionCache.ts index 594ec09d8..28dfacd5f 100644 --- a/src/main/presenter/permission/commandPermissionCache.ts +++ b/src/main/presenter/permission/commandPermissionCache.ts @@ -32,6 +32,22 @@ export class CommandPermissionCache { this.onceCache.delete(conversationId) } + /** + * Copy session-scoped approvals only (not one-shot). Used for parent → subagent inheritance. + */ + cloneConversation(sourceConversationId: string, targetConversationId: string): void { + const sourceId = sourceConversationId?.trim() + const targetId = targetConversationId?.trim() + if (!sourceId || !targetId || sourceId === targetId) return + const source = this.sessionCache.get(sourceId) + if (!source || source.size === 0) return + const target = this.sessionCache.get(targetId) ?? new Set() + for (const signature of source) { + target.add(signature) + } + this.sessionCache.set(targetId, target) + } + clearAll(): void { this.sessionCache.clear() this.onceCache.clear() diff --git a/src/main/presenter/permission/commandPermissionService.ts b/src/main/presenter/permission/commandPermissionService.ts index 502aff3c7..40b87a263 100644 --- a/src/main/presenter/permission/commandPermissionService.ts +++ b/src/main/presenter/permission/commandPermissionService.ts @@ -98,6 +98,10 @@ export class CommandPermissionService { this.cache.clearConversation(conversationId) } + cloneConversation(sourceConversationId: string, targetConversationId: string): void { + this.cache.cloneConversation(sourceConversationId, targetConversationId) + } + clearAll(): void { this.cache.clearAll() } diff --git a/src/main/presenter/permission/filePermissionService.ts b/src/main/presenter/permission/filePermissionService.ts index bb034f2e9..f72d0b578 100644 --- a/src/main/presenter/permission/filePermissionService.ts +++ b/src/main/presenter/permission/filePermissionService.ts @@ -58,6 +58,22 @@ export class FilePermissionService { this.approvals.delete(conversationId) } + /** + * Copy remembered path approvals from one conversation to another (e.g. parent → subagent). + */ + cloneConversation(sourceConversationId: string, targetConversationId: string): void { + const sourceId = sourceConversationId?.trim() + const targetId = targetConversationId?.trim() + if (!sourceId || !targetId || sourceId === targetId) return + const source = this.approvals.get(sourceId) + if (!source || source.size === 0) return + const target = this.approvals.get(targetId) ?? new Map() + for (const [filePath, permissionType] of source.entries()) { + target.set(filePath, this.mergePermission(target.get(filePath), permissionType)) + } + this.approvals.set(targetId, target) + } + clearAll(): void { this.approvals.clear() } diff --git a/src/main/presenter/permission/settingsPermissionService.ts b/src/main/presenter/permission/settingsPermissionService.ts index c3da6b189..457952f43 100644 --- a/src/main/presenter/permission/settingsPermissionService.ts +++ b/src/main/presenter/permission/settingsPermissionService.ts @@ -46,6 +46,19 @@ export class SettingsPermissionService { this.oneTimeApprovals.delete(conversationId) } + cloneConversation(sourceConversationId: string, targetConversationId: string): void { + const sourceId = sourceConversationId?.trim() + const targetId = targetConversationId?.trim() + if (!sourceId || !targetId || sourceId === targetId) return + const source = this.sessionApprovals.get(sourceId) + if (!source || source.size === 0) return + const target = this.sessionApprovals.get(targetId) ?? new Set() + for (const toolName of source) { + target.add(toolName) + } + this.sessionApprovals.set(targetId, target) + } + clearAll(): void { this.sessionApprovals.clear() this.oneTimeApprovals.clear() diff --git a/src/main/presenter/runtimePorts.ts b/src/main/presenter/runtimePorts.ts index d86bf32c3..1929c5649 100644 --- a/src/main/presenter/runtimePorts.ts +++ b/src/main/presenter/runtimePorts.ts @@ -57,6 +57,10 @@ export interface AcpProviderAdminPort { export interface SessionPermissionPort { clearSessionPermissions(sessionId: string): void + /** + * Copy session-scoped permission approvals from parent to child (subagent inheritance). + */ + cloneSessionPermissions?(sourceSessionId: string, targetSessionId: string): void approvePermission(sessionId: string, permission: SessionPermissionRequest): Promise } diff --git a/src/main/presenter/sessionApplication/lifecycleCoordinator.ts b/src/main/presenter/sessionApplication/lifecycleCoordinator.ts index 50e74d6ce..58b4d70e0 100644 --- a/src/main/presenter/sessionApplication/lifecycleCoordinator.ts +++ b/src/main/presenter/sessionApplication/lifecycleCoordinator.ts @@ -20,6 +20,7 @@ import type { SessionLifecycleRuntimePort, SessionLifecycleSkillPort, SessionLifecycleStorePort, + SessionLifecyclePermissionPort, SessionLifecycleSubagentInput, SessionLifecycleTranscriptPort } from './ports' @@ -36,6 +37,7 @@ export interface SessionLifecycleCoordinatorDependencies { initialTurn: SessionInitialTurnPort projection: SessionLifecycleProjectionPort deletion: SessionLifecycleDeletionPort + permissions?: SessionLifecyclePermissionPort } export class SessionLifecycleCoordinator implements SessionLifecyclePort { @@ -257,6 +259,8 @@ export class SessionLifecycleCoordinator implements SessionLifecyclePort { if (runtimeConfig.activeSkills.length > 0) { await this.dependencies.skills.setActiveSkills(sessionId, runtimeConfig.activeSkills) } + // Inherit parent session approvals so child does not re-prompt for already-trusted work. + this.dependencies.permissions?.cloneSessionPermissions?.(parentSessionId, sessionId) if (!this.dependencies.sessions.get(sessionId)) { throw new Error(`Subagent session not found after creation: ${sessionId}`) } diff --git a/src/main/presenter/sessionApplication/lifecycleDeletionTransaction.ts b/src/main/presenter/sessionApplication/lifecycleDeletionTransaction.ts index 1e3a06820..cd5b90ba4 100644 --- a/src/main/presenter/sessionApplication/lifecycleDeletionTransaction.ts +++ b/src/main/presenter/sessionApplication/lifecycleDeletionTransaction.ts @@ -36,24 +36,42 @@ export class SessionDeletionTransaction implements SessionLifecycleDeletionPort } } - let backendCleanupError: unknown + // Best-effort staged cleanup: never leave a zombie session row when later stages still work. + const stageErrors: Array<{ stage: string; error: unknown }> = [] try { await this.dependencies.runtime.cleanupSessionBackends(toAppSessionId(sessionId)) } catch (error) { - backendCleanupError = error + stageErrors.push({ stage: 'backend', error }) + console.warn(`[SessionDeletionTransaction] backend cleanup failed for ${sessionId}:`, error) } try { await this.dependencies.state.destroySession(sessionId) } catch (error) { - if (!backendCleanupError) throw error + stageErrors.push({ stage: 'state', error }) + console.warn(`[SessionDeletionTransaction] state destroy failed for ${sessionId}:`, error) + } + + try { + this.dependencies.permissions.clearSessionPermissions(sessionId) + } catch (error) { + stageErrors.push({ stage: 'permissions', error }) + } + try { + await this.dependencies.skills.clearNewAgentSessionSkills(sessionId) + } catch (error) { + stageErrors.push({ stage: 'skills', error }) } - if (backendCleanupError) throw backendCleanupError - this.dependencies.permissions.clearSessionPermissions(sessionId) - await this.dependencies.skills.clearNewAgentSessionSkills(sessionId) this.dependencies.sessions.delete(sessionId) this.dependencies.projection.forgetStatus([sessionId]) deletedSessionIds.push(sessionId) + + if (stageErrors.length > 0) { + console.warn( + `[SessionDeletionTransaction] completed delete for ${sessionId} with partial failures:`, + stageErrors.map((entry) => entry.stage).join(', ') + ) + } return deletedSessionIds } } diff --git a/src/main/presenter/sessionApplication/ports.ts b/src/main/presenter/sessionApplication/ports.ts index 1b994aa39..a6aeb8476 100644 --- a/src/main/presenter/sessionApplication/ports.ts +++ b/src/main/presenter/sessionApplication/ports.ts @@ -558,6 +558,11 @@ export interface SessionDeletionPermissionPort { clearSessionPermissions(sessionId: string): void } +export interface SessionLifecyclePermissionPort { + clearSessionPermissions(sessionId: string): void + cloneSessionPermissions?(sourceSessionId: string, targetSessionId: string): void +} + export interface SessionDeletionSkillPort { clearNewAgentSessionSkills(sessionId: string): Promise } diff --git a/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts b/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts index fd3c5534e..8830f819f 100644 --- a/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts +++ b/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts @@ -16,7 +16,8 @@ const SUBAGENT_WORKDIR_RULE = 'Every child session inherits the same working directory as the parent session.' const SUBAGENT_PROMPT_DESCRIPTION = [ 'Describe only the delegated subtask itself.', - 'The child session uses the same working directory as the parent session.' + 'The child session uses the same working directory as the parent session.', + 'When a child waits for permission or a question, open that child sessionId from progress to respond.' ].join(' ') export const subagentOrchestratorTaskSchema = z.object({ @@ -192,6 +193,16 @@ const renderProgressMarkdown = ( if (task.sessionId) { lines.push(`- Session: \`${task.sessionId}\``) } + if (task.status === 'waiting_permission' || task.status === 'waiting_question') { + lines.push( + `- Action required: open child session \`${task.sessionId ?? 'pending'}\` to respond (${task.status === 'waiting_permission' ? 'permission' : 'question'}).` + ) + if (task.waitingInteraction) { + lines.push( + `- Waiting: ${task.waitingInteraction.type} · message \`${task.waitingInteraction.messageId}\` · tool \`${task.waitingInteraction.toolCallId}\`` + ) + } + } if (task.tapeFinalizeError?.trim()) { lines.push(`- Tape Finalization: failed: ${task.tapeFinalizeError}`) } diff --git a/src/main/routes/chat/chatService.ts b/src/main/routes/chat/chatService.ts index ba13c57e2..26385c1d2 100644 --- a/src/main/routes/chat/chatService.ts +++ b/src/main/routes/chat/chatService.ts @@ -6,7 +6,7 @@ import type { ToolInteractionResponse, ToolInteractionResult } from '@shared/types/agent-interface' -import type { ProviderCatalogPort, SessionPermissionPort } from '@/presenter/runtimePorts' +import type { SessionPermissionPort } from '@/presenter/runtimePorts' import type { Scheduler } from '../scheduler' const CHAT_LOOKUP_TIMEOUT_MS = 5_000 @@ -31,14 +31,18 @@ export interface ChatServiceProjectionPort { getMessage(messageId: string): Promise } +/** + * Route-layer chat operations. Generation concurrency is owned by the agent runtime + * (queue / pending / active generation), not by this service's AbortController map. + * Controllers here only support route-level abort for the in-flight accept path. + */ export class ChatService { - private readonly activeControllers = new Map() + private readonly acceptControllers = new Map() constructor( private readonly deps: { turn: ChatServiceTurnPort projection: ChatServiceProjectionPort - providerCatalogPort: Pick sessionPermissionPort: Pick scheduler: Scheduler } @@ -52,12 +56,8 @@ export class ChatService { requestId: string | null messageId: string | null }> { - if (this.activeControllers.has(sessionId)) { - throw new Error(`A stream is already active for session ${sessionId}`) - } - const controller = new AbortController() - this.activeControllers.set(sessionId, controller) + this.acceptControllers.set(sessionId, controller) try { const session = await this.deps.scheduler.timeout({ @@ -70,16 +70,6 @@ export class ChatService { throw new Error(`Session not found: ${sessionId}`) } - const agentType = await this.deps.scheduler.timeout({ - task: this.deps.providerCatalogPort.getAgentType(session.agentId), - ms: CHAT_LOOKUP_TIMEOUT_MS, - reason: `chat.sendMessage:${sessionId}:agentType` - }) - - if (!agentType) { - throw new Error(`Agent type not found: ${session.agentId}`) - } - const result = await this.deps.scheduler.timeout({ task: this.deps.turn.sendMessage(sessionId, content), ms: CHAT_SEND_TIMEOUT_MS, @@ -94,29 +84,12 @@ export class ChatService { } } catch (error) { if (error instanceof Error && error.name === 'TimeoutError') { - const cleanupResults = await Promise.allSettled([ - Promise.resolve(this.deps.sessionPermissionPort.clearSessionPermissions(sessionId)), - this.deps.turn.cancelGeneration(sessionId) - ]) - const clearPermissionsResult = cleanupResults[0] - if (clearPermissionsResult?.status === 'rejected') { - console.warn( - `[ChatService] Failed to clear session permissions after send timeout for ${sessionId}:`, - clearPermissionsResult.reason - ) - } - const cancelGenerationResult = cleanupResults[1] - if (cancelGenerationResult?.status === 'rejected') { - console.warn( - `[ChatService] Failed to cancel generation after send timeout for ${sessionId}:`, - cancelGenerationResult.reason - ) - } + await this.bestEffortCancel(sessionId, 'send timeout') } throw error } finally { - if (this.activeControllers.get(sessionId) === controller) { - this.activeControllers.delete(sessionId) + if (this.acceptControllers.get(sessionId) === controller) { + this.acceptControllers.delete(sessionId) } } } @@ -163,12 +136,13 @@ export class ChatService { return { stopped: false } } - const controller = this.activeControllers.get(targetSessionId) + const controller = this.acceptControllers.get(targetSessionId) if (controller) { controller.abort() - this.activeControllers.delete(targetSessionId) + this.acceptControllers.delete(targetSessionId) } + let cancelFailed = false await this.deps.scheduler.timeout({ task: Promise.allSettled([ Promise.resolve().then(() => @@ -186,6 +160,7 @@ export class ChatService { const cancelGenerationResult = results[1] if (cancelGenerationResult?.status === 'rejected') { + cancelFailed = true console.warn( `[ChatService] Failed to cancel generation during stop for ${targetSessionId}:`, cancelGenerationResult.reason @@ -196,34 +171,40 @@ export class ChatService { reason: `chat.stopStream:${targetSessionId}` }) - return { stopped: true } + return { stopped: !cancelFailed } } - async respondToolInteraction(input: { - sessionId: string - messageId: string - toolCallId: string + async respondToolInteraction( + sessionId: string, + messageId: string, + toolCallId: string, response: ToolInteractionResponse - }): Promise<{ - accepted: true - resumed?: boolean - waitingForUserMessage?: boolean - handledInline?: boolean - }> { - const result = await this.deps.scheduler.timeout({ - task: this.deps.turn.respondToolInteraction( - input.sessionId, - input.messageId, - input.toolCallId, - input.response - ), + ): Promise { + return await this.deps.scheduler.timeout({ + task: this.deps.turn.respondToolInteraction(sessionId, messageId, toolCallId, response), ms: CHAT_INTERACTION_TIMEOUT_MS, - reason: `chat.respondToolInteraction:${input.sessionId}:${input.toolCallId}` + reason: `chat.respondToolInteraction:${sessionId}:${toolCallId}` }) + } - return { - accepted: true, - ...result + private async bestEffortCancel(sessionId: string, reason: string): Promise { + const cleanupResults = await Promise.allSettled([ + Promise.resolve(this.deps.sessionPermissionPort.clearSessionPermissions(sessionId)), + this.deps.turn.cancelGeneration(sessionId) + ]) + const clearPermissionsResult = cleanupResults[0] + if (clearPermissionsResult?.status === 'rejected') { + console.warn( + `[ChatService] Failed to clear session permissions after ${reason} for ${sessionId}:`, + clearPermissionsResult.reason + ) + } + const cancelGenerationResult = cleanupResults[1] + if (cancelGenerationResult?.status === 'rejected') { + console.warn( + `[ChatService] Failed to cancel generation after ${reason} for ${sessionId}:`, + cancelGenerationResult.reason + ) } } } diff --git a/src/main/routes/index.ts b/src/main/routes/index.ts index 62ce50514..394f96e92 100644 --- a/src/main/routes/index.ts +++ b/src/main/routes/index.ts @@ -789,7 +789,6 @@ export function createMainKernelRouteRuntime(deps: { const chatService = new ChatService({ turn: deps.sessionTurnPort, projection: deps.sessionProjectionPort, - providerCatalogPort: hotPathPorts.providerCatalogPort, sessionPermissionPort: deps.sessionPermissionPort, scheduler }) diff --git a/src/main/routes/sessions/sessionService.ts b/src/main/routes/sessions/sessionService.ts index 29341ba9f..d59e8719f 100644 --- a/src/main/routes/sessions/sessionService.ts +++ b/src/main/routes/sessions/sessionService.ts @@ -7,6 +7,9 @@ import type { import type { Scheduler } from '../scheduler' const SESSION_OPERATION_TIMEOUT_MS = 5_000 +/** ACP process hydrate/prepare can exceed the generic read timeout. */ +const SESSION_CREATE_TIMEOUT_MS = 60_000 +const SESSION_LIST_TIMEOUT_MS = 15_000 const DEFAULT_RESTORE_MESSAGE_LIMIT = 100 export type SessionRouteContext = { @@ -52,7 +55,7 @@ export class SessionService { ): Promise { return await this.deps.scheduler.timeout({ task: this.deps.lifecycle.createSession(input, context.webContentsId), - ms: SESSION_OPERATION_TIMEOUT_MS, + ms: SESSION_CREATE_TIMEOUT_MS, reason: 'sessions.create' }) } @@ -119,7 +122,7 @@ export class SessionService { async listSessions(filters?: SessionListFilters) { return await this.deps.scheduler.timeout({ task: this.deps.projection.listSessions(filters), - ms: SESSION_OPERATION_TIMEOUT_MS, + ms: SESSION_LIST_TIMEOUT_MS, reason: 'sessions.list' }) } diff --git a/src/renderer/src/i18n/da-DK/settings.json b/src/renderer/src/i18n/da-DK/settings.json index 2f2967b65..59950772e 100644 --- a/src/renderer/src/i18n/da-DK/settings.json +++ b/src/renderer/src/i18n/da-DK/settings.json @@ -2979,6 +2979,9 @@ "cuaDescription": "DeepChats ComputerUse-plugin implementeret med udgangspunkt i trycua/cua-projektet.", "acpUnavailableTitle": "Plugins er ikke tilgængelige for ACP-agenter", "acpUnavailableDescription": "ACP-agenter administrerer værktøjer og udvidelser i deres eget kørselsmiljø. Vælg en DeepChat-agent for at bruge plugins.", - "agentScopeUnsupported": "Agentafgrænsede udvidelser er kun tilgængelige for DeepChat-agenter." + "agentScopeUnsupported": "Agentafgrænsede udvidelser er kun tilgængelige for DeepChat-agenter.", + "scopeGlobalPlugins": "This page is app-global: official plugin install/enable/process state is shared by every DeepChat agent.", + "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", + "currentAgentFallback": "current agent" } } diff --git a/src/renderer/src/i18n/de-DE/settings.json b/src/renderer/src/i18n/de-DE/settings.json index 3129ffe7f..6d066c78b 100644 --- a/src/renderer/src/i18n/de-DE/settings.json +++ b/src/renderer/src/i18n/de-DE/settings.json @@ -2970,6 +2970,9 @@ "cuaDescription": "DeepChats ComputerUse-Plugin, implementiert auf Basis des Projekts trycua/cua.", "acpUnavailableTitle": "Plugins sind für ACP-Agenten nicht verfügbar", "acpUnavailableDescription": "ACP-Agenten verwalten Werkzeuge und Erweiterungen in ihrer eigenen Laufzeitumgebung. Wählen Sie einen DeepChat-Agenten aus, um Plugins zu verwenden.", - "agentScopeUnsupported": "Agentenspezifische Erweiterungen sind nur für DeepChat-Agenten verfügbar." + "agentScopeUnsupported": "Agentenspezifische Erweiterungen sind nur für DeepChat-Agenten verfügbar.", + "scopeGlobalPlugins": "This page is app-global: official plugin install/enable/process state is shared by every DeepChat agent.", + "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", + "currentAgentFallback": "current agent" } } diff --git a/src/renderer/src/i18n/en-US/settings.json b/src/renderer/src/i18n/en-US/settings.json index 9fa6df5ba..ed9bff8d0 100644 --- a/src/renderer/src/i18n/en-US/settings.json +++ b/src/renderer/src/i18n/en-US/settings.json @@ -2774,7 +2774,10 @@ "cuaDescription": "DeepChat's ComputerUse plugin built on the trycua/cua project.", "acpUnavailableTitle": "Plugins are unavailable for ACP agents", "acpUnavailableDescription": "ACP agents manage tools and extensions through their own runtime. Select a DeepChat agent to use Plugins.", - "agentScopeUnsupported": "Agent-scoped extensions are only available for DeepChat agents." + "agentScopeUnsupported": "Agent-scoped extensions are only available for DeepChat agents.", + "scopeGlobalPlugins": "This page is app-global: official plugin install/enable/process state is shared by every DeepChat agent.", + "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", + "currentAgentFallback": "current agent" }, "controlCenter": { "groups": { diff --git a/src/renderer/src/i18n/es-ES/settings.json b/src/renderer/src/i18n/es-ES/settings.json index 81336ae4f..dc7e99354 100644 --- a/src/renderer/src/i18n/es-ES/settings.json +++ b/src/renderer/src/i18n/es-ES/settings.json @@ -2970,6 +2970,9 @@ "cuaDescription": "Plugin ComputerUse de DeepChat implementado a partir del proyecto trycua/cua.", "acpUnavailableTitle": "Los plugins no están disponibles para los agentes ACP", "acpUnavailableDescription": "Los agentes ACP gestionan herramientas y extensiones en su propio entorno de ejecución. Selecciona un agente de DeepChat para usar plugins.", - "agentScopeUnsupported": "Las extensiones específicas de cada agente solo están disponibles para los agentes de DeepChat." + "agentScopeUnsupported": "Las extensiones específicas de cada agente solo están disponibles para los agentes de DeepChat.", + "scopeGlobalPlugins": "This page is app-global: official plugin install/enable/process state is shared by every DeepChat agent.", + "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", + "currentAgentFallback": "current agent" } } diff --git a/src/renderer/src/i18n/fa-IR/settings.json b/src/renderer/src/i18n/fa-IR/settings.json index 7e1d3f9dd..f1ffe89b8 100644 --- a/src/renderer/src/i18n/fa-IR/settings.json +++ b/src/renderer/src/i18n/fa-IR/settings.json @@ -2979,6 +2979,9 @@ "cuaDescription": "افزونه ComputerUse DeepChat که بر پایه پروژه trycua/cua پیاده‌سازی شده است.", "acpUnavailableTitle": "افزونه‌ها برای عامل‌های ACP در دسترس نیستند", "acpUnavailableDescription": "عامل‌های ACP ابزارها و افزونه‌ها را در محیط اجرای خود مدیریت می‌کنند. برای استفاده از افزونه‌ها، یک عامل DeepChat را انتخاب کنید.", - "agentScopeUnsupported": "افزونه‌های ویژهٔ هر عامل فقط برای عامل‌های DeepChat در دسترس هستند." + "agentScopeUnsupported": "افزونه‌های ویژهٔ هر عامل فقط برای عامل‌های DeepChat در دسترس هستند.", + "scopeGlobalPlugins": "This page is app-global: official plugin install/enable/process state is shared by every DeepChat agent.", + "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", + "currentAgentFallback": "current agent" } } diff --git a/src/renderer/src/i18n/fr-FR/settings.json b/src/renderer/src/i18n/fr-FR/settings.json index a2e27896e..1c246826c 100644 --- a/src/renderer/src/i18n/fr-FR/settings.json +++ b/src/renderer/src/i18n/fr-FR/settings.json @@ -2979,6 +2979,9 @@ "cuaDescription": "Plugin ComputerUse de DeepChat, implémenté à partir du projet trycua/cua.", "acpUnavailableTitle": "Les plugins ne sont pas disponibles pour les agents ACP", "acpUnavailableDescription": "Les agents ACP gèrent les outils et les extensions dans leur propre environnement d’exécution. Sélectionnez un agent DeepChat pour utiliser les plugins.", - "agentScopeUnsupported": "Les extensions propres à un agent ne sont disponibles que pour les agents DeepChat." + "agentScopeUnsupported": "Les extensions propres à un agent ne sont disponibles que pour les agents DeepChat.", + "scopeGlobalPlugins": "This page is app-global: official plugin install/enable/process state is shared by every DeepChat agent.", + "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", + "currentAgentFallback": "current agent" } } diff --git a/src/renderer/src/i18n/he-IL/settings.json b/src/renderer/src/i18n/he-IL/settings.json index 78cd2644b..483ab2da9 100644 --- a/src/renderer/src/i18n/he-IL/settings.json +++ b/src/renderer/src/i18n/he-IL/settings.json @@ -2979,6 +2979,9 @@ "cuaDescription": "תוסף ComputerUse של DeepChat שמיושם על בסיס הפרויקט trycua/cua.", "acpUnavailableTitle": "תוספים אינם זמינים לסוכני ACP", "acpUnavailableDescription": "סוכני ACP מנהלים כלים והרחבות בסביבת הריצה שלהם. יש לבחור סוכן DeepChat כדי להשתמש בתוספים.", - "agentScopeUnsupported": "הרחבות ברמת הסוכן זמינות רק לסוכני DeepChat." + "agentScopeUnsupported": "הרחבות ברמת הסוכן זמינות רק לסוכני DeepChat.", + "scopeGlobalPlugins": "This page is app-global: official plugin install/enable/process state is shared by every DeepChat agent.", + "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", + "currentAgentFallback": "current agent" } } diff --git a/src/renderer/src/i18n/id-ID/settings.json b/src/renderer/src/i18n/id-ID/settings.json index 561f2bea3..2f34623e0 100644 --- a/src/renderer/src/i18n/id-ID/settings.json +++ b/src/renderer/src/i18n/id-ID/settings.json @@ -2970,6 +2970,9 @@ "cuaDescription": "Plugin ComputerUse DeepChat yang diimplementasikan berdasarkan proyek trycua/cua.", "acpUnavailableTitle": "Plugin tidak tersedia untuk agen ACP", "acpUnavailableDescription": "Agen ACP mengelola alat dan ekstensi di lingkungan runtime mereka sendiri. Pilih agen DeepChat untuk menggunakan plugin.", - "agentScopeUnsupported": "Ekstensi khusus agen hanya tersedia untuk agen DeepChat." + "agentScopeUnsupported": "Ekstensi khusus agen hanya tersedia untuk agen DeepChat.", + "scopeGlobalPlugins": "This page is app-global: official plugin install/enable/process state is shared by every DeepChat agent.", + "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", + "currentAgentFallback": "current agent" } } diff --git a/src/renderer/src/i18n/it-IT/settings.json b/src/renderer/src/i18n/it-IT/settings.json index 95bc0de32..4cdbc4c2d 100644 --- a/src/renderer/src/i18n/it-IT/settings.json +++ b/src/renderer/src/i18n/it-IT/settings.json @@ -2970,6 +2970,9 @@ "cuaDescription": "Plugin ComputerUse di DeepChat implementato sulla base del progetto trycua/cua.", "acpUnavailableTitle": "I plugin non sono disponibili per gli agenti ACP", "acpUnavailableDescription": "Gli agenti ACP gestiscono strumenti ed estensioni nel proprio ambiente di esecuzione. Seleziona un agente DeepChat per usare i plugin.", - "agentScopeUnsupported": "Le estensioni specifiche per agente sono disponibili solo per gli agenti DeepChat." + "agentScopeUnsupported": "Le estensioni specifiche per agente sono disponibili solo per gli agenti DeepChat.", + "scopeGlobalPlugins": "This page is app-global: official plugin install/enable/process state is shared by every DeepChat agent.", + "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", + "currentAgentFallback": "current agent" } } diff --git a/src/renderer/src/i18n/ja-JP/settings.json b/src/renderer/src/i18n/ja-JP/settings.json index 3f9ea95dc..f3545079b 100644 --- a/src/renderer/src/i18n/ja-JP/settings.json +++ b/src/renderer/src/i18n/ja-JP/settings.json @@ -2979,6 +2979,9 @@ "cuaDescription": "trycua/cua プロジェクトをベースに DeepChat が実装した ComputerUse プラグインです。", "acpUnavailableTitle": "ACP エージェントではプラグインを利用できません", "acpUnavailableDescription": "ACP エージェントは独自のランタイムでツールと拡張機能を管理します。プラグインを使用するには DeepChat エージェントを選択してください。", - "agentScopeUnsupported": "エージェント単位の拡張機能は DeepChat エージェントでのみ利用できます。" + "agentScopeUnsupported": "エージェント単位の拡張機能は DeepChat エージェントでのみ利用できます。", + "scopeGlobalPlugins": "This page is app-global: official plugin install/enable/process state is shared by every DeepChat agent.", + "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", + "currentAgentFallback": "current agent" } } diff --git a/src/renderer/src/i18n/ko-KR/settings.json b/src/renderer/src/i18n/ko-KR/settings.json index 56d31b37d..8f1929306 100644 --- a/src/renderer/src/i18n/ko-KR/settings.json +++ b/src/renderer/src/i18n/ko-KR/settings.json @@ -2979,6 +2979,9 @@ "cuaDescription": "DeepChat이 trycua/cua 프로젝트를 기반으로 구현한 ComputerUse 플러그인입니다.", "acpUnavailableTitle": "ACP 에이전트에서는 플러그인을 사용할 수 없습니다", "acpUnavailableDescription": "ACP 에이전트는 자체 런타임에서 도구와 확장 기능을 관리합니다. 플러그인을 사용하려면 DeepChat 에이전트를 선택하세요.", - "agentScopeUnsupported": "에이전트별 확장 기능은 DeepChat 에이전트에서만 사용할 수 있습니다." + "agentScopeUnsupported": "에이전트별 확장 기능은 DeepChat 에이전트에서만 사용할 수 있습니다.", + "scopeGlobalPlugins": "This page is app-global: official plugin install/enable/process state is shared by every DeepChat agent.", + "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", + "currentAgentFallback": "current agent" } } diff --git a/src/renderer/src/i18n/ms-MY/settings.json b/src/renderer/src/i18n/ms-MY/settings.json index 26560e2e3..cdec0d6c6 100644 --- a/src/renderer/src/i18n/ms-MY/settings.json +++ b/src/renderer/src/i18n/ms-MY/settings.json @@ -2970,6 +2970,9 @@ "cuaDescription": "Plugin ComputerUse DeepChat yang dilaksanakan berdasarkan projek trycua/cua.", "acpUnavailableTitle": "Pemalam tidak tersedia untuk ejen ACP", "acpUnavailableDescription": "Ejen ACP mengurus alat dan sambungan dalam persekitaran masa jalan mereka sendiri. Pilih ejen DeepChat untuk menggunakan pemalam.", - "agentScopeUnsupported": "Sambungan khusus ejen hanya tersedia untuk ejen DeepChat." + "agentScopeUnsupported": "Sambungan khusus ejen hanya tersedia untuk ejen DeepChat.", + "scopeGlobalPlugins": "This page is app-global: official plugin install/enable/process state is shared by every DeepChat agent.", + "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", + "currentAgentFallback": "current agent" } } diff --git a/src/renderer/src/i18n/pl-PL/settings.json b/src/renderer/src/i18n/pl-PL/settings.json index e6f03e2c3..0d14a1cf2 100644 --- a/src/renderer/src/i18n/pl-PL/settings.json +++ b/src/renderer/src/i18n/pl-PL/settings.json @@ -2970,6 +2970,9 @@ "cuaDescription": "Wtyczka ComputerUse DeepChat zaimplementowana na podstawie projektu trycua/cua.", "acpUnavailableTitle": "Wtyczki nie są dostępne dla agentów ACP", "acpUnavailableDescription": "Agenci ACP zarządzają narzędziami i rozszerzeniami we własnym środowisku uruchomieniowym. Wybierz agenta DeepChat, aby korzystać z wtyczek.", - "agentScopeUnsupported": "Rozszerzenia na poziomie agenta są dostępne tylko dla agentów DeepChat." + "agentScopeUnsupported": "Rozszerzenia na poziomie agenta są dostępne tylko dla agentów DeepChat.", + "scopeGlobalPlugins": "This page is app-global: official plugin install/enable/process state is shared by every DeepChat agent.", + "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", + "currentAgentFallback": "current agent" } } diff --git a/src/renderer/src/i18n/pt-BR/settings.json b/src/renderer/src/i18n/pt-BR/settings.json index 5b3e9bbfe..133468626 100644 --- a/src/renderer/src/i18n/pt-BR/settings.json +++ b/src/renderer/src/i18n/pt-BR/settings.json @@ -2979,6 +2979,9 @@ "cuaDescription": "Plugin ComputerUse do DeepChat implementado com base no projeto trycua/cua.", "acpUnavailableTitle": "Os plugins não estão disponíveis para agentes ACP", "acpUnavailableDescription": "Os agentes ACP gerenciam ferramentas e extensões no próprio ambiente de execução. Selecione um agente DeepChat para usar plugins.", - "agentScopeUnsupported": "As extensões por agente estão disponíveis apenas para agentes DeepChat." + "agentScopeUnsupported": "As extensões por agente estão disponíveis apenas para agentes DeepChat.", + "scopeGlobalPlugins": "This page is app-global: official plugin install/enable/process state is shared by every DeepChat agent.", + "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", + "currentAgentFallback": "current agent" } } diff --git a/src/renderer/src/i18n/ru-RU/settings.json b/src/renderer/src/i18n/ru-RU/settings.json index 2e4599686..e79aa1a9e 100644 --- a/src/renderer/src/i18n/ru-RU/settings.json +++ b/src/renderer/src/i18n/ru-RU/settings.json @@ -2979,6 +2979,9 @@ "cuaDescription": "Плагин ComputerUse от DeepChat, реализованный на базе проекта trycua/cua.", "acpUnavailableTitle": "Плагины недоступны для агентов ACP", "acpUnavailableDescription": "Агенты ACP управляют инструментами и расширениями в собственной среде выполнения. Выберите агента DeepChat, чтобы использовать плагины.", - "agentScopeUnsupported": "Расширения на уровне агента доступны только агентам DeepChat." + "agentScopeUnsupported": "Расширения на уровне агента доступны только агентам DeepChat.", + "scopeGlobalPlugins": "This page is app-global: official plugin install/enable/process state is shared by every DeepChat agent.", + "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", + "currentAgentFallback": "current agent" } } diff --git a/src/renderer/src/i18n/tr-TR/settings.json b/src/renderer/src/i18n/tr-TR/settings.json index ac9bff4d1..23d1ea879 100644 --- a/src/renderer/src/i18n/tr-TR/settings.json +++ b/src/renderer/src/i18n/tr-TR/settings.json @@ -2970,6 +2970,9 @@ "cuaDescription": "DeepChat'in trycua/cua projesi temel alınarak uygulanmış ComputerUse eklentisi.", "acpUnavailableTitle": "Eklentiler ACP ajanları için kullanılamaz", "acpUnavailableDescription": "ACP ajanları araçları ve uzantıları kendi çalışma ortamlarında yönetir. Eklentileri kullanmak için bir DeepChat ajanı seçin.", - "agentScopeUnsupported": "Ajan düzeyindeki uzantılar yalnızca DeepChat ajanları tarafından kullanılabilir." + "agentScopeUnsupported": "Ajan düzeyindeki uzantılar yalnızca DeepChat ajanları tarafından kullanılabilir.", + "scopeGlobalPlugins": "This page is app-global: official plugin install/enable/process state is shared by every DeepChat agent.", + "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", + "currentAgentFallback": "current agent" } } diff --git a/src/renderer/src/i18n/vi-VN/settings.json b/src/renderer/src/i18n/vi-VN/settings.json index 2e8d999cd..cdbed2aa8 100644 --- a/src/renderer/src/i18n/vi-VN/settings.json +++ b/src/renderer/src/i18n/vi-VN/settings.json @@ -2970,6 +2970,9 @@ "cuaDescription": "Plugin ComputerUse của DeepChat được triển khai dựa trên dự án trycua/cua.", "acpUnavailableTitle": "Plugin không khả dụng cho tác nhân ACP", "acpUnavailableDescription": "Tác nhân ACP quản lý công cụ và tiện ích mở rộng trong môi trường chạy riêng. Chọn một tác nhân DeepChat để sử dụng plugin.", - "agentScopeUnsupported": "Tiện ích mở rộng theo từng tác nhân chỉ khả dụng cho tác nhân DeepChat." + "agentScopeUnsupported": "Tiện ích mở rộng theo từng tác nhân chỉ khả dụng cho tác nhân DeepChat.", + "scopeGlobalPlugins": "This page is app-global: official plugin install/enable/process state is shared by every DeepChat agent.", + "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", + "currentAgentFallback": "current agent" } } diff --git a/src/renderer/src/i18n/zh-CN/settings.json b/src/renderer/src/i18n/zh-CN/settings.json index 920dc6ec3..edd31ff11 100644 --- a/src/renderer/src/i18n/zh-CN/settings.json +++ b/src/renderer/src/i18n/zh-CN/settings.json @@ -2773,7 +2773,10 @@ "cuaDescription": "DeepChat 基于 trycua/cua 项目实现的 ComputerUse 插件", "acpUnavailableTitle": "ACP Agent 无法使用插件", "acpUnavailableDescription": "ACP Agent 通过自己的运行时管理工具和扩展。请选择一个 DeepChat Agent 后再使用插件。", - "agentScopeUnsupported": "Agent 级扩展配置仅支持 DeepChat Agent。" + "agentScopeUnsupported": "Agent 级扩展配置仅支持 DeepChat Agent。", + "scopeGlobalPlugins": "当前页为应用全局:官方插件的安装、启用与进程对所有 DeepChat Agent 共享。", + "scopeCurrentAgent": "当前页为 Agent 策略:仅影响「{agent}」可用的 Skills / MCP,不会全局禁用资源。", + "currentAgentFallback": "当前 Agent" }, "controlCenter": { "groups": { diff --git a/src/renderer/src/i18n/zh-HK/settings.json b/src/renderer/src/i18n/zh-HK/settings.json index 5158ed433..9def7f55a 100644 --- a/src/renderer/src/i18n/zh-HK/settings.json +++ b/src/renderer/src/i18n/zh-HK/settings.json @@ -2979,6 +2979,9 @@ "cuaDescription": "DeepChat 基於 trycua/cua 專案實作的 ComputerUse 插件", "acpUnavailableTitle": "ACP Agent 無法使用插件", "acpUnavailableDescription": "ACP Agent 透過自身的執行環境管理工具和擴充功能。請選擇 DeepChat Agent 後再使用插件。", - "agentScopeUnsupported": "Agent 層級的擴充功能只適用於 DeepChat Agent。" + "agentScopeUnsupported": "Agent 層級的擴充功能只適用於 DeepChat Agent。", + "scopeGlobalPlugins": "当前页为应用全局:官方插件的安装、启用与进程对所有 DeepChat Agent 共享。", + "scopeCurrentAgent": "当前页为 Agent 策略:仅影响「{agent}」可用的 Skills / MCP,不会全局禁用资源。", + "currentAgentFallback": "当前 Agent" } } diff --git a/src/renderer/src/i18n/zh-TW/settings.json b/src/renderer/src/i18n/zh-TW/settings.json index 8b7fc803e..f821105f7 100644 --- a/src/renderer/src/i18n/zh-TW/settings.json +++ b/src/renderer/src/i18n/zh-TW/settings.json @@ -2979,6 +2979,9 @@ "cuaDescription": "DeepChat 基於 trycua/cua 專案實作的 ComputerUse 插件", "acpUnavailableTitle": "ACP Agent 無法使用外掛程式", "acpUnavailableDescription": "ACP Agent 透過自己的執行環境管理工具與擴充功能。請選擇 DeepChat Agent 後再使用外掛程式。", - "agentScopeUnsupported": "Agent 層級的擴充功能僅適用於 DeepChat Agent。" + "agentScopeUnsupported": "Agent 層級的擴充功能僅適用於 DeepChat Agent。", + "scopeGlobalPlugins": "当前页为应用全局:官方插件的安装、启用与进程对所有 DeepChat Agent 共享。", + "scopeCurrentAgent": "当前页为 Agent 策略:仅影响「{agent}」可用的 Skills / MCP,不会全局禁用资源。", + "currentAgentFallback": "当前 Agent" } } diff --git a/src/renderer/src/pages/plugins/PluginsHubPage.vue b/src/renderer/src/pages/plugins/PluginsHubPage.vue index a69bfc34f..4db000df9 100644 --- a/src/renderer/src/pages/plugins/PluginsHubPage.vue +++ b/src/renderer/src/pages/plugins/PluginsHubPage.vue @@ -20,6 +20,12 @@ +

+ {{ scopeBannerText }} +

@@ -93,6 +99,18 @@ const activeTab = computed(() => { return 'plugins' }) +const selectedAgentName = computed(() => { + const agent = agentStore.selectedAgent + return agent?.name?.trim() || t('settings.pluginsHub.currentAgentFallback') +}) + +const scopeBannerText = computed(() => { + if (activeTab.value === 'plugins') { + return t('settings.pluginsHub.scopeGlobalPlugins') + } + return t('settings.pluginsHub.scopeCurrentAgent', { agent: selectedAgentName.value }) +}) + const isAcpAgent = computed(() => { const agent = agentStore.selectedAgent return Boolean(agent && (agent.agentType ?? agent.type) === 'acp') diff --git a/test/main/agent/acp/runtime/acpContentMapper.test.ts b/test/main/agent/acp/runtime/acpContentMapper.test.ts index dcc54a5f3..d29e6c789 100644 --- a/test/main/agent/acp/runtime/acpContentMapper.test.ts +++ b/test/main/agent/acp/runtime/acpContentMapper.test.ts @@ -40,6 +40,13 @@ describe('AcpContentMapper tool call handling', () => { }) ) + expect( + start.blocks.some( + (block) => block.type === 'action' && block.action_type === 'tool_call_permission' + ) + ).toBe(false) + expect(start.events.some((event) => event.type === 'reasoning')).toBe(true) + const startEvent = start.events.find((event) => event.type === 'tool_call_start') expect(startEvent).toMatchObject({ type: 'tool_call_start', diff --git a/test/main/presenter/agentSessionPresenter/agentSessionPresenter.test.ts b/test/main/presenter/agentSessionPresenter/agentSessionPresenter.test.ts index 047db1e41..c997665a4 100644 --- a/test/main/presenter/agentSessionPresenter/agentSessionPresenter.test.ts +++ b/test/main/presenter/agentSessionPresenter/agentSessionPresenter.test.ts @@ -3522,7 +3522,7 @@ describe('AgentSessionPresenter', () => { ) }) - it('preserves runtime cleanup errors after shared cleanup without deleting the app row', async () => { + it('still deletes the app row when runtime cleanup fails', async () => { const cleanupError = new Error('runtime cleanup failed') sqlitePresenter.newSessionsTable.get.mockReturnValue({ id: 's1', @@ -3535,14 +3535,14 @@ describe('AgentSessionPresenter', () => { }) agentManager.cleanupSessionBackends.mockRejectedValueOnce(cleanupError) - await expect(presenter.deleteSession('s1')).rejects.toBe(cleanupError) + await expect(presenter.deleteSession('s1')).resolves.toBeUndefined() expect(deepChatAgent.destroySession).toHaveBeenCalledExactlyOnceWith('s1') - expect(skillPresenter.clearNewAgentSessionSkills).not.toHaveBeenCalled() - expect(sqlitePresenter.newSessionsTable.delete).not.toHaveBeenCalled() + expect(skillPresenter.clearNewAgentSessionSkills).toHaveBeenCalledWith('s1') + expect(sqlitePresenter.newSessionsTable.delete).toHaveBeenCalledWith('s1') }) - it('prefers backend cleanup errors when shared cleanup also fails', async () => { + it('still removes the session row when backend and shared cleanup both fail', async () => { const backendError = new Error('backend cleanup failed') const sharedError = new Error('shared cleanup failed') sqlitePresenter.newSessionsTable.get.mockReturnValue({ @@ -3557,15 +3557,14 @@ describe('AgentSessionPresenter', () => { agentManager.cleanupSessionBackends.mockRejectedValueOnce(backendError) deepChatAgent.destroySession.mockRejectedValueOnce(sharedError) - await expect(presenter.deleteSession('s1')).rejects.toBe(backendError) + await expect(presenter.deleteSession('s1')).resolves.toBeUndefined() expect(deepChatAgent.destroySession).toHaveBeenCalledExactlyOnceWith('s1') - expect(skillPresenter.clearNewAgentSessionSkills).not.toHaveBeenCalled() - expect(sqlitePresenter.newSessionsTable.delete).not.toHaveBeenCalled() - expect(publishDeepchatEvent).not.toHaveBeenCalled() + expect(skillPresenter.clearNewAgentSessionSkills).toHaveBeenCalledWith('s1') + expect(sqlitePresenter.newSessionsTable.delete).toHaveBeenCalledWith('s1') }) - it('propagates shared cleanup failure when backend cleanup succeeds', async () => { + it('still removes the session row when shared cleanup fails after backend cleanup', async () => { const sharedError = new Error('shared cleanup failed') sqlitePresenter.newSessionsTable.get.mockReturnValue({ id: 's1', @@ -3578,12 +3577,11 @@ describe('AgentSessionPresenter', () => { }) deepChatAgent.destroySession.mockRejectedValueOnce(sharedError) - await expect(presenter.deleteSession('s1')).rejects.toBe(sharedError) + await expect(presenter.deleteSession('s1')).resolves.toBeUndefined() expect(agentManager.cleanupSessionBackends).toHaveBeenCalledExactlyOnceWith('s1') - expect(skillPresenter.clearNewAgentSessionSkills).not.toHaveBeenCalled() - expect(sqlitePresenter.newSessionsTable.delete).not.toHaveBeenCalled() - expect(publishDeepchatEvent).not.toHaveBeenCalled() + expect(skillPresenter.clearNewAgentSessionSkills).toHaveBeenCalledWith('s1') + expect(sqlitePresenter.newSessionsTable.delete).toHaveBeenCalledWith('s1') }) }) diff --git a/test/main/presenter/sessionApplication/lifecycleDeletionTransaction.test.ts b/test/main/presenter/sessionApplication/lifecycleDeletionTransaction.test.ts index e07b140f1..dc4757ae0 100644 --- a/test/main/presenter/sessionApplication/lifecycleDeletionTransaction.test.ts +++ b/test/main/presenter/sessionApplication/lifecycleDeletionTransaction.test.ts @@ -84,15 +84,16 @@ describe('SessionDeletionTransaction', () => { expect(harness.dependencies.projection.forgetStatus).toHaveBeenNthCalledWith(2, ['parent']) }) - it('preserves backend cleanup error precedence over shared-state cleanup', async () => { + it('still deletes the session row after partial backend/state cleanup failures', async () => { const harness = createHarness() harness.records.delete('child') const backendError = new Error('backend failed') harness.dependencies.runtime.cleanupSessionBackends.mockRejectedValue(backendError) harness.dependencies.state.destroySession.mockRejectedValue(new Error('state failed')) - await expect(harness.transaction.deleteSessionTree('parent')).rejects.toBe(backendError) + await expect(harness.transaction.deleteSessionTree('parent')).resolves.toEqual(['parent']) expect(harness.dependencies.state.destroySession).toHaveBeenCalledWith('parent') - expect(harness.dependencies.sessions.delete).not.toHaveBeenCalled() + expect(harness.dependencies.sessions.delete).toHaveBeenCalledWith('parent') + expect(harness.dependencies.permissions.clearSessionPermissions).toHaveBeenCalledWith('parent') }) }) diff --git a/test/main/routes/chatService.test.ts b/test/main/routes/chatService.test.ts index cc598a4e9..dc16ae4ce 100644 --- a/test/main/routes/chatService.test.ts +++ b/test/main/routes/chatService.test.ts @@ -50,16 +50,12 @@ function createHarness() { cancelGeneration: vi.fn().mockResolvedValue(undefined), respondToolInteraction: vi.fn().mockResolvedValue({ resumed: true }) } - const providerCatalogPort = { - getAgentType: vi.fn().mockResolvedValue('deepchat' as const) - } const sessionPermissionPort = { clearSessionPermissions: vi.fn() } const service = new ChatService({ projection, turn, - providerCatalogPort, sessionPermissionPort, scheduler }) @@ -69,13 +65,12 @@ function createHarness() { scheduler, projection, turn, - providerCatalogPort, sessionPermissionPort } } describe('ChatService', () => { - it('sends messages through the scheduler after resolving the session owner', async () => { + it('sends messages through the scheduler after resolving the session', async () => { const harness = createHarness() await expect(harness.service.sendMessage('session-1', 'hello')).resolves.toEqual({ @@ -85,9 +80,8 @@ describe('ChatService', () => { }) expect(harness.projection.getSession).toHaveBeenCalledWith('session-1') - expect(harness.providerCatalogPort.getAgentType).toHaveBeenCalledWith('deepchat') expect(harness.turn.sendMessage).toHaveBeenCalledWith('session-1', 'hello') - expect(harness.scheduler.timeout).toHaveBeenCalledTimes(3) + expect(harness.scheduler.timeout).toHaveBeenCalledTimes(2) expect(harness.scheduler.timeout).toHaveBeenNthCalledWith( 1, expect.objectContaining({ @@ -97,13 +91,6 @@ describe('ChatService', () => { ) expect(harness.scheduler.timeout).toHaveBeenNthCalledWith( 2, - expect.objectContaining({ - ms: 5_000, - reason: 'chat.sendMessage:session-1:agentType' - }) - ) - expect(harness.scheduler.timeout).toHaveBeenNthCalledWith( - 3, expect.objectContaining({ ms: 30 * 60 * 1_000, reason: 'chat.sendMessage:session-1', @@ -112,12 +99,38 @@ describe('ChatService', () => { ) }) - it('releases the send lock after missing session and agent type preflight failures', async () => { + it('allows concurrent send accepts so runtime queue owns generation concurrency', async () => { + const harness = createHarness() + let resolveFirstSend!: (value: { requestId: string; messageId: string }) => void + harness.turn.sendMessage.mockImplementationOnce( + async () => + await new Promise<{ requestId: string; messageId: string }>((resolve) => { + resolveFirstSend = resolve + }) + ) + harness.turn.sendMessage.mockResolvedValueOnce({ + requestId: 'assistant-2', + messageId: 'assistant-2' + }) + + const firstSend = harness.service.sendMessage('session-1', 'hello') + await expect(harness.service.sendMessage('session-1', 'again')).resolves.toEqual({ + accepted: true, + requestId: 'assistant-2', + messageId: 'assistant-2' + }) + + resolveFirstSend({ requestId: 'assistant-1', messageId: 'assistant-1' }) + await expect(firstSend).resolves.toEqual({ + accepted: true, + requestId: 'assistant-1', + messageId: 'assistant-1' + }) + }) + + it('releases accept controller after missing session preflight failures', async () => { const harness = createHarness() harness.projection.getSession.mockResolvedValueOnce(null).mockResolvedValue(createSession()) - harness.providerCatalogPort.getAgentType - .mockResolvedValueOnce(null) - .mockResolvedValue('deepchat') harness.turn.sendMessage.mockResolvedValueOnce({ requestId: 'request-1', messageId: 'message-1' @@ -126,12 +139,6 @@ describe('ChatService', () => { await expect(harness.service.sendMessage('session-1', 'missing session')).rejects.toThrow( 'Session not found: session-1' ) - expect(harness.providerCatalogPort.getAgentType).not.toHaveBeenCalled() - expect(harness.turn.sendMessage).not.toHaveBeenCalled() - - await expect(harness.service.sendMessage('session-1', 'missing agent type')).rejects.toThrow( - 'Agent type not found: deepchat' - ) expect(harness.turn.sendMessage).not.toHaveBeenCalled() await expect(harness.service.sendMessage('session-1', 'retry')).resolves.toEqual({ @@ -142,186 +149,69 @@ describe('ChatService', () => { expect(harness.turn.sendMessage).toHaveBeenCalledExactlyOnceWith('session-1', 'retry') }) - it('steers the active turn without claiming the normal send lock', async () => { + it('steers the active turn without blocking send accepts', async () => { const harness = createHarness() await expect(harness.service.steerActiveTurn('session-1', 'refine this')).resolves.toEqual({ accepted: true }) - - expect(harness.projection.getSession).toHaveBeenCalledWith('session-1') expect(harness.turn.steerActiveTurn).toHaveBeenCalledWith('session-1', 'refine this') - expect(harness.scheduler.timeout).toHaveBeenCalledWith( - expect.objectContaining({ reason: 'chat.steerActiveTurn:session-1' }) - ) }) - it('resolves stopStream by request id and cleans up the session', async () => { + it('stops by session id and reports cancel failure', async () => { const harness = createHarness() - harness.projection.getMessage.mockResolvedValueOnce(createMessage()) + harness.turn.cancelGeneration.mockRejectedValueOnce(new Error('cancel failed')) - await expect(harness.service.stopStream({ requestId: 'message-1' })).resolves.toEqual({ - stopped: true + await expect(harness.service.stopStream({ sessionId: 'session-1' })).resolves.toEqual({ + stopped: false }) - - expect(harness.projection.getMessage).toHaveBeenCalledWith('message-1') expect(harness.sessionPermissionPort.clearSessionPermissions).toHaveBeenCalledWith('session-1') expect(harness.turn.cancelGeneration).toHaveBeenCalledWith('session-1') - expect(harness.scheduler.timeout).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - ms: 5_000, - reason: 'chat.stopStream:message-1:message' - }) - ) - expect(harness.scheduler.timeout).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - ms: 5_000, - reason: 'chat.stopStream:session-1' - }) - ) }) - it('returns stopped false when a request id cannot be mapped to a session', async () => { + it('resolves stop target from message id when session id is omitted', async () => { const harness = createHarness() - - await expect(harness.service.stopStream({ requestId: 'missing-message' })).resolves.toEqual({ - stopped: false - }) - - expect(harness.scheduler.timeout).toHaveBeenCalledOnce() - expect(harness.sessionPermissionPort.clearSessionPermissions).not.toHaveBeenCalled() - expect(harness.turn.cancelGeneration).not.toHaveBeenCalled() - }) - - it('attempts both stopStream cleanups when permission cleanup fails', async () => { - const harness = createHarness() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - harness.projection.getMessage.mockResolvedValueOnce(createMessage()) - harness.sessionPermissionPort.clearSessionPermissions.mockRejectedValueOnce( - new Error('permission cleanup failed') - ) + harness.projection.getMessage.mockResolvedValue(createMessage()) await expect(harness.service.stopStream({ requestId: 'message-1' })).resolves.toEqual({ stopped: true }) - - expect(harness.sessionPermissionPort.clearSessionPermissions).toHaveBeenCalledWith('session-1') - expect(harness.turn.cancelGeneration).toHaveBeenCalledWith('session-1') - expect(warn).toHaveBeenCalledOnce() - warn.mockRestore() - }) - - it('responds to tool interactions through the turn port', async () => { - const harness = createHarness() - harness.turn.respondToolInteraction.mockResolvedValueOnce({ - resumed: true, - waitingForUserMessage: false - }) - - await expect( - harness.service.respondToolInteraction({ - sessionId: 'session-1', - messageId: 'message-1', - toolCallId: 'tool-1', - response: { kind: 'permission', granted: true } - }) - ).resolves.toEqual({ - accepted: true, - resumed: true, - waitingForUserMessage: false - }) - - expect(harness.turn.respondToolInteraction).toHaveBeenCalledWith( - 'session-1', - 'message-1', - 'tool-1', - { kind: 'permission', granted: true } - ) - expect(harness.scheduler.timeout).toHaveBeenCalledWith( - expect.objectContaining({ - ms: 30 * 60 * 1_000, - reason: 'chat.respondToolInteraction:session-1:tool-1' - }) - ) - }) - - it('attempts both timeout cleanups when permission cleanup fails', async () => { - const harness = createHarness() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const timeoutError = new Error('timed out') - timeoutError.name = 'TimeoutError' - harness.turn.sendMessage.mockRejectedValueOnce(timeoutError) - harness.sessionPermissionPort.clearSessionPermissions.mockRejectedValueOnce( - new Error('permission cleanup failed') - ) - - await expect(harness.service.sendMessage('session-1', 'hello')).rejects.toBe(timeoutError) - - expect(harness.sessionPermissionPort.clearSessionPermissions).toHaveBeenCalledWith('session-1') + expect(harness.projection.getMessage).toHaveBeenCalledWith('message-1') expect(harness.turn.cancelGeneration).toHaveBeenCalledWith('session-1') - expect(warn).toHaveBeenCalledOnce() - warn.mockRestore() - }) - - it('releases the send lock after non-timeout failures without cleanup', async () => { - const harness = createHarness() - const sendError = new Error('provider failed') - harness.turn.sendMessage - .mockRejectedValueOnce(sendError) - .mockResolvedValueOnce({ requestId: 'request-2', messageId: 'message-2' }) - - await expect(harness.service.sendMessage('session-1', 'first')).rejects.toBe(sendError) - await expect(harness.service.sendMessage('session-1', 'second')).resolves.toEqual({ - accepted: true, - requestId: 'request-2', - messageId: 'message-2' - }) - expect(harness.sessionPermissionPort.clearSessionPermissions).not.toHaveBeenCalled() - expect(harness.turn.cancelGeneration).not.toHaveBeenCalled() }) - it('aborts a pending send when stopStream races during preflight', async () => { - const createAbortError = (reason: string) => { - const error = new Error(reason) - error.name = 'AbortError' - return error - } + it('aborts an in-flight send accept path on stop', async () => { const scheduler = { sleep: vi.fn(), - timeout: vi.fn( - async ({ - task, - signal, - reason - }: { - task: Promise - signal?: AbortSignal - reason: string - }) => { - if (signal?.aborted) throw createAbortError(reason) - - return await new Promise((resolve, reject) => { - const onAbort = () => { - signal?.removeEventListener('abort', onAbort) - reject(createAbortError(reason)) - } - - signal?.addEventListener('abort', onAbort, { once: true }) - task.then( - (value) => { - signal?.removeEventListener('abort', onAbort) - resolve(value) - }, - (error) => { - signal?.removeEventListener('abort', onAbort) - reject(error) - } - ) - }) + timeout: vi.fn(async ({ task, signal }: { task: Promise; signal?: AbortSignal }) => { + if (!signal) { + return await task } - ), + return await new Promise((resolve, reject) => { + if (signal.aborted) { + const error = new Error('Aborted') + error.name = 'AbortError' + reject(error) + return + } + const onAbort = () => { + const error = new Error('Aborted') + error.name = 'AbortError' + reject(error) + } + signal.addEventListener('abort', onAbort, { once: true }) + void task.then( + (value) => { + signal.removeEventListener('abort', onAbort) + resolve(value) + }, + (error) => { + signal.removeEventListener('abort', onAbort) + reject(error) + } + ) + }) + }), retry: vi.fn() } let resolveSession!: (value: SessionWithState) => void @@ -340,16 +230,12 @@ describe('ChatService', () => { cancelGeneration: vi.fn().mockResolvedValue(undefined), respondToolInteraction: vi.fn().mockResolvedValue({}) } - const providerCatalogPort = { - getAgentType: vi.fn().mockResolvedValue('deepchat' as const) - } const sessionPermissionPort = { - clearSessionPermissions: vi.fn().mockResolvedValue(undefined) + clearSessionPermissions: vi.fn() } const service = new ChatService({ projection, turn, - providerCatalogPort, sessionPermissionPort, scheduler }) @@ -367,28 +253,4 @@ describe('ChatService', () => { expect(sessionPermissionPort.clearSessionPermissions).toHaveBeenCalledWith('session-1') expect(turn.cancelGeneration).toHaveBeenCalledWith('session-1') }) - - it('rejects a new send while another stream is active for the session', async () => { - const harness = createHarness() - let resolveFirstSend!: (value: { requestId: string; messageId: string }) => void - harness.turn.sendMessage.mockImplementationOnce( - async () => - await new Promise<{ requestId: string; messageId: string }>((resolve) => { - resolveFirstSend = resolve - }) - ) - - const firstSend = harness.service.sendMessage('session-1', 'hello') - - await expect(harness.service.sendMessage('session-1', 'again')).rejects.toThrow( - 'A stream is already active for session session-1' - ) - - resolveFirstSend({ requestId: 'assistant-1', messageId: 'assistant-1' }) - await expect(firstSend).resolves.toEqual({ - accepted: true, - requestId: 'assistant-1', - messageId: 'assistant-1' - }) - }) }) From 336dd6b1469c81f0c260841e15a3e7b12705aaf5 Mon Sep 17 00:00:00 2001 From: zhangmo8 Date: Tue, 14 Jul 2026 10:15:11 +0800 Subject: [PATCH 4/7] fix(i18n): use traditional Chinese for plugins hub scope --- src/renderer/src/i18n/zh-HK/settings.json | 6 +++--- src/renderer/src/i18n/zh-TW/settings.json | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/i18n/zh-HK/settings.json b/src/renderer/src/i18n/zh-HK/settings.json index 9def7f55a..a2fe37490 100644 --- a/src/renderer/src/i18n/zh-HK/settings.json +++ b/src/renderer/src/i18n/zh-HK/settings.json @@ -2980,8 +2980,8 @@ "acpUnavailableTitle": "ACP Agent 無法使用插件", "acpUnavailableDescription": "ACP Agent 透過自身的執行環境管理工具和擴充功能。請選擇 DeepChat Agent 後再使用插件。", "agentScopeUnsupported": "Agent 層級的擴充功能只適用於 DeepChat Agent。", - "scopeGlobalPlugins": "当前页为应用全局:官方插件的安装、启用与进程对所有 DeepChat Agent 共享。", - "scopeCurrentAgent": "当前页为 Agent 策略:仅影响「{agent}」可用的 Skills / MCP,不会全局禁用资源。", - "currentAgentFallback": "当前 Agent" + "scopeGlobalPlugins": "目前頁面為應用全域:官方外掛的安裝、啟用與程序對所有 DeepChat Agent 共用。", + "scopeCurrentAgent": "目前頁面為 Agent 策略:僅影響「{agent}」可用的 Skills / MCP,不會全域停用資源。", + "currentAgentFallback": "目前 Agent" } } diff --git a/src/renderer/src/i18n/zh-TW/settings.json b/src/renderer/src/i18n/zh-TW/settings.json index f821105f7..6b06b6f78 100644 --- a/src/renderer/src/i18n/zh-TW/settings.json +++ b/src/renderer/src/i18n/zh-TW/settings.json @@ -2980,8 +2980,8 @@ "acpUnavailableTitle": "ACP Agent 無法使用外掛程式", "acpUnavailableDescription": "ACP Agent 透過自己的執行環境管理工具與擴充功能。請選擇 DeepChat Agent 後再使用外掛程式。", "agentScopeUnsupported": "Agent 層級的擴充功能僅適用於 DeepChat Agent。", - "scopeGlobalPlugins": "当前页为应用全局:官方插件的安装、启用与进程对所有 DeepChat Agent 共享。", - "scopeCurrentAgent": "当前页为 Agent 策略:仅影响「{agent}」可用的 Skills / MCP,不会全局禁用资源。", - "currentAgentFallback": "当前 Agent" + "scopeGlobalPlugins": "目前頁面為應用全域:官方外掛的安裝、啟用與程序對所有 DeepChat Agent 共用。", + "scopeCurrentAgent": "目前頁面為 Agent 策略:僅影響「{agent}」可用的 Skills / MCP,不會全域停用資源。", + "currentAgentFallback": "目前 Agent" } } From 546ec9f6eabe3fe71c61bae88139f8f96949404c Mon Sep 17 00:00:00 2001 From: zhangmo8 Date: Tue, 14 Jul 2026 10:16:53 +0800 Subject: [PATCH 5/7] fix(i18n): localize plugins hub scope banners --- src/renderer/src/i18n/da-DK/settings.json | 6 +++--- src/renderer/src/i18n/de-DE/settings.json | 6 +++--- src/renderer/src/i18n/en-US/settings.json | 2 +- src/renderer/src/i18n/es-ES/settings.json | 6 +++--- src/renderer/src/i18n/fa-IR/settings.json | 6 +++--- src/renderer/src/i18n/fr-FR/settings.json | 6 +++--- src/renderer/src/i18n/he-IL/settings.json | 6 +++--- src/renderer/src/i18n/id-ID/settings.json | 6 +++--- src/renderer/src/i18n/it-IT/settings.json | 6 +++--- src/renderer/src/i18n/ja-JP/settings.json | 6 +++--- src/renderer/src/i18n/ko-KR/settings.json | 6 +++--- src/renderer/src/i18n/ms-MY/settings.json | 6 +++--- src/renderer/src/i18n/pl-PL/settings.json | 6 +++--- src/renderer/src/i18n/pt-BR/settings.json | 6 +++--- src/renderer/src/i18n/ru-RU/settings.json | 6 +++--- src/renderer/src/i18n/tr-TR/settings.json | 6 +++--- src/renderer/src/i18n/vi-VN/settings.json | 6 +++--- 17 files changed, 49 insertions(+), 49 deletions(-) diff --git a/src/renderer/src/i18n/da-DK/settings.json b/src/renderer/src/i18n/da-DK/settings.json index 59950772e..edaccc349 100644 --- a/src/renderer/src/i18n/da-DK/settings.json +++ b/src/renderer/src/i18n/da-DK/settings.json @@ -2980,8 +2980,8 @@ "acpUnavailableTitle": "Plugins er ikke tilgængelige for ACP-agenter", "acpUnavailableDescription": "ACP-agenter administrerer værktøjer og udvidelser i deres eget kørselsmiljø. Vælg en DeepChat-agent for at bruge plugins.", "agentScopeUnsupported": "Agentafgrænsede udvidelser er kun tilgængelige for DeepChat-agenter.", - "scopeGlobalPlugins": "This page is app-global: official plugin install/enable/process state is shared by every DeepChat agent.", - "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", - "currentAgentFallback": "current agent" + "scopeGlobalPlugins": "Denne side er app-global: installation, aktivering og processtatus for officielle plugins deles af alle DeepChat-agenter.", + "scopeCurrentAgent": "Denne side er agentpolitik: ændringer påvirker kun Skills/MCP-tilgængelighed for “{agent}”, ikke globale installationer.", + "currentAgentFallback": "aktuel agent" } } diff --git a/src/renderer/src/i18n/de-DE/settings.json b/src/renderer/src/i18n/de-DE/settings.json index 6d066c78b..ada18e462 100644 --- a/src/renderer/src/i18n/de-DE/settings.json +++ b/src/renderer/src/i18n/de-DE/settings.json @@ -2971,8 +2971,8 @@ "acpUnavailableTitle": "Plugins sind für ACP-Agenten nicht verfügbar", "acpUnavailableDescription": "ACP-Agenten verwalten Werkzeuge und Erweiterungen in ihrer eigenen Laufzeitumgebung. Wählen Sie einen DeepChat-Agenten aus, um Plugins zu verwenden.", "agentScopeUnsupported": "Agentenspezifische Erweiterungen sind nur für DeepChat-Agenten verfügbar.", - "scopeGlobalPlugins": "This page is app-global: official plugin install/enable/process state is shared by every DeepChat agent.", - "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", - "currentAgentFallback": "current agent" + "scopeGlobalPlugins": "Diese Seite ist app-weit: Installation, Aktivierung und Prozessstatus offizieller Plugins gelten für alle DeepChat-Agenten.", + "scopeCurrentAgent": "Diese Seite ist Agent-Richtlinie: Änderungen betreffen nur die Skills/MCP-Verfügbarkeit von „{agent}“, nicht die globalen Installationen.", + "currentAgentFallback": "aktueller Agent" } } diff --git a/src/renderer/src/i18n/en-US/settings.json b/src/renderer/src/i18n/en-US/settings.json index ed9bff8d0..8b20afa22 100644 --- a/src/renderer/src/i18n/en-US/settings.json +++ b/src/renderer/src/i18n/en-US/settings.json @@ -2775,7 +2775,7 @@ "acpUnavailableTitle": "Plugins are unavailable for ACP agents", "acpUnavailableDescription": "ACP agents manage tools and extensions through their own runtime. Select a DeepChat agent to use Plugins.", "agentScopeUnsupported": "Agent-scoped extensions are only available for DeepChat agents.", - "scopeGlobalPlugins": "This page is app-global: official plugin install/enable/process state is shared by every DeepChat agent.", + "scopeGlobalPlugins": "This page is app-global: official plugin install, enablement, and process state are shared by every DeepChat agent.", "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", "currentAgentFallback": "current agent" }, diff --git a/src/renderer/src/i18n/es-ES/settings.json b/src/renderer/src/i18n/es-ES/settings.json index dc7e99354..821639be7 100644 --- a/src/renderer/src/i18n/es-ES/settings.json +++ b/src/renderer/src/i18n/es-ES/settings.json @@ -2971,8 +2971,8 @@ "acpUnavailableTitle": "Los plugins no están disponibles para los agentes ACP", "acpUnavailableDescription": "Los agentes ACP gestionan herramientas y extensiones en su propio entorno de ejecución. Selecciona un agente de DeepChat para usar plugins.", "agentScopeUnsupported": "Las extensiones específicas de cada agente solo están disponibles para los agentes de DeepChat.", - "scopeGlobalPlugins": "This page is app-global: official plugin install/enable/process state is shared by every DeepChat agent.", - "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", - "currentAgentFallback": "current agent" + "scopeGlobalPlugins": "Esta página es global de la app: la instalación, habilitación y estado de proceso de los plugins oficiales se comparten entre todos los agentes DeepChat.", + "scopeCurrentAgent": "Esta página es política del agente: los cambios solo afectan la disponibilidad de Skills/MCP de «{agent}», no las instalaciones globales.", + "currentAgentFallback": "agente actual" } } diff --git a/src/renderer/src/i18n/fa-IR/settings.json b/src/renderer/src/i18n/fa-IR/settings.json index f1ffe89b8..80fb35a01 100644 --- a/src/renderer/src/i18n/fa-IR/settings.json +++ b/src/renderer/src/i18n/fa-IR/settings.json @@ -2980,8 +2980,8 @@ "acpUnavailableTitle": "افزونه‌ها برای عامل‌های ACP در دسترس نیستند", "acpUnavailableDescription": "عامل‌های ACP ابزارها و افزونه‌ها را در محیط اجرای خود مدیریت می‌کنند. برای استفاده از افزونه‌ها، یک عامل DeepChat را انتخاب کنید.", "agentScopeUnsupported": "افزونه‌های ویژهٔ هر عامل فقط برای عامل‌های DeepChat در دسترس هستند.", - "scopeGlobalPlugins": "This page is app-global: official plugin install/enable/process state is shared by every DeepChat agent.", - "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", - "currentAgentFallback": "current agent" + "scopeGlobalPlugins": "این صفحه سراسری برنامه است: نصب، فعال‌سازی و وضعیت فرایند افزونه‌های رسمی بین همه عامل‌های DeepChat مشترک است.", + "scopeCurrentAgent": "این صفحه سیاست عامل است: تغییرات فقط روی در دسترس بودن Skills/MCP برای «{agent}» اثر می‌گذارد، نه روی نصب‌های سراسری.", + "currentAgentFallback": "عامل فعلی" } } diff --git a/src/renderer/src/i18n/fr-FR/settings.json b/src/renderer/src/i18n/fr-FR/settings.json index 1c246826c..645de120c 100644 --- a/src/renderer/src/i18n/fr-FR/settings.json +++ b/src/renderer/src/i18n/fr-FR/settings.json @@ -2980,8 +2980,8 @@ "acpUnavailableTitle": "Les plugins ne sont pas disponibles pour les agents ACP", "acpUnavailableDescription": "Les agents ACP gèrent les outils et les extensions dans leur propre environnement d’exécution. Sélectionnez un agent DeepChat pour utiliser les plugins.", "agentScopeUnsupported": "Les extensions propres à un agent ne sont disponibles que pour les agents DeepChat.", - "scopeGlobalPlugins": "This page is app-global: official plugin install/enable/process state is shared by every DeepChat agent.", - "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", - "currentAgentFallback": "current agent" + "scopeGlobalPlugins": "Cette page est globale à l’application : l’installation, l’activation et l’état des processus des plugins officiels sont partagés par tous les agents DeepChat.", + "scopeCurrentAgent": "Cette page est une politique d’agent : les modifications n’affectent que la disponibilité Skills/MCP de « {agent} », pas les installations globales.", + "currentAgentFallback": "agent actuel" } } diff --git a/src/renderer/src/i18n/he-IL/settings.json b/src/renderer/src/i18n/he-IL/settings.json index 483ab2da9..786659f20 100644 --- a/src/renderer/src/i18n/he-IL/settings.json +++ b/src/renderer/src/i18n/he-IL/settings.json @@ -2980,8 +2980,8 @@ "acpUnavailableTitle": "תוספים אינם זמינים לסוכני ACP", "acpUnavailableDescription": "סוכני ACP מנהלים כלים והרחבות בסביבת הריצה שלהם. יש לבחור סוכן DeepChat כדי להשתמש בתוספים.", "agentScopeUnsupported": "הרחבות ברמת הסוכן זמינות רק לסוכני DeepChat.", - "scopeGlobalPlugins": "This page is app-global: official plugin install/enable/process state is shared by every DeepChat agent.", - "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", - "currentAgentFallback": "current agent" + "scopeGlobalPlugins": "עמוד זה הוא גלובלי לאפליקציה: התקנה, הפעלה ומצב תהליך של תוספים רשמיים משותפים לכל סוכני DeepChat.", + "scopeCurrentAgent": "עמוד זה הוא מדיניות סוכן: השינויים משפיעים רק על זמינות Skills/MCP של „{agent}”, לא על התקנות גלובליות.", + "currentAgentFallback": "הסוכן הנוכחי" } } diff --git a/src/renderer/src/i18n/id-ID/settings.json b/src/renderer/src/i18n/id-ID/settings.json index 2f34623e0..b85d83c05 100644 --- a/src/renderer/src/i18n/id-ID/settings.json +++ b/src/renderer/src/i18n/id-ID/settings.json @@ -2971,8 +2971,8 @@ "acpUnavailableTitle": "Plugin tidak tersedia untuk agen ACP", "acpUnavailableDescription": "Agen ACP mengelola alat dan ekstensi di lingkungan runtime mereka sendiri. Pilih agen DeepChat untuk menggunakan plugin.", "agentScopeUnsupported": "Ekstensi khusus agen hanya tersedia untuk agen DeepChat.", - "scopeGlobalPlugins": "This page is app-global: official plugin install/enable/process state is shared by every DeepChat agent.", - "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", - "currentAgentFallback": "current agent" + "scopeGlobalPlugins": "Halaman ini bersifat global aplikasi: instalasi, pengaktifan, dan status proses plugin resmi dibagikan ke semua agen DeepChat.", + "scopeCurrentAgent": "Halaman ini adalah kebijakan agen: perubahan hanya memengaruhi ketersediaan Skills/MCP untuk “{agent}”, bukan instalasi global.", + "currentAgentFallback": "agen saat ini" } } diff --git a/src/renderer/src/i18n/it-IT/settings.json b/src/renderer/src/i18n/it-IT/settings.json index 4cdbc4c2d..ac6229a2e 100644 --- a/src/renderer/src/i18n/it-IT/settings.json +++ b/src/renderer/src/i18n/it-IT/settings.json @@ -2971,8 +2971,8 @@ "acpUnavailableTitle": "I plugin non sono disponibili per gli agenti ACP", "acpUnavailableDescription": "Gli agenti ACP gestiscono strumenti ed estensioni nel proprio ambiente di esecuzione. Seleziona un agente DeepChat per usare i plugin.", "agentScopeUnsupported": "Le estensioni specifiche per agente sono disponibili solo per gli agenti DeepChat.", - "scopeGlobalPlugins": "This page is app-global: official plugin install/enable/process state is shared by every DeepChat agent.", - "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", - "currentAgentFallback": "current agent" + "scopeGlobalPlugins": "Questa pagina è globale all’app: installazione, abilitazione e stato dei processi dei plugin ufficiali sono condivisi da tutti gli agenti DeepChat.", + "scopeCurrentAgent": "Questa pagina è policy dell’agente: le modifiche riguardano solo la disponibilità Skills/MCP di «{agent}», non le installazioni globali.", + "currentAgentFallback": "agente corrente" } } diff --git a/src/renderer/src/i18n/ja-JP/settings.json b/src/renderer/src/i18n/ja-JP/settings.json index f3545079b..70b5c5f74 100644 --- a/src/renderer/src/i18n/ja-JP/settings.json +++ b/src/renderer/src/i18n/ja-JP/settings.json @@ -2980,8 +2980,8 @@ "acpUnavailableTitle": "ACP エージェントではプラグインを利用できません", "acpUnavailableDescription": "ACP エージェントは独自のランタイムでツールと拡張機能を管理します。プラグインを使用するには DeepChat エージェントを選択してください。", "agentScopeUnsupported": "エージェント単位の拡張機能は DeepChat エージェントでのみ利用できます。", - "scopeGlobalPlugins": "This page is app-global: official plugin install/enable/process state is shared by every DeepChat agent.", - "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", - "currentAgentFallback": "current agent" + "scopeGlobalPlugins": "このページはアプリ全体設定です。公式プラグインのインストール・有効化・プロセス状態は、すべての DeepChat エージェントで共有されます。", + "scopeCurrentAgent": "このページはエージェント方針です。変更は「{agent}」の Skills/MCP 利用可否にのみ影響し、グローバルなインストールには影響しません。", + "currentAgentFallback": "現在のエージェント" } } diff --git a/src/renderer/src/i18n/ko-KR/settings.json b/src/renderer/src/i18n/ko-KR/settings.json index 8f1929306..97b89823e 100644 --- a/src/renderer/src/i18n/ko-KR/settings.json +++ b/src/renderer/src/i18n/ko-KR/settings.json @@ -2980,8 +2980,8 @@ "acpUnavailableTitle": "ACP 에이전트에서는 플러그인을 사용할 수 없습니다", "acpUnavailableDescription": "ACP 에이전트는 자체 런타임에서 도구와 확장 기능을 관리합니다. 플러그인을 사용하려면 DeepChat 에이전트를 선택하세요.", "agentScopeUnsupported": "에이전트별 확장 기능은 DeepChat 에이전트에서만 사용할 수 있습니다.", - "scopeGlobalPlugins": "This page is app-global: official plugin install/enable/process state is shared by every DeepChat agent.", - "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", - "currentAgentFallback": "current agent" + "scopeGlobalPlugins": "이 페이지는 앱 전역 설정입니다. 공식 플러그인의 설치·활성화·프로세스 상태는 모든 DeepChat 에이전트에서 공유됩니다.", + "scopeCurrentAgent": "이 페이지는 에이전트 정책입니다. 변경 사항은 “{agent}”의 Skills/MCP 사용 가능 여부에만 영향을 주며, 전역 설치에는 영향을 주지 않습니다.", + "currentAgentFallback": "현재 에이전트" } } diff --git a/src/renderer/src/i18n/ms-MY/settings.json b/src/renderer/src/i18n/ms-MY/settings.json index cdec0d6c6..88bac5500 100644 --- a/src/renderer/src/i18n/ms-MY/settings.json +++ b/src/renderer/src/i18n/ms-MY/settings.json @@ -2971,8 +2971,8 @@ "acpUnavailableTitle": "Pemalam tidak tersedia untuk ejen ACP", "acpUnavailableDescription": "Ejen ACP mengurus alat dan sambungan dalam persekitaran masa jalan mereka sendiri. Pilih ejen DeepChat untuk menggunakan pemalam.", "agentScopeUnsupported": "Sambungan khusus ejen hanya tersedia untuk ejen DeepChat.", - "scopeGlobalPlugins": "This page is app-global: official plugin install/enable/process state is shared by every DeepChat agent.", - "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", - "currentAgentFallback": "current agent" + "scopeGlobalPlugins": "Halaman ini bersifat global aplikasi: pemasangan, pengaktifan dan status proses plugin rasmi dikongsi oleh semua ejen DeepChat.", + "scopeCurrentAgent": "Halaman ini ialah polisi ejen: perubahan hanya menjejaskan ketersediaan Skills/MCP untuk “{agent}”, bukan pemasangan global.", + "currentAgentFallback": "ejen semasa" } } diff --git a/src/renderer/src/i18n/pl-PL/settings.json b/src/renderer/src/i18n/pl-PL/settings.json index 0d14a1cf2..e16edc34d 100644 --- a/src/renderer/src/i18n/pl-PL/settings.json +++ b/src/renderer/src/i18n/pl-PL/settings.json @@ -2971,8 +2971,8 @@ "acpUnavailableTitle": "Wtyczki nie są dostępne dla agentów ACP", "acpUnavailableDescription": "Agenci ACP zarządzają narzędziami i rozszerzeniami we własnym środowisku uruchomieniowym. Wybierz agenta DeepChat, aby korzystać z wtyczek.", "agentScopeUnsupported": "Rozszerzenia na poziomie agenta są dostępne tylko dla agentów DeepChat.", - "scopeGlobalPlugins": "This page is app-global: official plugin install/enable/process state is shared by every DeepChat agent.", - "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", - "currentAgentFallback": "current agent" + "scopeGlobalPlugins": "Ta strona jest globalna dla aplikacji: instalacja, włączanie i stan procesów oficjalnych wtyczek są wspólne dla wszystkich agentów DeepChat.", + "scopeCurrentAgent": "Ta strona to polityka agenta: zmiany dotyczą tylko dostępności Skills/MCP dla „{agent}”, a nie globalnych instalacji.", + "currentAgentFallback": "bieżący agent" } } diff --git a/src/renderer/src/i18n/pt-BR/settings.json b/src/renderer/src/i18n/pt-BR/settings.json index 133468626..a45fb24b4 100644 --- a/src/renderer/src/i18n/pt-BR/settings.json +++ b/src/renderer/src/i18n/pt-BR/settings.json @@ -2980,8 +2980,8 @@ "acpUnavailableTitle": "Os plugins não estão disponíveis para agentes ACP", "acpUnavailableDescription": "Os agentes ACP gerenciam ferramentas e extensões no próprio ambiente de execução. Selecione um agente DeepChat para usar plugins.", "agentScopeUnsupported": "As extensões por agente estão disponíveis apenas para agentes DeepChat.", - "scopeGlobalPlugins": "This page is app-global: official plugin install/enable/process state is shared by every DeepChat agent.", - "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", - "currentAgentFallback": "current agent" + "scopeGlobalPlugins": "Esta página é global do app: instalação, ativação e estado de processo dos plugins oficiais são compartilhados por todos os agentes DeepChat.", + "scopeCurrentAgent": "Esta página é política do agente: as alterações afetam apenas a disponibilidade de Skills/MCP de “{agent}”, não as instalações globais.", + "currentAgentFallback": "agente atual" } } diff --git a/src/renderer/src/i18n/ru-RU/settings.json b/src/renderer/src/i18n/ru-RU/settings.json index e79aa1a9e..5ca3be015 100644 --- a/src/renderer/src/i18n/ru-RU/settings.json +++ b/src/renderer/src/i18n/ru-RU/settings.json @@ -2980,8 +2980,8 @@ "acpUnavailableTitle": "Плагины недоступны для агентов ACP", "acpUnavailableDescription": "Агенты ACP управляют инструментами и расширениями в собственной среде выполнения. Выберите агента DeepChat, чтобы использовать плагины.", "agentScopeUnsupported": "Расширения на уровне агента доступны только агентам DeepChat.", - "scopeGlobalPlugins": "This page is app-global: official plugin install/enable/process state is shared by every DeepChat agent.", - "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", - "currentAgentFallback": "current agent" + "scopeGlobalPlugins": "Эта страница глобальна для приложения: установка, включение и состояние процессов официальных плагинов общие для всех агентов DeepChat.", + "scopeCurrentAgent": "Эта страница — политика агента: изменения влияют только на доступность Skills/MCP у «{agent}», а не на глобальные установки.", + "currentAgentFallback": "текущий агент" } } diff --git a/src/renderer/src/i18n/tr-TR/settings.json b/src/renderer/src/i18n/tr-TR/settings.json index 23d1ea879..ca619acb8 100644 --- a/src/renderer/src/i18n/tr-TR/settings.json +++ b/src/renderer/src/i18n/tr-TR/settings.json @@ -2971,8 +2971,8 @@ "acpUnavailableTitle": "Eklentiler ACP ajanları için kullanılamaz", "acpUnavailableDescription": "ACP ajanları araçları ve uzantıları kendi çalışma ortamlarında yönetir. Eklentileri kullanmak için bir DeepChat ajanı seçin.", "agentScopeUnsupported": "Ajan düzeyindeki uzantılar yalnızca DeepChat ajanları tarafından kullanılabilir.", - "scopeGlobalPlugins": "This page is app-global: official plugin install/enable/process state is shared by every DeepChat agent.", - "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", - "currentAgentFallback": "current agent" + "scopeGlobalPlugins": "Bu sayfa uygulama genelindedir: resmi eklentilerin kurulumu, etkinleştirilmesi ve süreç durumu tüm DeepChat ajanları arasında paylaşılır.", + "scopeCurrentAgent": "Bu sayfa ajan politikasıdır: değişiklikler yalnızca “{agent}” için Skills/MCP kullanılabilirliğini etkiler, global kurulumları etkilemez.", + "currentAgentFallback": "geçerli ajan" } } diff --git a/src/renderer/src/i18n/vi-VN/settings.json b/src/renderer/src/i18n/vi-VN/settings.json index cdbed2aa8..12d424a73 100644 --- a/src/renderer/src/i18n/vi-VN/settings.json +++ b/src/renderer/src/i18n/vi-VN/settings.json @@ -2971,8 +2971,8 @@ "acpUnavailableTitle": "Plugin không khả dụng cho tác nhân ACP", "acpUnavailableDescription": "Tác nhân ACP quản lý công cụ và tiện ích mở rộng trong môi trường chạy riêng. Chọn một tác nhân DeepChat để sử dụng plugin.", "agentScopeUnsupported": "Tiện ích mở rộng theo từng tác nhân chỉ khả dụng cho tác nhân DeepChat.", - "scopeGlobalPlugins": "This page is app-global: official plugin install/enable/process state is shared by every DeepChat agent.", - "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", - "currentAgentFallback": "current agent" + "scopeGlobalPlugins": "Trang này mang tính toàn ứng dụng: cài đặt, bật/tắt và trạng thái tiến trình của plugin chính thức được chia sẻ cho mọi tác nhân DeepChat.", + "scopeCurrentAgent": "Trang này là chính sách tác nhân: thay đổi chỉ ảnh hưởng đến khả dụng Skills/MCP của “{agent}”, không ảnh hưởng cài đặt toàn cục.", + "currentAgentFallback": "tác nhân hiện tại" } } From ef44dc20f87e99f98682728a8fa580dbf38655a4 Mon Sep 17 00:00:00 2001 From: zhangmo8 Date: Tue, 14 Jul 2026 10:22:11 +0800 Subject: [PATCH 6/7] fix(chat): restore respondToolInteraction input object API --- src/main/routes/chat/chatService.ts | 35 +++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/src/main/routes/chat/chatService.ts b/src/main/routes/chat/chatService.ts index 26385c1d2..d586ae179 100644 --- a/src/main/routes/chat/chatService.ts +++ b/src/main/routes/chat/chatService.ts @@ -26,6 +26,13 @@ export interface ChatServiceTurnPort { ): Promise } +export interface ChatRespondToolInteractionInput { + sessionId: string + messageId: string + toolCallId: string + response: ToolInteractionResponse +} + export interface ChatServiceProjectionPort { getSession(sessionId: string): Promise getMessage(messageId: string): Promise @@ -174,17 +181,27 @@ export class ChatService { return { stopped: !cancelFailed } } - async respondToolInteraction( - sessionId: string, - messageId: string, - toolCallId: string, - response: ToolInteractionResponse - ): Promise { - return await this.deps.scheduler.timeout({ - task: this.deps.turn.respondToolInteraction(sessionId, messageId, toolCallId, response), + async respondToolInteraction(input: ChatRespondToolInteractionInput): Promise<{ + accepted: true + resumed?: boolean + waitingForUserMessage?: boolean + handledInline?: boolean + }> { + const result = await this.deps.scheduler.timeout({ + task: this.deps.turn.respondToolInteraction( + input.sessionId, + input.messageId, + input.toolCallId, + input.response + ), ms: CHAT_INTERACTION_TIMEOUT_MS, - reason: `chat.respondToolInteraction:${sessionId}:${toolCallId}` + reason: `chat.respondToolInteraction:${input.sessionId}:${input.toolCallId}` }) + + return { + accepted: true, + ...result + } } private async bestEffortCancel(sessionId: string, reason: string): Promise { From 563015c9e99ad4e6c4b7e443e7c077192f563761 Mon Sep 17 00:00:00 2001 From: zhangmo8 Date: Tue, 14 Jul 2026 12:11:00 +0800 Subject: [PATCH 7/7] fix(agent): address isolation review --- .../multi-agent-isolation/plan.md | 3 +- .../multi-agent-isolation/spec.md | 12 ++- .../multi-agent-isolation/tasks.md | 4 + .../subagent-host-policy-isolation/plan.md | 4 +- .../subagent-host-policy-isolation/spec.md | 3 + .../subagent-host-policy-isolation/tasks.md | 2 + .../spec.md | 8 +- .../spec.md | 6 +- .../spec.md | 30 +++++- .../agentRuntimePresenter/dispatch.ts | 14 +++ .../presenter/agentRuntimePresenter/index.ts | 22 ++--- src/main/presenter/index.ts | 3 + src/main/presenter/mcpPresenter/index.ts | 4 + src/main/presenter/runtimePorts.ts | 3 +- .../lifecycleCoordinator.ts | 7 +- .../agentTools/agentToolManager.ts | 12 ++- src/main/routes/chat/chatService.ts | 97 ++++++++++++------- src/main/routes/sessions/sessionService.ts | 10 +- src/renderer/src/i18n/zh-CN/settings.json | 2 +- .../types/presenters/core.presenter.d.ts | 1 + .../acp/runtime/acpContentMapper.test.ts | 7 ++ .../agentRuntimePresenter/dispatch.test.ts | 35 +++++++ test/main/presenter/mcpPresenter.test.ts | 17 +++- .../lifecycleCoordinator.test.ts | 44 ++++++++- .../agentTools/agentToolManagerRead.test.ts | 27 +++++- test/main/routes/chatService.test.ts | 61 ++++++++++-- test/main/routes/sessionService.test.ts | 5 +- 27 files changed, 355 insertions(+), 88 deletions(-) diff --git a/docs/architecture/multi-agent-isolation/plan.md b/docs/architecture/multi-agent-isolation/plan.md index 06d3b5213..290940bf8 100644 --- a/docs/architecture/multi-agent-isolation/plan.md +++ b/docs/architecture/multi-agent-isolation/plan.md @@ -26,4 +26,5 @@ - Rewrite agentRuntimePresenter tool-discovery expectations to enforce MCP policy. - Keep plugin policy omission assertion. - Workspace allow-list unit coverage for call workdir only. -- Transfer/rebind clears permissions and refilters skills. +- Transfer/rebind clears command, file, settings, and MCP permissions and refilters skills. +- Dispatch rejects tool names missing from the current session catalog. diff --git a/docs/architecture/multi-agent-isolation/spec.md b/docs/architecture/multi-agent-isolation/spec.md index 4df30dfa3..b261868e3 100644 --- a/docs/architecture/multi-agent-isolation/spec.md +++ b/docs/architecture/multi-agent-isolation/spec.md @@ -19,9 +19,9 @@ tools, memory, permissions, and workspace boundaries aligned with the session's | MCP servers | global process | agent `enabledMcpServerIds` | catalog + call + pre-check + fingerprint | | Plugins | global | none (always global for DeepChat) | plugin lifecycle only | | Plugin-owned MCP | global | exempt from agent MCP allow-list | always available when plugin enabled | -| Built-in agent tools | process | session `disabledAgentTools` | catalog filter | +| Built-in agent tools | process | session `disabledAgentTools` | catalog filter + execution membership check | | Memory | per-agent storage | agent memory settings | agentId-keyed rows / vectors | -| Permission approvals | session | session `permissionMode` + caches | conversation-scoped; cleared on transfer | +| Permission approvals | session | session `permissionMode` + caches | conversation-scoped; all caches cleared on transfer | | Workspace | session `project_dir` | session | tool allowed dirs from call workdir only | | Tape / messages | session | session | session-scoped | | ACP MCP | ACP process | ACP `agent_mcp_selections` | ACP session/call path | @@ -37,6 +37,8 @@ tools, memory, permissions, and workspace boundaries aligned with the session's 5. Plugins remain globally enabled for DeepChat agents; UI/docs distinguish global plugins from agent-scoped Skills/MCP. 6. ACP agents continue using ACP MCP selections, not DeepChat `enabledMcpServerIds`. +7. Tool calls absent from the current session's tool definitions are rejected before dispatch, even + when a process-global tool mapper contains the same name. ## Constraints @@ -59,5 +61,7 @@ tools, memory, permissions, and workspace boundaries aligned with the session's `resolveDeepChatAgentConfig(session.agentId)` into tool discovery and MCP call paths. - Historical `enabledPluginIds` remains omitted from tool discovery forever. - Tool workspace allow lists trust the conversation workdir argument, not a shared mutable manager - workspace field. -- Transfer is a security boundary: approvals and pinned skills do not silently survive host change. + workspace field. A missing or failed conversation workdir lookup uses the isolated default + workspace and never falls back to the manager's last synchronized workspace. +- Transfer is a security boundary: command, file, settings, and MCP session approvals plus pinned + skills do not silently survive host change. diff --git a/docs/architecture/multi-agent-isolation/tasks.md b/docs/architecture/multi-agent-isolation/tasks.md index e5903b630..d39d53cc6 100644 --- a/docs/architecture/multi-agent-isolation/tasks.md +++ b/docs/architecture/multi-agent-isolation/tasks.md @@ -8,3 +8,7 @@ - [x] Block permission-retry success leak; align deferred tool options - [x] Update tests and run format / i18n / lint / focused vitest - [x] Phase 1: subagent target host policy isolation +- [x] Make missing conversation workdirs fall back to the isolated default, never manager state +- [x] Clear MCP session approvals through the aggregate permission port +- [x] Reject tool calls absent from the current session definitions +- [x] Add review regression tests and rerun validation diff --git a/docs/architecture/subagent-host-policy-isolation/plan.md b/docs/architecture/subagent-host-policy-isolation/plan.md index 1c1844944..be4acd2ac 100644 --- a/docs/architecture/subagent-host-policy-isolation/plan.md +++ b/docs/architecture/subagent-host-policy-isolation/plan.md @@ -8,9 +8,11 @@ - self: inherit input surface - cross-agent DeepChat: load target config for permission/tools/prompt/skill filter 4. Lifecycle initializes runtime with resolved `permissionMode`. -5. Orchestrator supplies `parentAgentId` from the parent session. +5. Lifecycle clones non-MCP approvals only when the resolved child agent matches `parentAgentId`. +6. Orchestrator supplies `parentAgentId` from the parent session. ## Tests - Policy unit: self vs cross-agent vs ACP - Lifecycle mock: uses resolved permissionMode +- Lifecycle permission inheritance: self-target clones; cross-agent starts clean diff --git a/docs/architecture/subagent-host-policy-isolation/spec.md b/docs/architecture/subagent-host-policy-isolation/spec.md index ee747f95b..fe5629284 100644 --- a/docs/architecture/subagent-host-policy-isolation/spec.md +++ b/docs/architecture/subagent-host-policy-isolation/spec.md @@ -22,6 +22,7 @@ while keeping intentional parent inheritance for workspace and model selection. | `disabledAgentTools` | parent session | **target agent config** | | `systemPrompt` | parent generation settings | **target agent config** | | `activeSkills` | parent session pins | parent pins **∩ target `enabledSkillNames`** | +| Permission approvals | inherit non-MCP session approvals | **start clean** | | MCP / plugins | runtime host policy (agentId) | runtime host policy (target agentId) | ## Acceptance Criteria @@ -32,6 +33,8 @@ while keeping intentional parent inheritance for workspace and model selection. 3. Cross-agent active skills are filtered by the target agent allow-list (`[]` means none). 4. Workdir remains the parent session workdir for both cases. 5. ACP subagent targets remain ACP-shaped (no DeepChat tool/skill inheritance). +6. Only self-target children inherit parent session approvals. Cross-agent children start with + empty approval caches, and MCP temporary approvals are never cloned. ## Constraints diff --git a/docs/architecture/subagent-host-policy-isolation/tasks.md b/docs/architecture/subagent-host-policy-isolation/tasks.md index c9eba1620..f15f7bdb5 100644 --- a/docs/architecture/subagent-host-policy-isolation/tasks.md +++ b/docs/architecture/subagent-host-policy-isolation/tasks.md @@ -5,3 +5,5 @@ - [x] Lifecycle + orchestrator wiring - [x] Tests - [x] format / i18n / lint +- [x] Restrict permission inheritance to self-target children +- [x] Add self-target and cross-agent approval regression coverage diff --git a/docs/issues/agent-tool-workspace-cross-session-leak/spec.md b/docs/issues/agent-tool-workspace-cross-session-leak/spec.md index 2905e4870..b6a37a5f9 100644 --- a/docs/issues/agent-tool-workspace-cross-session-leak/spec.md +++ b/docs/issues/agent-tool-workspace-cross-session-leak/spec.md @@ -13,19 +13,25 @@ distinct `project_dir`. ## Root Cause -Shared mutable `this.agentWorkspacePath` is merged into every call's allow list. +Shared mutable `this.agentWorkspacePath` is used as a fallback when a conversation workdir is null +or cannot be resolved, so the last synchronized session workspace can still enter another +conversation's allow list. ## Fix Plan - Build allow lists from the call's workspace path only (plus skill roots, runtime roots, approvals). - Do not add the manager's last synced workspace path. +- When a conversation is present but has no resolved workdir, use the isolated default workspace; + reserve manager state only for calls that have no conversation context. ## Tasks - [x] Remove shared workspace merge from `buildAllowedDirectories` - [x] Covered by toolPresenter suite + multi-agent isolation contract - [x] format / lint / focused tests +- [x] Cover a null/failed lookup after another session synchronized a different workspace ## Validation - Allowed directories for workdir `/a` do not include a previously synced `/b`. +- Session `/a` with no resolved project directory still cannot inherit `/b` from manager state. diff --git a/docs/issues/agent-transfer-permission-skill-reset/spec.md b/docs/issues/agent-transfer-permission-skill-reset/spec.md index 6f1a51872..ed923a614 100644 --- a/docs/issues/agent-transfer-permission-skill-reset/spec.md +++ b/docs/issues/agent-transfer-permission-skill-reset/spec.md @@ -3,8 +3,8 @@ ## Issue Moving a session to another DeepChat agent updates `agent_id`, model, permission mode, and disabled -tools, but keeps conversation-scoped command/file/settings approvals, unfiltered active skill pins, -plan state, and runtime-activated skills from the previous host. +tools, but can keep conversation-scoped command/file/settings/MCP approvals, unfiltered active skill +pins, plan state, and runtime-activated skills from the previous host. ## Impact @@ -20,6 +20,7 @@ pins that the target host policy would not grant. On agent rebind: - clear session permission approvals +- include MCP `ToolManager.sessionPermissions` in the aggregate clear operation - clear agent plan state - clear runtime-activated skills - refilter persisted active skills to the target `enabledSkillNames` @@ -30,6 +31,7 @@ On agent rebind: - [x] Implement reset in `setSessionAgentContext` - [x] Extend skillPresenter port with `setActiveSkills` where needed - [x] format / lint / focused tests +- [x] Clear MCP temporary approvals and define them as non-cloneable ## Validation diff --git a/docs/issues/chat-generation-lifecycle-timeouts/spec.md b/docs/issues/chat-generation-lifecycle-timeouts/spec.md index fa542507e..fcd61d8c5 100644 --- a/docs/issues/chat-generation-lifecycle-timeouts/spec.md +++ b/docs/issues/chat-generation-lifecycle-timeouts/spec.md @@ -6,8 +6,34 @@ ChatService used a fake stream lock and agentType preflight that did not match e generation ownership. Session create used a 5s timeout that could fail ACP cold start. Session delete aborted row removal when backend cleanup failed, leaving zombie sessions. +## Impact + +Concurrent accepts could survive stop, timeout cleanup could hang or replace the original error, +and a late session create could publish after the route already reported failure. + +## Root Cause + +- ChatService stored one accept controller per session and did not bound all cleanup paths. +- `Scheduler.timeout` races but does not cancel the mutating `createSession` task. + ## Fix -- ChatService: accept-path abort only; no mutual exclusion over runtime generation; honest stop result -- SessionService: longer create/list timeouts +- ChatService: retain every concurrent accept controller per session, bound best-effort cleanup, and + convert stop cleanup timeouts into an honest `{ stopped: false }` result +- SessionService: do not race the mutating create operation against a non-cancelling route timeout; + keep the longer list timeout for read availability - SessionDeletionTransaction: best-effort stages, always attempt row delete + +## Tasks + +- [x] Retain and abort every concurrent accept controller per session +- [x] Bound best-effort cancel and convert stop cleanup timeout into `{ stopped: false }` +- [x] Preserve the original send timeout when cleanup fails synchronously or times out +- [x] Remove the non-cancelling route timeout around session creation +- [x] Update timeout assertions and add lifecycle regression tests + +## Validation + +- Concurrent send accepts are both aborted by one stop request. +- Stop cleanup timeout returns `{ stopped: false }`; send timeout remains the caller-visible error. +- Session create is invoked once without a scheduler race, while session list keeps its 15s timeout. diff --git a/src/main/presenter/agentRuntimePresenter/dispatch.ts b/src/main/presenter/agentRuntimePresenter/dispatch.ts index 629de6101..2ada0b571 100644 --- a/src/main/presenter/agentRuntimePresenter/dispatch.ts +++ b/src/main/presenter/agentRuntimePresenter/dispatch.ts @@ -1779,6 +1779,20 @@ export async function executeTools( const execution = buildToolExecutionContext(tc, tools, io.sessionId, providerId) const { toolCall, toolContext } = execution + if (!execution.toolDef) { + stagedResults.push({ + toolCallId: tc.id, + toolName: tc.name, + toolArgs: tc.arguments, + responseText: `Error: Tool is not available in the current session: ${tc.name}`, + isError: true, + searchPayload: null, + postHookKind: 'failure' + }) + executed += 1 + continue + } + try { if (toolCall.function.name === QUESTION_TOOL_NAME) { const parsedQuestion = parseQuestionToolArgs(tc.arguments) diff --git a/src/main/presenter/agentRuntimePresenter/index.ts b/src/main/presenter/agentRuntimePresenter/index.ts index 1e517ac6c..a3889fb13 100644 --- a/src/main/presenter/agentRuntimePresenter/index.ts +++ b/src/main/presenter/agentRuntimePresenter/index.ts @@ -5146,7 +5146,7 @@ export class AgentRuntimePresenter { const allowedSkillNameSet = extensionPolicy.enabledSkillNames === null || extensionPolicy.enabledSkillNames === undefined ? null - : new Set(this.normalizeSkillNames(extensionPolicy.enabledSkillNames)) + : new Set(this.normalizeStringList(extensionPolicy.enabledSkillNames)) if (skillsEnabled && skillPresenter) { if (skillPresenter.getMetadataList) { @@ -5489,15 +5489,15 @@ export class AgentRuntimePresenter { sessionActiveSkillNames: string[], instance: DeepChatAgentInstance ): string[] { - return this.normalizeSkillNames([ + return this.normalizeStringList([ ...sessionActiveSkillNames, ...instance.getRuntimeActivatedSkills() ]) } - private normalizeSkillNames(skillNames: string[]): string[] { + private normalizeStringList(values: string[]): string[] { return Array.from( - new Set(skillNames.map((name) => name.trim()).filter((name) => name.length > 0)) + new Set(values.map((value) => value.trim()).filter((value) => value.length > 0)) ).sort((a, b) => a.localeCompare(b)) } @@ -6298,7 +6298,7 @@ export class AgentRuntimePresenter { const files = Array.isArray((parsed as { files?: unknown }).files) ? ((parsed as { files?: unknown }).files as MessageFile[]).filter((file) => Boolean(file)) : [] - const activeSkills = this.normalizeSkillNames( + const activeSkills = this.normalizeStringList( Array.isArray((parsed as { activeSkills?: unknown }).activeSkills) ? ((parsed as { activeSkills?: unknown }).activeSkills as string[]) : [] @@ -6332,7 +6332,7 @@ export class AgentRuntimePresenter { const files = Array.isArray(input.files) ? input.files.filter((file): file is MessageFile => Boolean(file)) : [] - const activeSkills = this.normalizeSkillNames( + const activeSkills = this.normalizeStringList( Array.isArray(input.activeSkills) ? input.activeSkills : [] ) const inlineItems = Array.isArray(input.inlineItems) ? input.inlineItems : [] @@ -7490,7 +7490,7 @@ export class AgentRuntimePresenter { try { const policy = await this.resolveAgentExtensionPolicy(sessionId, resourceInstance) return this.filterSkillNamesByPolicy( - this.normalizeSkillNames(await this.skillPresenter.getActiveSkills(sessionId)), + this.normalizeStringList(await this.skillPresenter.getActiveSkills(sessionId)), policy ) } catch (error) { @@ -7531,7 +7531,7 @@ export class AgentRuntimePresenter { if (value === null || value === undefined) { return undefined } - return this.normalizeSkillNames(value) + return this.normalizeStringList(value) } private async refilterActiveSkillsForAgentPolicy( @@ -7569,19 +7569,19 @@ export class AgentRuntimePresenter { if (value === null || value === undefined) { return value } - return this.normalizeSkillNames(value) + return this.normalizeStringList(value) } private filterSkillNamesByPolicy( skillNames: string[] | undefined, policy: AgentExtensionPolicy ): string[] { - const normalizedSkillNames = this.normalizeSkillNames(skillNames ?? []) + const normalizedSkillNames = this.normalizeStringList(skillNames ?? []) if (policy.enabledSkillNames === null || policy.enabledSkillNames === undefined) { return normalizedSkillNames } - const allowed = new Set(this.normalizeSkillNames(policy.enabledSkillNames)) + const allowed = new Set(this.normalizeStringList(policy.enabledSkillNames)) return normalizedSkillNames.filter((skillName) => allowed.has(skillName)) } diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index 17734ce9d..47f79f184 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -564,8 +564,11 @@ export class Presenter implements IPresenter { this.commandPermissionService.clearConversation(sessionId) this.filePermissionService.clearConversation(sessionId) this.settingsPermissionService.clearConversation(sessionId) + this.mcpPresenter.clearSessionPermissions(sessionId) }, cloneSessionPermissions: (sourceSessionId, targetSessionId) => { + // MCP temporary approvals are intentionally never inherited. + this.mcpPresenter.clearSessionPermissions(targetSessionId) this.commandPermissionService.cloneConversation(sourceSessionId, targetSessionId) this.filePermissionService.cloneConversation(sourceSessionId, targetSessionId) this.settingsPermissionService.cloneConversation(sourceSessionId, targetSessionId) diff --git a/src/main/presenter/mcpPresenter/index.ts b/src/main/presenter/mcpPresenter/index.ts index 635b185b7..5dd1b9e4f 100644 --- a/src/main/presenter/mcpPresenter/index.ts +++ b/src/main/presenter/mcpPresenter/index.ts @@ -1040,6 +1040,10 @@ export class McpPresenter implements IMCPPresenter { } } + clearSessionPermissions(conversationId: string): void { + this.toolManager.clearSessionPermissions(conversationId) + } + async getNpmRegistryStatus(): Promise<{ currentRegistry: string | null isFromCache: boolean diff --git a/src/main/presenter/runtimePorts.ts b/src/main/presenter/runtimePorts.ts index 1929c5649..7c1d56678 100644 --- a/src/main/presenter/runtimePorts.ts +++ b/src/main/presenter/runtimePorts.ts @@ -58,7 +58,8 @@ export interface AcpProviderAdminPort { export interface SessionPermissionPort { clearSessionPermissions(sessionId: string): void /** - * Copy session-scoped permission approvals from parent to child (subagent inheritance). + * Copy non-MCP session approvals from parent to child. MCP temporary approvals are never + * inherited and the target MCP cache is cleared before cloning. */ cloneSessionPermissions?(sourceSessionId: string, targetSessionId: string): void approvePermission(sessionId: string, permission: SessionPermissionRequest): Promise diff --git a/src/main/presenter/sessionApplication/lifecycleCoordinator.ts b/src/main/presenter/sessionApplication/lifecycleCoordinator.ts index 58b4d70e0..832248f33 100644 --- a/src/main/presenter/sessionApplication/lifecycleCoordinator.ts +++ b/src/main/presenter/sessionApplication/lifecycleCoordinator.ts @@ -259,8 +259,11 @@ export class SessionLifecycleCoordinator implements SessionLifecyclePort { if (runtimeConfig.activeSkills.length > 0) { await this.dependencies.skills.setActiveSkills(sessionId, runtimeConfig.activeSkills) } - // Inherit parent session approvals so child does not re-prompt for already-trusted work. - this.dependencies.permissions?.cloneSessionPermissions?.(parentSessionId, sessionId) + const parentAgentId = input.parentAgentId?.trim() + if (parentAgentId && runtimeConfig.agentId === parentAgentId) { + // Only self-target children share the parent's trust boundary. + this.dependencies.permissions?.cloneSessionPermissions?.(parentSessionId, sessionId) + } if (!this.dependencies.sessions.get(sessionId)) { throw new Error(`Subagent session not found after creation: ${sessionId}`) } diff --git a/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts b/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts index 54a721ed2..30b4b1c02 100644 --- a/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts +++ b/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts @@ -959,8 +959,7 @@ export class AgentToolManager { } } - const workspaceRoot = - dynamicWorkdir ?? this.agentWorkspacePath ?? this.getDefaultAgentWorkspacePath() + const workspaceRoot = this.resolveCallWorkspaceRoot(dynamicWorkdir, conversationId) const allowedDirectories = await this.buildAllowedDirectories(workspaceRoot, conversationId, { includeSkillRoots: toolName !== 'exec', includeRuntimeRoots: toolName !== 'exec', @@ -1783,6 +1782,12 @@ export class AgentToolManager { return tempDir } + private resolveCallWorkspaceRoot(dynamicWorkdir: string | null, conversationId?: string): string { + if (dynamicWorkdir) return dynamicWorkdir + if (conversationId) return this.getDefaultAgentWorkspacePath() + return this.agentWorkspacePath ?? this.getDefaultAgentWorkspacePath() + } + private isSkillsEnabled(): boolean { return this.configPresenter.getSkillsEnabled() } @@ -2020,8 +2025,7 @@ export class AgentToolManager { } } - const workspaceRoot = - dynamicWorkdir ?? this.agentWorkspacePath ?? this.getDefaultAgentWorkspacePath() + const workspaceRoot = this.resolveCallWorkspaceRoot(dynamicWorkdir, conversationId) const allowedDirectories = await this.buildAllowedDirectories(workspaceRoot, conversationId, { includeSkillRoots: toolName !== 'exec', includeRuntimeRoots: toolName !== 'exec', diff --git a/src/main/routes/chat/chatService.ts b/src/main/routes/chat/chatService.ts index d586ae179..519315cf1 100644 --- a/src/main/routes/chat/chatService.ts +++ b/src/main/routes/chat/chatService.ts @@ -44,7 +44,7 @@ export interface ChatServiceProjectionPort { * Controllers here only support route-level abort for the in-flight accept path. */ export class ChatService { - private readonly acceptControllers = new Map() + private readonly acceptControllers = new Map>() constructor( private readonly deps: { @@ -64,7 +64,9 @@ export class ChatService { messageId: string | null }> { const controller = new AbortController() - this.acceptControllers.set(sessionId, controller) + const controllers = this.acceptControllers.get(sessionId) ?? new Set() + controllers.add(controller) + this.acceptControllers.set(sessionId, controllers) try { const session = await this.deps.scheduler.timeout({ @@ -95,7 +97,9 @@ export class ChatService { } throw error } finally { - if (this.acceptControllers.get(sessionId) === controller) { + const activeControllers = this.acceptControllers.get(sessionId) + activeControllers?.delete(controller) + if (activeControllers?.size === 0) { this.acceptControllers.delete(sessionId) } } @@ -143,40 +147,47 @@ export class ChatService { return { stopped: false } } - const controller = this.acceptControllers.get(targetSessionId) - if (controller) { - controller.abort() + const controllers = this.acceptControllers.get(targetSessionId) + if (controllers) { + for (const controller of controllers) { + controller.abort() + } this.acceptControllers.delete(targetSessionId) } let cancelFailed = false - await this.deps.scheduler.timeout({ - task: Promise.allSettled([ - Promise.resolve().then(() => - this.deps.sessionPermissionPort.clearSessionPermissions(targetSessionId) - ), - Promise.resolve().then(() => this.deps.turn.cancelGeneration(targetSessionId)) - ]).then((results) => { - const clearPermissionsResult = results[0] - if (clearPermissionsResult?.status === 'rejected') { - console.warn( - `[ChatService] Failed to clear session permissions during stop for ${targetSessionId}:`, - clearPermissionsResult.reason - ) - } - - const cancelGenerationResult = results[1] - if (cancelGenerationResult?.status === 'rejected') { - cancelFailed = true - console.warn( - `[ChatService] Failed to cancel generation during stop for ${targetSessionId}:`, - cancelGenerationResult.reason - ) - } - }), - ms: CHAT_STOP_TIMEOUT_MS, - reason: `chat.stopStream:${targetSessionId}` - }) + try { + await this.deps.scheduler.timeout({ + task: Promise.allSettled([ + Promise.resolve().then(() => + this.deps.sessionPermissionPort.clearSessionPermissions(targetSessionId) + ), + Promise.resolve().then(() => this.deps.turn.cancelGeneration(targetSessionId)) + ]).then((results) => { + const clearPermissionsResult = results[0] + if (clearPermissionsResult?.status === 'rejected') { + console.warn( + `[ChatService] Failed to clear session permissions during stop for ${targetSessionId}:`, + clearPermissionsResult.reason + ) + } + + const cancelGenerationResult = results[1] + if (cancelGenerationResult?.status === 'rejected') { + cancelFailed = true + console.warn( + `[ChatService] Failed to cancel generation during stop for ${targetSessionId}:`, + cancelGenerationResult.reason + ) + } + }), + ms: CHAT_STOP_TIMEOUT_MS, + reason: `chat.stopStream:${targetSessionId}` + }) + } catch (error) { + cancelFailed = true + console.warn(`[ChatService] Stop cleanup timed out for ${targetSessionId}:`, error) + } return { stopped: !cancelFailed } } @@ -205,10 +216,22 @@ export class ChatService { } private async bestEffortCancel(sessionId: string, reason: string): Promise { - const cleanupResults = await Promise.allSettled([ - Promise.resolve(this.deps.sessionPermissionPort.clearSessionPermissions(sessionId)), - this.deps.turn.cancelGeneration(sessionId) - ]) + let cleanupResults: PromiseSettledResult[] + try { + cleanupResults = await this.deps.scheduler.timeout({ + task: Promise.allSettled([ + Promise.resolve().then(() => + this.deps.sessionPermissionPort.clearSessionPermissions(sessionId) + ), + Promise.resolve().then(() => this.deps.turn.cancelGeneration(sessionId)) + ]), + ms: CHAT_STOP_TIMEOUT_MS, + reason: `chat.bestEffortCancel:${sessionId}` + }) + } catch (error) { + console.warn(`[ChatService] Cleanup timed out after ${reason} for ${sessionId}:`, error) + return + } const clearPermissionsResult = cleanupResults[0] if (clearPermissionsResult?.status === 'rejected') { console.warn( diff --git a/src/main/routes/sessions/sessionService.ts b/src/main/routes/sessions/sessionService.ts index d59e8719f..68e893ab6 100644 --- a/src/main/routes/sessions/sessionService.ts +++ b/src/main/routes/sessions/sessionService.ts @@ -7,8 +7,6 @@ import type { import type { Scheduler } from '../scheduler' const SESSION_OPERATION_TIMEOUT_MS = 5_000 -/** ACP process hydrate/prepare can exceed the generic read timeout. */ -const SESSION_CREATE_TIMEOUT_MS = 60_000 const SESSION_LIST_TIMEOUT_MS = 15_000 const DEFAULT_RESTORE_MESSAGE_LIMIT = 100 @@ -53,11 +51,9 @@ export class SessionService { input: CreateSessionInput, context: SessionRouteContext ): Promise { - return await this.deps.scheduler.timeout({ - task: this.deps.lifecycle.createSession(input, context.webContentsId), - ms: SESSION_CREATE_TIMEOUT_MS, - reason: 'sessions.create' - }) + // Creation mutates durable/session runtime state. Scheduler.timeout only races the promise and + // cannot cancel the underlying operation, so timing out here could publish a late duplicate. + return await this.deps.lifecycle.createSession(input, context.webContentsId) } async restoreSession( diff --git a/src/renderer/src/i18n/zh-CN/settings.json b/src/renderer/src/i18n/zh-CN/settings.json index edd31ff11..4d82be404 100644 --- a/src/renderer/src/i18n/zh-CN/settings.json +++ b/src/renderer/src/i18n/zh-CN/settings.json @@ -2775,7 +2775,7 @@ "acpUnavailableDescription": "ACP Agent 通过自己的运行时管理工具和扩展。请选择一个 DeepChat Agent 后再使用插件。", "agentScopeUnsupported": "Agent 级扩展配置仅支持 DeepChat Agent。", "scopeGlobalPlugins": "当前页为应用全局:官方插件的安装、启用与进程对所有 DeepChat Agent 共享。", - "scopeCurrentAgent": "当前页为 Agent 策略:仅影响「{agent}」可用的 Skills / MCP,不会全局禁用资源。", + "scopeCurrentAgent": "当前页为 Agent 策略:仅影响「{agent}」可用的 Skills / MCP,不会影响全局插件的安装、启用状态或进程状态。", "currentAgentFallback": "当前 Agent" }, "controlCenter": { diff --git a/src/shared/types/presenters/core.presenter.d.ts b/src/shared/types/presenters/core.presenter.d.ts index a24ec249e..d3998bea8 100644 --- a/src/shared/types/presenters/core.presenter.d.ts +++ b/src/shared/types/presenters/core.presenter.d.ts @@ -1825,6 +1825,7 @@ export interface IMCPPresenter { remember?: boolean, conversationId?: string ): Promise + clearSessionPermissions(conversationId: string): void // NPM Registry management methods getNpmRegistryStatus?(): Promise<{ currentRegistry: string | null diff --git a/test/main/agent/acp/runtime/acpContentMapper.test.ts b/test/main/agent/acp/runtime/acpContentMapper.test.ts index d29e6c789..2d89351ef 100644 --- a/test/main/agent/acp/runtime/acpContentMapper.test.ts +++ b/test/main/agent/acp/runtime/acpContentMapper.test.ts @@ -69,6 +69,13 @@ describe('AcpContentMapper tool call handling', () => { }) ) + expect( + completion.blocks.some( + (block) => block.type === 'action' && block.action_type === 'tool_call_permission' + ) + ).toBe(false) + expect(completion.events.some((event) => event.type === 'reasoning')).toBe(true) + const endEvent = completion.events.find((event) => event.type === 'tool_call_end') expect(endEvent).toMatchObject({ type: 'tool_call_end', diff --git a/test/main/presenter/agentRuntimePresenter/dispatch.test.ts b/test/main/presenter/agentRuntimePresenter/dispatch.test.ts index 864539380..2715e279e 100644 --- a/test/main/presenter/agentRuntimePresenter/dispatch.test.ts +++ b/test/main/presenter/agentRuntimePresenter/dispatch.test.ts @@ -349,6 +349,41 @@ describe('dispatch', () => { expect(toolBlock!.status).toBe('success') }) + it('rejects calls missing from the current session tool definitions', async () => { + const tools = [makeAgentTool('read')] + const toolPresenter = createMockToolPresenter() + const conversation: any[] = [] + state.blocks.push({ + type: 'tool_call', + content: '', + status: 'pending', + timestamp: Date.now(), + tool_call: { id: 'tc1', name: 'exec', params: '{}', response: '' } + }) + state.completedToolCalls = [{ id: 'tc1', name: 'exec', arguments: '{}' }] + + const outcome = await executeTools( + state, + conversation, + 0, + tools, + toolPresenter, + 'gpt-4', + io, + 'full_access', + new ToolOutputGuard(), + 32000, + 1024 + ) + + expect(outcome.executed).toBe(1) + expect(toolPresenter.callTool).not.toHaveBeenCalled() + expect(conversation.find((message: any) => message.role === 'tool')?.content).toBe( + 'Error: Tool is not available in the current session: exec' + ) + expect(state.blocks.find((block) => block.type === 'tool_call')?.status).toBe('error') + }) + it('publishes plan update events without inserting plan blocks into messages', async () => { const tools = [makeAgentTool('update_plan')] const snapshot = { diff --git a/test/main/presenter/mcpPresenter.test.ts b/test/main/presenter/mcpPresenter.test.ts index 1c07bb7ad..ce3dd7e2d 100644 --- a/test/main/presenter/mcpPresenter.test.ts +++ b/test/main/presenter/mcpPresenter.test.ts @@ -16,7 +16,8 @@ const serverManagerMocks = vi.hoisted(() => ({ const toolManagerMocks = vi.hoisted(() => ({ getAllToolDefinitions: vi.fn().mockResolvedValue([]), - getRunningClients: vi.fn().mockResolvedValue([]) + getRunningClients: vi.fn().mockResolvedValue([]), + clearSessionPermissions: vi.fn() })) const publishDeepchatEventMock = vi.hoisted(() => vi.fn()) @@ -40,7 +41,8 @@ vi.mock('../../../src/main/presenter/mcpPresenter/serverManager', () => ({ vi.mock('../../../src/main/presenter/mcpPresenter/toolManager', () => ({ ToolManager: vi.fn().mockImplementation(() => ({ getAllToolDefinitions: toolManagerMocks.getAllToolDefinitions, - getRunningClients: toolManagerMocks.getRunningClients + getRunningClients: toolManagerMocks.getRunningClients, + clearSessionPermissions: toolManagerMocks.clearSessionPermissions })) })) @@ -134,6 +136,17 @@ describe('McpPresenter#setMcpServerEnabled', () => { ) }) + it('clears MCP temporary approvals for a session', () => { + const presenter = new McpPresenter(createConfigPresenter(true)) + ;(presenter as any).toolManager = { + clearSessionPermissions: toolManagerMocks.clearSessionPermissions + } + + presenter.clearSessionPermissions('session-1') + + expect(toolManagerMocks.clearSessionPermissions).toHaveBeenCalledExactlyOnceWith('session-1') + }) + it('stops a server immediately after disabling it when MCP is active', async () => { const configPresenter = createConfigPresenter(true) const presenter = new McpPresenter(configPresenter) diff --git a/test/main/presenter/sessionApplication/lifecycleCoordinator.test.ts b/test/main/presenter/sessionApplication/lifecycleCoordinator.test.ts index 16eaa2b57..7ee59b70f 100644 --- a/test/main/presenter/sessionApplication/lifecycleCoordinator.test.ts +++ b/test/main/presenter/sessionApplication/lifecycleCoordinator.test.ts @@ -222,6 +222,7 @@ function createHarness(initialSessions: SessionRecord[] = []) { }) } const deletion = { deleteSessionTree: vi.fn().mockResolvedValue([]) } + const permissions = { cloneSessionPermissions: vi.fn() } const dependencies = { sessions, runtime, @@ -231,7 +232,8 @@ function createHarness(initialSessions: SessionRecord[] = []) { workdir, initialTurn, projection, - deletion + deletion, + permissions } as unknown as SessionLifecycleCoordinatorDependencies return { @@ -247,7 +249,8 @@ function createHarness(initialSessions: SessionRecord[] = []) { workdir, initialTurn, projection, - deletion + deletion, + permissions } } @@ -454,6 +457,43 @@ describe('SessionLifecycleCoordinator', () => { warn.mockRestore() }) + it('inherits approvals only for self-target subagents', async () => { + const harness = createHarness() + + await harness.coordinator.createSubagentSession({ + parentSessionId: 'parent', + parentAgentId: 'deepchat', + agentId: 'deepchat', + slotId: 'self', + displayName: 'Self child', + targetAgentId: 'deepchat', + projectDir: '/repo', + providerId: 'openai', + modelId: 'model-1', + permissionMode: 'default' + }) + + expect(harness.permissions.cloneSessionPermissions).toHaveBeenCalledExactlyOnceWith( + 'parent', + 'session-1' + ) + + await harness.coordinator.createSubagentSession({ + parentSessionId: 'parent', + parentAgentId: 'deepchat', + agentId: 'reviewer', + slotId: 'reviewer', + displayName: 'Reviewer', + targetAgentId: 'reviewer', + projectDir: '/repo', + providerId: 'openai', + modelId: 'model-1', + permissionMode: 'default' + }) + + expect(harness.permissions.cloneSessionPermissions).toHaveBeenCalledTimes(1) + }) + it('reuses an empty ACP draft and synchronizes changed permission state', async () => { const draft = createRecord({ id: 'draft-1', diff --git a/test/main/presenter/toolPresenter/agentTools/agentToolManagerRead.test.ts b/test/main/presenter/toolPresenter/agentTools/agentToolManagerRead.test.ts index 2ca5497e8..03c260fcb 100644 --- a/test/main/presenter/toolPresenter/agentTools/agentToolManagerRead.test.ts +++ b/test/main/presenter/toolPresenter/agentTools/agentToolManagerRead.test.ts @@ -64,7 +64,7 @@ describe('AgentToolManager read routing', () => { generateCompletionStandalone: vi.fn(), generateImageStandalone: vi.fn() } - resolveConversationWorkdir = vi.fn().mockResolvedValue(null) + resolveConversationWorkdir = vi.fn().mockResolvedValue(workspaceDir) resolveConversationSessionInfo = vi.fn().mockResolvedValue({ agentId: 'deepchat', providerId: 'openai', @@ -157,6 +157,31 @@ describe('AgentToolManager read routing', () => { ) }) + it('does not inherit the last synchronized workspace when session workdir lookup is empty', async () => { + const otherWorkspace = await fs.mkdtemp(path.join(os.tmpdir(), 'deepchat-read-other-')) + const otherFile = path.join(otherWorkspace, 'other-session.txt') + await fs.writeFile(otherFile, 'other session', 'utf-8') + manager.syncContext({ chatMode: 'agent', agentWorkspacePath: otherWorkspace }) + resolveConversationWorkdir.mockImplementation(async (conversationId: string) => + conversationId === 'session-a' ? null : otherWorkspace + ) + + const permission = await manager.preCheckToolPermission( + 'read', + { path: otherFile }, + 'session-a' + ) + const resolvedOtherFile = await fs.realpath(otherFile) + + expect(permission).toEqual( + expect.objectContaining({ + needsPermission: true, + permissionType: 'read', + paths: [resolvedOtherFile] + }) + ) + }) + it('requests permission for workspace symlinks that point outside', async () => { const externalDir = await fs.mkdtemp(path.join(os.tmpdir(), 'deepchat-read-link-target-')) const externalFile = path.join(externalDir, 'outside.txt') diff --git a/test/main/routes/chatService.test.ts b/test/main/routes/chatService.test.ts index dc16ae4ce..8e0d11a39 100644 --- a/test/main/routes/chatService.test.ts +++ b/test/main/routes/chatService.test.ts @@ -169,6 +169,50 @@ describe('ChatService', () => { expect(harness.turn.cancelGeneration).toHaveBeenCalledWith('session-1') }) + it('returns an honest stop result when bounded cleanup times out', async () => { + const harness = createHarness() + const cleanupTimeout = new Error('cleanup timed out') + cleanupTimeout.name = 'TimeoutError' + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + harness.scheduler.timeout.mockRejectedValueOnce(cleanupTimeout) + + await expect(harness.service.stopStream({ sessionId: 'session-1' })).resolves.toEqual({ + stopped: false + }) + + expect(harness.sessionPermissionPort.clearSessionPermissions).toHaveBeenCalledWith('session-1') + expect(harness.turn.cancelGeneration).toHaveBeenCalledWith('session-1') + warn.mockRestore() + }) + + it('bounds timeout cleanup and preserves the original send timeout', async () => { + const harness = createHarness() + const sendTimeout = new Error('send timed out') + sendTimeout.name = 'TimeoutError' + const clearError = new Error('clear failed synchronously') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + harness.sessionPermissionPort.clearSessionPermissions.mockImplementation(() => { + throw clearError + }) + harness.scheduler.timeout.mockImplementation( + async ({ task, reason }: { task: Promise; reason: string }) => { + if (reason === 'chat.sendMessage:session-1') throw sendTimeout + return await task + } + ) + + await expect(harness.service.sendMessage('session-1', 'hello')).rejects.toBe(sendTimeout) + + expect(harness.turn.cancelGeneration).toHaveBeenCalledWith('session-1') + expect(harness.scheduler.timeout).toHaveBeenCalledWith( + expect.objectContaining({ + ms: 5_000, + reason: 'chat.bestEffortCancel:session-1' + }) + ) + warn.mockRestore() + }) + it('resolves stop target from message id when session id is omitted', async () => { const harness = createHarness() harness.projection.getMessage.mockResolvedValue(createMessage()) @@ -180,7 +224,7 @@ describe('ChatService', () => { expect(harness.turn.cancelGeneration).toHaveBeenCalledWith('session-1') }) - it('aborts an in-flight send accept path on stop', async () => { + it('aborts every concurrent in-flight send accept path on stop', async () => { const scheduler = { sleep: vi.fn(), timeout: vi.fn(async ({ task, signal }: { task: Promise; signal?: AbortSignal }) => { @@ -214,12 +258,12 @@ describe('ChatService', () => { }), retry: vi.fn() } - let resolveSession!: (value: SessionWithState) => void + const resolveSessions: Array<(value: SessionWithState) => void> = [] const projection = { getSession: vi.fn().mockImplementation( async () => await new Promise((resolve) => { - resolveSession = resolve + resolveSessions.push(resolve) }) ), getMessage: vi.fn().mockResolvedValue(null) @@ -240,16 +284,21 @@ describe('ChatService', () => { scheduler }) - const pendingSend = service.sendMessage('session-1', 'hello') + const firstPendingSend = service.sendMessage('session-1', 'hello') + const secondPendingSend = service.sendMessage('session-1', 'again') await Promise.resolve() await expect(service.stopStream({ sessionId: 'session-1' })).resolves.toEqual({ stopped: true }) - resolveSession(createSession()) + expect(resolveSessions).toHaveLength(2) + for (const resolveSession of resolveSessions) { + resolveSession(createSession()) + } - await expect(pendingSend).rejects.toMatchObject({ name: 'AbortError' }) + await expect(firstPendingSend).rejects.toMatchObject({ name: 'AbortError' }) + await expect(secondPendingSend).rejects.toMatchObject({ name: 'AbortError' }) expect(sessionPermissionPort.clearSessionPermissions).toHaveBeenCalledWith('session-1') expect(turn.cancelGeneration).toHaveBeenCalledWith('session-1') }) diff --git a/test/main/routes/sessionService.test.ts b/test/main/routes/sessionService.test.ts index b063d6a01..8af935dc8 100644 --- a/test/main/routes/sessionService.test.ts +++ b/test/main/routes/sessionService.test.ts @@ -119,7 +119,7 @@ describe('SessionService', () => { expect(projection.listMessagesPage).not.toHaveBeenCalled() }) - it('routes session lifecycle operations through five-second scheduler boundaries', async () => { + it('does not race session creation and applies read-operation scheduler boundaries', async () => { const scheduler = createScheduler() const session = { id: 'session-1' } const lifecycle = { createSession: vi.fn().mockResolvedValue(session) } @@ -159,8 +159,7 @@ describe('SessionService', () => { expect(projection.deactivate).toHaveBeenCalledWith(42) expect(projection.getActive).toHaveBeenCalledWith(42) expect(scheduler.timeout.mock.calls.map(([options]) => [options.ms, options.reason])).toEqual([ - [5_000, 'sessions.create'], - [5_000, 'sessions.list'], + [15_000, 'sessions.list'], [5_000, 'sessions.listMessagesPage:session-1'], [5_000, 'sessions.activate:session-1'], [5_000, 'sessions.deactivate'],