diff --git a/docs/architecture/agent-scoped-extensions/spec.md b/docs/architecture/agent-scoped-extensions/spec.md index ba0e344728..7ef29d442d 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 0000000000..290940bf8a --- /dev/null +++ b/docs/architecture/multi-agent-isolation/plan.md @@ -0,0 +1,30 @@ +# 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 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 new file mode 100644 index 0000000000..b261868e3c --- /dev/null +++ b/docs/architecture/multi-agent-isolation/spec.md @@ -0,0 +1,67 @@ +# 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 + execution membership check | +| Memory | per-agent storage | agent memory settings | agentId-keyed rows / vectors | +| 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 | + +## 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`. +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 + +- 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. 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 new file mode 100644 index 0000000000..d39d53cc6a --- /dev/null +++ b/docs/architecture/multi-agent-isolation/tasks.md @@ -0,0 +1,14 @@ +# 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 +- [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 new file mode 100644 index 0000000000..be4acd2ace --- /dev/null +++ b/docs/architecture/subagent-host-policy-isolation/plan.md @@ -0,0 +1,18 @@ +# 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. 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 new file mode 100644 index 0000000000..fe56292842 --- /dev/null +++ b/docs/architecture/subagent-host-policy-isolation/spec.md @@ -0,0 +1,49 @@ +# 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`** | +| Permission approvals | inherit non-MCP session approvals | **start clean** | +| 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). +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 + +- 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 0000000000..f15f7bdb58 --- /dev/null +++ b/docs/architecture/subagent-host-policy-isolation/tasks.md @@ -0,0 +1,9 @@ +# Subagent Host Policy Isolation — Tasks + +- [x] Spec / plan +- [x] Ports + policy resolution +- [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/acp-tool-progress-permission-ui/spec.md b/docs/issues/acp-tool-progress-permission-ui/spec.md new file mode 100644 index 0000000000..23d9d51efb --- /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/agent-mcp-allowlist-runtime-enforce/spec.md b/docs/issues/agent-mcp-allowlist-runtime-enforce/spec.md new file mode 100644 index 0000000000..6468e58229 --- /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 0000000000..b6a37a5f9b --- /dev/null +++ b/docs/issues/agent-tool-workspace-cross-session-leak/spec.md @@ -0,0 +1,37 @@ +# 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 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 new file mode 100644 index 0000000000..ed923a6143 --- /dev/null +++ b/docs/issues/agent-transfer-permission-skill-reset/spec.md @@ -0,0 +1,39 @@ +# Agent Transfer Permission / Skill Reset + +## Issue + +Moving a session to another DeepChat agent updates `agent_id`, model, permission mode, and disabled +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 + +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 +- 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` +- 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 +- [x] Clear MCP temporary approvals and define them as non-cloneable + +## 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/docs/issues/chat-generation-lifecycle-timeouts/spec.md b/docs/issues/chat-generation-lifecycle-timeouts/spec.md new file mode 100644 index 0000000000..fcd61d8c54 --- /dev/null +++ b/docs/issues/chat-generation-lifecycle-timeouts/spec.md @@ -0,0 +1,39 @@ +# 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. + +## 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: 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/agent/acp/runtime/acpContentMapper.ts b/src/main/agent/acp/runtime/acpContentMapper.ts index 4a713c8f57..70373fc3c7 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/agentRuntimePresenter/dispatch.ts b/src/main/presenter/agentRuntimePresenter/dispatch.ts index 1e3f359c30..2ada0b5715 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) @@ -1750,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 8c250c02e9..a3889fb13e 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) { @@ -5132,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) { @@ -5475,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)) } @@ -6284,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[]) : [] @@ -6318,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 : [] @@ -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 }) @@ -7462,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) { @@ -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,23 +7527,61 @@ export class AgentRuntimePresenter { } } + private toToolDefinitionMcpServerIds(value?: string[] | null): string[] | undefined { + if (value === null || value === undefined) { + return undefined + } + return this.normalizeStringList(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 } - 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/agentRuntimePresenter/types.ts b/src/main/presenter/agentRuntimePresenter/types.ts index c918341c43..b3a63a28ef 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/index.ts b/src/main/presenter/index.ts index 6d59b6b86e..3116b5e8e2 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -559,6 +559,14 @@ 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) }, approvePermission: async (sessionId, permission) => { const permissionType = permission.permissionType @@ -918,7 +926,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/mcpPresenter/index.ts b/src/main/presenter/mcpPresenter/index.ts index 635b185b7b..5dd1b9e4f3 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/permission/commandPermissionCache.ts b/src/main/presenter/permission/commandPermissionCache.ts index 594ec09d8a..28dfacd5f2 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 502aff3c70..40b87a2639 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 bb034f2e9a..f72d0b5787 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 c3da6b1894..457952f434 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 d86bf32c3b..7c1d56678c 100644 --- a/src/main/presenter/runtimePorts.ts +++ b/src/main/presenter/runtimePorts.ts @@ -57,6 +57,11 @@ export interface AcpProviderAdminPort { export interface SessionPermissionPort { clearSessionPermissions(sessionId: string): void + /** + * 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/agentAssignmentPolicy.ts b/src/main/presenter/sessionApplication/agentAssignmentPolicy.ts index a1c4c28c97..45bf1599ad 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 464eb16191..832248f33b 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 { @@ -213,10 +215,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,12 +253,17 @@ 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) { await this.dependencies.skills.setActiveSkills(sessionId, runtimeConfig.activeSkills) } + 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/sessionApplication/lifecycleDeletionTransaction.ts b/src/main/presenter/sessionApplication/lifecycleDeletionTransaction.ts index 1e3a068203..cd5b90ba44 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 090e48d84c..a6aeb84769 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 @@ -554,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/agentToolManager.ts b/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts index 955ad91860..30b4b1c023 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', @@ -1260,8 +1259,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( @@ -1782,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() } @@ -2019,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/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts b/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts index 1dc9c00813..8830f819f4 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}`) } @@ -1125,6 +1136,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 bc63ad32c3..856f1707d1 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/src/main/routes/chat/chatService.ts b/src/main/routes/chat/chatService.ts index ba13c57e25..519315cf16 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 @@ -26,19 +26,30 @@ 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 } +/** + * 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 +63,10 @@ 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) + const controllers = this.acceptControllers.get(sessionId) ?? new Set() + controllers.add(controller) + this.acceptControllers.set(sessionId, controllers) try { const session = await this.deps.scheduler.timeout({ @@ -70,16 +79,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 +93,14 @@ 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) + const activeControllers = this.acceptControllers.get(sessionId) + activeControllers?.delete(controller) + if (activeControllers?.size === 0) { + this.acceptControllers.delete(sessionId) } } } @@ -163,48 +147,52 @@ export class ChatService { return { stopped: false } } - const controller = this.activeControllers.get(targetSessionId) - if (controller) { - controller.abort() - this.activeControllers.delete(targetSessionId) + const controllers = this.acceptControllers.get(targetSessionId) + if (controllers) { + for (const controller of controllers) { + controller.abort() + } + this.acceptControllers.delete(targetSessionId) } - 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') { - console.warn( - `[ChatService] Failed to cancel generation during stop for ${targetSessionId}:`, - cancelGenerationResult.reason - ) - } - }), - ms: CHAT_STOP_TIMEOUT_MS, - reason: `chat.stopStream:${targetSessionId}` - }) + let cancelFailed = false + 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: true } + return { stopped: !cancelFailed } } - async respondToolInteraction(input: { - sessionId: string - messageId: string - toolCallId: string - response: ToolInteractionResponse - }): Promise<{ + async respondToolInteraction(input: ChatRespondToolInteractionInput): Promise<{ accepted: true resumed?: boolean waitingForUserMessage?: boolean @@ -226,4 +214,37 @@ export class ChatService { ...result } } + + private async bestEffortCancel(sessionId: string, reason: string): Promise { + 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( + `[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 ec018ed410..5bc832b6d4 100644 --- a/src/main/routes/index.ts +++ b/src/main/routes/index.ts @@ -809,7 +809,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 29341ba9fa..68e893ab63 100644 --- a/src/main/routes/sessions/sessionService.ts +++ b/src/main/routes/sessions/sessionService.ts @@ -7,6 +7,7 @@ import type { import type { Scheduler } from '../scheduler' const SESSION_OPERATION_TIMEOUT_MS = 5_000 +const SESSION_LIST_TIMEOUT_MS = 15_000 const DEFAULT_RESTORE_MESSAGE_LIMIT = 100 export type SessionRouteContext = { @@ -50,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_OPERATION_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( @@ -119,7 +118,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 e4d8872f7f..df4cfcd75e 100644 --- a/src/renderer/src/i18n/da-DK/settings.json +++ b/src/renderer/src/i18n/da-DK/settings.json @@ -2993,6 +2993,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": "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 8ec49de2f7..0d90340479 100644 --- a/src/renderer/src/i18n/de-DE/settings.json +++ b/src/renderer/src/i18n/de-DE/settings.json @@ -2984,6 +2984,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": "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 d51d71b82a..f612ee326a 100644 --- a/src/renderer/src/i18n/en-US/settings.json +++ b/src/renderer/src/i18n/en-US/settings.json @@ -2788,7 +2788,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, 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" }, "controlCenter": { "groups": { diff --git a/src/renderer/src/i18n/es-ES/settings.json b/src/renderer/src/i18n/es-ES/settings.json index 3821e1961e..e631772278 100644 --- a/src/renderer/src/i18n/es-ES/settings.json +++ b/src/renderer/src/i18n/es-ES/settings.json @@ -2984,6 +2984,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": "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 24c3b483fb..5c21ece000 100644 --- a/src/renderer/src/i18n/fa-IR/settings.json +++ b/src/renderer/src/i18n/fa-IR/settings.json @@ -2993,6 +2993,9 @@ "cuaDescription": "افزونه ComputerUse DeepChat که بر پایه پروژه trycua/cua پیاده‌سازی شده است.", "acpUnavailableTitle": "افزونه‌ها برای عامل‌های ACP در دسترس نیستند", "acpUnavailableDescription": "عامل‌های ACP ابزارها و افزونه‌ها را در محیط اجرای خود مدیریت می‌کنند. برای استفاده از افزونه‌ها، یک عامل DeepChat را انتخاب کنید.", - "agentScopeUnsupported": "افزونه‌های ویژهٔ هر عامل فقط برای عامل‌های DeepChat در دسترس هستند." + "agentScopeUnsupported": "افزونه‌های ویژهٔ هر عامل فقط برای عامل‌های DeepChat در دسترس هستند.", + "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 d0c70f024d..6ea3efa01f 100644 --- a/src/renderer/src/i18n/fr-FR/settings.json +++ b/src/renderer/src/i18n/fr-FR/settings.json @@ -2993,6 +2993,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": "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 bd2e23f67c..d043a7565d 100644 --- a/src/renderer/src/i18n/he-IL/settings.json +++ b/src/renderer/src/i18n/he-IL/settings.json @@ -2993,6 +2993,9 @@ "cuaDescription": "תוסף ComputerUse של DeepChat שמיושם על בסיס הפרויקט trycua/cua.", "acpUnavailableTitle": "תוספים אינם זמינים לסוכני ACP", "acpUnavailableDescription": "סוכני ACP מנהלים כלים והרחבות בסביבת הריצה שלהם. יש לבחור סוכן DeepChat כדי להשתמש בתוספים.", - "agentScopeUnsupported": "הרחבות ברמת הסוכן זמינות רק לסוכני DeepChat." + "agentScopeUnsupported": "הרחבות ברמת הסוכן זמינות רק לסוכני DeepChat.", + "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 8a18a0a2ba..e28e8b2c26 100644 --- a/src/renderer/src/i18n/id-ID/settings.json +++ b/src/renderer/src/i18n/id-ID/settings.json @@ -2984,6 +2984,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": "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 e6476823d4..a93110c27c 100644 --- a/src/renderer/src/i18n/it-IT/settings.json +++ b/src/renderer/src/i18n/it-IT/settings.json @@ -2984,6 +2984,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": "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 6a82844fd9..e612e0bae5 100644 --- a/src/renderer/src/i18n/ja-JP/settings.json +++ b/src/renderer/src/i18n/ja-JP/settings.json @@ -2993,6 +2993,9 @@ "cuaDescription": "trycua/cua プロジェクトをベースに DeepChat が実装した ComputerUse プラグインです。", "acpUnavailableTitle": "ACP エージェントではプラグインを利用できません", "acpUnavailableDescription": "ACP エージェントは独自のランタイムでツールと拡張機能を管理します。プラグインを使用するには DeepChat エージェントを選択してください。", - "agentScopeUnsupported": "エージェント単位の拡張機能は DeepChat エージェントでのみ利用できます。" + "agentScopeUnsupported": "エージェント単位の拡張機能は DeepChat エージェントでのみ利用できます。", + "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 e2b6ebae0b..bddae82d95 100644 --- a/src/renderer/src/i18n/ko-KR/settings.json +++ b/src/renderer/src/i18n/ko-KR/settings.json @@ -2993,6 +2993,9 @@ "cuaDescription": "DeepChat이 trycua/cua 프로젝트를 기반으로 구현한 ComputerUse 플러그인입니다.", "acpUnavailableTitle": "ACP 에이전트에서는 플러그인을 사용할 수 없습니다", "acpUnavailableDescription": "ACP 에이전트는 자체 런타임에서 도구와 확장 기능을 관리합니다. 플러그인을 사용하려면 DeepChat 에이전트를 선택하세요.", - "agentScopeUnsupported": "에이전트별 확장 기능은 DeepChat 에이전트에서만 사용할 수 있습니다." + "agentScopeUnsupported": "에이전트별 확장 기능은 DeepChat 에이전트에서만 사용할 수 있습니다.", + "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 12755e5a88..6affc8a112 100644 --- a/src/renderer/src/i18n/ms-MY/settings.json +++ b/src/renderer/src/i18n/ms-MY/settings.json @@ -2984,6 +2984,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": "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 4f5722abec..a1be7b2494 100644 --- a/src/renderer/src/i18n/pl-PL/settings.json +++ b/src/renderer/src/i18n/pl-PL/settings.json @@ -2984,6 +2984,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": "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 e4f7c3aa15..48f0348135 100644 --- a/src/renderer/src/i18n/pt-BR/settings.json +++ b/src/renderer/src/i18n/pt-BR/settings.json @@ -2993,6 +2993,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": "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 8f1c8bd7e8..193a83fe3b 100644 --- a/src/renderer/src/i18n/ru-RU/settings.json +++ b/src/renderer/src/i18n/ru-RU/settings.json @@ -2993,6 +2993,9 @@ "cuaDescription": "Плагин ComputerUse от DeepChat, реализованный на базе проекта trycua/cua.", "acpUnavailableTitle": "Плагины недоступны для агентов ACP", "acpUnavailableDescription": "Агенты ACP управляют инструментами и расширениями в собственной среде выполнения. Выберите агента DeepChat, чтобы использовать плагины.", - "agentScopeUnsupported": "Расширения на уровне агента доступны только агентам DeepChat." + "agentScopeUnsupported": "Расширения на уровне агента доступны только агентам DeepChat.", + "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 34c6205ada..cdf14ed2ce 100644 --- a/src/renderer/src/i18n/tr-TR/settings.json +++ b/src/renderer/src/i18n/tr-TR/settings.json @@ -2984,6 +2984,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": "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 1952ba7246..501ec8cf29 100644 --- a/src/renderer/src/i18n/vi-VN/settings.json +++ b/src/renderer/src/i18n/vi-VN/settings.json @@ -2984,6 +2984,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": "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" } } diff --git a/src/renderer/src/i18n/zh-CN/settings.json b/src/renderer/src/i18n/zh-CN/settings.json index f95c72d335..28a27c4a0a 100644 --- a/src/renderer/src/i18n/zh-CN/settings.json +++ b/src/renderer/src/i18n/zh-CN/settings.json @@ -2787,7 +2787,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 96bc97cff5..1896285977 100644 --- a/src/renderer/src/i18n/zh-HK/settings.json +++ b/src/renderer/src/i18n/zh-HK/settings.json @@ -2993,6 +2993,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 3fa1430feb..599afb7ba7 100644 --- a/src/renderer/src/i18n/zh-TW/settings.json +++ b/src/renderer/src/i18n/zh-TW/settings.json @@ -2993,6 +2993,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 a69bfc34fd..4db000df90 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/src/shared/types/presenters/core.presenter.d.ts b/src/shared/types/presenters/core.presenter.d.ts index da48829c97..7e0da01816 100644 --- a/src/shared/types/presenters/core.presenter.d.ts +++ b/src/shared/types/presenters/core.presenter.d.ts @@ -1828,6 +1828,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 dcc54a5f3f..2d89351ef5 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', @@ -62,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/agentRuntimePresenter.test.ts b/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts index 02c6fc4f4f..ab3b55d9ad 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 3cc49a32ca..2715e279ec 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 = { @@ -1345,6 +1380,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(), diff --git a/test/main/presenter/mcpPresenter.test.ts b/test/main/presenter/mcpPresenter.test.ts index 1c07bb7ad8..ce3dd7e2db 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/agentAssignmentPolicy.test.ts b/test/main/presenter/sessionApplication/agentAssignmentPolicy.test.ts index c724e49cce..f65f545573 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 9191cef9f3..7ee59b70f5 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 ?? [] @@ -219,6 +222,7 @@ function createHarness(initialSessions: SessionRecord[] = []) { }) } const deletion = { deleteSessionTree: vi.fn().mockResolvedValue([]) } + const permissions = { cloneSessionPermissions: vi.fn() } const dependencies = { sessions, runtime, @@ -228,7 +232,8 @@ function createHarness(initialSessions: SessionRecord[] = []) { workdir, initialTurn, projection, - deletion + deletion, + permissions } as unknown as SessionLifecycleCoordinatorDependencies return { @@ -244,7 +249,8 @@ function createHarness(initialSessions: SessionRecord[] = []) { workdir, initialTurn, projection, - deletion + deletion, + permissions } } @@ -451,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/sessionApplication/lifecycleDeletionTransaction.test.ts b/test/main/presenter/sessionApplication/lifecycleDeletionTransaction.test.ts index e07b140f1d..dc4757ae02 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/presenter/sessionApplication/sessionApplication.integration.test.ts b/test/main/presenter/sessionApplication/sessionApplication.integration.test.ts index e9eaec6767..fe4f482baa 100644 --- a/test/main/presenter/sessionApplication/sessionApplication.integration.test.ts +++ b/test/main/presenter/sessionApplication/sessionApplication.integration.test.ts @@ -3507,7 +3507,7 @@ describe('Session application coordinators', () => { ) }) - 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', @@ -3520,14 +3520,14 @@ describe('Session application coordinators', () => { }) agentManager.cleanupSessionBackends.mockRejectedValueOnce(cleanupError) - await expect(lifecycle.deleteSession('s1')).rejects.toBe(cleanupError) + await expect(lifecycle.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({ @@ -3542,15 +3542,14 @@ describe('Session application coordinators', () => { agentManager.cleanupSessionBackends.mockRejectedValueOnce(backendError) deepChatAgent.destroySession.mockRejectedValueOnce(sharedError) - await expect(lifecycle.deleteSession('s1')).rejects.toBe(backendError) + await expect(lifecycle.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', @@ -3563,12 +3562,11 @@ describe('Session application coordinators', () => { }) deepChatAgent.destroySession.mockRejectedValueOnce(sharedError) - await expect(lifecycle.deleteSession('s1')).rejects.toBe(sharedError) + await expect(lifecycle.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/toolPresenter/agentTools/agentToolManagerRead.test.ts b/test/main/presenter/toolPresenter/agentTools/agentToolManagerRead.test.ts index 2ca5497e8a..03c260fcbd 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/presenter/toolPresenter/agentTools/subagentOrchestratorTool.test.ts b/test/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.test.ts index 79ffec18c8..680ed23336 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:') diff --git a/test/main/routes/chatService.test.ts b/test/main/routes/chatService.test.ts index cc598a4e9d..8e0d11a398 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,194 +149,121 @@ 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 () => { - 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 () => { + 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.projection.getMessage.mockResolvedValueOnce(createMessage()) - harness.sessionPermissionPort.clearSessionPermissions.mockRejectedValueOnce( - new Error('permission cleanup failed') - ) + harness.scheduler.timeout.mockRejectedValueOnce(cleanupTimeout) - 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.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 () => { + it('bounds timeout cleanup and preserves the original send timeout', async () => { const harness = createHarness() - harness.turn.respondToolInteraction.mockResolvedValueOnce({ - resumed: true, - waitingForUserMessage: false + 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.respondToolInteraction({ - sessionId: 'session-1', - messageId: 'message-1', - toolCallId: 'tool-1', - response: { kind: 'permission', granted: true } - }) - ).resolves.toEqual({ - accepted: true, - resumed: true, - waitingForUserMessage: false - }) + await expect(harness.service.sendMessage('session-1', 'hello')).rejects.toBe(sendTimeout) - expect(harness.turn.respondToolInteraction).toHaveBeenCalledWith( - 'session-1', - 'message-1', - 'tool-1', - { kind: 'permission', granted: true } - ) + expect(harness.turn.cancelGeneration).toHaveBeenCalledWith('session-1') expect(harness.scheduler.timeout).toHaveBeenCalledWith( expect.objectContaining({ - ms: 30 * 60 * 1_000, - reason: 'chat.respondToolInteraction:session-1:tool-1' + ms: 5_000, + reason: 'chat.bestEffortCancel:session-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.turn.cancelGeneration).toHaveBeenCalledWith('session-1') - expect(warn).toHaveBeenCalledOnce() warn.mockRestore() }) - it('releases the send lock after non-timeout failures without cleanup', async () => { + it('resolves stop target from message id when session id is omitted', async () => { const harness = createHarness() - const sendError = new Error('provider failed') - harness.turn.sendMessage - .mockRejectedValueOnce(sendError) - .mockResolvedValueOnce({ requestId: 'request-2', messageId: 'message-2' }) + harness.projection.getMessage.mockResolvedValue(createMessage()) - 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' + await expect(harness.service.stopStream({ requestId: 'message-1' })).resolves.toEqual({ + stopped: true }) - expect(harness.sessionPermissionPort.clearSessionPermissions).not.toHaveBeenCalled() - expect(harness.turn.cancelGeneration).not.toHaveBeenCalled() + expect(harness.projection.getMessage).toHaveBeenCalledWith('message-1') + expect(harness.turn.cancelGeneration).toHaveBeenCalledWith('session-1') }) - 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 every concurrent 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 + 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) @@ -340,55 +274,32 @@ 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 }) - 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') }) - - 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' - }) - }) }) diff --git a/test/main/routes/sessionService.test.ts b/test/main/routes/sessionService.test.ts index b063d6a012..8af935dc8a 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'],