From f0f3f3a98626ec09b0dcbb56cf196f86156928ad Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Thu, 16 Jul 2026 10:02:20 +0800 Subject: [PATCH 01/10] docs(agent): specify subagent capability --- .../subagent-capability-policy/plan.md | 108 ++++++++++ .../subagent-capability-policy/spec.md | 189 ++++++++++++++++++ .../subagent-capability-policy/tasks.md | 15 ++ 3 files changed, 312 insertions(+) create mode 100644 docs/architecture/subagent-capability-policy/plan.md create mode 100644 docs/architecture/subagent-capability-policy/spec.md create mode 100644 docs/architecture/subagent-capability-policy/tasks.md diff --git a/docs/architecture/subagent-capability-policy/plan.md b/docs/architecture/subagent-capability-policy/plan.md new file mode 100644 index 000000000..13e910112 --- /dev/null +++ b/docs/architecture/subagent-capability-policy/plan.md @@ -0,0 +1,108 @@ +# Subagent Capability Policy - Implementation Plan + +## Phase 1: Specify the policy boundary + +- Record Agent configuration as the only persisted delegation policy. +- Define the closed capability, default-on migration matrix, Session compatibility, cache contract, + security boundary, and model delegation guidance. +- Keep run guardrails, host-policy isolation, and Tape lineage as unchanged dependencies. + +## Phase 2: Migrate defaults and enforce Agent invariants + +- Advance `unifiedAgentsMigrationVersion` to 3. +- Migrate only the built-in legacy disabled-empty state to default-on, seed defaults for enabled + empty configurations, and preserve custom disabled policies. +- Mark the version only after all writes succeed so retries are idempotent. +- Validate the merged Agent configuration at repository create/update boundaries. +- Make Settings restore defaults on enable, retain disabled slots, and prevent deletion of the last + enabled slot. +- Cover migration retry, explicit disable preservation, invalid direct writes, and UI behavior. + +## Phase 3: Centralize runtime capability + +- Add one pure capability resolver shared by tool-profile and execution paths. +- Carry the snapshot through the internal tool-definition context and include its canonical + `cacheKey` in the tool profile fingerprint. +- Build the model schema from that snapshot and re-resolve policy before run admission. +- Initially retain the legacy Session boolean as an additional compatibility gate so this phase is + independently correct before the Session surface is removed. +- Reserve and reclassify `subagent_orchestrator` so MCP and generic disabled-tool policy cannot + shadow or independently control it. + +## Phase 4: Retire Session-level state + +- Remove the Session route, application ports, assignment fields, create/transfer/cron/remote + propagation, renderer Session/Draft state, and composer item. +- Remove the compatibility gate from capability admission; Agent policy becomes the only user + policy. +- Keep the database column and legacy reader compatibility while leaving all new rows at the + existing default. +- Prove legacy `0` and `1` values are non-interfering and existing Sessions observe Agent changes on + the next turn. + +## Phase 5: Guide model delegation + +- Update the orchestrator description and default prompt with conservative delegation criteria, + user opt-out precedence, overlapping-write cautions, and cost/latency guidance. +- Update Agent Settings copy without adding another switch. +- Add request-level tool snapshot coverage and configuration-race/active-run integration tests. + +## Phase 6: Validate and finalize contracts + +- Run targeted and full main/renderer suites, native SQLite coverage, typecheck, format, i18n, lint, + and architecture guards. +- Review the complete branch for hidden writes, migration compatibility, cache correctness, + security boundaries, performance, naming, test depth, and maintenance cost. +- Update current session/application and tool-system architecture contracts only for validated + behavior. + +## Commit Strategy + +1. `docs(agent): specify subagent capability` +2. `fix(agent): migrate subagent defaults` +3. `fix(agent): centralize subagent capability` +4. `refactor(session): remove subagent toggle` +5. `fix(agent): guide subagent delegation` +6. `docs(agent): finalize subagent capability` + +Any additional remediation commit must name its concrete behavior. Existing commits are not amended +or rebased. + +## Review Gate + +Before every commit: + +1. Inspect status, the complete unstaged diff/stat/check, and run the smallest sufficient tests. +2. Review P0-P3 for hidden writes, compatibility, migration/retry boundaries, performance, + authorization, naming, test quality, and maintenance cost. +3. Fix every in-scope actionable finding and repeat the review. +4. Stage explicit task paths only; inspect the complete staged diff/stat/check and repeat the same + severity review. +5. Commit only when the staged change has no unrelated file and no actionable P0-P3 finding. + +After implementation, repeat the review over `dev...HEAD`. If a finding requires a new global +policy, database-column removal, child permission change, or external protocol expansion, stop and +request authorization rather than expanding this goal. + +## Compatibility and Rollback + +- Agent config fields remain readable by older versions. +- The default migration changes only JSON config and is idempotent; rollback code can still read + the resulting policy and slots. +- The legacy Session column remains present with its original default, so older database readers do + not fail schema checks. +- Older Session values are ignored by new code and are not destructively rewritten. +- No provider/ACP registry refresh or full build is part of this change. + +## Validation Strategy + +- Repository/config tests: defaults, migration matrix, idempotency, failure retry, write invariant. +- Tool tests: capability reasons, slot schema, cache refresh, call-time revalidation, reserved name, + generic-disabled non-interference, child recursion prevention. +- Session tests: create/transfer/cron/remote inputs, legacy column compatibility, existing Session + Agent-policy refresh, ACP non-interference. +- Renderer tests: no composer/draft toggle, Settings default restoration and final-slot protection. +- Integration tests: actual provider tool snapshot appears/disappears without restart, active runs + retain their admitted snapshot, Tape finalization and child activity remain unchanged. +- Final commands: targeted Vitest suites, `test:main:native-sqlite`, full main and renderer suites, + typecheck, format/check, i18n, and lint. diff --git a/docs/architecture/subagent-capability-policy/spec.md b/docs/architecture/subagent-capability-policy/spec.md new file mode 100644 index 000000000..f2a7ab8ff --- /dev/null +++ b/docs/architecture/subagent-capability-policy/spec.md @@ -0,0 +1,189 @@ +# Subagent Capability Policy - Specification + +> Status: **planned** + +## Problem + +DeepChat currently models Subagent availability through several independent states: + +1. `DeepChatAgentConfig.subagentEnabled` is the Agent-level default. +2. `new_sessions.subagent_enabled` is copied into each regular Session and can be toggled from the + composer. +3. `DeepChatAgentConfig.subagents` must contain at least one valid slot before + `subagent_orchestrator` can be exposed. +4. The runtime tool catalog is cached without the Agent Subagent policy or slot configuration in + its fingerprint. + +These states can disagree. The composer can report that Subagents are enabled while the model never +receives `subagent_orchestrator`, and Agent configuration changes can remain hidden behind a stale +tool catalog. The same capability is also controlled by both a dedicated boolean and the generic +disabled-Agent-tool mechanism. + +## Decision + +Agent configuration is the single persisted policy source for Subagent delegation. The policy is +enabled by default, and the model decides whether a particular task benefits from delegation. The +runtime remains the final authority for availability, slot selection, ownership, permissions, +timeouts, cancellation, and recursion prevention. + +DeepChat keeps one advanced Agent-level opt-out. It removes the Session/Draft/composer toggle and +does not add a global user-facing switch. + +## Capability Contract + +Runtime availability is represented by a closed capability snapshot: + +```ts +type DeepChatSubagentCapability = + | { + available: true + slots: DeepChatSubagentSlot[] + cacheKey: string + } + | { + available: false + reason: 'policy_disabled' | 'unsupported_session' | 'no_valid_slots' + cacheKey: string + } +``` + +The snapshot obeys these invariants: + +1. Only a regular DeepChat parent Session can have an available capability. +2. A Subagent child Session always resolves `unsupported_session` and cannot recurse. +3. An Agent with `subagentEnabled === false` resolves `policy_disabled` even when slots remain + configured. +4. An enabled Agent must have at least one structurally valid, normalized slot. Corrupt enabled + configuration resolves `no_valid_slots` and fails closed. +5. `cacheKey` covers every field that changes the model-visible tool definition: policy state, + slot ID, target type, target Agent ID, display name, and description. +6. Tool-definition construction uses one immutable capability snapshot. Tool execution resolves + the current capability again so a policy disabled between definition and invocation cannot be + bypassed. +7. A run that already passed admission keeps its start-time task and slot snapshot. Later policy + changes affect future calls, not active runs; users cancel active runs through the existing run + controls. + +Target-Agent existence and host-policy validity remain call-time checks. A stale target fails before +the child Session is created and cannot create a Tape link. + +## Agent Configuration and Migration + +`DeepChatAgentConfig.subagentEnabled` and `DeepChatAgentConfig.subagents` remain compatible stored +fields. Their meaning is Agent delegation policy and reusable child slots, not Session state. + +New built-in and custom Agent configurations default to `subagentEnabled = true` with Explorer, +Implementer, and Reviewer self-target slots. + +The unified Agent configuration migration advances to version 3 with this matrix: + +| Stored configuration | Migration result | +| --- | --- | +| Built-in `deepchat`: `false` and zero valid slots | `true` plus default slots | +| Any Agent: enabled and zero valid slots | enabled plus default slots | +| Custom Agent: disabled and zero valid slots | unchanged | +| Any Agent: disabled with configured slots | unchanged; slots are retained | +| Missing policy or slots | existing default normalization remains `true` plus default slots | + +The migration is idempotent and records version 3 only after all Agent updates succeed. A failure +leaves the version unchanged so startup can retry safely. + +After migration, Agent writes enforce `enabled => at least one valid slot` at the main-process +repository boundary. The Settings UI restores defaults when enabling an empty legacy form and does +not allow the final slot to be removed while enabled. Disabling an Agent preserves its slots. + +## Tool Exposure and Security + +`subagent_orchestrator` is a dedicated system/model capability rather than an ordinary +user-configurable Agent tool: + +- the Agent-level policy is its only user-controlled hard gate; +- generic `disabledAgentTools` cannot create a second policy source; +- it is absent from the generic tool toggle catalog; +- its name is reserved so an MCP server cannot shadow the privileged built-in orchestrator; +- execution revalidates regular-parent ownership and the current Agent policy before admitting a + new run. + +Existing child permission inheritance, cross-Agent host-policy isolation, direct ACP target +behavior, maximum run count, task count, deadlines, cancellation settlement, and Tape link +finalization do not change. + +## Session and UI Compatibility + +The Session-level capability surface is retired: + +- Session create, detached create, transfer, cron, remote, and draft inputs no longer accept or + propagate `subagentEnabled`; +- `sessions.setSubagentEnabled` is removed; +- renderer Session/Draft stores and the synthetic composer `subagent` item are removed; +- Agent Settings remains the only configuration surface; +- Subagent progress cards, child navigation, logs, wait/kill controls, and cancellation remain. + +The physical `new_sessions.subagent_enabled` column stays in the current database schema for +rollback and old-database compatibility. New code leaves it at the database default and never uses +it to authorize the capability. Legacy imports may contain the field; readers tolerate and ignore +it instead of reintroducing Session policy. Current exports and renderer contracts do not present +it as meaningful state. + +Existing Sessions resolve the current Agent policy on the next tool-catalog resolution. They do not +need to be recreated. A legacy Session value of either `0` or `1` has no effect after retirement. + +## Model Delegation Policy + +When available, the model may delegate only when the expected benefit exceeds the extra +coordination, token, latency, and resource cost. Tool and default system guidance must require: + +- respecting an explicit user request to use or avoid Subagents; +- using delegation for genuinely independent, isolatable, or parallel work; +- avoiding delegation for simple, latency-sensitive, or strongly sequential tasks; +- avoiding concurrent write-heavy tasks that can modify overlapping files; +- preferring bounded prompts and observable validation results from each child. + +The Agent-level switch is a hard policy. A one-turn natural-language instruction is model guidance +and does not create another persisted state source. + +## Performance Constraints + +- Capability resolution performs a bounded Agent-config read and one Session-kind lookup per tool + profile resolution; it does not scan Sessions or Agents. +- Slot normalization remains bounded by `DEEPCHAT_SUBAGENT_SLOT_LIMIT`. +- A stable capability cache key invalidates only when model-visible policy changes. +- No event-only cache invalidation is relied upon for correctness. +- Default exposure adds only the existing orchestrator schema; no discovery tool or additional + model round trip is introduced. + +## Acceptance Criteria + +1. A new or migrated built-in DeepChat Agent exposes `subagent_orchestrator` in a regular Session + without a Session toggle. +2. An Agent-level disable removes the tool from existing Sessions on their next turn without an app + restart. +3. Slot changes update the tool enum and description on the next turn despite a previously cached + catalog. +4. Enabled configuration cannot persist with zero valid slots; disabled configuration retains its + slots. +5. Legacy `subagent_enabled` values do not affect availability, and the physical column remains + schema-compatible. +6. Child Sessions cannot receive or invoke `subagent_orchestrator`. +7. A policy change between tool definition and invocation fails closed, while an admitted active + run is not silently cancelled. +8. An MCP server cannot shadow `subagent_orchestrator`, and generic disabled-tool state cannot + override the dedicated policy. +9. Composer and New Thread UI contain no Subagent toggle; Agent Settings contains the default-on + policy with cost guidance. +10. Existing run guardrails, host-policy isolation, Tape lineage, cross-Tape recall, ACP behavior, + and child activity UI remain compatible. + +## Non-goals + +- A global or enterprise-managed Subagent policy. +- A per-turn hard-disable UI or a three-state automatic/explicit/off policy. +- Recursive Subagents or a change to concurrency, task, timeout, or permission limits. +- Changing slot target model selection or child workspace inheritance. +- Removing the legacy database column in this change. +- Adding a new model tool, lineage UI, or provider build refresh. +- Syncing a GitHub issue or creating a PR. + +## Open Questions + +None. diff --git a/docs/architecture/subagent-capability-policy/tasks.md b/docs/architecture/subagent-capability-policy/tasks.md new file mode 100644 index 000000000..93caf8be5 --- /dev/null +++ b/docs/architecture/subagent-capability-policy/tasks.md @@ -0,0 +1,15 @@ +# Subagent Capability Policy - Tasks + +- [x] SCP-001: Specify the Agent policy, capability, migration, cache, compatibility, and security + contracts. +- [ ] SCP-002: Add the default-on migration and enforce enabled-slot write invariants. +- [ ] SCP-003: Protect the enabled Settings form from empty slot configuration. +- [ ] SCP-004: Add a typed capability snapshot and canonical cache key. +- [ ] SCP-005: Build and execute `subagent_orchestrator` from the centralized capability. +- [ ] SCP-006: Reserve the orchestrator name and remove generic disabled-tool control. +- [ ] SCP-007: Remove Session/Draft capability inputs, state, route, and composer UI. +- [ ] SCP-008: Preserve legacy database/import compatibility without authorizing from Session data. +- [ ] SCP-009: Add conservative delegation guidance and Agent Settings cost copy. +- [ ] SCP-010: Cover migration, cache, runtime, Session, renderer, ACP, and active-run boundaries. +- [ ] SCP-011: Complete cumulative P0-P3 review and full validation. +- [ ] SCP-012: Update maintained architecture contracts with validated behavior. From 7cc318ccf207870ec8c5c0f888639d47b780a696 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Thu, 16 Jul 2026 10:10:19 +0800 Subject: [PATCH 02/10] fix(agent): migrate subagent defaults --- .../agent/deepchat/deepChatAgentRepository.ts | 17 +- src/main/presenter/configPresenter/index.ts | 54 ++++- .../components/DeepChatAgentsSettings.vue | 11 +- src/shared/lib/deepchatSubagents.ts | 10 + .../deepchat/deepChatAgentRepository.test.ts | 124 +++++++++++ .../deprecatedProviderCleanup.test.ts | 194 +++++++++++++++++- .../components/DeepChatAgentsSettings.test.ts | 95 +++++++++ 7 files changed, 487 insertions(+), 18 deletions(-) diff --git a/src/main/agent/deepchat/deepChatAgentRepository.ts b/src/main/agent/deepchat/deepChatAgentRepository.ts index 99ae807cd..de5a8bb0a 100644 --- a/src/main/agent/deepchat/deepChatAgentRepository.ts +++ b/src/main/agent/deepchat/deepChatAgentRepository.ts @@ -4,6 +4,7 @@ import type { AppSessionId } from '@/agent/shared/agentSessionIds' import { normalizeDisabledAgentTools } from '@/agent/shared/agentSessionNormalization' import type { AgentRow } from '@/presenter/sqlitePresenter/tables/agents' import { + assertDeepChatSubagentConfigInvariant, createDefaultDeepChatSubagentSlots, normalizeDeepChatSubagentConfig } from '@shared/lib/deepchatSubagents' @@ -54,6 +55,12 @@ const normalizeExplicitDisabledAgentTools = (config: DeepChatAgentConfig): DeepC } } +const prepareConfigWrite = (config: DeepChatAgentConfig): DeepChatAgentConfig => { + const normalized = normalizeExplicitDisabledAgentTools(config) + assertDeepChatSubagentConfigInvariant(normalized) + return normalized +} + const normalizeNullableStringList = ( value: string[] | null | undefined ): string[] | null | undefined => { @@ -137,9 +144,7 @@ export class DeepChatAgentRepository { protected: true, icon: sanitizeString(defaults?.icon), avatarJson: stringifyJson(defaults?.avatar ?? null), - configJson: stringifyJson( - defaults?.config ? normalizeExplicitDisabledAgentTools(defaults.config) : null - ) + configJson: stringifyJson(defaults?.config ? prepareConfigWrite(defaults.config) : null) }) } else { rows.update(BUILTIN_DEEPCHAT_AGENT_ID, { enabled: true, protected: true }) @@ -159,9 +164,7 @@ export class DeepChatAgentRepository { description: sanitizeString(input.description), icon: sanitizeString(input.icon), avatarJson: stringifyJson(input.avatar ?? null), - configJson: stringifyJson( - input.config ? normalizeExplicitDisabledAgentTools(input.config) : null - ) + configJson: stringifyJson(input.config ? prepareConfigWrite(input.config) : null) }) return this.dependencies.rows.get(id) as AgentRow } @@ -175,7 +178,7 @@ export class DeepChatAgentRepository { const nextConfig = updates.config === undefined ? currentConfig - : normalizeExplicitDisabledAgentTools({ + : prepareConfigWrite({ ...currentConfig, ...clone(updates.config ?? {}) }) diff --git a/src/main/presenter/configPresenter/index.ts b/src/main/presenter/configPresenter/index.ts index a4ecc75de..38654ea6a 100644 --- a/src/main/presenter/configPresenter/index.ts +++ b/src/main/presenter/configPresenter/index.ts @@ -75,7 +75,11 @@ import { AcpLaunchSpecService } from '@/agent/acp/launch/acpLaunchSpecService' import { AcpProvider } from '../llmProviderPresenter/providers/acpProvider' import { resolveAcpAgentAlias } from '@shared/utils/acpAgentAlias' import { AgentRepository, BUILTIN_DEEPCHAT_AGENT_ID } from '../agentRepository' -import { normalizeDeepChatSubagentConfig } from '@shared/lib/deepchatSubagents' +import { + createDefaultDeepChatSubagentSlots, + normalizeDeepChatSubagentConfig, + normalizeDeepChatSubagentSlots +} from '@shared/lib/deepchatSubagents' import type { SQLitePresenter } from '../sqlitePresenter' import type { SettingsKey, SettingsSnapshotValues } from '@shared/contracts/routes' import { publishDeepchatEvent } from '@/routes/publishDeepchatEvent' @@ -175,7 +179,7 @@ const defaultProviders = DEFAULT_PROVIDERS.map((provider) => ({ })) const PROVIDERS_STORE_KEY = 'providers' -const UNIFIED_AGENTS_MIGRATION_VERSION = 2 +const UNIFIED_AGENTS_MIGRATION_VERSION = 3 const DEPRECATED_BUILTIN_PROVIDER_IDS = ['qwenlm', 'laoshi'] as const type AnthropicLegacyProvider = LLM_PROVIDER & { authMode?: 'apikey' | 'oauth' } type ModelSelection = { providerId: string; modelId: string } @@ -931,7 +935,8 @@ export class ConfigPresenter implements IConfigPresenter { config: this.buildLegacyBuiltinDeepChatConfig() }) - let migratedVersion = this.getSetting('unifiedAgentsMigrationVersion') ?? 0 + const storedMigrationVersion = this.getSetting('unifiedAgentsMigrationVersion') ?? 0 + let migratedVersion = storedMigrationVersion let registryAgentsSynced = false if (migratedVersion < 1) { this.acpCatalogConfigAdapter.getManualAgents().forEach((agent) => { @@ -946,6 +951,36 @@ export class ConfigPresenter implements IConfigPresenter { migratedVersion = 1 } + const shouldMigrateSubagentDefaults = migratedVersion < 3 + if (shouldMigrateSubagentDefaults) { + for (const agent of repository.listAgents({ agentType: 'deepchat' })) { + const config = repository.getDeepChatAgentConfig(agent.id) + if (!config) { + continue + } + + const slots = normalizeDeepChatSubagentSlots(config.subagents) + const isBuiltinLegacyDisabledEmpty = + agent.id === BUILTIN_DEEPCHAT_AGENT_ID && + config.subagentEnabled === false && + slots.length === 0 + const isEnabledEmpty = config.subagentEnabled !== false && slots.length === 0 + if (!isBuiltinLegacyDisabledEmpty && !isEnabledEmpty) { + continue + } + + const updated = repository.updateDeepChatAgent(agent.id, { + config: { + ...(isBuiltinLegacyDisabledEmpty ? { subagentEnabled: true } : {}), + subagents: createDefaultDeepChatSubagentSlots() + } + }) + if (!updated) { + throw new Error(`Failed to migrate DeepChat Agent ${agent.id} Subagent defaults.`) + } + } + } + if (migratedVersion < 2) { for (const agent of repository.listAgents({ agentType: 'deepchat' })) { const config = repository.getDeepChatAgentConfig(agent.id) ?? {} @@ -959,11 +994,22 @@ export class ConfigPresenter implements IConfigPresenter { disabledAgentTools.length !== config.disabledAgentTools.length || disabledAgentTools.some((tool) => !config.disabledAgentTools?.includes(tool)) ) { - repository.updateDeepChatAgent(agent.id, { + const updated = repository.updateDeepChatAgent(agent.id, { config: { disabledAgentTools } }) + if (!updated) { + throw new Error(`Failed to migrate DeepChat Agent ${agent.id} tool defaults.`) + } } } + migratedVersion = 2 + } + + if (shouldMigrateSubagentDefaults) { + migratedVersion = 3 + } + + if (migratedVersion !== storedMigrationVersion) { this.store.set('unifiedAgentsMigrationVersion', UNIFIED_AGENTS_MIGRATION_VERSION) } diff --git a/src/renderer/settings/components/DeepChatAgentsSettings.vue b/src/renderer/settings/components/DeepChatAgentsSettings.vue index 671ac3e89..deecbe645 100644 --- a/src/renderer/settings/components/DeepChatAgentsSettings.vue +++ b/src/renderer/settings/components/DeepChatAgentsSettings.vue @@ -426,7 +426,7 @@ @@ -446,6 +446,7 @@ variant="ghost" size="sm" class="h-7 px-2 text-xs" + :disabled="form.subagentEnabled && form.subagents.length <= 1" @click="removeSubagentSlot(index)" > {{ t('common.delete') }} @@ -1371,8 +1372,14 @@ const addSubagentSlot = () => { description: '' }) } +const setSubagentEnabled = (enabled: boolean) => { + if (enabled && normalizeDeepChatSubagentSlots(form.subagents).length === 0) { + form.subagents = normalizeDeepChatSubagentSlots(createDefaultDeepChatSubagentSlots()) + } + form.subagentEnabled = enabled +} const removeSubagentSlot = (index: number) => { - if (!form.subagents[index]) { + if (!form.subagents[index] || (form.subagentEnabled && form.subagents.length <= 1)) { return } diff --git a/src/shared/lib/deepchatSubagents.ts b/src/shared/lib/deepchatSubagents.ts index 7a5465b79..9bcbeefa6 100644 --- a/src/shared/lib/deepchatSubagents.ts +++ b/src/shared/lib/deepchatSubagents.ts @@ -130,3 +130,13 @@ export const normalizeDeepChatSubagentConfig = ( : createDefaultDeepChatSubagentSlots() } } + +export const assertDeepChatSubagentConfigInvariant = (config: DeepChatAgentConfig): void => { + const normalized = normalizeDeepChatSubagentConfig(config) + if ( + normalized.subagentEnabled !== false && + normalizeDeepChatSubagentSlots(normalized.subagents).length === 0 + ) { + throw new Error('Enabled DeepChat Subagents require at least one valid slot.') + } +} diff --git a/test/main/agent/deepchat/deepChatAgentRepository.test.ts b/test/main/agent/deepchat/deepChatAgentRepository.test.ts index 8354bcbf5..6f1c70d61 100644 --- a/test/main/agent/deepchat/deepChatAgentRepository.test.ts +++ b/test/main/agent/deepchat/deepChatAgentRepository.test.ts @@ -17,6 +17,52 @@ function createRepository(sqlitePresenter: any): DeepChatAgentRepository { }) } +function createMutableRepository(initialRows: any[] = []) { + const rows = new Map(initialRows.map((row) => [row.id, row])) + const agentsTable = { + get: (id: string) => rows.get(id), + list: () => [...rows.values()], + create: (input: any) => { + const now = Date.now() + rows.set(input.id, { + id: input.id, + agent_type: input.agentType, + source: input.source, + name: input.name, + enabled: input.enabled ? 1 : 0, + protected: input.protected ? 1 : 0, + description: input.description ?? null, + icon: input.icon ?? null, + avatar_json: input.avatarJson ?? null, + config_json: input.configJson ?? null, + state_json: null, + created_at: now, + updated_at: Date.now() + }) + }, + update: (id: string, input: any) => { + const current = rows.get(id) + if (!current) return + rows.set(id, { + ...current, + name: input.name ?? current.name, + enabled: input.enabled === undefined ? current.enabled : input.enabled ? 1 : 0, + description: input.description === undefined ? current.description : input.description, + icon: input.icon === undefined ? current.icon : input.icon, + avatar_json: input.avatarJson === undefined ? current.avatar_json : input.avatarJson, + config_json: input.configJson === undefined ? current.config_json : input.configJson, + updated_at: Date.now() + }) + }, + delete: (id: string) => rows.delete(id) + } + + return { + repository: createRepository({ agentsTable }), + rows + } +} + describe('DeepChatAgentRepository', () => { it('deletes DeepChat agent memory rows and the agent row in one transaction', () => { const agents = new Map([ @@ -328,6 +374,84 @@ describe('DeepChatAgentRepository', () => { expect(config.subagents?.every((slot) => slot.targetType === 'self')).toBe(true) }) + it('rejects enabled DeepChat Agent writes without a valid Subagent slot', () => { + const { repository, rows } = createMutableRepository() + + expect(() => + repository.create({ + name: 'Invalid Writer', + config: { subagentEnabled: true, subagents: [] } + }) + ).toThrow('Enabled DeepChat Subagents require at least one valid slot.') + expect(rows.size).toBe(0) + + const created = repository.create({ + name: 'Writer', + config: { + subagentEnabled: true, + subagents: [ + { + id: 'reviewer', + targetType: 'self', + displayName: 'Reviewer', + description: '' + } + ] + } + }) + const previousConfig = rows.get(created.id)?.config_json + + expect(() => + repository.update(created.id, { + config: { + subagents: [ + { + id: 'invalid-target', + targetType: 'agent', + targetAgentId: ' ', + displayName: 'Invalid', + description: '' + } + ] + } + }) + ).toThrow('Enabled DeepChat Subagents require at least one valid slot.') + expect(rows.get(created.id)?.config_json).toBe(previousConfig) + }) + + it('allows disabled DeepChat Agents to retain empty or configured Subagent slots', () => { + const { repository } = createMutableRepository() + const disabledEmpty = repository.create({ + name: 'Disabled Empty', + config: { subagentEnabled: false, subagents: [] } + }) + const configured = repository.create({ + name: 'Configured', + config: { + subagentEnabled: true, + subagents: [ + { + id: 'explorer', + targetType: 'self', + displayName: 'Explorer', + description: '' + } + ] + } + }) + + repository.update(configured.id, { config: { subagentEnabled: false } }) + + expect(repository.getConfig(disabledEmpty.id)).toMatchObject({ + subagentEnabled: false, + subagents: [] + }) + expect(repository.getConfig(configured.id)).toMatchObject({ + subagentEnabled: false, + subagents: [expect.objectContaining({ id: 'explorer' })] + }) + }) + it('keeps non-configurable Tape names out of persisted and resolved Agent configs', () => { const rows = new Map() const agentsTable = { diff --git a/test/main/presenter/configPresenter/deprecatedProviderCleanup.test.ts b/test/main/presenter/configPresenter/deprecatedProviderCleanup.test.ts index 27f4403dc..c0a96764e 100644 --- a/test/main/presenter/configPresenter/deprecatedProviderCleanup.test.ts +++ b/test/main/presenter/configPresenter/deprecatedProviderCleanup.test.ts @@ -229,6 +229,12 @@ describe('cleanupDeprecatedBuiltinAgentSelections', () => { describe('initializeUnifiedAgents', () => { it('seeds default disabled tools into existing explicit agent configs once', () => { + const configuredSlot = { + id: 'explorer', + targetType: 'self' as const, + displayName: 'Explorer', + description: '' + } const repository = { ensureBuiltinDeepChatAgent: vi.fn(), listAgents: vi.fn(() => [ @@ -238,12 +244,12 @@ describe('initializeUnifiedAgents', () => { ]), getDeepChatAgentConfig: vi.fn((agentId: string) => agentId === BUILTIN_DEEPCHAT_AGENT_ID - ? { disabledAgentTools: ['tool-a'] } + ? { disabledAgentTools: ['tool-a'], subagents: [configuredSlot] } : agentId === 'deepchat-custom' - ? { disabledAgentTools: [] } - : {} + ? { disabledAgentTools: [], subagents: [configuredSlot] } + : { subagents: [configuredSlot] } ), - updateDeepChatAgent: vi.fn() + updateDeepChatAgent: vi.fn(() => ({})) } const presenter = Object.assign(Object.create(ConfigPresenter.prototype), { getAgentRepositoryOrThrow: vi.fn(() => repository), @@ -266,7 +272,185 @@ describe('initializeUnifiedAgents', () => { config: { disabledAgentTools: [CRON_JOB_AGENT_TOOL_NAME] } }) expect(repository.updateDeepChatAgent).toHaveBeenCalledTimes(2) - expect(presenter.store.set).toHaveBeenCalledWith('unifiedAgentsMigrationVersion', 2) + expect(presenter.store.set).toHaveBeenCalledWith('unifiedAgentsMigrationVersion', 3) + expect(presenter.syncRegistryAgentsToRepository).toHaveBeenCalledTimes(1) + }) + + it('repairs enabled-empty policies before older tool-default writes', () => { + let config = { + subagentEnabled: true, + subagents: [] as Array<{ + id: string + targetType: 'self' + displayName: string + description: string + }>, + disabledAgentTools: [] as string[] + } + const repository = { + ensureBuiltinDeepChatAgent: vi.fn(), + listAgents: vi.fn(() => [{ id: BUILTIN_DEEPCHAT_AGENT_ID }]), + getDeepChatAgentConfig: vi.fn(() => config), + updateDeepChatAgent: vi.fn( + (_agentId: string, updates: { config: Partial }) => { + if ( + updates.config.disabledAgentTools && + config.subagentEnabled && + config.subagents.length === 0 + ) { + throw new Error('repository invariant rejected enabled-empty config') + } + config = { ...config, ...updates.config } + return {} + } + ) + } + const presenter = Object.assign(Object.create(ConfigPresenter.prototype), { + getAgentRepositoryOrThrow: vi.fn(() => repository), + buildLegacyBuiltinDeepChatConfig: vi.fn(() => ({})), + getSetting: vi.fn(() => 1), + store: { set: vi.fn() }, + syncRegistryAgentsToRepository: vi.fn() + }) + + const initialize = () => { + ;( + presenter as ConfigPresenter & { + initializeUnifiedAgents(): void + } + ).initializeUnifiedAgents() + } + + expect(initialize).not.toThrow() + + expect(repository.updateDeepChatAgent).toHaveBeenNthCalledWith(1, BUILTIN_DEEPCHAT_AGENT_ID, { + config: { subagents: expect.any(Array) } + }) + expect(repository.updateDeepChatAgent).toHaveBeenNthCalledWith(2, BUILTIN_DEEPCHAT_AGENT_ID, { + config: { disabledAgentTools: [CRON_JOB_AGENT_TOOL_NAME] } + }) + expect(presenter.store.set).toHaveBeenCalledWith('unifiedAgentsMigrationVersion', 3) + }) + + it('migrates only builtin disabled-empty and enabled-empty Subagent policies', () => { + const configuredSlot = { + id: 'reviewer', + targetType: 'self' as const, + displayName: 'Reviewer', + description: '' + } + const configs = new Map([ + [BUILTIN_DEEPCHAT_AGENT_ID, { subagentEnabled: false, subagents: [] }], + ['enabled-empty', { subagentEnabled: true, subagents: [] }], + ['disabled-empty', { subagentEnabled: false, subagents: [] }], + ['disabled-configured', { subagentEnabled: false, subagents: [configuredSlot] }] + ]) + const repository = { + ensureBuiltinDeepChatAgent: vi.fn(), + listAgents: vi.fn(() => [...configs.keys()].map((id) => ({ id }))), + getDeepChatAgentConfig: vi.fn((agentId: string) => configs.get(agentId)), + updateDeepChatAgent: vi.fn(() => ({})) + } + const presenter = Object.assign(Object.create(ConfigPresenter.prototype), { + getAgentRepositoryOrThrow: vi.fn(() => repository), + buildLegacyBuiltinDeepChatConfig: vi.fn(() => ({})), + getSetting: vi.fn(() => 2), + store: { set: vi.fn() }, + syncRegistryAgentsToRepository: vi.fn() + }) + + ;( + presenter as ConfigPresenter & { + initializeUnifiedAgents(): void + } + ).initializeUnifiedAgents() + + expect(repository.updateDeepChatAgent).toHaveBeenCalledTimes(2) + expect(repository.updateDeepChatAgent).toHaveBeenCalledWith(BUILTIN_DEEPCHAT_AGENT_ID, { + config: { + subagentEnabled: true, + subagents: expect.arrayContaining([ + expect.objectContaining({ id: 'explorer' }), + expect.objectContaining({ id: 'implementer' }), + expect.objectContaining({ id: 'reviewer' }) + ]) + } + }) + expect(repository.updateDeepChatAgent).toHaveBeenCalledWith('enabled-empty', { + config: { + subagents: expect.arrayContaining([ + expect.objectContaining({ id: 'explorer' }), + expect.objectContaining({ id: 'implementer' }), + expect.objectContaining({ id: 'reviewer' }) + ]) + } + }) + expect(repository.updateDeepChatAgent).not.toHaveBeenCalledWith( + 'disabled-empty', + expect.anything() + ) + expect(repository.updateDeepChatAgent).not.toHaveBeenCalledWith( + 'disabled-configured', + expect.anything() + ) + expect(presenter.store.set).toHaveBeenCalledWith('unifiedAgentsMigrationVersion', 3) + }) + + it('records migration v3 only after all Agent updates succeed and can retry', () => { + const repository = { + ensureBuiltinDeepChatAgent: vi.fn(), + listAgents: vi.fn(() => [{ id: 'enabled-empty' }]), + getDeepChatAgentConfig: vi.fn(() => ({ subagentEnabled: true, subagents: [] })), + updateDeepChatAgent: vi.fn().mockReturnValueOnce(null).mockReturnValue({}) + } + const store = { set: vi.fn() } + const presenter = Object.assign(Object.create(ConfigPresenter.prototype), { + getAgentRepositoryOrThrow: vi.fn(() => repository), + buildLegacyBuiltinDeepChatConfig: vi.fn(() => ({})), + getSetting: vi.fn(() => 2), + store, + syncRegistryAgentsToRepository: vi.fn() + }) + const initialize = () => { + ;( + presenter as ConfigPresenter & { + initializeUnifiedAgents(): void + } + ).initializeUnifiedAgents() + } + + expect(initialize).toThrow('Failed to migrate DeepChat Agent enabled-empty Subagent defaults.') + expect(store.set).not.toHaveBeenCalled() + + expect(initialize).not.toThrow() + expect(repository.updateDeepChatAgent).toHaveBeenCalledTimes(2) + expect(store.set).toHaveBeenCalledWith('unifiedAgentsMigrationVersion', 3) + }) + + it('does not rerun unified Agent migrations after version 3', () => { + const repository = { + ensureBuiltinDeepChatAgent: vi.fn(), + listAgents: vi.fn(), + getDeepChatAgentConfig: vi.fn(), + updateDeepChatAgent: vi.fn() + } + const presenter = Object.assign(Object.create(ConfigPresenter.prototype), { + getAgentRepositoryOrThrow: vi.fn(() => repository), + buildLegacyBuiltinDeepChatConfig: vi.fn(() => ({})), + getSetting: vi.fn(() => 3), + store: { set: vi.fn() }, + syncRegistryAgentsToRepository: vi.fn() + }) + + ;( + presenter as ConfigPresenter & { + initializeUnifiedAgents(): void + } + ).initializeUnifiedAgents() + + expect(repository.listAgents).not.toHaveBeenCalled() + expect(repository.updateDeepChatAgent).not.toHaveBeenCalled() + expect(presenter.store.set).not.toHaveBeenCalled() expect(presenter.syncRegistryAgentsToRepository).toHaveBeenCalledTimes(1) }) }) diff --git a/test/renderer/components/DeepChatAgentsSettings.test.ts b/test/renderer/components/DeepChatAgentsSettings.test.ts index 6f42c3ca4..81dd41242 100644 --- a/test/renderer/components/DeepChatAgentsSettings.test.ts +++ b/test/renderer/components/DeepChatAgentsSettings.test.ts @@ -1692,6 +1692,101 @@ describe('DeepChatAgentsSettings', () => { expect(payload.config).not.toHaveProperty('assistantModel') }) + it('restores default Subagent slots when enabling an empty legacy policy', async () => { + const existingAgent = { + id: 'deepchat', + type: 'deepchat', + name: 'DeepChat', + enabled: true, + protected: true, + description: '', + avatar: null, + config: { subagentEnabled: false, subagents: [] } + } + const { wrapper, configPresenter } = await mountSettings({ agents: [existingAgent] }) + const subagentSwitch = wrapper.get('[aria-label="settings.deepchatAgents.subagentsEnabled"]') + + expect(subagentSwitch.attributes('data-model-value')).toBe('false') + expect(wrapper.findAll('select')).toHaveLength(0) + + await subagentSwitch.trigger('click') + + expect(subagentSwitch.attributes('data-model-value')).toBe('true') + expect(wrapper.findAll('select')).toHaveLength(3) + expect(wrapper.text()).toContain('explorer') + expect(wrapper.text()).toContain('implementer') + expect(wrapper.text()).toContain('reviewer') + + const saveButton = wrapper + .findAll('button') + .find((button) => button.text().includes('common.save')) + await saveButton!.trigger('click') + await flushPromises() + + expect(configPresenter.updateDeepChatAgent).toHaveBeenCalledWith( + 'deepchat', + expect.objectContaining({ + config: { + subagentEnabled: true, + subagents: [ + expect.objectContaining({ id: 'explorer', targetType: 'self' }), + expect.objectContaining({ id: 'implementer', targetType: 'self' }), + expect.objectContaining({ id: 'reviewer', targetType: 'self' }) + ] + } + }) + ) + }) + + it('protects the final enabled Subagent slot and retains slots while disabled', async () => { + const existingAgent = { + id: 'deepchat', + type: 'deepchat', + name: 'DeepChat', + enabled: true, + protected: true, + description: '', + avatar: null, + config: { + subagentEnabled: true, + subagents: [ + { + id: 'reviewer', + targetType: 'self', + displayName: 'Reviewer', + description: '' + } + ] + } + } + const { wrapper, configPresenter } = await mountSettings({ agents: [existingAgent] }) + const deleteSlotButton = wrapper + .findAll('button') + .find((button) => button.text() === 'common.delete') + + expect(deleteSlotButton).toBeDefined() + expect(deleteSlotButton!.attributes('disabled')).toBeDefined() + await deleteSlotButton!.trigger('click') + expect(wrapper.findAll('select')).toHaveLength(1) + + const subagentSwitch = wrapper.get('[aria-label="settings.deepchatAgents.subagentsEnabled"]') + await subagentSwitch.trigger('click') + + expect(subagentSwitch.attributes('data-model-value')).toBe('false') + expect(wrapper.findAll('select')).toHaveLength(1) + + const saveButton = wrapper + .findAll('button') + .find((button) => button.text().includes('common.save')) + await saveButton!.trigger('click') + await flushPromises() + + expect(configPresenter.updateDeepChatAgent).toHaveBeenCalledWith( + 'deepchat', + expect.objectContaining({ config: { subagentEnabled: false } }) + ) + }) + it('uses a flat target agent select for subagent slots', async () => { vi.resetModules() From 2a35117769d4676fb69a7fb92056f0cf1e7d7d1c Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Thu, 16 Jul 2026 10:30:12 +0800 Subject: [PATCH 03/10] fix(agent): centralize subagent capability --- .../agentRuntimePresenter/toolResolver.ts | 83 +++++-- src/main/presenter/index.ts | 20 +- .../agentTools/agentToolManager.ts | 15 +- .../agentTools/subagentOrchestratorTool.ts | 47 ++-- src/main/presenter/toolPresenter/index.ts | 5 +- .../presenter/toolPresenter/runtimePorts.ts | 5 +- src/shared/agentTools.ts | 25 ++- src/shared/lib/deepchatSubagents.ts | 55 ++++- src/shared/types/agent-interface.d.ts | 12 + .../types/presenters/tool.presenter.d.ts | 3 +- .../deepchat/deepChatAgentRepository.test.ts | 10 +- .../toolResolver.test.ts | 209 ++++++++++++++++++ .../agentTools/agentMemoryTools.test.ts | 9 +- .../agentTools/agentTapeTools.test.ts | 9 +- .../agentToolManagerSettings.test.ts | 23 +- .../subagentOrchestratorTool.test.ts | 103 +++++---- .../toolPresenter/toolPresenter.test.ts | 65 ++++++ test/main/shared/deepchatSubagents.test.ts | 73 ++++++ 18 files changed, 625 insertions(+), 146 deletions(-) create mode 100644 test/main/presenter/agentRuntimePresenter/toolResolver.test.ts create mode 100644 test/main/shared/deepchatSubagents.test.ts diff --git a/src/main/presenter/agentRuntimePresenter/toolResolver.ts b/src/main/presenter/agentRuntimePresenter/toolResolver.ts index 3208a8e80..04148245b 100644 --- a/src/main/presenter/agentRuntimePresenter/toolResolver.ts +++ b/src/main/presenter/agentRuntimePresenter/toolResolver.ts @@ -1,7 +1,7 @@ import type { IConfigPresenter, ISkillPresenter } from '@shared/presenter' import type { MCPToolDefinition } from '@shared/types/core/mcp' import type { IToolPresenter } from '@shared/types/presenters/tool.presenter' -import type { DeepChatSessionState } from '@shared/types/agent-interface' +import type { DeepChatAgentConfig, DeepChatSessionState } from '@shared/types/agent-interface' import type { SQLitePresenter } from '../sqlitePresenter' import type { DeepChatAgentInstance, @@ -15,6 +15,8 @@ import { type AgentExtensionPolicy } from '@/agent/deepchat/resources/systemPromptBuilder' import { createToolCatalogPort } from './toolAdapters' +import { resolveDeepChatSubagentCapability } from '@shared/lib/deepchatSubagents' +import type { DeepChatSubagentCapability } from '@shared/types/agent-interface' type ToolResolverSkillPort = Pick @@ -68,16 +70,18 @@ export class DeepChatToolResolver { resourceInstance.getAgentId()?.trim() || this.dependencies.getSessionAgentId(sessionId) || 'deepchat' - const policy = await this.resolveAgentExtensionPolicy(sessionId, resourceInstance) + const toolPolicy = await this.resolveAgentToolPolicy(sessionId, resourceInstance) + const policy = toolPolicy.extensionPolicy const effectiveActiveSkillNames = activeSkillNamesOverride === undefined - ? await this.resolveActiveSkillNamesForToolProfile(sessionId, resourceInstance) + ? await this.resolveActiveSkillNamesForToolProfile(sessionId, resourceInstance, policy) : filterSkillNamesByPolicy(activeSkillNamesOverride, policy) - const profile = await this.resolveToolProfile( + const profile = this.resolveToolProfile( sessionId, projectDir, effectiveActiveSkillNames, policy, + toolPolicy.subagentCapability, resourceInstance ) this.dependencies.assertCurrent(sessionId, resourceInstance) @@ -94,6 +98,7 @@ export class DeepChatToolResolver { conversationId: sessionId, agentWorkspacePath: projectDir, activeSkillNames: effectiveActiveSkillNames, + subagentCapability: toolPolicy.subagentCapability, ...(enabledMcpServerIds === undefined ? {} : { enabledMcpServerIds }) } } @@ -127,22 +132,17 @@ export class DeepChatToolResolver { } } - private async resolveToolProfile( + private resolveToolProfile( sessionId: string, projectDir: string | null, - activeSkillNamesOverride?: string[], - extensionPolicy?: AgentExtensionPolicy, + activeSkillNamesOverride: string[], + extensionPolicy: AgentExtensionPolicy, + subagentCapability: DeepChatSubagentCapability, resourceInstance?: DeepChatAgentInstance - ): Promise<{ kind: DeepChatToolProfileKind; fingerprint: string }> { + ): { kind: DeepChatToolProfileKind; fingerprint: string } { const normalizedProjectDir = projectDir?.trim() || null const skillsEnabled = this.dependencies.configPresenter.getSkillsEnabled() - const policy = - extensionPolicy ?? (await this.resolveAgentExtensionPolicy(sessionId, resourceInstance)) - const activeSkillNames = filterSkillNamesByPolicy( - activeSkillNamesOverride ?? - (await this.resolveActiveSkillNamesForToolProfile(sessionId, resourceInstance)), - policy - ) + const activeSkillNames = filterSkillNamesByPolicy(activeSkillNamesOverride, extensionPolicy) const disabledAgentTools = this.getDisabledAgentTools(sessionId) const state = resourceInstance?.getRuntimeState() ?? this.dependencies.getRuntimeState(sessionId) @@ -164,17 +164,19 @@ export class DeepChatToolResolver { disabledAgentTools: [...disabledAgentTools].sort((left, right) => left.localeCompare(right) ), - enabledSkillNames: this.normalizeNullablePolicyList(policy.enabledSkillNames), - enabledMcpServerIds: this.normalizeNullablePolicyList(policy.enabledMcpServerIds), + enabledSkillNames: this.normalizeNullablePolicyList(extensionPolicy.enabledSkillNames), + enabledMcpServerIds: this.normalizeNullablePolicyList(extensionPolicy.enabledMcpServerIds), skillsEnabled, - activeSkillNames + activeSkillNames, + subagentCapability: subagentCapability.cacheKey }) } } async resolveActiveSkillNamesForToolProfile( sessionId: string, - resourceInstance?: DeepChatAgentInstance + resourceInstance?: DeepChatAgentInstance, + extensionPolicy?: AgentExtensionPolicy ): Promise { if ( !this.dependencies.configPresenter.getSkillsEnabled() || @@ -184,7 +186,8 @@ export class DeepChatToolResolver { } try { - const policy = await this.resolveAgentExtensionPolicy(sessionId, resourceInstance) + const policy = + extensionPolicy ?? (await this.resolveAgentExtensionPolicy(sessionId, resourceInstance)) return filterSkillNamesByPolicy( normalizeStringList(await this.dependencies.skillPresenter.getActiveSkills(sessionId)), policy @@ -198,6 +201,46 @@ export class DeepChatToolResolver { } } + private async resolveAgentToolPolicy( + sessionId: string, + resourceInstance?: DeepChatAgentInstance + ): Promise<{ + extensionPolicy: AgentExtensionPolicy + subagentCapability: DeepChatSubagentCapability + }> { + const agentId = + resourceInstance?.getAgentId()?.trim() || + this.dependencies.getSessionAgentId(sessionId) || + 'deepchat' + const sessionRow = this.dependencies.sqlitePresenter.newSessionsTable?.get?.(sessionId) + const resolveCapability = (config?: DeepChatAgentConfig | null) => + resolveDeepChatSubagentCapability({ + agentType: 'deepchat', + sessionKind: sessionRow?.session_kind ?? null, + agentPolicyEnabled: config?.subagentEnabled !== false, + slots: config?.subagents, + legacySessionPolicyEnabled: sessionRow?.subagent_enabled === 1 + }) + + if (typeof this.dependencies.configPresenter.resolveDeepChatAgentConfig !== 'function') { + return { extensionPolicy: {}, subagentCapability: resolveCapability(null) } + } + + try { + const config = await this.dependencies.configPresenter.resolveDeepChatAgentConfig(agentId) + return { + extensionPolicy: { + enabledSkillNames: config.enabledSkillNames, + enabledMcpServerIds: config.enabledMcpServerIds + }, + subagentCapability: resolveCapability(config) + } + } catch (error) { + console.warn(`[DeepChatAgent] Failed to resolve tool policy for agent ${agentId}:`, error) + return { extensionPolicy: {}, subagentCapability: resolveCapability(null) } + } + } + async resolveAgentExtensionPolicy( sessionId: string, resourceInstance?: DeepChatAgentInstance diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index fe8f37440..6f8cf31e8 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -99,7 +99,7 @@ import { PluginPresenter } from './pluginPresenter' import { AgentRepository, BUILTIN_DEEPCHAT_AGENT_ID } from './agentRepository' import type { SQLitePresenter } from './sqlitePresenter' import { DatabaseSecurityPresenter } from './databaseSecurityPresenter' -import { normalizeDeepChatSubagentSlots } from '@shared/lib/deepchatSubagents' +import { resolveDeepChatSubagentCapability } from '@shared/lib/deepchatSubagents' import { subscribeDeepChatInternalSessionUpdates } from './agentRuntimePresenter/internalSessionEvents' import type { AcpAsLlmProviderPermissionPort, @@ -329,12 +329,17 @@ export class Presenter implements IPresenter { const disabledAgentTools = await this.sessionAgentAssignmentCoordinator.getSessionDisabledAgentTools(session.id) const activeSkills = await this.skillPresenter.getActiveSkills(session.id) - const availableSubagentSlots = + const subagentConfig = agentType === 'deepchat' && session.sessionKind === 'regular' - ? normalizeDeepChatSubagentSlots( - (await this.configPresenter.resolveDeepChatAgentConfig(session.agentId)).subagents - ) - : [] + ? await this.configPresenter.resolveDeepChatAgentConfig(session.agentId) + : null + const subagentCapability = resolveDeepChatSubagentCapability({ + agentType, + sessionKind: session.sessionKind, + agentPolicyEnabled: subagentConfig?.subagentEnabled !== false, + slots: subagentConfig?.subagents, + legacySessionPolicyEnabled: session.subagentEnabled + }) return { sessionId: session.id, @@ -350,9 +355,8 @@ export class Presenter implements IPresenter { activeSkills, sessionKind: session.sessionKind, parentSessionId: session.parentSessionId ?? null, - subagentEnabled: session.subagentEnabled, subagentMeta: session.subagentMeta ?? null, - availableSubagentSlots + subagentCapability } }, searchTape: async (conversationId, query, options) => { diff --git a/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts b/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts index 0dabe6a3c..f02d1aaea 100644 --- a/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts +++ b/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts @@ -53,6 +53,7 @@ import { cronJobActionNeedsPermission } from './cronJobTool' import { isYoBrowserUnavailableError } from '../../browser/YoBrowserErrors' +import type { DeepChatSubagentCapability } from '@shared/types/agent-interface' // Consider moving to a shared handlers location in future refactoring import { @@ -371,6 +372,7 @@ export class AgentToolManager { agentWorkspacePath: string | null conversationId?: string activeSkillNames?: string[] + subagentCapability?: DeepChatSubagentCapability catalogPurpose?: 'runtime' | 'configurable' }): Promise { const defs: MCPToolDefinition[] = [] @@ -449,13 +451,18 @@ export class AgentToolManager { } // 2.5. Subagent orchestration tool (deepchat regular sessions only) - if (isAgentMode && context.conversationId && this.subagentOrchestratorTool) { + if ( + isAgentMode && + acceptsExposure('system-model') && + context.conversationId && + this.subagentOrchestratorTool + ) { try { - const subagentToolDefinition = await this.subagentOrchestratorTool.getToolDefinition( - context.conversationId + const subagentToolDefinition = this.subagentOrchestratorTool.getToolDefinition( + context.subagentCapability ) if (subagentToolDefinition) { - appendDefinitions([subagentToolDefinition], 'user-configurable') + appendDefinitions([subagentToolDefinition], 'system-model') } } catch (error) { logger.warn('[AgentToolManager] Failed to resolve subagent tool availability', { error }) diff --git a/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts b/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts index 3abb46b08..23deadc69 100644 --- a/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts +++ b/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts @@ -6,8 +6,10 @@ import type { AgentToolProgressUpdate } from '@shared/types/presenters/tool.pres import type { AgentToolCallResult } from './agentToolManager' import type { AgentToolRuntimePort, ConversationSessionInfo } from '../runtimePorts' import { awaitWithAbort } from '@/lib/awaitWithAbort' +import { SUBAGENT_ORCHESTRATOR_TOOL_NAME } from '@shared/agentTools' +import type { DeepChatSubagentCapability } from '@shared/types/agent-interface' -export const SUBAGENT_ORCHESTRATOR_TOOL_NAME = 'subagent_orchestrator' +export { SUBAGENT_ORCHESTRATOR_TOOL_NAME } from '@shared/agentTools' const DEFAULT_RUN_TIMEOUT_MS = 300000 const MIN_RUN_TIMEOUT_MS = 1000 const MAX_RUN_TIMEOUT_MS = 1800000 @@ -741,30 +743,6 @@ export class SubagentOrchestratorTool { } } - private async getAvailableSession( - conversationId?: string - ): Promise { - if (!conversationId) { - return null - } - - const session = await this.runtimePort.resolveConversationSessionInfo(conversationId) - if (!session) { - return null - } - - return session.agentType === 'deepchat' && - session.sessionKind === 'regular' && - session.subagentEnabled === true && - session.availableSubagentSlots.length > 0 - ? session - : null - } - - async isAvailable(conversationId?: string): Promise { - return Boolean(await this.getAvailableSession(conversationId)) - } - private buildSlotIdParameter(slots: DeepChatSubagentSlot[]) { const normalizedSlots = [...slots] .map((slot) => ({ @@ -816,13 +794,12 @@ export class SubagentOrchestratorTool { } } - async getToolDefinition(conversationId?: string): Promise { - const session = await this.getAvailableSession(conversationId) - if (!session) { + getToolDefinition(capability?: DeepChatSubagentCapability): MCPToolDefinition | null { + if (!capability?.available) { return null } - const slotIdParameter = this.buildSlotIdParameter(session.availableSubagentSlots) + const slotIdParameter = this.buildSlotIdParameter(capability.slots) return { type: 'function', @@ -934,12 +911,14 @@ export class SubagentOrchestratorTool { if ( parent.agentType !== 'deepchat' || parent.sessionKind !== 'regular' || - parent.subagentEnabled !== true + !parent.subagentCapability.available ) { - throw new Error( - 'subagent_orchestrator is only available in DeepChat regular sessions with subagents enabled.' - ) + const reason = parent.subagentCapability.available + ? 'unsupported_session' + : parent.subagentCapability.reason + throw new Error(`subagent_orchestrator is unavailable for the current session (${reason}).`) } + const subagentCapability = parent.subagentCapability if (args.operation !== 'run') { return this.handleRunOperation(args, conversationId, options) @@ -966,7 +945,7 @@ export class SubagentOrchestratorTool { ) } - const slotMap = new Map(parent.availableSubagentSlots.map((slot) => [slot.id, slot])) + const slotMap = new Map(subagentCapability.slots.map((slot) => [slot.id, slot])) const now = Date.now() const runTimeoutMs = args.runTimeoutMs ?? DEFAULT_RUN_TIMEOUT_MS const tasks = taskSpecs.map((task, index): MutableTaskState => { diff --git a/src/main/presenter/toolPresenter/index.ts b/src/main/presenter/toolPresenter/index.ts index 62874308e..0baa5d620 100644 --- a/src/main/presenter/toolPresenter/index.ts +++ b/src/main/presenter/toolPresenter/index.ts @@ -17,6 +17,7 @@ import { QUESTION_TOOL_NAME } from '@/presenter/toolPresenter/agentTools/questio import { ToolMapper, type ToolSource } from './toolMapper' import { CRON_JOB_AGENT_TOOL_NAME, + SUBAGENT_ORCHESTRATOR_TOOL_NAME, TAPE_TOOL_NAMES, getAgentToolExposure, isUserConfigurableAgentTool @@ -75,6 +76,7 @@ const RESERVED_AGENT_TOOL_NAMES = new Set([ IMAGE_GENERATE_TOOL_NAME, UPDATE_PLAN_TOOL_NAME, CRON_JOB_AGENT_TOOL_NAME, + SUBAGENT_ORCHESTRATOR_TOOL_NAME, ...Object.values(TAPE_TOOL_NAMES) ]) @@ -186,7 +188,8 @@ export class ToolPresenter implements IToolPresenter { supportsVision, agentWorkspacePath, conversationId: context.conversationId, - activeSkillNames: context.activeSkillNames + activeSkillNames: context.activeSkillNames, + subagentCapability: context.subagentCapability }), 'agent' ) diff --git a/src/main/presenter/toolPresenter/runtimePorts.ts b/src/main/presenter/toolPresenter/runtimePorts.ts index f316a092d..33a1ea3b6 100644 --- a/src/main/presenter/toolPresenter/runtimePorts.ts +++ b/src/main/presenter/toolPresenter/runtimePorts.ts @@ -6,7 +6,7 @@ import type { } from '@shared/presenter' import type { DeepChatSubagentMeta, - DeepChatSubagentSlot, + DeepChatSubagentCapability, AgentTapeContextOptions, AgentTapeContextResult, AgentTapeSearchOptions, @@ -47,9 +47,8 @@ export interface ConversationSessionInfo { activeSkills: string[] sessionKind: SessionKind parentSessionId: string | null - subagentEnabled: boolean subagentMeta: DeepChatSubagentMeta | null - availableSubagentSlots: DeepChatSubagentSlot[] + subagentCapability: DeepChatSubagentCapability } export interface CreateSubagentSessionInput { diff --git a/src/shared/agentTools.ts b/src/shared/agentTools.ts index 0de94fb6b..1ff3317c8 100644 --- a/src/shared/agentTools.ts +++ b/src/shared/agentTools.ts @@ -1,4 +1,5 @@ export const CRON_JOB_AGENT_TOOL_NAME = 'cronjob' +export const SUBAGENT_ORCHESTRATOR_TOOL_NAME = 'subagent_orchestrator' export const DEFAULT_DISABLED_AGENT_TOOLS = [CRON_JOB_AGENT_TOOL_NAME] as const export const TAPE_TOOL_NAMES = Object.freeze({ @@ -13,21 +14,23 @@ export type TapeToolName = (typeof TAPE_TOOL_NAMES)[keyof typeof TAPE_TOOL_NAMES export type AgentToolExposure = 'user-configurable' | 'system-model' | 'diagnostic' | 'runtime-only' -const AGENT_TOOL_EXPOSURE_BY_NAME: Readonly> = - Object.freeze({ - [TAPE_TOOL_NAMES.search]: 'system-model', - [TAPE_TOOL_NAMES.context]: 'system-model', - [TAPE_TOOL_NAMES.info]: 'diagnostic', - [TAPE_TOOL_NAMES.anchors]: 'diagnostic', - [TAPE_TOOL_NAMES.handoff]: 'runtime-only' - }) +const AGENT_TOOL_EXPOSURE_BY_NAME: Readonly> = Object.freeze({ + [TAPE_TOOL_NAMES.search]: 'system-model', + [TAPE_TOOL_NAMES.context]: 'system-model', + [TAPE_TOOL_NAMES.info]: 'diagnostic', + [TAPE_TOOL_NAMES.anchors]: 'diagnostic', + [TAPE_TOOL_NAMES.handoff]: 'runtime-only', + [SUBAGENT_ORCHESTRATOR_TOOL_NAME]: 'system-model' +}) + +const TAPE_TOOL_NAME_SET = new Set(Object.values(TAPE_TOOL_NAMES)) export const isTapeToolName = (toolName: string): toolName is TapeToolName => - Object.prototype.hasOwnProperty.call(AGENT_TOOL_EXPOSURE_BY_NAME, toolName) + TAPE_TOOL_NAME_SET.has(toolName) export const getAgentToolExposure = (toolName: string): AgentToolExposure => - isTapeToolName(toolName) - ? AGENT_TOOL_EXPOSURE_BY_NAME[toolName as TapeToolName] + Object.prototype.hasOwnProperty.call(AGENT_TOOL_EXPOSURE_BY_NAME, toolName) + ? AGENT_TOOL_EXPOSURE_BY_NAME[toolName] : 'user-configurable' export const assertAgentToolExposure = ( diff --git a/src/shared/lib/deepchatSubagents.ts b/src/shared/lib/deepchatSubagents.ts index 9bcbeefa6..447be6df2 100644 --- a/src/shared/lib/deepchatSubagents.ts +++ b/src/shared/lib/deepchatSubagents.ts @@ -1,4 +1,10 @@ -import type { DeepChatAgentConfig, DeepChatSubagentSlot } from '@shared/types/agent-interface' +import type { + AgentType, + DeepChatAgentConfig, + DeepChatSubagentCapability, + DeepChatSubagentSlot, + SessionKind +} from '@shared/types/agent-interface' export const DEEPCHAT_SUBAGENT_SLOT_LIMIT = 5 export const DEEPCHAT_SELF_SUBAGENT_SLOT_ID = 'self' @@ -6,6 +12,17 @@ export const DEEPCHAT_EXPLORER_SUBAGENT_SLOT_ID = 'explorer' export const DEEPCHAT_IMPLEMENTER_SUBAGENT_SLOT_ID = 'implementer' export const DEEPCHAT_REVIEWER_SUBAGENT_SLOT_ID = 'reviewer' +export type { DeepChatSubagentCapability } from '@shared/types/agent-interface' + +export interface ResolveDeepChatSubagentCapabilityInput { + agentType: AgentType | null + sessionKind: SessionKind | null | undefined + agentPolicyEnabled: boolean + slots?: DeepChatSubagentSlot[] | null + /** Compatibility bridge removed with the legacy Session-level policy. */ + legacySessionPolicyEnabled?: boolean +} + export const createDefaultDeepChatSelfSubagentSlot = (): DeepChatSubagentSlot => ({ id: DEEPCHAT_SELF_SUBAGENT_SLOT_ID, targetType: 'self', @@ -117,6 +134,42 @@ export const normalizeDeepChatSubagentSlots = ( return normalized } +const compareSubagentSlots = (left: DeepChatSubagentSlot, right: DeepChatSubagentSlot): number => + left.id.localeCompare(right.id) || + left.targetType.localeCompare(right.targetType) || + (left.targetAgentId ?? '').localeCompare(right.targetAgentId ?? '') || + left.displayName.localeCompare(right.displayName) || + left.description.localeCompare(right.description) + +const createUnavailableSubagentCapability = ( + reason: Extract['reason'] +): DeepChatSubagentCapability => { + const cacheKey = JSON.stringify({ available: false, reason }) + return { available: false, reason, cacheKey } +} + +export const resolveDeepChatSubagentCapability = ( + input: ResolveDeepChatSubagentCapabilityInput +): DeepChatSubagentCapability => { + if (input.agentType !== 'deepchat' || input.sessionKind !== 'regular') { + return createUnavailableSubagentCapability('unsupported_session') + } + + if (input.agentPolicyEnabled === false || input.legacySessionPolicyEnabled === false) { + return createUnavailableSubagentCapability('policy_disabled') + } + + const slots = normalizeDeepChatSubagentSlots(input.slots) + .map((slot) => ({ ...slot })) + .sort(compareSubagentSlots) + if (slots.length === 0) { + return createUnavailableSubagentCapability('no_valid_slots') + } + + const cacheKey = JSON.stringify({ available: true, slots }) + return { available: true, slots, cacheKey } +} + export const normalizeDeepChatSubagentConfig = ( config?: DeepChatAgentConfig | null ): DeepChatAgentConfig => { diff --git a/src/shared/types/agent-interface.d.ts b/src/shared/types/agent-interface.d.ts index 0c651b817..b4872e4f0 100644 --- a/src/shared/types/agent-interface.d.ts +++ b/src/shared/types/agent-interface.d.ts @@ -546,6 +546,18 @@ export interface DeepChatSubagentSlot { description: string } +export type DeepChatSubagentCapability = + | { + available: true + slots: DeepChatSubagentSlot[] + cacheKey: string + } + | { + available: false + reason: 'policy_disabled' | 'unsupported_session' | 'no_valid_slots' + cacheKey: string + } + export type SessionKind = 'regular' | 'subagent' export interface DeepChatSubagentMeta { diff --git a/src/shared/types/presenters/tool.presenter.d.ts b/src/shared/types/presenters/tool.presenter.d.ts index d76d462ee..8a718c9cf 100644 --- a/src/shared/types/presenters/tool.presenter.d.ts +++ b/src/shared/types/presenters/tool.presenter.d.ts @@ -4,7 +4,7 @@ */ import type { MCPToolDefinition, MCPToolCall, MCPToolResponse } from '../core/mcp' -import type { PermissionMode } from '../agent-interface' +import type { DeepChatSubagentCapability, PermissionMode } from '../agent-interface' import type { AgentPlanSnapshot } from '../agent-plan' export type AgentToolProgressUpdate = @@ -30,6 +30,7 @@ export interface ToolDefinitionContext { agentWorkspacePath?: string | null conversationId?: string activeSkillNames?: string[] + subagentCapability?: DeepChatSubagentCapability } export interface ToolCallOptions { diff --git a/test/main/agent/deepchat/deepChatAgentRepository.test.ts b/test/main/agent/deepchat/deepChatAgentRepository.test.ts index 6f1c70d61..07172fd78 100644 --- a/test/main/agent/deepchat/deepChatAgentRepository.test.ts +++ b/test/main/agent/deepchat/deepChatAgentRepository.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { DeepChatAgentRepository } from '@/agent/deepchat/deepChatAgentRepository' -import { TAPE_TOOL_NAMES } from '@shared/agentTools' +import { SUBAGENT_ORCHESTRATOR_TOOL_NAME, TAPE_TOOL_NAMES } from '@shared/agentTools' function createRepository(sqlitePresenter: any): DeepChatAgentRepository { return new DeepChatAgentRepository({ @@ -486,11 +486,15 @@ describe('DeepChatAgentRepository', () => { const repository = createRepository({ agentsTable }) repository.ensureBuiltin({ - config: { disabledAgentTools: [TAPE_TOOL_NAMES.search, 'read'] } + config: { + disabledAgentTools: [TAPE_TOOL_NAMES.search, SUBAGENT_ORCHESTRATOR_TOOL_NAME, 'read'] + } }) const created = repository.create({ name: 'Writer', - config: { disabledAgentTools: [TAPE_TOOL_NAMES.handoff, 'exec'] } + config: { + disabledAgentTools: [TAPE_TOOL_NAMES.handoff, SUBAGENT_ORCHESTRATOR_TOOL_NAME, 'exec'] + } }) expect(repository.getConfig('deepchat')?.disabledAgentTools).toEqual(['read']) diff --git a/test/main/presenter/agentRuntimePresenter/toolResolver.test.ts b/test/main/presenter/agentRuntimePresenter/toolResolver.test.ts new file mode 100644 index 000000000..42ba8a83b --- /dev/null +++ b/test/main/presenter/agentRuntimePresenter/toolResolver.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it, vi } from 'vitest' +import { DeepChatToolResolver } from '@/presenter/agentRuntimePresenter/toolResolver' +import type { DeepChatAgentConfig } from '@shared/types/agent-interface' + +const createResourceInstance = () => { + let cached: { profile: 'general'; fingerprint: string; tools: [] } | undefined + return { + getAgentId: vi.fn(() => 'deepchat'), + getRuntimeState: vi.fn(() => ({ + status: 'idle', + providerId: 'openai', + modelId: 'gpt-4.1', + permissionMode: 'full_access' + })), + getToolProfileCache: vi.fn(() => cached), + setToolProfileCache: vi.fn((entry) => { + cached = entry + }) + } +} + +describe('DeepChatToolResolver Subagent capability', () => { + it('refreshes the catalog from capability cache keys without event invalidation', async () => { + let config: DeepChatAgentConfig = { + subagentEnabled: true, + subagents: [ + { + id: 'reviewer', + targetType: 'self', + displayName: 'Reviewer', + description: 'Review the result.' + } + ] + } + const row = { + session_kind: 'regular' as const, + subagent_enabled: 1 + } + const getAllToolDefinitions = vi.fn().mockResolvedValue([]) + const resourceInstance = createResourceInstance() + const resolver = new DeepChatToolResolver({ + configPresenter: { + getSkillsEnabled: vi.fn(() => false), + resolveDeepChatAgentConfig: vi.fn(async () => config) + }, + sqlitePresenter: { + newSessionsTable: { + get: vi.fn(() => row), + getDisabledAgentTools: vi.fn(() => []) + } + }, + toolPresenter: { + getAllToolDefinitions, + syncAgentToolContext: vi.fn() + }, + deepChatRuntime: { getToolRegistryRevision: vi.fn(() => 1) }, + getDeepChatInstance: vi.fn(() => resourceInstance), + getSessionAgentId: vi.fn(() => 'deepchat'), + getRuntimeState: vi.fn(), + assertCurrent: vi.fn(), + isAcpBackedSubagentSession: vi.fn(() => false), + isStaleInstanceError: vi.fn(() => false) + } as any) + + await resolver.loadToolDefinitionsForSession( + 'session-1', + null, + undefined, + resourceInstance as any + ) + await resolver.loadToolDefinitionsForSession( + 'session-1', + null, + undefined, + resourceInstance as any + ) + expect(getAllToolDefinitions).toHaveBeenCalledTimes(1) + expect(getAllToolDefinitions.mock.calls[0][0].subagentCapability).toMatchObject({ + available: true, + slots: [expect.objectContaining({ id: 'reviewer' })] + }) + + config = { + ...config, + subagents: [{ ...config.subagents![0], description: 'Review security boundaries.' }] + } + await resolver.loadToolDefinitionsForSession( + 'session-1', + null, + undefined, + resourceInstance as any + ) + expect(getAllToolDefinitions).toHaveBeenCalledTimes(2) + expect(getAllToolDefinitions.mock.calls[1][0].subagentCapability.cacheKey).not.toBe( + getAllToolDefinitions.mock.calls[0][0].subagentCapability.cacheKey + ) + + config = { ...config, subagentEnabled: false } + await resolver.loadToolDefinitionsForSession( + 'session-1', + null, + undefined, + resourceInstance as any + ) + expect(getAllToolDefinitions).toHaveBeenCalledTimes(3) + expect(getAllToolDefinitions.mock.calls[2][0].subagentCapability).toMatchObject({ + available: false, + reason: 'policy_disabled' + }) + }) + + it('marks child sessions unsupported before building their catalog', async () => { + const getAllToolDefinitions = vi.fn().mockResolvedValue([]) + const resourceInstance = createResourceInstance() + const resolver = new DeepChatToolResolver({ + configPresenter: { + getSkillsEnabled: vi.fn(() => false), + resolveDeepChatAgentConfig: vi.fn(async () => ({ + subagentEnabled: true, + subagents: [ + { + id: 'reviewer', + targetType: 'self', + displayName: 'Reviewer', + description: '' + } + ] + })) + }, + sqlitePresenter: { + newSessionsTable: { + get: vi.fn(() => ({ session_kind: 'subagent', subagent_enabled: 1 })), + getDisabledAgentTools: vi.fn(() => []) + } + }, + toolPresenter: { getAllToolDefinitions }, + deepChatRuntime: { getToolRegistryRevision: vi.fn(() => 1) }, + getDeepChatInstance: vi.fn(() => resourceInstance), + getSessionAgentId: vi.fn(() => 'deepchat'), + getRuntimeState: vi.fn(), + assertCurrent: vi.fn(), + isAcpBackedSubagentSession: vi.fn(() => false), + isStaleInstanceError: vi.fn(() => false) + } as any) + + await resolver.loadToolDefinitionsForSession( + 'child-1', + null, + undefined, + resourceInstance as any + ) + + expect(getAllToolDefinitions).toHaveBeenCalledWith( + expect.objectContaining({ + subagentCapability: expect.objectContaining({ + available: false, + reason: 'unsupported_session' + }) + }) + ) + }) + + it('fails closed without misreporting config resolution failures as an explicit disable', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const getAllToolDefinitions = vi.fn().mockResolvedValue([]) + const resourceInstance = createResourceInstance() + const resolver = new DeepChatToolResolver({ + configPresenter: { + getSkillsEnabled: vi.fn(() => false), + resolveDeepChatAgentConfig: vi.fn().mockRejectedValue(new Error('config unavailable')) + }, + sqlitePresenter: { + newSessionsTable: { + get: vi.fn(() => ({ session_kind: 'regular', subagent_enabled: 1 })), + getDisabledAgentTools: vi.fn(() => []) + } + }, + toolPresenter: { getAllToolDefinitions }, + deepChatRuntime: { getToolRegistryRevision: vi.fn(() => 1) }, + getDeepChatInstance: vi.fn(() => resourceInstance), + getSessionAgentId: vi.fn(() => 'deepchat'), + getRuntimeState: vi.fn(), + assertCurrent: vi.fn(), + isAcpBackedSubagentSession: vi.fn(() => false), + isStaleInstanceError: vi.fn(() => false) + } as any) + + await resolver.loadToolDefinitionsForSession( + 'session-1', + null, + undefined, + resourceInstance as any + ) + + expect(getAllToolDefinitions).toHaveBeenCalledWith( + expect.objectContaining({ + subagentCapability: expect.objectContaining({ + available: false, + reason: 'no_valid_slots' + }) + }) + ) + expect(warnSpy).toHaveBeenCalledWith( + '[DeepChatAgent] Failed to resolve tool policy for agent deepchat:', + expect.any(Error) + ) + warnSpy.mockRestore() + }) +}) diff --git a/test/main/presenter/toolPresenter/agentTools/agentMemoryTools.test.ts b/test/main/presenter/toolPresenter/agentTools/agentMemoryTools.test.ts index 251af8c68..34f2b7e65 100644 --- a/test/main/presenter/toolPresenter/agentTools/agentMemoryTools.test.ts +++ b/test/main/presenter/toolPresenter/agentTools/agentMemoryTools.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { AGENT_MEMORY_MANUAL_CONTENT_MAX_CHARS } from '@shared/types/agent-memory' +import { resolveDeepChatSubagentCapability } from '@shared/lib/deepchatSubagents' import { AgentMemoryToolHandler, @@ -22,9 +23,13 @@ const buildRuntimePort = (overrides: Record = {}) => activeSkills: [], sessionKind: 'regular', parentSessionId: null, - subagentEnabled: false, subagentMeta: null, - availableSubagentSlots: [] + subagentCapability: resolveDeepChatSubagentCapability({ + agentType: 'deepchat', + sessionKind: 'regular', + agentPolicyEnabled: false, + slots: [] + }) }), isMemoryEnabled: vi.fn().mockReturnValue(true), rememberMemory: vi.fn(), diff --git a/test/main/presenter/toolPresenter/agentTools/agentTapeTools.test.ts b/test/main/presenter/toolPresenter/agentTools/agentTapeTools.test.ts index 17e4f8a63..7e99ea5f6 100644 --- a/test/main/presenter/toolPresenter/agentTools/agentTapeTools.test.ts +++ b/test/main/presenter/toolPresenter/agentTools/agentTapeTools.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { AgentToolManager } from '@/presenter/toolPresenter/agentTools/agentToolManager' import { AgentTapeToolHandler, TAPE_TOOL_NAMES } from '@/presenter/toolPresenter/agentTools' +import { resolveDeepChatSubagentCapability } from '@shared/lib/deepchatSubagents' vi.mock('electron', () => ({ app: { @@ -30,9 +31,13 @@ const buildRuntimePort = (overrides: Record = {}) => activeSkills: [], sessionKind: 'regular', parentSessionId: null, - subagentEnabled: false, subagentMeta: null, - availableSubagentSlots: [] + subagentCapability: resolveDeepChatSubagentCapability({ + agentType: 'deepchat', + sessionKind: 'regular', + agentPolicyEnabled: false, + slots: [] + }) }), getTapeInfo: vi.fn().mockResolvedValue({ sessionId: 'conv-1', diff --git a/test/main/presenter/toolPresenter/agentTools/agentToolManagerSettings.test.ts b/test/main/presenter/toolPresenter/agentTools/agentToolManagerSettings.test.ts index ac8f01057..ad64ffe27 100644 --- a/test/main/presenter/toolPresenter/agentTools/agentToolManagerSettings.test.ts +++ b/test/main/presenter/toolPresenter/agentTools/agentToolManagerSettings.test.ts @@ -4,6 +4,7 @@ import { CHAT_SETTINGS_SKILL_NAME, CHAT_SETTINGS_TOOL_NAMES } from '@/presenter/toolPresenter/agentTools/chatSettingsTools' +import { resolveDeepChatSubagentCapability } from '@shared/lib/deepchatSubagents' vi.mock('electron', () => ({ app: { @@ -384,23 +385,11 @@ describe('AgentToolManager DeepChat settings tool gating', () => { it('builds a stable slotId enum for subagent_orchestrator from the session config', async () => { skillPresenter.getActiveSkills.mockResolvedValue([]) skillPresenter.getActiveSkillsAllowedTools.mockResolvedValue([]) - resolveConversationSessionInfo.mockResolvedValue({ - sessionId: 'conv-1', - agentId: 'deepchat', - agentName: 'DeepChat', + const subagentCapability = resolveDeepChatSubagentCapability({ agentType: 'deepchat', - providerId: 'openai', - modelId: 'gpt-4.1', - projectDir: '/tmp/workspace', - permissionMode: 'full_access', - generationSettings: null, - disabledAgentTools: [], - activeSkills: [], sessionKind: 'regular', - parentSessionId: null, - subagentEnabled: true, - subagentMeta: null, - availableSubagentSlots: [ + agentPolicyEnabled: true, + slots: [ { id: 'writer', targetType: 'self', @@ -422,7 +411,8 @@ describe('AgentToolManager DeepChat settings tool gating', () => { chatMode: 'agent', supportsVision: false, agentWorkspacePath: null, - conversationId: 'conv-1' + conversationId: 'conv-1', + subagentCapability }) const subagentDef = defs.find((def) => def.function.name === 'subagent_orchestrator') @@ -440,5 +430,6 @@ describe('AgentToolManager DeepChat settings tool gating', () => { expect(promptSchema?.description).toContain( 'The child session uses the same working directory as the parent session' ) + expect(resolveConversationSessionInfo).toHaveBeenCalledTimes(1) }) }) diff --git a/test/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.test.ts b/test/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.test.ts index b8f762c13..4d58ff79e 100644 --- a/test/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.test.ts +++ b/test/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.test.ts @@ -6,6 +6,27 @@ import { } from '@/presenter/toolPresenter/agentTools/subagentOrchestratorTool' import type { ConversationSessionInfo } from '@/presenter/toolPresenter/runtimePorts' import type { SubagentTapeLinkInput, SubagentTapeLinkReceipt } from '@shared/types/agent-interface' +import { resolveDeepChatSubagentCapability } from '@shared/lib/deepchatSubagents' + +const parentSubagentCapability = resolveDeepChatSubagentCapability({ + agentType: 'deepchat', + sessionKind: 'regular', + agentPolicyEnabled: true, + slots: [ + { + id: 'reviewer', + targetType: 'self', + displayName: 'Reviewer Clone', + description: 'Review the delegated task.' + } + ] +}) +const childSubagentCapability = resolveDeepChatSubagentCapability({ + agentType: 'deepchat', + sessionKind: 'subagent', + agentPolicyEnabled: true, + slots: parentSubagentCapability.available ? parentSubagentCapability.slots : [] +}) const buildTapeLinkReceipt = (input: SubagentTapeLinkInput): SubagentTapeLinkReceipt => ({ linkEntry: { sessionId: input.parentSessionId, entryId: 1 }, @@ -34,16 +55,8 @@ const buildSessionInfo = ( activeSkills: [], sessionKind: 'regular', parentSessionId: null, - subagentEnabled: true, subagentMeta: null, - availableSubagentSlots: [ - { - id: 'reviewer', - targetType: 'self', - displayName: 'Reviewer Clone', - description: 'Review the delegated task.' - } - ], + subagentCapability: parentSubagentCapability, ...overrides }) @@ -58,8 +71,7 @@ const buildRuntimePort = ( sessionId: 'child-session', sessionKind: 'subagent', parentSessionId: parentSession.sessionId, - subagentEnabled: false, - availableSubagentSlots: [] + subagentCapability: childSubagentCapability }) ), sendConversationMessage: vi.fn().mockResolvedValue(undefined), @@ -94,6 +106,31 @@ const createDeferredPromise = () => { } describe('SubagentOrchestratorTool', () => { + it('fails closed when the Agent policy changes after tool definition', async () => { + const currentParent = buildSessionInfo({ + subagentCapability: resolveDeepChatSubagentCapability({ + agentType: 'deepchat', + sessionKind: 'regular', + agentPolicyEnabled: false, + slots: parentSubagentCapability.available ? parentSubagentCapability.slots : [] + }) + }) + const runtimePort = buildRuntimePort(currentParent) + const tool = new SubagentOrchestratorTool(runtimePort as any) + + expect(tool.getToolDefinition(parentSubagentCapability)).not.toBeNull() + await expect( + tool.call( + { + mode: 'parallel', + tasks: [{ slotId: 'reviewer', title: 'Review', prompt: 'Review the change.' }] + }, + currentParent.sessionId + ) + ).rejects.toThrow('(policy_disabled)') + expect(runtimePort.createSubagentSession).not.toHaveBeenCalled() + }) + it('includes the parent session workdir in the child handoff', async () => { let listener: ((update: DeepChatInternalSessionUpdate) => void) | null = null let handoffMessage = '' @@ -108,8 +145,7 @@ describe('SubagentOrchestratorTool', () => { projectDir: '/workspace/child-session-record', sessionKind: 'subagent', parentSessionId: parentSession.sessionId, - subagentEnabled: false, - availableSubagentSlots: [] + subagentCapability: childSubagentCapability }) const resolveConversationWorkdir = vi.fn().mockResolvedValue(resolvedWorkdir) const createSubagentSession = vi.fn().mockResolvedValue(childSession) @@ -217,8 +253,7 @@ describe('SubagentOrchestratorTool', () => { sessionId: 'deadline-child', sessionKind: 'subagent', parentSessionId: parentSession.sessionId, - subagentEnabled: false, - availableSubagentSlots: [] + subagentCapability: childSubagentCapability }) const cancelConversation = vi.fn().mockResolvedValue(undefined) const runtimePort = buildRuntimePort(parentSession, { @@ -227,7 +262,7 @@ describe('SubagentOrchestratorTool', () => { }) const tool = new SubagentOrchestratorTool(runtimePort as any) - const definition = await tool.getToolDefinition(parentSession.sessionId) + const definition = tool.getToolDefinition(parentSession.subagentCapability) const runTimeoutProperty = (definition?.function.parameters as any).properties.runTimeoutMs expect(runTimeoutProperty).toMatchObject({ type: 'number', @@ -305,8 +340,7 @@ describe('SubagentOrchestratorTool', () => { sessionId: 'slow-cancel-child', sessionKind: 'subagent', parentSessionId: parentSession.sessionId, - subagentEnabled: false, - availableSubagentSlots: [] + subagentCapability: childSubagentCapability }) let settleCancellation: (() => void) | undefined const cancelConversation = vi.fn( @@ -383,8 +417,7 @@ describe('SubagentOrchestratorTool', () => { sessionId: 'blocked-completed-link-child', sessionKind: 'subagent', parentSessionId: parentSession.sessionId, - subagentEnabled: false, - availableSubagentSlots: [] + subagentCapability: childSubagentCapability }) const link = createDeferredPromise() let linkInput: SubagentTapeLinkInput | undefined @@ -462,8 +495,7 @@ describe('SubagentOrchestratorTool', () => { sessionId: 'blocked-error-link-child', sessionKind: 'subagent', parentSessionId: parentSession.sessionId, - subagentEnabled: false, - availableSubagentSlots: [] + subagentCapability: childSubagentCapability }) const link = createDeferredPromise() let linkInput: SubagentTapeLinkInput | undefined @@ -531,8 +563,7 @@ describe('SubagentOrchestratorTool', () => { sessionId: 'late-deadline-child', sessionKind: 'subagent', parentSessionId: parentSession.sessionId, - subagentEnabled: false, - availableSubagentSlots: [] + subagentCapability: childSubagentCapability }) const childCreation = createDeferredPromise() const cancellation = createDeferredPromise() @@ -592,8 +623,7 @@ describe('SubagentOrchestratorTool', () => { sessionId: `active-child-${childIndex}`, sessionKind: 'subagent', parentSessionId: parentSession.sessionId, - subagentEnabled: false, - availableSubagentSlots: [] + subagentCapability: childSubagentCapability }) }) }) @@ -653,8 +683,7 @@ describe('SubagentOrchestratorTool', () => { agentName: 'Reviewer Clone', sessionKind: 'subagent', parentSessionId: parentSession.sessionId, - subagentEnabled: false, - availableSubagentSlots: [] + subagentCapability: childSubagentCapability }) const createSubagentSession = vi.fn().mockResolvedValue(childSession) const cancelConversation = vi.fn().mockResolvedValue(undefined) @@ -726,8 +755,7 @@ describe('SubagentOrchestratorTool', () => { agentName: 'Reviewer Clone', sessionKind: 'subagent', parentSessionId: parentSession.sessionId, - subagentEnabled: false, - availableSubagentSlots: [] + subagentCapability: childSubagentCapability }) const linkSubagentTape = createTapeLinkMock() @@ -932,8 +960,7 @@ describe('SubagentOrchestratorTool', () => { sessionId, sessionKind: 'subagent', parentSessionId: parentSession.sessionId, - subagentEnabled: false, - availableSubagentSlots: [] + subagentCapability: childSubagentCapability }) ) const earlyHandoff = createDeferredPromise() @@ -1032,8 +1059,7 @@ describe('SubagentOrchestratorTool', () => { agentName: 'Reviewer Clone', sessionKind: 'subagent', parentSessionId: parentSession.sessionId, - subagentEnabled: false, - availableSubagentSlots: [] + subagentCapability: childSubagentCapability }) const linkSubagentTape = vi .fn() @@ -1128,8 +1154,7 @@ describe('SubagentOrchestratorTool', () => { agentName: 'Reviewer Clone', sessionKind: 'subagent', parentSessionId: parentSession.sessionId, - subagentEnabled: false, - availableSubagentSlots: [] + subagentCapability: childSubagentCapability }) const linkSubagentTape = vi.fn().mockRejectedValue(new Error('link still failed')) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) @@ -1232,8 +1257,7 @@ describe('SubagentOrchestratorTool', () => { sessionId: 'blocked-handoff-child', sessionKind: 'subagent', parentSessionId: parentSession.sessionId, - subagentEnabled: false, - availableSubagentSlots: [] + subagentCapability: childSubagentCapability }) const abortController = new AbortController() const handoff = createDeferredPromise() @@ -1306,8 +1330,7 @@ describe('SubagentOrchestratorTool', () => { sessionId: 'late-created-child', sessionKind: 'subagent', parentSessionId: parentSession.sessionId, - subagentEnabled: false, - availableSubagentSlots: [] + subagentCapability: childSubagentCapability }) const abortController = new AbortController() const childCreation = createDeferredPromise() diff --git a/test/main/presenter/toolPresenter/toolPresenter.test.ts b/test/main/presenter/toolPresenter/toolPresenter.test.ts index d7bd0fa6c..84a2749b1 100644 --- a/test/main/presenter/toolPresenter/toolPresenter.test.ts +++ b/test/main/presenter/toolPresenter/toolPresenter.test.ts @@ -12,9 +12,11 @@ import { QUESTION_TOOL_NAME } from '@/presenter/toolPresenter/agentTools/questio import { IMAGE_GENERATE_TOOL_NAME } from '@shared/agentImageGenerationTool' import { CRON_JOB_AGENT_TOOL_NAME, + SUBAGENT_ORCHESTRATOR_TOOL_NAME, assertAgentToolExposure, getAgentToolExposure } from '@shared/agentTools' +import { resolveDeepChatSubagentCapability } from '@shared/lib/deepchatSubagents' vi.mock('electron', () => ({ app: { @@ -483,6 +485,7 @@ describe('ToolPresenter', () => { expect(getAgentToolExposure(TAPE_TOOL_NAMES.info)).toBe('diagnostic') expect(getAgentToolExposure(TAPE_TOOL_NAMES.anchors)).toBe('diagnostic') expect(getAgentToolExposure(TAPE_TOOL_NAMES.handoff)).toBe('runtime-only') + expect(getAgentToolExposure(SUBAGENT_ORCHESTRATOR_TOOL_NAME)).toBe('system-model') expect(getAgentToolExposure('read')).toBe('user-configurable') expect(getAgentToolExposure('__proto__')).toBe('user-configurable') expect(() => assertAgentToolExposure(TAPE_TOOL_NAMES.handoff, 'user-configurable')).toThrow( @@ -490,6 +493,68 @@ describe('ToolPresenter', () => { ) }) + it('reserves the Subagent orchestrator and ignores generic disabled-tool state', async () => { + const toolPresenter = new ToolPresenter({ + mcpPresenter: { + getAllToolDefinitions: vi + .fn() + .mockResolvedValue([ + buildToolDefinition(SUBAGENT_ORCHESTRATOR_TOOL_NAME, 'untrusted-mcp') + ]) + } as any, + configPresenter: { + getSkillsEnabled: vi.fn().mockReturnValue(false), + getSkillsPath: vi.fn().mockReturnValue('C:\\skills'), + getModelConfig: vi.fn() + } as any, + commandPermissionHandler: new CommandPermissionService(), + agentToolRuntime: buildAgentToolRuntimeMock() + }) + const subagentCapability = resolveDeepChatSubagentCapability({ + agentType: 'deepchat', + sessionKind: 'regular', + agentPolicyEnabled: true, + slots: [ + { + id: 'reviewer', + targetType: 'self', + displayName: 'Reviewer', + description: 'Review the result.' + } + ] + }) + + const defs = await toolPresenter.getAllToolDefinitions({ + disabledAgentTools: [SUBAGENT_ORCHESTRATOR_TOOL_NAME], + chatMode: 'agent', + supportsVision: false, + agentWorkspacePath: null, + conversationId: 'conv-1', + subagentCapability + }) + const orchestrators = defs.filter( + (definition) => definition.function.name === SUBAGENT_ORCHESTRATOR_TOOL_NAME + ) + + expect(orchestrators).toHaveLength(1) + expect(orchestrators[0]).toMatchObject({ + source: 'agent', + server: { name: 'agent-subagents' } + }) + + const configurable = await toolPresenter.getConfigurableAgentToolDefinitions({ + chatMode: 'agent', + supportsVision: false, + agentWorkspacePath: null, + conversationId: 'conv-1' + }) + expect( + configurable.some( + (definition) => definition.function.name === SUBAGENT_ORCHESTRATOR_TOOL_NAME + ) + ).toBe(false) + }) + it('keeps the recall pair in the runtime catalog despite stale disabled values', async () => { const toolPresenter = new ToolPresenter({ mcpPresenter: { diff --git a/test/main/shared/deepchatSubagents.test.ts b/test/main/shared/deepchatSubagents.test.ts new file mode 100644 index 000000000..604c59e98 --- /dev/null +++ b/test/main/shared/deepchatSubagents.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import { + resolveDeepChatSubagentCapability, + type ResolveDeepChatSubagentCapabilityInput +} from '@shared/lib/deepchatSubagents' +import type { DeepChatSubagentSlot } from '@shared/types/agent-interface' + +const reviewerSlot: DeepChatSubagentSlot = { + id: 'reviewer', + targetType: 'agent', + targetAgentId: 'review-agent', + displayName: 'Reviewer', + description: 'Review the result.' +} + +const explorerSlot: DeepChatSubagentSlot = { + id: 'explorer', + targetType: 'self', + displayName: 'Explorer', + description: 'Collect evidence.' +} + +const availableInput = ( + overrides: Partial = {} +): ResolveDeepChatSubagentCapabilityInput => ({ + agentType: 'deepchat', + sessionKind: 'regular', + agentPolicyEnabled: true, + legacySessionPolicyEnabled: true, + slots: [reviewerSlot, explorerSlot], + ...overrides +}) + +describe('DeepChat Subagent capability', () => { + it('normalizes and canonicalizes every model-visible slot field in the cache key', () => { + const first = resolveDeepChatSubagentCapability(availableInput()) + const reordered = resolveDeepChatSubagentCapability( + availableInput({ slots: [explorerSlot, reviewerSlot] }) + ) + + expect(first).toMatchObject({ + available: true, + slots: [{ id: 'explorer' }, { id: 'reviewer' }] + }) + expect(reordered.cacheKey).toBe(first.cacheKey) + + const variants: DeepChatSubagentSlot[] = [ + { ...reviewerSlot, id: 'reviewer-2' }, + { ...reviewerSlot, targetAgentId: 'other-agent' }, + { ...reviewerSlot, displayName: 'Security Reviewer' }, + { ...reviewerSlot, description: 'Review security boundaries.' }, + { ...reviewerSlot, targetType: 'self', targetAgentId: undefined } + ] + for (const variant of variants) { + const changed = resolveDeepChatSubagentCapability( + availableInput({ slots: [variant, explorerSlot] }) + ) + expect(changed.cacheKey).not.toBe(first.cacheKey) + } + }) + + it.each([ + ['policy_disabled', { agentPolicyEnabled: false }, 'policy_disabled'], + ['legacy policy bridge disabled', { legacySessionPolicyEnabled: false }, 'policy_disabled'], + ['child session', { sessionKind: 'subagent' as const }, 'unsupported_session'], + ['ACP session', { agentType: 'acp' as const }, 'unsupported_session'], + ['invalid slots', { slots: [] }, 'no_valid_slots'] + ])('fails closed for %s', (_case, overrides, expectedReason) => { + const capability = resolveDeepChatSubagentCapability(availableInput(overrides)) + + expect(capability).toMatchObject({ available: false, reason: expectedReason }) + }) +}) From f0de27cf5e76e521191cb87814a17e7e389b3d97 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Thu, 16 Jul 2026 10:47:54 +0800 Subject: [PATCH 04/10] refactor(session): remove subagent toggle --- src/main/agent/shared/appSessionService.ts | 9 --- .../agentRuntimePresenter/toolResolver.ts | 3 +- .../presenter/cronJobs/runSessionStarter.ts | 4 - src/main/presenter/index.ts | 3 +- .../agentAssignmentCoordinator.ts | 26 +----- .../agentAssignmentPolicy.ts | 9 +-- .../lifecycleCoordinator.ts | 17 +--- .../presenter/sessionApplication/ports.ts | 10 +-- .../sqlitePresenter/tables/newSessions.ts | 10 +-- .../routes/debug/createMockChatSession.ts | 4 +- src/main/routes/index.ts | 10 --- src/renderer/api/SessionClient.ts | 10 --- .../components/chat-input/McpIndicator.vue | 68 +++------------- .../src/components/chat/ChatStatusBar.vue | 67 ---------------- src/renderer/src/pages/NewThreadPage.vue | 5 -- src/renderer/src/stores/ui/draft.ts | 4 - src/renderer/src/stores/ui/session.ts | 17 ---- src/shared/contracts/common.ts | 1 - src/shared/contracts/routes.ts | 2 - .../contracts/routes/sessions.routes.ts | 12 --- src/shared/lib/deepchatSubagents.ts | 4 +- src/shared/types/agent-interface.d.ts | 3 - .../agent/shared/appSessionService.test.ts | 4 +- .../toolResolver.test.ts | 11 ++- .../cronJobRunSessionStarter.test.ts | 1 - .../exporter/agentSessionExporter.test.ts | 1 - .../remoteConversationRunner.test.ts | 1 - .../agentAssignmentCoordinator.test.ts | 17 +--- .../agentAssignmentPolicy.test.ts | 7 +- .../lifecycleCoordinator.test.ts | 10 +-- .../lifecycleDeletionTransaction.test.ts | 1 - .../projectionCoordinator.test.ts | 1 - .../sessionApplication.integration.test.ts | 80 +++---------------- .../turnCoordinator.test.ts | 1 - .../sqlitePresenter/newSessionsTable.test.ts | 50 ++++++++---- test/main/routes/chatService.test.ts | 1 - test/main/routes/contracts.test.ts | 15 +++- test/main/routes/dispatcher.test.ts | 3 - test/main/shared/deepchatSubagents.test.ts | 2 - .../renderer/components/ChatStatusBar.test.ts | 70 +++------------- test/renderer/components/McpIndicator.test.ts | 22 +---- .../NewThreadPage.onboarding.test.ts | 1 - .../stores/memoryActivityStore.test.ts | 1 - test/renderer/stores/sessionStore.test.ts | 3 - 44 files changed, 115 insertions(+), 486 deletions(-) diff --git a/src/main/agent/shared/appSessionService.ts b/src/main/agent/shared/appSessionService.ts index 1a59f8c81..d64c9e510 100644 --- a/src/main/agent/shared/appSessionService.ts +++ b/src/main/agent/shared/appSessionService.ts @@ -62,7 +62,6 @@ export class AppSessionService implements AppSessionReadPort { options?: { isDraft?: boolean disabledAgentTools?: string[] - subagentEnabled?: boolean sessionKind?: SessionKind parentSessionId?: string | null subagentMeta?: DeepChatSubagentMeta | null @@ -73,7 +72,6 @@ export class AppSessionService implements AppSessionReadPort { this.dependencies.newSessionsTable.create(id, agentId, title, projectDir, { isDraft: options?.isDraft, disabledAgentTools: options?.disabledAgentTools, - subagentEnabled: options?.subagentEnabled, sessionKind: options?.sessionKind, parentSessionId: options?.parentSessionId, subagentMetaJson: options?.subagentMeta ? JSON.stringify(options.subagentMeta) : null @@ -153,7 +151,6 @@ export class AppSessionService implements AppSessionReadPort { | 'isDraft' | 'sessionKind' | 'parentSessionId' - | 'subagentEnabled' | 'subagentMeta' > > @@ -170,7 +167,6 @@ export class AppSessionService implements AppSessionReadPort { project_dir?: string | null is_pinned?: number is_draft?: number - subagent_enabled?: number session_kind?: SessionKind parent_session_id?: string | null subagent_meta_json?: string | null @@ -179,9 +175,6 @@ export class AppSessionService implements AppSessionReadPort { if (fields.projectDir !== undefined) dbFields.project_dir = fields.projectDir if (fields.isPinned !== undefined) dbFields.is_pinned = fields.isPinned ? 1 : 0 if (fields.isDraft !== undefined) dbFields.is_draft = fields.isDraft ? 1 : 0 - if (fields.subagentEnabled !== undefined) { - dbFields.subagent_enabled = fields.subagentEnabled ? 1 : 0 - } if (fields.sessionKind !== undefined) dbFields.session_kind = fields.sessionKind if (fields.parentSessionId !== undefined) { dbFields.parent_session_id = fields.parentSessionId @@ -254,7 +247,6 @@ export class AppSessionService implements AppSessionReadPort { is_draft: number session_kind: string parent_session_id: string | null - subagent_enabled: number subagent_meta_json: string | null created_at: number updated_at: number @@ -269,7 +261,6 @@ export class AppSessionService implements AppSessionReadPort { isDraft: row.is_draft === 1, sessionKind: row.session_kind === 'subagent' ? 'subagent' : 'regular', parentSessionId: row.parent_session_id ?? null, - subagentEnabled: row.subagent_enabled === 1, subagentMeta: parseSubagentMeta(row.subagent_meta_json), createdAt: row.created_at, updatedAt: row.updated_at, diff --git a/src/main/presenter/agentRuntimePresenter/toolResolver.ts b/src/main/presenter/agentRuntimePresenter/toolResolver.ts index 04148245b..7083e671c 100644 --- a/src/main/presenter/agentRuntimePresenter/toolResolver.ts +++ b/src/main/presenter/agentRuntimePresenter/toolResolver.ts @@ -218,8 +218,7 @@ export class DeepChatToolResolver { agentType: 'deepchat', sessionKind: sessionRow?.session_kind ?? null, agentPolicyEnabled: config?.subagentEnabled !== false, - slots: config?.subagents, - legacySessionPolicyEnabled: sessionRow?.subagent_enabled === 1 + slots: config?.subagents }) if (typeof this.dependencies.configPresenter.resolveDeepChatAgentConfig !== 'function') { diff --git a/src/main/presenter/cronJobs/runSessionStarter.ts b/src/main/presenter/cronJobs/runSessionStarter.ts index 054f5d47a..231887543 100644 --- a/src/main/presenter/cronJobs/runSessionStarter.ts +++ b/src/main/presenter/cronJobs/runSessionStarter.ts @@ -42,7 +42,6 @@ export const createCronJobRunSessionStarter = (deps: { defaultModelPreset?: { providerId?: string; modelId?: string } | null permissionMode?: 'default' | 'auto_approve' | 'full_access' disabledAgentTools?: string[] - subagentEnabled?: boolean systemPrompt?: string } | null @@ -65,9 +64,6 @@ export const createCronJobRunSessionStarter = (deps: { ...(job.toolPolicy === 'snapshot' && snapshotConfig?.disabledAgentTools ? { disabledAgentTools: snapshotConfig.disabledAgentTools } : {}), - ...(snapshotConfig?.subagentEnabled !== undefined - ? { subagentEnabled: snapshotConfig.subagentEnabled } - : {}), ...(systemPrompt ? { generationSettings: { systemPrompt } } : {}), metadata: { source: 'cron_job', diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index 6f8cf31e8..3cf44e0ba 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -337,8 +337,7 @@ export class Presenter implements IPresenter { agentType, sessionKind: session.sessionKind, agentPolicyEnabled: subagentConfig?.subagentEnabled !== false, - slots: subagentConfig?.subagents, - legacySessionPolicyEnabled: session.subagentEnabled + slots: subagentConfig?.subagents }) return { diff --git a/src/main/presenter/sessionApplication/agentAssignmentCoordinator.ts b/src/main/presenter/sessionApplication/agentAssignmentCoordinator.ts index fce174b87..7300fb835 100644 --- a/src/main/presenter/sessionApplication/agentAssignmentCoordinator.ts +++ b/src/main/presenter/sessionApplication/agentAssignmentCoordinator.ts @@ -272,29 +272,6 @@ export class SessionAgentAssignmentCoordinator .handle.settings.setPermissionMode(mode) } - async setSessionSubagentEnabled(sessionId: string, enabled: boolean): Promise { - const session = this.requireSession(sessionId) - if (session.sessionKind !== 'regular') { - throw new Error('Only regular sessions can change subagent state.') - } - - if (this.dependencies.runtime.getSessionAgentKind(toAppSessionId(sessionId)) !== 'deepchat') { - throw new Error('Only DeepChat sessions can change subagent state.') - } - - this.dependencies.sessions.update(sessionId, { subagentEnabled: enabled }) - if (!this.dependencies.sessions.get(sessionId)) { - throw new Error(`Session not found after update: ${sessionId}`) - } - - this.dependencies.projection.notify({ sessionIds: [sessionId], reason: 'updated' }) - const materialized = await this.dependencies.projection.materialize(sessionId) - if (!materialized) { - throw new Error(`Failed to build session state for sessionId: ${sessionId}`) - } - return materialized - } - async setSessionModel( sessionId: string, providerId: string, @@ -521,8 +498,7 @@ export class SessionAgentAssignmentCoordinator this.dependencies.sessions.updateAgentId(sessionId, target.agentId) this.dependencies.sessions.update(sessionId, { - projectDir: target.projectDir, - subagentEnabled: session.sessionKind === 'regular' ? target.subagentEnabled : false + projectDir: target.projectDir }) this.dependencies.sessions.updateDisabledAgentTools(sessionId, target.disabledAgentTools) await this.syncAcpSessionWorkdir( diff --git a/src/main/presenter/sessionApplication/agentAssignmentPolicy.ts b/src/main/presenter/sessionApplication/agentAssignmentPolicy.ts index 7926937da..660fcc23f 100644 --- a/src/main/presenter/sessionApplication/agentAssignmentPolicy.ts +++ b/src/main/presenter/sessionApplication/agentAssignmentPolicy.ts @@ -69,11 +69,7 @@ export class SessionAgentAssignmentPolicy implements SessionAssignmentPolicyPort disabledAgentTools: descriptor.kind === 'deepchat' ? normalizeDisabledAgentTools(input.disabledAgentTools ?? agentConfig?.disabledAgentTools) - : [], - subagentEnabled: - descriptor.kind === 'deepchat' - ? (input.subagentEnabled ?? agentConfig?.subagentEnabled ?? false) - : false + : [] } } @@ -198,8 +194,7 @@ export class SessionAgentAssignmentPolicy implements SessionAssignmentPolicyPort null, permissionMode: resolveAssignmentPermissionMode(agentConfig?.permissionMode), generationSettings: this.mergeDefaultGenerationSettings(agentConfig), - disabledAgentTools: normalizeDisabledAgentTools(agentConfig?.disabledAgentTools), - subagentEnabled: agentConfig?.subagentEnabled === true + disabledAgentTools: normalizeDisabledAgentTools(agentConfig?.disabledAgentTools) } } diff --git a/src/main/presenter/sessionApplication/lifecycleCoordinator.ts b/src/main/presenter/sessionApplication/lifecycleCoordinator.ts index 832248f33..8e59af60a 100644 --- a/src/main/presenter/sessionApplication/lifecycleCoordinator.ts +++ b/src/main/presenter/sessionApplication/lifecycleCoordinator.ts @@ -52,7 +52,6 @@ export class SessionLifecycleCoordinator implements SessionLifecyclePort { permissionMode: input.permissionMode, generationSettings: input.generationSettings, disabledAgentTools: input.disabledAgentTools, - subagentEnabled: input.subagentEnabled, preserveExplicitNullProjectDir: true }) const { @@ -62,8 +61,7 @@ export class SessionLifecycleCoordinator implements SessionLifecyclePort { projectDir, permissionMode, generationSettings, - disabledAgentTools, - subagentEnabled + disabledAgentTools } = assignment logger.info( `[SessionLifecycleCoordinator] createSession agent=${agentId} webContentsId=${webContentsId}` @@ -74,8 +72,7 @@ export class SessionLifecycleCoordinator implements SessionLifecyclePort { const title = normalizedInput.text.slice(0, 50) || 'New Chat' const sessionId = this.dependencies.sessions.create(agentId, title, projectDir, { isDraft: false, - disabledAgentTools, - subagentEnabled + disabledAgentTools }) logger.info(`[SessionLifecycleCoordinator] session created id=${sessionId}`) @@ -112,7 +109,6 @@ export class SessionLifecycleCoordinator implements SessionLifecyclePort { isDraft: false, sessionKind: 'regular', parentSessionId: null, - subagentEnabled, subagentMeta: null, createdAt: Date.now(), updatedAt: Date.now(), @@ -141,8 +137,7 @@ export class SessionLifecycleCoordinator implements SessionLifecyclePort { projectDir, permissionMode, generationSettings, - disabledAgentTools, - subagentEnabled + disabledAgentTools } = await this.dependencies.assignmentPolicy.resolveCreateAssignment({ agentId: input.agentId?.trim() || 'deepchat', providerId: input.providerId, @@ -151,14 +146,12 @@ export class SessionLifecycleCoordinator implements SessionLifecyclePort { permissionMode: input.permissionMode, generationSettings: input.generationSettings, disabledAgentTools: input.disabledAgentTools, - subagentEnabled: input.subagentEnabled, preserveExplicitNullProjectDir: false }) const sessionId = this.dependencies.sessions.create(agentId, title, projectDir, { isDraft: false, disabledAgentTools, - subagentEnabled, metadata: input.metadata ?? null }) try { @@ -190,7 +183,6 @@ export class SessionLifecycleCoordinator implements SessionLifecyclePort { isDraft: false, sessionKind: 'regular', parentSessionId: null, - subagentEnabled, subagentMeta: null, createdAt: Date.now(), updatedAt: Date.now(), @@ -240,7 +232,6 @@ export class SessionLifecycleCoordinator implements SessionLifecyclePort { { isDraft: false, disabledAgentTools: runtimeConfig.disabledAgentTools, - subagentEnabled: false, sessionKind: 'subagent', parentSessionId, subagentMeta @@ -308,7 +299,7 @@ export class SessionLifecycleCoordinator implements SessionLifecyclePort { canonicalAgentId, 'New Chat', projectDir, - { isDraft: true, subagentEnabled: false } + { isDraft: true } ) try { await this.ensureSessionRuntimeInitialized(sessionId, { diff --git a/src/main/presenter/sessionApplication/ports.ts b/src/main/presenter/sessionApplication/ports.ts index 0fc33e007..ef2450396 100644 --- a/src/main/presenter/sessionApplication/ports.ts +++ b/src/main/presenter/sessionApplication/ports.ts @@ -296,7 +296,6 @@ export interface CreateAssignmentInput { permissionMode?: PermissionMode generationSettings?: Partial disabledAgentTools?: string[] - subagentEnabled?: boolean preserveExplicitNullProjectDir: boolean } @@ -309,7 +308,6 @@ export interface ResolvedSessionAssignment { permissionMode: PermissionMode generationSettings?: Partial disabledAgentTools: string[] - subagentEnabled: boolean } export interface SubagentAssignmentInput { @@ -344,7 +342,6 @@ export interface ResolvedTransferTarget { permissionMode: PermissionMode generationSettings?: Partial disabledAgentTools: string[] - subagentEnabled: boolean } export interface SessionAssignmentPolicyPort { @@ -364,10 +361,7 @@ export interface SessionAssignmentPolicyPort { export interface SessionAssignmentStorePort { get(sessionId: string): SessionRecord | null list(filters?: SessionListFilters): SessionRecord[] - update( - sessionId: string, - fields: Partial> - ): void + update(sessionId: string, fields: Partial>): void updateAgentId(sessionId: string, agentId: string): void getDisabledAgentTools(sessionId: string): string[] updateDisabledAgentTools(sessionId: string, disabledAgentTools: string[]): void @@ -414,7 +408,6 @@ export interface SessionLifecycleStorePort { options?: { isDraft?: boolean disabledAgentTools?: string[] - subagentEnabled?: boolean sessionKind?: SessionKind parentSessionId?: string | null subagentMeta?: DeepChatSubagentMeta | null @@ -520,7 +513,6 @@ export interface SessionAgentAssignmentPort { ): Promise getPermissionMode(sessionId: string): Promise setPermissionMode(sessionId: string, mode: PermissionMode): Promise - setSessionSubagentEnabled(sessionId: string, enabled: boolean): Promise setSessionModel(sessionId: string, providerId: string, modelId: string): Promise setSessionProjectDir(sessionId: string, projectDir: string | null): Promise getSessionGenerationSettings(sessionId: string): Promise diff --git a/src/main/presenter/sqlitePresenter/tables/newSessions.ts b/src/main/presenter/sqlitePresenter/tables/newSessions.ts index bae517ad4..7c945ede7 100644 --- a/src/main/presenter/sqlitePresenter/tables/newSessions.ts +++ b/src/main/presenter/sqlitePresenter/tables/newSessions.ts @@ -110,7 +110,6 @@ export class NewSessionsTable extends BaseTable { isPinned?: boolean activeSkills?: string[] disabledAgentTools?: string[] - subagentEnabled?: boolean sessionKind?: 'regular' | 'subagent' parentSessionId?: string | null subagentMetaJson?: string | null @@ -132,13 +131,12 @@ export class NewSessionsTable extends BaseTable { is_draft, active_skills, disabled_agent_tools, - subagent_enabled, session_kind, parent_session_id, subagent_meta_json, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .run( id, @@ -149,7 +147,6 @@ export class NewSessionsTable extends BaseTable { options?.isDraft ? 1 : 0, JSON.stringify(options?.activeSkills ?? []), JSON.stringify(options?.disabledAgentTools ?? []), - options?.subagentEnabled ? 1 : 0, options?.sessionKind === 'subagent' ? 'subagent' : 'regular', options?.parentSessionId ?? null, options?.subagentMetaJson ?? null, @@ -273,7 +270,6 @@ export class NewSessionsTable extends BaseTable { | 'is_draft' | 'active_skills' | 'disabled_agent_tools' - | 'subagent_enabled' | 'session_kind' | 'parent_session_id' | 'subagent_meta_json' @@ -307,10 +303,6 @@ export class NewSessionsTable extends BaseTable { setClauses.push('disabled_agent_tools = ?') params.push(fields.disabled_agent_tools) } - if (fields.subagent_enabled !== undefined) { - setClauses.push('subagent_enabled = ?') - params.push(fields.subagent_enabled) - } if (fields.session_kind !== undefined) { setClauses.push('session_kind = ?') params.push(fields.session_kind) diff --git a/src/main/routes/debug/createMockChatSession.ts b/src/main/routes/debug/createMockChatSession.ts index 84ac39cfa..3be4bfe9f 100644 --- a/src/main/routes/debug/createMockChatSession.ts +++ b/src/main/routes/debug/createMockChatSession.ts @@ -97,13 +97,12 @@ export function createDebugMockChatSession(db: DebugMockChatDatabase): DebugMock is_draft, active_skills, disabled_agent_tools, - subagent_enabled, session_kind, parent_session_id, subagent_meta_json, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) const insertDeepchatSession = db.prepare( `INSERT INTO deepchat_sessions ( @@ -138,7 +137,6 @@ export function createDebugMockChatSession(db: DebugMockChatDatabase): DebugMock 0, '[]', '[]', - 0, 'regular', null, null, diff --git a/src/main/routes/index.ts b/src/main/routes/index.ts index fd1ae308e..1519dc7f2 100644 --- a/src/main/routes/index.ts +++ b/src/main/routes/index.ts @@ -292,7 +292,6 @@ import { sessionsSetModelRoute, sessionsSetPermissionModeRoute, sessionsSetProjectDirRoute, - sessionsSetSubagentEnabledRoute, sessionsSteerPendingInputRoute, sessionsTogglePinnedRoute, sessionsTranslateTextRoute, @@ -3203,15 +3202,6 @@ export async function dispatchDeepchatRoute( return sessionsSetPermissionModeRoute.output.parse({ updated: true }) } - case sessionsSetSubagentEnabledRoute.name: { - const input = sessionsSetSubagentEnabledRoute.input.parse(rawInput) - const session = await runtime.sessionAssignmentPort.setSessionSubagentEnabled( - input.sessionId, - input.enabled - ) - return sessionsSetSubagentEnabledRoute.output.parse({ session }) - } - case sessionsSetModelRoute.name: { const input = sessionsSetModelRoute.input.parse(rawInput) const session = await runtime.sessionAssignmentPort.setSessionModel( diff --git a/src/renderer/api/SessionClient.ts b/src/renderer/api/SessionClient.ts index ff028e851..a6186d9b9 100644 --- a/src/renderer/api/SessionClient.ts +++ b/src/renderer/api/SessionClient.ts @@ -56,7 +56,6 @@ import { sessionsSetModelRoute, sessionsSetPermissionModeRoute, sessionsSetProjectDirRoute, - sessionsSetSubagentEnabledRoute, sessionsSteerPendingInputRoute, sessionsTogglePinnedRoute, sessionsTranslateTextRoute, @@ -396,14 +395,6 @@ export function createSessionClient(bridge: DeepchatBridge = getDeepchatBridge() await bridge.invoke(sessionsSetPermissionModeRoute.name, { sessionId, mode }) } - async function setSessionSubagentEnabled(sessionId: string, enabled: boolean) { - const result = await bridge.invoke(sessionsSetSubagentEnabledRoute.name, { - sessionId, - enabled - }) - return result.session - } - async function setSessionModel(sessionId: string, providerId: string, modelId: string) { const result = await bridge.invoke(sessionsSetModelRoute.name, { sessionId, @@ -593,7 +584,6 @@ export function createSessionClient(bridge: DeepchatBridge = getDeepchatBridge() setAcpSessionConfigOption, getPermissionMode, setPermissionMode, - setSessionSubagentEnabled, setSessionModel, setSessionProjectDir, getSessionGenerationSettings, diff --git a/src/renderer/src/components/chat-input/McpIndicator.vue b/src/renderer/src/components/chat-input/McpIndicator.vue index f3f322bbd..0393fad95 100644 --- a/src/renderer/src/components/chat-input/McpIndicator.vue +++ b/src/renderer/src/components/chat-input/McpIndicator.vue @@ -293,18 +293,11 @@ import { useDraftStore } from '@/stores/ui/draft' import { useAgentStore } from '@/stores/ui/agent' import { useProjectStore } from '@/stores/ui/project' -type ToolGroupItem = - | { - kind: 'tool' - id: string - label: string - toolName: string - } - | { - kind: 'subagent' - id: 'subagent' - label: string - } +type ToolGroupItem = { + id: string + label: string + toolName: string +} type ToolGroup = { name: string @@ -332,25 +325,18 @@ const props = withDefaults( systemPromptOptions?: SystemPromptMenuOption[] selectedSystemPromptId?: string showCustomSystemPromptBadge?: boolean - showSubagentToggle?: boolean - subagentEnabled?: boolean - subagentTogglePending?: boolean }>(), { showSystemPromptSection: false, systemPromptOptions: () => [], selectedSystemPromptId: 'empty', - showCustomSystemPromptBadge: false, - showSubagentToggle: false, - subagentEnabled: false, - subagentTogglePending: false + showCustomSystemPromptBadge: false } ) const emit = defineEmits<{ (e: 'select-system-prompt', optionId: string): void (e: 'open-change', open: boolean): void - (e: 'toggle-subagents', enabled: boolean): void }>() const { t } = useI18n() @@ -475,7 +461,6 @@ const groupedAgentTools = computed(() => { for (const tool of agentTools.value) { const existing = groups.get(tool.server.name) ?? [] existing.push({ - kind: 'tool', id: tool.function.name, label: tool.function.name, toolName: tool.function.name @@ -483,16 +468,6 @@ const groupedAgentTools = computed(() => { groups.set(tool.server.name, existing) } - if (props.showSubagentToggle) { - const existing = groups.get('agent-core') ?? [] - existing.push({ - kind: 'subagent', - id: 'subagent', - label: t('chat.subagents.label') - }) - groups.set('agent-core', existing) - } - return Array.from(groups.entries()) .map(([name, items]) => ({ name, @@ -518,12 +493,9 @@ const groupedAgentTools = computed(() => { const isToolEnabled = (toolName: string) => !disabledToolNames.value.includes(toolName) const isToolPending = (toolName: string) => pendingToolNames.value.includes(toolName) -const isGroupItemEnabled = (item: ToolGroupItem) => - item.kind === 'subagent' ? props.subagentEnabled : isToolEnabled(item.toolName) -const isGroupItemPending = (item: ToolGroupItem) => - item.kind === 'subagent' ? props.subagentTogglePending : isToolPending(item.toolName) -const getGroupToolNames = (group: ToolGroup) => - group.items.flatMap((item) => (item.kind === 'tool' ? [item.toolName] : [])) +const isGroupItemEnabled = (item: ToolGroupItem) => isToolEnabled(item.toolName) +const isGroupItemPending = (item: ToolGroupItem) => isToolPending(item.toolName) +const getGroupToolNames = (group: ToolGroup) => group.items.map((item) => item.toolName) const isGroupEnabled = (group: ToolGroup) => group.items.some((item) => isGroupItemEnabled(item)) const isGroupPending = (group: ToolGroup) => group.items.some((item) => isGroupItemPending(item)) @@ -651,15 +623,6 @@ const toggleAgentTool = async (toolName: string) => { } const toggleGroupItem = async (item: ToolGroupItem) => { - if (item.kind === 'subagent') { - if (!isDeepchatContext.value || props.subagentTogglePending) { - return - } - - emit('toggle-subagents', !props.subagentEnabled) - return - } - await toggleAgentTool(item.toolName) } @@ -681,20 +644,11 @@ const setGroupEnabled = async (group: ToolGroup, enabled: boolean) => { const nextList = Array.from(nextDisabledTools).sort((left, right) => left.localeCompare(right)) const shouldUpdateTools = nextList.join('\n') !== disabledToolNames.value.join('\n') - const shouldUpdateSubagents = - group.items.some((item) => item.kind === 'subagent') && props.subagentEnabled !== enabled - - if (!shouldUpdateTools && !shouldUpdateSubagents) { + if (!shouldUpdateTools) { return } - if (shouldUpdateTools) { - await persistDisabledTools(nextList, groupToolNames) - } - - if (shouldUpdateSubagents) { - emit('toggle-subagents', enabled) - } + await persistDisabledTools(nextList, groupToolNames) } const handleSkillRuntimeChange = (payload: { diff --git a/src/renderer/src/components/chat/ChatStatusBar.vue b/src/renderer/src/components/chat/ChatStatusBar.vue index 40a26747c..5eda711e4 100644 --- a/src/renderer/src/components/chat/ChatStatusBar.vue +++ b/src/renderer/src/components/chat/ChatStatusBar.vue @@ -932,12 +932,8 @@ :system-prompt-options="systemPromptMenuOptions" :selected-system-prompt-id="selectedSystemPromptId" :show-custom-system-prompt-badge="selectedSystemPromptId === '__custom__'" - :show-subagent-toggle="showSubagentToggle" - :subagent-enabled="subagentEnabled" - :subagent-toggle-pending="isSubagentToggleUpdating" @select-system-prompt="onSystemPromptSelect" @open-change="handleSessionPanelOpenChange" - @toggle-subagents="onSubagentToggle" /> @@ -1143,7 +1139,6 @@ const { t } = useI18n() const draftModelSelection = ref(null) const permissionMode = ref('full_access') -const subagentEnabled = ref(false) const localSettings = ref(null) const loadedSettingsSelection = ref(null) const systemPromptList = ref([]) @@ -1170,7 +1165,6 @@ let generationPersistRequestToken = 0 let generationLocalRevision = 0 let unsubscribeAcpConfigOptionsReady: (() => void) | null = null let cancelAcpConfigSyncTask: (() => void) | null = null -const isSubagentToggleUpdating = ref(false) const { numericInputDrafts, @@ -1322,20 +1316,6 @@ const moonshotKimiTemperatureHint = computed(() => ) const canSelectPermissionMode = computed(() => !isAcpAgent.value) -const showSubagentToggle = computed(() => { - if (isAcpAgent.value) { - return false - } - - if (hasActiveSession.value) { - return ( - sessionStore.activeSession?.sessionKind === 'regular' && - inferAgentType(sessionStore.activeSession?.agentId) === 'deepchat' - ) - } - - return selectedAgentType.value === 'deepchat' -}) const providerNameMap = computed(() => { const map = new Map() @@ -2350,29 +2330,6 @@ watch( { immediate: true } ) -watch( - [ - () => sessionStore.activeSessionId, - showSubagentToggle, - () => sessionStore.activeSession?.subagentEnabled, - () => draftStore.subagentEnabled - ], - ([sessionId, canShow, activeEnabled, draftEnabled]) => { - if (!canShow) { - subagentEnabled.value = false - return - } - - if (sessionId) { - subagentEnabled.value = activeEnabled === true - return - } - - subagentEnabled.value = draftEnabled === true - }, - { immediate: true } -) - // Prefer revision/fingerprint deps (no deep watch). Generation settings already // self-coalesce via generationSyncQueued; ACP config uses deferred task cancel. watch( @@ -2999,34 +2956,10 @@ async function selectPermissionMode(mode: PermissionMode) { } } -async function onSubagentToggle(enabled: boolean) { - if (!showSubagentToggle.value || subagentEnabled.value === enabled) { - return - } - - subagentEnabled.value = enabled - const sessionId = sessionStore.activeSessionId - if (!sessionId) { - draftStore.subagentEnabled = enabled - return - } - - isSubagentToggleUpdating.value = true - try { - await sessionStore.setSessionSubagentEnabled(sessionId, enabled) - } catch (error) { - console.warn('[ChatStatusBar] Failed to set subagent toggle:', error) - subagentEnabled.value = sessionStore.activeSession?.subagentEnabled === true - } finally { - isSubagentToggleUpdating.value = false - } -} - defineExpose({ acpConfigState, localSettings, permissionMode, - subagentEnabled, showSystemPromptSection, showReasoningEffort, onTemperatureInput, diff --git a/src/renderer/src/pages/NewThreadPage.vue b/src/renderer/src/pages/NewThreadPage.vue index 2a76e50a3..960932497 100644 --- a/src/renderer/src/pages/NewThreadPage.vue +++ b/src/renderer/src/pages/NewThreadPage.vue @@ -838,7 +838,6 @@ async function submitText(text: string, files: MessageFile[]) { const isAcp = isAcpSelectedAgent.value const draftPermissionMode = draftStore.permissionMode const draftDisabledAgentTools = [...draftStore.disabledAgentTools] - const draftSubagentEnabled = draftStore.subagentEnabled const draftGenerationSettings = draftStore.toGenerationSettings() try { @@ -889,7 +888,6 @@ async function submitText(text: string, files: MessageFile[]) { modelId, permissionMode: draftPermissionMode, disabledAgentTools: isAcp ? undefined : draftDisabledAgentTools, - subagentEnabled: isAcp ? false : draftSubagentEnabled, generationSettings: draftGenerationSettings, activeSkills: messagePayload.activeSkills }) @@ -943,7 +941,6 @@ const applyDraftDefaultsForSelectedAgent = async (requestSeq: number): Promise { ) const permissionMode = ref('full_access') const disabledAgentTools = ref([...DEFAULT_DISABLED_AGENT_TOOLS]) - const subagentEnabled = ref(false) const pendingStartDeeplink = ref(null) let nextStartToken = 0 @@ -104,7 +103,6 @@ export const useDraftStore = defineStore('draft', () => { modelId: modelId.value, permissionMode: permissionMode.value, disabledAgentTools: [...disabledAgentTools.value], - subagentEnabled: subagentEnabled.value, generationSettings: toGenerationSettings() } } @@ -174,7 +172,6 @@ export const useDraftStore = defineStore('draft', () => { agentId.value = 'deepchat' permissionMode.value = 'full_access' disabledAgentTools.value = [...DEFAULT_DISABLED_AGENT_TOOLS] - subagentEnabled.value = false resetGenerationSettings() } @@ -213,7 +210,6 @@ export const useDraftStore = defineStore('draft', () => { videoGeneration, permissionMode, disabledAgentTools, - subagentEnabled, pendingStartDeeplink, toGenerationSettings, toCreateInput, diff --git a/src/renderer/src/stores/ui/session.ts b/src/renderer/src/stores/ui/session.ts index 4f76a08b9..1356a6423 100644 --- a/src/renderer/src/stores/ui/session.ts +++ b/src/renderer/src/stores/ui/session.ts @@ -40,7 +40,6 @@ export interface UISession { isDraft: boolean sessionKind: SessionKind parentSessionId: string | null - subagentEnabled: boolean subagentMeta: DeepChatSubagentMeta | null metadata?: SessionMetadata | null createdAt: number @@ -107,7 +106,6 @@ function mapToUISession(session: SessionListItem | SessionWithState): UISession isDraft: Boolean(session.isDraft), sessionKind: session.sessionKind, parentSessionId: session.parentSessionId ?? null, - subagentEnabled: session.subagentEnabled, subagentMeta: session.subagentMeta ?? null, ...(metadata ? { metadata } : {}), createdAt: session.createdAt, @@ -870,20 +868,6 @@ export const useSessionStore = defineStore('session', () => { } } - async function setSessionSubagentEnabled(sessionId: string, enabled: boolean): Promise { - error.value = null - try { - const updated = await sessionClient.setSessionSubagentEnabled(sessionId, enabled) - upsertSessions([mapToUISession(updated)]) - if (activeSessionId.value === sessionId) { - applyRestoredSession(updated) - } - } catch (updateError) { - error.value = `Failed to update subagent state: ${updateError}` - throw updateError - } - } - async function setSessionProjectDir(sessionId: string, projectDir: string | null): Promise { error.value = null try { @@ -1125,7 +1109,6 @@ export const useSessionStore = defineStore('session', () => { clearSessionMessages, exportSession, deleteSession, - setSessionSubagentEnabled, setSessionProjectDir, moveSessionToAgent, toggleGroupMode, diff --git a/src/shared/contracts/common.ts b/src/shared/contracts/common.ts index 4ba1d9322..4d5f99d8c 100644 --- a/src/shared/contracts/common.ts +++ b/src/shared/contracts/common.ts @@ -233,7 +233,6 @@ export const SessionWithStateSchema = z.object({ isDraft: z.boolean().optional(), sessionKind: SessionKindSchema, parentSessionId: EntityIdSchema.nullable().optional(), - subagentEnabled: z.boolean(), subagentMeta: DeepChatSubagentMetaSchema.optional(), createdAt: TimestampMsSchema, updatedAt: TimestampMsSchema, diff --git a/src/shared/contracts/routes.ts b/src/shared/contracts/routes.ts index 7472ac1ec..80514baab 100644 --- a/src/shared/contracts/routes.ts +++ b/src/shared/contracts/routes.ts @@ -389,7 +389,6 @@ import { sessionsSetModelRoute, sessionsSetPermissionModeRoute, sessionsSetProjectDirRoute, - sessionsSetSubagentEnabledRoute, sessionsSteerPendingInputRoute, sessionsTogglePinnedRoute, sessionsTranslateTextRoute, @@ -820,7 +819,6 @@ const DEEPCHAT_ROUTE_CATALOG_PART_4 = { [sessionsSetAcpSessionConfigOptionRoute.name]: sessionsSetAcpSessionConfigOptionRoute, [sessionsGetPermissionModeRoute.name]: sessionsGetPermissionModeRoute, [sessionsSetPermissionModeRoute.name]: sessionsSetPermissionModeRoute, - [sessionsSetSubagentEnabledRoute.name]: sessionsSetSubagentEnabledRoute, [sessionsSetModelRoute.name]: sessionsSetModelRoute, [sessionsSetProjectDirRoute.name]: sessionsSetProjectDirRoute, [sessionsGetGenerationSettingsRoute.name]: sessionsGetGenerationSettingsRoute, diff --git a/src/shared/contracts/routes/sessions.routes.ts b/src/shared/contracts/routes/sessions.routes.ts index 54916483d..513d6cb79 100644 --- a/src/shared/contracts/routes/sessions.routes.ts +++ b/src/shared/contracts/routes/sessions.routes.ts @@ -94,7 +94,6 @@ export const CreateSessionInputSchema = z.object({ permissionMode: PermissionModeSchema.optional(), activeSkills: z.array(z.string()).optional(), disabledAgentTools: z.array(z.string()).optional(), - subagentEnabled: z.boolean().optional(), generationSettings: SessionGenerationSettingsPatchSchema.optional() }) @@ -602,17 +601,6 @@ export const sessionsSetPermissionModeRoute = defineRouteContract({ }) }) -export const sessionsSetSubagentEnabledRoute = defineRouteContract({ - name: 'sessions.setSubagentEnabled', - input: z.object({ - sessionId: EntityIdSchema, - enabled: z.boolean() - }), - output: z.object({ - session: SessionWithStateSchema - }) -}) - export const sessionsSetModelRoute = defineRouteContract({ name: 'sessions.setModel', input: z.object({ diff --git a/src/shared/lib/deepchatSubagents.ts b/src/shared/lib/deepchatSubagents.ts index 447be6df2..dfa3ee773 100644 --- a/src/shared/lib/deepchatSubagents.ts +++ b/src/shared/lib/deepchatSubagents.ts @@ -19,8 +19,6 @@ export interface ResolveDeepChatSubagentCapabilityInput { sessionKind: SessionKind | null | undefined agentPolicyEnabled: boolean slots?: DeepChatSubagentSlot[] | null - /** Compatibility bridge removed with the legacy Session-level policy. */ - legacySessionPolicyEnabled?: boolean } export const createDefaultDeepChatSelfSubagentSlot = (): DeepChatSubagentSlot => ({ @@ -155,7 +153,7 @@ export const resolveDeepChatSubagentCapability = ( return createUnavailableSubagentCapability('unsupported_session') } - if (input.agentPolicyEnabled === false || input.legacySessionPolicyEnabled === false) { + if (input.agentPolicyEnabled === false) { return createUnavailableSubagentCapability('policy_disabled') } diff --git a/src/shared/types/agent-interface.d.ts b/src/shared/types/agent-interface.d.ts index b4872e4f0..c65f5e666 100644 --- a/src/shared/types/agent-interface.d.ts +++ b/src/shared/types/agent-interface.d.ts @@ -672,7 +672,6 @@ export interface SessionRecord { isDraft?: boolean sessionKind: SessionKind parentSessionId?: string | null - subagentEnabled: boolean subagentMeta?: DeepChatSubagentMeta | null createdAt: number updatedAt: number @@ -767,7 +766,6 @@ export interface CreateSessionInput { permissionMode?: PermissionMode activeSkills?: string[] disabledAgentTools?: string[] - subagentEnabled?: boolean generationSettings?: Partial } @@ -780,7 +778,6 @@ export interface CreateDetachedSessionInput { permissionMode?: PermissionMode activeSkills?: string[] disabledAgentTools?: string[] - subagentEnabled?: boolean generationSettings?: Partial metadata?: SessionMetadata | null } diff --git a/test/main/agent/shared/appSessionService.test.ts b/test/main/agent/shared/appSessionService.test.ts index 6769954e0..556eeb0ec 100644 --- a/test/main/agent/shared/appSessionService.test.ts +++ b/test/main/agent/shared/appSessionService.test.ts @@ -55,7 +55,6 @@ describe('AppSessionService', () => { { isDraft: undefined, disabledAgentTools: undefined, - subagentEnabled: undefined, sessionKind: undefined, parentSessionId: undefined, subagentMetaJson: null @@ -103,6 +102,7 @@ describe('AppSessionService', () => { project_dir: '/tmp/proj', is_pinned: 1, is_draft: 0, + subagent_enabled: 1, created_at: 1000, updated_at: 2000 }) @@ -118,11 +118,11 @@ describe('AppSessionService', () => { isDraft: false, sessionKind: 'regular', parentSessionId: null, - subagentEnabled: false, subagentMeta: null, createdAt: 1000, updatedAt: 2000 }) + expect(record).not.toHaveProperty('subagentEnabled') }) it('returns stored metadata when present', () => { diff --git a/test/main/presenter/agentRuntimePresenter/toolResolver.test.ts b/test/main/presenter/agentRuntimePresenter/toolResolver.test.ts index 42ba8a83b..f56d9f4f2 100644 --- a/test/main/presenter/agentRuntimePresenter/toolResolver.test.ts +++ b/test/main/presenter/agentRuntimePresenter/toolResolver.test.ts @@ -34,7 +34,7 @@ describe('DeepChatToolResolver Subagent capability', () => { } const row = { session_kind: 'regular' as const, - subagent_enabled: 1 + subagent_enabled: 0 } const getAllToolDefinitions = vi.fn().mockResolvedValue([]) const resourceInstance = createResourceInstance() @@ -80,6 +80,15 @@ describe('DeepChatToolResolver Subagent capability', () => { slots: [expect.objectContaining({ id: 'reviewer' })] }) + row.subagent_enabled = 1 + await resolver.loadToolDefinitionsForSession( + 'session-1', + null, + undefined, + resourceInstance as any + ) + expect(getAllToolDefinitions).toHaveBeenCalledTimes(1) + config = { ...config, subagents: [{ ...config.subagents![0], description: 'Review security boundaries.' }] diff --git a/test/main/presenter/cronJobRunSessionStarter.test.ts b/test/main/presenter/cronJobRunSessionStarter.test.ts index 93d4b9df8..bf7f2a6c7 100644 --- a/test/main/presenter/cronJobRunSessionStarter.test.ts +++ b/test/main/presenter/cronJobRunSessionStarter.test.ts @@ -93,7 +93,6 @@ describe('createCronJobRunSessionStarter', () => { modelId: 'claude-sonnet', permissionMode: 'full_access', disabledAgentTools: ['write_file'], - subagentEnabled: false, generationSettings: { systemPrompt: 'Task-specific system prompt' }, metadata: { source: 'cron_job', diff --git a/test/main/presenter/exporter/agentSessionExporter.test.ts b/test/main/presenter/exporter/agentSessionExporter.test.ts index 13948d188..a0e9f9c61 100644 --- a/test/main/presenter/exporter/agentSessionExporter.test.ts +++ b/test/main/presenter/exporter/agentSessionExporter.test.ts @@ -33,7 +33,6 @@ function createFixture(options?: { isDraft: false, sessionKind: 'regular' as const, parentSessionId: null, - subagentEnabled: false, subagentMeta: null, createdAt: 100, updatedAt: 200 diff --git a/test/main/presenter/remoteControlPresenter/remoteConversationRunner.test.ts b/test/main/presenter/remoteControlPresenter/remoteConversationRunner.test.ts index e22b8745a..18ecbf799 100644 --- a/test/main/presenter/remoteControlPresenter/remoteConversationRunner.test.ts +++ b/test/main/presenter/remoteControlPresenter/remoteConversationRunner.test.ts @@ -24,7 +24,6 @@ const createSession = (overrides: Partial = {}): SessionWithSt isPinned: false, isDraft: false, sessionKind: 'regular', - subagentEnabled: false, createdAt: 1, updatedAt: 1, status: 'idle', diff --git a/test/main/presenter/sessionApplication/agentAssignmentCoordinator.test.ts b/test/main/presenter/sessionApplication/agentAssignmentCoordinator.test.ts index b8220c4eb..f5d03e992 100644 --- a/test/main/presenter/sessionApplication/agentAssignmentCoordinator.test.ts +++ b/test/main/presenter/sessionApplication/agentAssignmentCoordinator.test.ts @@ -18,7 +18,6 @@ const createSession = (overrides: Partial = {}): SessionRecord => isDraft: false, sessionKind: 'regular', parentSessionId: null, - subagentEnabled: false, subagentMeta: null, createdAt: 100, updatedAt: 200, @@ -165,8 +164,7 @@ function createHarness(initialSessions: SessionRecord[] = [createSession()]) { modelId: 'gpt-4', projectDir: projectDir ?? '/target', permissionMode: 'full_access', - disabledAgentTools: ['write'], - subagentEnabled: true + disabledAgentTools: ['write'] })), assertAcpSessionHasWorkdir: vi.fn((providerId: string, projectDir: string | null) => { if (providerId === 'acp' && !projectDir) throw new Error('workdir required') @@ -266,8 +264,7 @@ describe('SessionAgentAssignmentCoordinator', () => { modelId: 'gpt-4', projectDir, permissionMode: 'full_access', - disabledAgentTools: [], - subagentEnabled: false + disabledAgentTools: [] } } ) @@ -332,16 +329,6 @@ describe('SessionAgentAssignmentCoordinator', () => { expect(order).toEqual(['store', 'environment', 'runtime-setting', 'acp-workdir']) }) - it('uses descriptor-only lookup before mutating subagent-enabled state', async () => { - const harness = createHarness() - - await harness.coordinator.setSessionSubagentEnabled('s1', true) - - expect(harness.runtime.getSessionAgentKind).toHaveBeenCalledWith('s1') - expect(harness.runtime.resolveSession).not.toHaveBeenCalled() - expect(harness.sessions.update).toHaveBeenCalledWith('s1', { subagentEnabled: true }) - }) - it('falls back to requested model identity when the post-set snapshot is null', async () => { const harness = createHarness() harness.deepchatHandle.snapshot.mockResolvedValue(null) diff --git a/test/main/presenter/sessionApplication/agentAssignmentPolicy.test.ts b/test/main/presenter/sessionApplication/agentAssignmentPolicy.test.ts index 878730a86..d8bbb9948 100644 --- a/test/main/presenter/sessionApplication/agentAssignmentPolicy.test.ts +++ b/test/main/presenter/sessionApplication/agentAssignmentPolicy.test.ts @@ -61,8 +61,7 @@ describe('SessionAgentAssignmentPolicy', () => { projectDir: '/agent-project', permissionMode: 'auto_approve', generationSettings: { systemPrompt: 'Agent prompt', temperature: 0.2 }, - disabledAgentTools: ['write'], - subagentEnabled: true + disabledAgentTools: ['write'] }) }) @@ -121,7 +120,6 @@ describe('SessionAgentAssignmentPolicy', () => { modelId: 'ignored', projectDir: '/repo', disabledAgentTools: ['write'], - subagentEnabled: true, preserveExplicitNullProjectDir: true }) ).resolves.toMatchObject({ @@ -129,8 +127,7 @@ describe('SessionAgentAssignmentPolicy', () => { agentType: 'acp', providerId: 'acp', modelId: 'claude-acp', - disabledAgentTools: [], - subagentEnabled: false + disabledAgentTools: [] }) }) diff --git a/test/main/presenter/sessionApplication/lifecycleCoordinator.test.ts b/test/main/presenter/sessionApplication/lifecycleCoordinator.test.ts index 7ee59b70f..296cb8586 100644 --- a/test/main/presenter/sessionApplication/lifecycleCoordinator.test.ts +++ b/test/main/presenter/sessionApplication/lifecycleCoordinator.test.ts @@ -19,7 +19,6 @@ const createRecord = (overrides: Partial = {}): SessionRecord => isDraft: false, sessionKind: 'regular', parentSessionId: null, - subagentEnabled: false, subagentMeta: null, createdAt: 100, updatedAt: 200, @@ -91,7 +90,6 @@ function createHarness(initialSessions: SessionRecord[] = []) { options: { isDraft?: boolean disabledAgentTools?: string[] - subagentEnabled?: boolean sessionKind?: SessionRecord['sessionKind'] parentSessionId?: string | null subagentMeta?: SessionRecord['subagentMeta'] @@ -110,7 +108,6 @@ function createHarness(initialSessions: SessionRecord[] = []) { isDraft: options.isDraft ?? false, sessionKind: options.sessionKind ?? 'regular', parentSessionId: options.parentSessionId ?? null, - subagentEnabled: options.subagentEnabled ?? false, subagentMeta: options.subagentMeta ?? null, metadata: options.metadata ?? null }) @@ -147,7 +144,6 @@ function createHarness(initialSessions: SessionRecord[] = []) { permissionMode?: DeepChatSessionState['permissionMode'] generationSettings?: Partial disabledAgentTools?: string[] - subagentEnabled?: boolean }) => ({ agentId: input.agentId, agentType: input.providerId === 'acp' ? ('acp' as const) : ('deepchat' as const), @@ -156,8 +152,7 @@ function createHarness(initialSessions: SessionRecord[] = []) { projectDir: input.projectDir === undefined ? '/default' : input.projectDir, permissionMode: input.permissionMode ?? ('full_access' as const), generationSettings: input.generationSettings, - disabledAgentTools: input.disabledAgentTools ?? [], - subagentEnabled: input.subagentEnabled ?? false + disabledAgentTools: input.disabledAgentTools ?? [] }) ), resolveAcpDraftAssignment: vi.fn( @@ -316,8 +311,7 @@ describe('SessionLifecycleCoordinator', () => { modelId: 'acp-coder', projectDir: '/repo', permissionMode: 'full_access', - disabledAgentTools: [], - subagentEnabled: false + disabledAgentTools: [] }) harness.workdir.syncAcpSessionWorkdir.mockRejectedValueOnce(initializationError) harness.workdir.clearCompatibilityAcpSession.mockRejectedValueOnce(clearError) diff --git a/test/main/presenter/sessionApplication/lifecycleDeletionTransaction.test.ts b/test/main/presenter/sessionApplication/lifecycleDeletionTransaction.test.ts index dc4757ae0..f95a2e9c7 100644 --- a/test/main/presenter/sessionApplication/lifecycleDeletionTransaction.test.ts +++ b/test/main/presenter/sessionApplication/lifecycleDeletionTransaction.test.ts @@ -14,7 +14,6 @@ const createSession = (overrides: Partial = {}): SessionRecord => isDraft: false, sessionKind: 'regular', parentSessionId: null, - subagentEnabled: false, subagentMeta: null, createdAt: 100, updatedAt: 200, diff --git a/test/main/presenter/sessionApplication/projectionCoordinator.test.ts b/test/main/presenter/sessionApplication/projectionCoordinator.test.ts index 7dad0a62f..0f5dc2b8c 100644 --- a/test/main/presenter/sessionApplication/projectionCoordinator.test.ts +++ b/test/main/presenter/sessionApplication/projectionCoordinator.test.ts @@ -14,7 +14,6 @@ const createSessionRecord = (overrides: Partial = {}): SessionRec isDraft: false, sessionKind: 'regular', parentSessionId: null, - subagentEnabled: false, subagentMeta: null, createdAt: 100, updatedAt: 200, diff --git a/test/main/presenter/sessionApplication/sessionApplication.integration.test.ts b/test/main/presenter/sessionApplication/sessionApplication.integration.test.ts index 092ce997e..b584fcb82 100644 --- a/test/main/presenter/sessionApplication/sessionApplication.integration.test.ts +++ b/test/main/presenter/sessionApplication/sessionApplication.integration.test.ts @@ -449,7 +449,7 @@ function installSessionStore(sqlitePresenter: ReturnType { { isDraft: false, disabledAgentTools: [], - subagentEnabled: false, sessionKind: undefined, parentSessionId: undefined, subagentMetaJson: null @@ -2615,7 +2614,7 @@ describe('Session application coordinators', () => { project_dir: projectDir, is_pinned: 0, is_draft: options?.isDraft ? 1 : 0, - subagent_enabled: options?.subagentEnabled ? 1 : 0, + subagent_enabled: 0, session_kind: options?.sessionKind ?? 'regular', parent_session_id: options?.parentSessionId ?? null, subagent_meta_json: options?.subagentMetaJson ?? null, @@ -2706,7 +2705,7 @@ describe('Session application coordinators', () => { project_dir: projectDir, is_pinned: 0, is_draft: options?.isDraft ? 1 : 0, - subagent_enabled: options?.subagentEnabled ? 1 : 0, + subagent_enabled: 0, session_kind: options?.sessionKind ?? 'regular', parent_session_id: options?.parentSessionId ?? null, subagent_meta_json: options?.subagentMetaJson ?? null, @@ -2778,7 +2777,7 @@ describe('Session application coordinators', () => { project_dir: projectDir, is_pinned: 0, is_draft: options?.isDraft ? 1 : 0, - subagent_enabled: options?.subagentEnabled ? 1 : 0, + subagent_enabled: 0, session_kind: options?.sessionKind ?? 'regular', parent_session_id: options?.parentSessionId ?? null, subagent_meta_json: options?.subagentMetaJson ?? null, @@ -3768,65 +3767,6 @@ describe('Session application coordinators', () => { }) }) - describe('setSessionSubagentEnabled', () => { - it('rejects regular ACP sessions before updating persisted state', async () => { - sqlitePresenter.newSessionsTable.get.mockReturnValue({ - id: 's-acp', - agent_id: 'acp-coder', - title: 'ACP', - project_dir: '/tmp/workspace', - is_pinned: 0, - is_draft: 0, - subagent_enabled: 0, - session_kind: 'regular', - parent_session_id: null, - subagent_meta_json: null, - created_at: 1000, - updated_at: 1000 - }) - - await expect(assignment.setSessionSubagentEnabled('s-acp', true)).rejects.toThrow( - 'Only DeepChat sessions can change subagent state.' - ) - - expect(sqlitePresenter.newSessionsTable.update).not.toHaveBeenCalled() - }) - - it('throws when the updated session state cannot be rebuilt', async () => { - const row = { - id: 's1', - agent_id: 'deepchat', - title: 'Test', - project_dir: null, - is_pinned: 0, - is_draft: 0, - subagent_enabled: 0, - session_kind: 'regular', - parent_session_id: null, - subagent_meta_json: null, - created_at: 1000, - updated_at: 1000 - } - sqlitePresenter.newSessionsTable.get.mockImplementation((id: string) => - id === 's1' ? row : undefined - ) - sqlitePresenter.newSessionsTable.update.mockImplementation((_: string, fields: any) => { - Object.assign(row, fields) - }) - deepChatAgent.getSessionState.mockRejectedValueOnce(new Error('state unavailable')) - - await expect(assignment.setSessionSubagentEnabled('s1', true)).rejects.toThrow( - 'Failed to build session state for sessionId: s1' - ) - - expect(sqlitePresenter.newSessionsTable.update).toHaveBeenCalledWith('s1', { - subagent_enabled: 1 - }) - expect(row.subagent_enabled).toBe(1) - expectSessionsUpdated({ reason: 'updated', sessionIds: ['s1'] }) - }) - }) - describe('setSessionModel', () => { it('updates deepchat session model and emits LIST_UPDATED', async () => { sqlitePresenter.newSessionsTable.get.mockReturnValue({ @@ -4021,9 +3961,6 @@ describe('Session application coordinators', () => { if (fields.project_dir !== undefined) { row.project_dir = fields.project_dir } - if (fields.subagent_enabled !== undefined) { - row.subagent_enabled = fields.subagent_enabled - } }) configPresenter.getAgentType.mockImplementation(async (agentId: string) => { if (agentId === 'deepchat-writer' || agentId === 'deepchat-coder') { @@ -4078,6 +4015,10 @@ describe('Session application coordinators', () => { ).toBeLessThan(deepChatAgent.getSessionState.mock.invocationCallOrder.at(-1)!) expect(updated.agentId).toBe('deepchat-coder') expect(updated.providerId).toBe('anthropic') + expect(row.subagent_enabled).toBe(1) + expect(sqlitePresenter.newSessionsTable.update).toHaveBeenCalledWith('s1', { + project_dir: '/repo' + }) expectSessionsUpdated({ reason: 'updated', sessionIds: ['s1'] }) }) @@ -4331,9 +4272,6 @@ describe('Session application coordinators', () => { if (fields.project_dir !== undefined) { row.project_dir = fields.project_dir } - if (fields.subagent_enabled !== undefined) { - row.subagent_enabled = fields.subagent_enabled - } }) configPresenter.getAgentType.mockImplementation(async (agentId: string) => { if (agentId === 'deepchat-writer' || agentId === 'deepchat-coder') { @@ -4361,6 +4299,8 @@ describe('Session application coordinators', () => { expect(rows.get('s-ready-1').agent_id).toBe('deepchat-coder') expect(rows.get('s-ready-2').agent_id).toBe('deepchat-writer') + expect(rows.get('s-ready-1').subagent_enabled).toBe(1) + expect(rows.get('s-ready-2').subagent_enabled).toBe(1) expectSessionsUpdated({ reason: 'updated' }) }) diff --git a/test/main/presenter/sessionApplication/turnCoordinator.test.ts b/test/main/presenter/sessionApplication/turnCoordinator.test.ts index 54984cea1..f08be6211 100644 --- a/test/main/presenter/sessionApplication/turnCoordinator.test.ts +++ b/test/main/presenter/sessionApplication/turnCoordinator.test.ts @@ -18,7 +18,6 @@ const createSession = (overrides: Partial = {}): SessionRecord => isDraft: false, sessionKind: 'regular', parentSessionId: null, - subagentEnabled: false, subagentMeta: null, createdAt: 100, updatedAt: 200, diff --git a/test/main/presenter/sqlitePresenter/newSessionsTable.test.ts b/test/main/presenter/sqlitePresenter/newSessionsTable.test.ts index f0fd1d544..24638946d 100644 --- a/test/main/presenter/sqlitePresenter/newSessionsTable.test.ts +++ b/test/main/presenter/sqlitePresenter/newSessionsTable.test.ts @@ -1,24 +1,29 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { Database, nativeSqliteDescribeIf } from '../../nativeSqliteHarness' -const sqliteModule = await import('better-sqlite3-multiple-ciphers').catch(() => null) -const tableModule = sqliteModule +const tableModule = Database ? await import('@/presenter/sqlitePresenter/tables/newSessions').catch(() => null) : null -const Database = sqliteModule?.default +const activeSkillsTableModule = Database + ? await import('@/presenter/sqlitePresenter/tables/newSessionActiveSkills').catch(() => null) + : null +const disabledToolsTableModule = Database + ? await import('@/presenter/sqlitePresenter/tables/newSessionDisabledAgentTools').catch( + () => null + ) + : null const NewSessionsTable = tableModule?.NewSessionsTable -let sqliteAvailable = false -if (Database) { - try { - const smokeDb = new Database(':memory:') - smokeDb.close() - sqliteAvailable = true - } catch { - sqliteAvailable = false - } -} +const NewSessionActiveSkillsTable = activeSkillsTableModule?.NewSessionActiveSkillsTable +const NewSessionDisabledAgentToolsTable = + disabledToolsTableModule?.NewSessionDisabledAgentToolsTable const DatabaseCtor = Database! const NewSessionsTableCtor = NewSessionsTable! -const describeIfSqlite = sqliteAvailable && NewSessionsTable ? describe : describe.skip +const NewSessionActiveSkillsTableCtor = NewSessionActiveSkillsTable! +const NewSessionDisabledAgentToolsTableCtor = NewSessionDisabledAgentToolsTable! +const describeIfSqlite = nativeSqliteDescribeIf( + Boolean(NewSessionsTable && NewSessionActiveSkillsTable && NewSessionDisabledAgentToolsTable), + 'New Session native table modules are unavailable' +) describeIfSqlite('NewSessionsTable', () => { let db: InstanceType | null @@ -27,6 +32,8 @@ describeIfSqlite('NewSessionsTable', () => { beforeEach(() => { db = new DatabaseCtor(':memory:') table = new NewSessionsTableCtor(db) + new NewSessionActiveSkillsTableCtor(db).createTable() + new NewSessionDisabledAgentToolsTableCtor(db).createTable() table.createTable() }) @@ -103,4 +110,17 @@ describeIfSqlite('NewSessionsTable', () => { .sort() ).toEqual(['first', 'last']) }) + + it('leaves the legacy Subagent policy column at its database-owned value', () => { + table.create('session-1', 'deepchat', 'Session', null) + expect(table.get('session-1')?.subagent_enabled).toBe(0) + + db!.prepare('UPDATE new_sessions SET subagent_enabled = 1 WHERE id = ?').run('session-1') + table.update('session-1', { title: 'Renamed' }) + + expect(table.get('session-1')).toMatchObject({ + title: 'Renamed', + subagent_enabled: 1 + }) + }) }) diff --git a/test/main/routes/chatService.test.ts b/test/main/routes/chatService.test.ts index 8e0d11a39..db8afa6d3 100644 --- a/test/main/routes/chatService.test.ts +++ b/test/main/routes/chatService.test.ts @@ -10,7 +10,6 @@ const createSession = (): SessionWithState => ({ isDraft: false, sessionKind: 'regular', parentSessionId: null, - subagentEnabled: false, subagentMeta: null, createdAt: 1, updatedAt: 1, diff --git a/test/main/routes/contracts.test.ts b/test/main/routes/contracts.test.ts index 3776bc31f..37db961df 100644 --- a/test/main/routes/contracts.test.ts +++ b/test/main/routes/contracts.test.ts @@ -49,6 +49,20 @@ import { import { SessionGenerationSettingsPatchSchema } from '@shared/contracts/common' describe('main kernel contracts', () => { + it('accepts and ignores the retired Session-level Subagent input', () => { + expect( + sessionsCreateRoute.input.parse({ + agentId: 'deepchat', + message: 'hello', + subagentEnabled: true + }) + ).toEqual({ + agentId: 'deepchat', + message: 'hello' + }) + expect(DEEPCHAT_ROUTE_CATALOG).not.toHaveProperty('sessions.setSubagentEnabled') + }) + it('registers typed route catalog entries through phase4', () => { const routeKeys = Object.keys(DEEPCHAT_ROUTE_CATALOG).sort() @@ -195,7 +209,6 @@ describe('main kernel contracts', () => { 'sessions.setModel', 'sessions.setPermissionMode', 'sessions.setProjectDir', - 'sessions.setSubagentEnabled', 'sessions.steerPendingInput', 'sessions.togglePinned', 'sessions.translateText', diff --git a/test/main/routes/dispatcher.test.ts b/test/main/routes/dispatcher.test.ts index 1bb19d8cd..5f066873d 100644 --- a/test/main/routes/dispatcher.test.ts +++ b/test/main/routes/dispatcher.test.ts @@ -428,7 +428,6 @@ function createRuntime() { isDraft: false, sessionKind: 'regular' as const, parentSessionId: null, - subagentEnabled: false, subagentMeta: null, createdAt: 1, updatedAt: 2, @@ -534,7 +533,6 @@ function createRuntime() { setAcpSessionConfigOption: vi.fn().mockResolvedValue(null), getPermissionMode: vi.fn().mockResolvedValue('full_access'), setPermissionMode: vi.fn().mockResolvedValue(undefined), - setSessionSubagentEnabled: vi.fn().mockResolvedValue(sessionSnapshot), setSessionModel: vi.fn().mockResolvedValue(sessionSnapshot), setSessionProjectDir: vi.fn().mockResolvedValue(sessionSnapshot), getSessionGenerationSettings: vi.fn().mockResolvedValue({ @@ -4178,7 +4176,6 @@ describe('dispatchDeepchatRoute', () => { isDraft: false, sessionKind: 'regular', parentSessionId: null, - subagentEnabled: false, subagentMeta: null, createdAt: 1, updatedAt: 2, diff --git a/test/main/shared/deepchatSubagents.test.ts b/test/main/shared/deepchatSubagents.test.ts index 604c59e98..5d37315fd 100644 --- a/test/main/shared/deepchatSubagents.test.ts +++ b/test/main/shared/deepchatSubagents.test.ts @@ -26,7 +26,6 @@ const availableInput = ( agentType: 'deepchat', sessionKind: 'regular', agentPolicyEnabled: true, - legacySessionPolicyEnabled: true, slots: [reviewerSlot, explorerSlot], ...overrides }) @@ -61,7 +60,6 @@ describe('DeepChat Subagent capability', () => { it.each([ ['policy_disabled', { agentPolicyEnabled: false }, 'policy_disabled'], - ['legacy policy bridge disabled', { legacySessionPolicyEnabled: false }, 'policy_disabled'], ['child session', { sessionKind: 'subagent' as const }, 'unsupported_session'], ['ACP session', { agentType: 'acp' as const }, 'unsupported_session'], ['invalid slots', { slots: [] }, 'no_valid_slots'] diff --git a/test/renderer/components/ChatStatusBar.test.ts b/test/renderer/components/ChatStatusBar.test.ts index b9fd38649..008ec03aa 100644 --- a/test/renderer/components/ChatStatusBar.test.ts +++ b/test/renderer/components/ChatStatusBar.test.ts @@ -45,8 +45,6 @@ type SetupOptions = { activeModelId?: string activeProjectDir?: string | null activePermissionMode?: PermissionMode - activeSessionSubagentEnabled?: boolean - draftSubagentEnabled?: boolean supportsEffort?: boolean setSessionModelError?: Error defaultModel?: { providerId: string; modelId: string } | null @@ -388,20 +386,12 @@ const setup = async (options: SetupOptions = {}) => { modelId: options.activeModelId ?? 'gpt-4', projectDir: options.activeProjectDir ?? options.projectPath ?? null, status: 'idle', - sessionKind: 'regular', - subagentEnabled: options.activeSessionSubagentEnabled === true + sessionKind: 'regular' } : null, setSessionModel: options.setSessionModelError ? vi.fn().mockRejectedValue(options.setSessionModelError) - : vi.fn().mockResolvedValue(undefined), - setSessionSubagentEnabled: vi - .fn() - .mockImplementation(async (_sessionId: string, enabled: boolean) => { - if (sessionStore.activeSession) { - sessionStore.activeSession.subagentEnabled = enabled - } - }) + : vi.fn().mockResolvedValue(undefined) }) const draftStore = reactive({ @@ -418,7 +408,6 @@ const setup = async (options: SetupOptions = {}) => { reasoningVisibility: undefined as 'omitted' | 'summarized' | undefined, verbosity: undefined as 'low' | 'medium' | 'high' | undefined, imageGeneration: undefined as ImageGenerationOptions | undefined, - subagentEnabled: options.draftSubagentEnabled === true, ...options.draftGenerationSettings, updateGenerationSettings: vi.fn((patch: Record) => Object.assign(draftStore, patch) @@ -674,14 +663,10 @@ const setup = async (options: SetupOptions = {}) => { default: defineComponent({ name: 'McpIndicator', props: { - showSystemPromptSection: { type: Boolean, default: false }, - showSubagentToggle: { type: Boolean, default: false }, - subagentEnabled: { type: Boolean, default: false }, - subagentTogglePending: { type: Boolean, default: false } + showSystemPromptSection: { type: Boolean, default: false } }, - emits: ['toggle-subagents'], template: - '
' + '
' }) })) @@ -816,48 +801,13 @@ describe('ChatStatusBar model and session panels', () => { expect(agentSessionPresenter.setPermissionMode).toHaveBeenCalledWith('s1', 'auto_approve') }) - it('routes the subagent toggle through the unified tools panel', async () => { - const active = await setup({ - agentId: 'deepchat', - hasActiveSession: true, - activeSessionSubagentEnabled: true - }) - - const activeIndicator = active.wrapper.find('.mcp-indicator-stub') - expect(activeIndicator.attributes('data-show-subagent-toggle')).toBe('true') - expect(activeIndicator.attributes('data-subagent-enabled')).toBe('true') - expect(active.wrapper.text()).not.toContain('chat.subagents.label') - - await active.wrapper.get('.mcp-subagents-toggle-stub').trigger('click') - await flushPromises() - - expect(active.sessionStore.setSessionSubagentEnabled).toHaveBeenCalledWith('s1', false) - - const draft = await setup({ - agentId: 'deepchat', - hasActiveSession: false, - draftSubagentEnabled: false - }) - - const draftIndicator = draft.wrapper.find('.mcp-indicator-stub') - expect(draftIndicator.attributes('data-show-subagent-toggle')).toBe('true') - expect(draftIndicator.attributes('data-subagent-enabled')).toBe('false') - - await draft.wrapper.get('.mcp-subagents-toggle-stub').trigger('click') - await flushPromises() - - expect(draft.draftStore.subagentEnabled).toBe(true) - }) - - it('hides the subagent toggle for active regular sessions that are not deepchat', async () => { - const active = await setup({ - agentId: 'acp-agent', - hasActiveSession: true, - activeProviderId: 'openai' - }) + it('does not expose Session-level Subagent controls through the unified tools panel', async () => { + const { wrapper } = await setup({ agentId: 'deepchat', hasActiveSession: true }) + const indicator = wrapper.get('.mcp-indicator-stub') - const activeIndicator = active.wrapper.find('.mcp-indicator-stub') - expect(activeIndicator.attributes('data-show-subagent-toggle')).toBe('false') + expect(indicator.attributes()).not.toHaveProperty('data-show-subagent-toggle') + expect(indicator.attributes()).not.toHaveProperty('data-subagent-enabled') + expect(wrapper.find('.mcp-subagents-toggle-stub').exists()).toBe(false) }) it('shows loading state and hides partial model groups before full initialization completes', async () => { diff --git a/test/renderer/components/McpIndicator.test.ts b/test/renderer/components/McpIndicator.test.ts index ee7e09e42..0e2b0c22a 100644 --- a/test/renderer/components/McpIndicator.test.ts +++ b/test/renderer/components/McpIndicator.test.ts @@ -53,8 +53,6 @@ const setup = async (options?: { activeAgentId?: string selectedAgentId?: string disabledAgentTools?: string[] - showSubagentToggle?: boolean - subagentEnabled?: boolean pluginEnabled?: boolean regularMcpEnabled?: boolean }) => { @@ -214,7 +212,6 @@ const setup = async (options?: { 'chat.advancedSettings.systemPrompt': 'System Prompt', 'chat.advancedSettings.systemPromptPlaceholder': 'Select preset', 'chat.advancedSettings.currentCustomPrompt': 'Current custom', - 'chat.subagents.label': 'subagent', 'chat.input.mcp.title': 'Enabled MCP', 'chat.input.mcp.empty': 'No enabled services', 'chat.input.mcp.openSettings': 'Open MCP settings', @@ -244,10 +241,6 @@ const setup = async (options?: { const McpIndicator = (await import('@/components/chat-input/McpIndicator.vue')).default const wrapper = mount(McpIndicator, { - props: { - showSubagentToggle: options?.showSubagentToggle ?? false, - subagentEnabled: options?.subagentEnabled ?? false - }, global: { stubs: { Button: ButtonStub, @@ -422,23 +415,16 @@ describe('McpIndicator', () => { expect(agentSessionPresenter.updateSessionDisabledAgentTools).not.toHaveBeenCalled() }) - it('renders subagent as a regular tool button inside Agent Core and emits updates', async () => { + it('does not synthesize a Session-level Subagent tool toggle', async () => { const { wrapper } = await setup({ hasActiveSession: true, - activeAgentId: 'deepchat', - showSubagentToggle: true, - subagentEnabled: true + activeAgentId: 'deepchat' }) expect(wrapper.text()).toContain('Agent Core') - const subagentButton = wrapper.findAll('button').find((node) => node.text() === 'subagent') - - expect(subagentButton).toBeTruthy() - - await subagentButton!.trigger('click') - - expect(wrapper.emitted('toggle-subagents')).toEqual([[false]]) + expect(subagentButton).toBeUndefined() + expect(wrapper.emitted('toggle-subagents')).toBeUndefined() }) it('reloads deepchat tools when the active session emits skill activation changes', async () => { diff --git a/test/renderer/components/NewThreadPage.onboarding.test.ts b/test/renderer/components/NewThreadPage.onboarding.test.ts index 5ba377012..588e5c345 100644 --- a/test/renderer/components/NewThreadPage.onboarding.test.ts +++ b/test/renderer/components/NewThreadPage.onboarding.test.ts @@ -118,7 +118,6 @@ const setup = async () => { modelId: 'gpt-4.1' as string | undefined, permissionMode: 'full_access' as PermissionMode, disabledAgentTools: [] as string[], - subagentEnabled: false, systemPrompt: undefined as string | undefined, temperature: undefined as number | undefined, contextLength: undefined as number | undefined, diff --git a/test/renderer/stores/memoryActivityStore.test.ts b/test/renderer/stores/memoryActivityStore.test.ts index 589e3d419..fd8a25bc4 100644 --- a/test/renderer/stores/memoryActivityStore.test.ts +++ b/test/renderer/stores/memoryActivityStore.test.ts @@ -38,7 +38,6 @@ const makeSession = () => ({ isDraft: false, sessionKind: 'regular', parentSessionId: null, - subagentEnabled: false, subagentMeta: null, createdAt: 1, updatedAt: 1 diff --git a/test/renderer/stores/sessionStore.test.ts b/test/renderer/stores/sessionStore.test.ts index e4090d795..739740f69 100644 --- a/test/renderer/stores/sessionStore.test.ts +++ b/test/renderer/stores/sessionStore.test.ts @@ -54,7 +54,6 @@ const createSession = (overrides: Record = {}) => ({ isDraft: false, sessionKind: 'regular', parentSessionId: null, - subagentEnabled: false, subagentMeta: null, createdAt: 1, updatedAt: 1, @@ -980,7 +979,6 @@ describe('sessionStore streaming cleanup', () => { isDraft: false, sessionKind: 'regular', parentSessionId: null, - subagentEnabled: false, subagentMeta: null, createdAt: 1, updatedAt: 2 @@ -1010,7 +1008,6 @@ describe('sessionStore streaming cleanup', () => { isDraft: false, sessionKind: 'regular', parentSessionId: null, - subagentEnabled: false, subagentMeta: null, createdAt: 1, updatedAt: 2 From da643cf33fc975cbbf005356ab3a4dc5b4050975 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Thu, 16 Jul 2026 10:56:52 +0800 Subject: [PATCH 05/10] fix(agent): guide subagent delegation --- .../configPresenter/systemPromptHelper.ts | 3 +- .../agentTools/subagentOrchestratorTool.ts | 4 +- src/renderer/src/i18n/da-DK/settings.json | 4 +- src/renderer/src/i18n/de-DE/settings.json | 4 +- src/renderer/src/i18n/en-US/settings.json | 4 +- src/renderer/src/i18n/es-ES/settings.json | 4 +- src/renderer/src/i18n/fa-IR/settings.json | 4 +- src/renderer/src/i18n/fr-FR/settings.json | 4 +- src/renderer/src/i18n/he-IL/settings.json | 4 +- src/renderer/src/i18n/id-ID/settings.json | 4 +- src/renderer/src/i18n/it-IT/settings.json | 4 +- src/renderer/src/i18n/ja-JP/settings.json | 4 +- src/renderer/src/i18n/ko-KR/settings.json | 4 +- src/renderer/src/i18n/ms-MY/settings.json | 4 +- src/renderer/src/i18n/pl-PL/settings.json | 4 +- src/renderer/src/i18n/pt-BR/settings.json | 4 +- src/renderer/src/i18n/ru-RU/settings.json | 4 +- src/renderer/src/i18n/tr-TR/settings.json | 4 +- src/renderer/src/i18n/vi-VN/settings.json | 4 +- src/renderer/src/i18n/zh-CN/settings.json | 4 +- src/renderer/src/i18n/zh-HK/settings.json | 4 +- src/renderer/src/i18n/zh-TW/settings.json | 4 +- src/shared/lib/deepchatSubagents.ts | 9 +++ .../agentRuntimePresenter.test.ts | 66 +++++++++++++++++++ .../systemPromptHelper.test.ts | 10 +++ .../agentToolManagerSettings.test.ts | 6 +- .../subagentOrchestratorTool.test.ts | 64 ++++++++++++++++++ 27 files changed, 199 insertions(+), 43 deletions(-) create mode 100644 test/main/presenter/configPresenter/systemPromptHelper.test.ts diff --git a/src/main/presenter/configPresenter/systemPromptHelper.ts b/src/main/presenter/configPresenter/systemPromptHelper.ts index 1ed4f348d..86dfb8452 100644 --- a/src/main/presenter/configPresenter/systemPromptHelper.ts +++ b/src/main/presenter/configPresenter/systemPromptHelper.ts @@ -2,6 +2,7 @@ import { SystemPrompt } from '@shared/presenter' import ElectronStore from 'electron-store' import { publishDeepchatEvent } from '@/routes/publishDeepchatEvent' import { emitDefaultSystemPromptChanged } from './eventPublishers' +import { DEEPCHAT_SUBAGENT_MODEL_GUIDANCE } from '@shared/lib/deepchatSubagents' type SetSetting = (key: string, value: T) => void @@ -28,7 +29,7 @@ You have access to powerful tools — use them proactively: - **Terminal** (exec, process): Run builds, tests, git commands, package managers. Use \`background: true\` for long-running tasks. Always check process output before launching another command. - **Browser** (YoBrowser): Automate web interactions, take screenshots, inspect DOM elements when web research or testing is needed. - **Skills**: Specialized knowledge modules. Before starting domain-specific work, check if a relevant skill exists with \`skill_list\` and \`skill_view\`. Load it to inherit expert-level guidance. -- **Subagents**: For complex tasks with independent subtasks, use the subagent orchestrator to delegate work in parallel or chain mode. This is especially powerful for: (a) exploring multiple code paths simultaneously, (b) implementing and reviewing in parallel, (c) any task where isolated context prevents cross-contamination. +- **Subagents**: When \`subagent_orchestrator\` is available, let the expected benefit exceed its coordination cost. ${DEEPCHAT_SUBAGENT_MODEL_GUIDANCE} - **MCP tools**: External integrations (databases, APIs, services). Use them when they extend your capabilities beyond file/code operations. ### Code Quality diff --git a/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts b/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts index 23deadc69..d220a4c45 100644 --- a/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts +++ b/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts @@ -8,6 +8,7 @@ import type { AgentToolRuntimePort, ConversationSessionInfo } from '../runtimePo import { awaitWithAbort } from '@/lib/awaitWithAbort' import { SUBAGENT_ORCHESTRATOR_TOOL_NAME } from '@shared/agentTools' import type { DeepChatSubagentCapability } from '@shared/types/agent-interface' +import { DEEPCHAT_SUBAGENT_MODEL_GUIDANCE } from '@shared/lib/deepchatSubagents' export { SUBAGENT_ORCHESTRATOR_TOOL_NAME } from '@shared/agentTools' const DEFAULT_RUN_TIMEOUT_MS = 300000 @@ -18,6 +19,7 @@ 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.', + 'Keep its scope bounded and request concrete evidence or validation.', '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(' ') @@ -805,7 +807,7 @@ export class SubagentOrchestratorTool { type: 'function', function: { name: SUBAGENT_ORCHESTRATOR_TOOL_NAME, - description: `Delegate up to 5 tasks to configured subagents, run them in parallel or in chain mode, and return a single aggregated markdown result after every child session finishes. Use background=true for long-running subagent work, then use operation=list/info/log/wait/kill with the returned runId. ${SUBAGENT_WORKDIR_RULE}`, + description: `Delegate up to 5 tasks to configured Subagents and aggregate their results. ${DEEPCHAT_SUBAGENT_MODEL_GUIDANCE} Use parallel mode only for independent tasks and chain mode when later tasks depend on earlier results. Use background=true for long-running work, then use operation=list/info/log/wait/kill with the returned runId. ${SUBAGENT_WORKDIR_RULE}`, parameters: { type: 'object', properties: { diff --git a/src/renderer/src/i18n/da-DK/settings.json b/src/renderer/src/i18n/da-DK/settings.json index 7cdf9ddef..578f651e5 100644 --- a/src/renderer/src/i18n/da-DK/settings.json +++ b/src/renderer/src/i18n/da-DK/settings.json @@ -2546,8 +2546,8 @@ "compactionRetainPairs": "Behold seneste par", "deleteConfirm": "Slet agenten \"{name}\"? Dens historik falder tilbage til den indbyggede DeepChat-agent.", "subagentsTitle": "Underagenter", - "subagentsDescription": "Konfigurer de genanvendelige slots til undersessioner, som `subagent_orchestrator` stiller til rådighed.", - "subagentsEnabled": "Aktivér underagenter", + "subagentsDescription": "Tillad denne agent at uddelegere uafhængige delopgaver. Modellen afgør, hvornår konfigurerede pladser bruges; uddelegering bruger ekstra tokens, tid og systemressourcer.", + "subagentsEnabled": "Tillad denne agent at uddelegere delopgaver", "subagentTargetType": "Måltype", "subagentTargetSelf": "Denne agent", "subagentTargetAgent": "Agent", diff --git a/src/renderer/src/i18n/de-DE/settings.json b/src/renderer/src/i18n/de-DE/settings.json index 3d01d6e92..2adcf9231 100644 --- a/src/renderer/src/i18n/de-DE/settings.json +++ b/src/renderer/src/i18n/de-DE/settings.json @@ -130,8 +130,8 @@ "permissionFullAccess": "Alle aktivieren", "permissionDefault": "Standardberechtigung", "subagentsTitle": "subagent", - "subagentsDescription": "Wiederverwendbare subagent-Sitzungsplätze für `subagent_orchestrator` konfigurieren.", - "subagentsEnabled": "subagent aktivieren", + "subagentsDescription": "Erlaubt diesem Agenten, unabhängige Teilaufgaben zu delegieren. Das Modell entscheidet, wann konfigurierte Slots verwendet werden; Delegation benötigt zusätzliche Tokens, Zeit und Systemressourcen.", + "subagentsEnabled": "Diesem Agenten das Delegieren von Teilaufgaben erlauben", "subagentTargetType": "Zieltyp", "subagentTargetSelf": "Aktueller Agent", "subagentTargetAgent": "Bestimmter Agent", diff --git a/src/renderer/src/i18n/en-US/settings.json b/src/renderer/src/i18n/en-US/settings.json index 16ba32cf6..20719cc3c 100644 --- a/src/renderer/src/i18n/en-US/settings.json +++ b/src/renderer/src/i18n/en-US/settings.json @@ -130,8 +130,8 @@ "permissionFullAccess": "Full access", "permissionDefault": "Default permission", "subagentsTitle": "Subagents", - "subagentsDescription": "Configure the reusable child-session slots exposed to subagent_orchestrator.", - "subagentsEnabled": "Enable subagents", + "subagentsDescription": "Allow this Agent to delegate independent subtasks. The model decides when to use configured slots; delegation uses additional tokens, time, and system resources.", + "subagentsEnabled": "Allow this Agent to delegate subtasks", "subagentTargetType": "Target type", "subagentTargetSelf": "Self", "subagentTargetAgent": "Agent", diff --git a/src/renderer/src/i18n/es-ES/settings.json b/src/renderer/src/i18n/es-ES/settings.json index e2b2e34d1..e45c40af7 100644 --- a/src/renderer/src/i18n/es-ES/settings.json +++ b/src/renderer/src/i18n/es-ES/settings.json @@ -130,8 +130,8 @@ "permissionFullAccess": "Acceso completo", "permissionDefault": "Permiso predeterminado", "subagentsTitle": "Subagents", - "subagentsDescription": "Configura los espacios de sesión secundaria reutilizables expuestos a subagent_orchestrator.", - "subagentsEnabled": "Habilitar subagents", + "subagentsDescription": "Permite que este agente delegue subtareas independientes. El modelo decide cuándo usar los espacios configurados; la delegación consume tokens, tiempo y recursos del sistema adicionales.", + "subagentsEnabled": "Permitir que este agente delegue subtareas", "subagentTargetType": "Tipo de destino", "subagentTargetSelf": "Este Agent", "subagentTargetAgent": "Agent", diff --git a/src/renderer/src/i18n/fa-IR/settings.json b/src/renderer/src/i18n/fa-IR/settings.json index 8ca6d156e..ddf1e419c 100644 --- a/src/renderer/src/i18n/fa-IR/settings.json +++ b/src/renderer/src/i18n/fa-IR/settings.json @@ -2546,8 +2546,8 @@ "compactionRetainPairs": "نگه‌داری جفت‌های اخیر", "deleteConfirm": "ایجنت «{name}» حذف شود؟ تاریخچه آن به ایجنت داخلی DeepChat برمی‌گردد.", "subagentsTitle": "زیرایجنت‌ها", - "subagentsDescription": "اسلات‌های قابل‌استفاده‌مجددِ نشست فرزند را که `subagent_orchestrator` در اختیار می‌گذارد پیکربندی کنید.", - "subagentsEnabled": "فعال‌کردن زیرایجنت‌ها", + "subagentsDescription": "به این عامل اجازه دهید زیروظیفه‌های مستقل را واگذار کند. مدل زمان استفاده از اسلات‌های پیکربندی‌شده را تعیین می‌کند؛ واگذاری توکن، زمان و منابع سیستمی بیشتری مصرف می‌کند.", + "subagentsEnabled": "اجازه به این عامل برای واگذاری زیروظیفه‌ها", "subagentTargetType": "نوع مقصد", "subagentTargetSelf": "همین ایجنت", "subagentTargetAgent": "ایجنت", diff --git a/src/renderer/src/i18n/fr-FR/settings.json b/src/renderer/src/i18n/fr-FR/settings.json index b6ed25888..33d602aa7 100644 --- a/src/renderer/src/i18n/fr-FR/settings.json +++ b/src/renderer/src/i18n/fr-FR/settings.json @@ -2546,8 +2546,8 @@ "compactionRetainPairs": "Conserver les dernières paires", "deleteConfirm": "Supprimer l'agent « {name} » ? Son historique sera rattaché à l'agent DeepChat intégré.", "subagentsTitle": "Sous-agents", - "subagentsDescription": "Configurez les emplacements de sessions enfants réutilisables exposés par `subagent_orchestrator`.", - "subagentsEnabled": "Activer les sous-agents", + "subagentsDescription": "Autorisez cet agent à déléguer des sous-tâches indépendantes. Le modèle décide quand utiliser les emplacements configurés ; la délégation consomme des jetons, du temps et des ressources système supplémentaires.", + "subagentsEnabled": "Autoriser cet agent à déléguer des sous-tâches", "subagentTargetType": "Type de cible", "subagentTargetSelf": "Cet agent", "subagentTargetAgent": "Agent", diff --git a/src/renderer/src/i18n/he-IL/settings.json b/src/renderer/src/i18n/he-IL/settings.json index dc145bf19..1c84c76a6 100644 --- a/src/renderer/src/i18n/he-IL/settings.json +++ b/src/renderer/src/i18n/he-IL/settings.json @@ -2546,8 +2546,8 @@ "compactionRetainPairs": "שמירת הזוגות האחרונים", "deleteConfirm": "למחוק את הסוכן \"{name}\"? ההיסטוריה שלו תחזור לסוכן DeepChat המובנה.", "subagentsTitle": "סוכני משנה", - "subagentsDescription": "הגדירו את משבצות שיחות-הבת הניתנות לשימוש חוזר ש-`subagent_orchestrator` חושף.", - "subagentsEnabled": "הפעלת סוכני משנה", + "subagentsDescription": "אפשרו לסוכן הזה להאציל תת־משימות עצמאיות. המודל מחליט מתי להשתמש במשבצות שהוגדרו; ההאצלה צורכת טוקנים, זמן ומשאבי מערכת נוספים.", + "subagentsEnabled": "לאפשר לסוכן הזה להאציל תת־משימות", "subagentTargetType": "סוג יעד", "subagentTargetSelf": "הסוכן הזה", "subagentTargetAgent": "סוכן", diff --git a/src/renderer/src/i18n/id-ID/settings.json b/src/renderer/src/i18n/id-ID/settings.json index 1557b0f6e..8497c6492 100644 --- a/src/renderer/src/i18n/id-ID/settings.json +++ b/src/renderer/src/i18n/id-ID/settings.json @@ -130,8 +130,8 @@ "permissionFullAccess": "Aktifkan semua", "permissionDefault": "Izin bawaan", "subagentsTitle": "subagent", - "subagentsDescription": "Konfigurasikan slot sesi `subagent_orchestrator` yang dapat digunakan kembali.", - "subagentsEnabled": "Aktifkan subagent", + "subagentsDescription": "Izinkan Agent ini mendelegasikan subtugas independen. Model menentukan kapan slot terkonfigurasi digunakan; delegasi memakai token, waktu, dan sumber daya sistem tambahan.", + "subagentsEnabled": "Izinkan Agent ini mendelegasikan subtugas", "subagentTargetType": "tipe sasaran", "subagentTargetSelf": "Saat iniAgent", "subagentTargetAgent": "Tentukan Agent", diff --git a/src/renderer/src/i18n/it-IT/settings.json b/src/renderer/src/i18n/it-IT/settings.json index 517b6bc70..cbc47ac7f 100644 --- a/src/renderer/src/i18n/it-IT/settings.json +++ b/src/renderer/src/i18n/it-IT/settings.json @@ -130,8 +130,8 @@ "permissionFullAccess": "Tutto attivo", "permissionDefault": "Permesso predefinito", "subagentsTitle": "subagent", - "subagentsDescription": "Configura gli slot di sessione subagent riutilizzabili da `subagent_orchestrator`.", - "subagentsEnabled": "Attiva subagent", + "subagentsDescription": "Consenti a questo agente di delegare sottoattività indipendenti. Il modello decide quando usare gli slot configurati; la delega richiede token, tempo e risorse di sistema aggiuntivi.", + "subagentsEnabled": "Consenti a questo agente di delegare sottoattività", "subagentTargetType": "Tipo target", "subagentTargetSelf": "Agent corrente", "subagentTargetAgent": "Agent specifico", diff --git a/src/renderer/src/i18n/ja-JP/settings.json b/src/renderer/src/i18n/ja-JP/settings.json index e2027df1b..9dd5a970c 100644 --- a/src/renderer/src/i18n/ja-JP/settings.json +++ b/src/renderer/src/i18n/ja-JP/settings.json @@ -2546,8 +2546,8 @@ "compactionRetainPairs": "保持する直近ペア数", "deleteConfirm": "エージェント「{name}」を削除しますか? その履歴は内蔵 DeepChat エージェントに戻されます。", "subagentsTitle": "サブエージェント", - "subagentsDescription": "`subagent_orchestrator` で使える再利用可能な子セッションスロットを設定します。", - "subagentsEnabled": "サブエージェントを有効にする", + "subagentsDescription": "このエージェントに独立したサブタスクの委任を許可します。モデルが設定済みスロットを使うタイミングを判断し、委任には追加のトークン、時間、システムリソースが必要です。", + "subagentsEnabled": "このエージェントにサブタスクの委任を許可", "subagentTargetType": "対象タイプ", "subagentTargetSelf": "このエージェント", "subagentTargetAgent": "エージェント", diff --git a/src/renderer/src/i18n/ko-KR/settings.json b/src/renderer/src/i18n/ko-KR/settings.json index 4bce16be5..62e9ecfa3 100644 --- a/src/renderer/src/i18n/ko-KR/settings.json +++ b/src/renderer/src/i18n/ko-KR/settings.json @@ -2546,8 +2546,8 @@ "compactionRetainPairs": "최근 페어 유지", "deleteConfirm": "에이전트 \"{name}\"을(를) 삭제할까요? 기록은 내장 DeepChat 에이전트로 이동합니다.", "subagentsTitle": "하위 에이전트", - "subagentsDescription": "`subagent_orchestrator` 에서 노출하는 재사용 가능한 하위 세션 슬롯을 설정합니다.", - "subagentsEnabled": "하위 에이전트 사용", + "subagentsDescription": "이 에이전트가 독립적인 하위 작업을 위임하도록 허용합니다. 모델이 구성된 슬롯을 사용할 시점을 판단하며, 위임에는 추가 토큰, 시간 및 시스템 리소스가 소요됩니다.", + "subagentsEnabled": "이 에이전트의 하위 작업 위임 허용", "subagentTargetType": "대상 유형", "subagentTargetSelf": "현재 에이전트", "subagentTargetAgent": "에이전트", diff --git a/src/renderer/src/i18n/ms-MY/settings.json b/src/renderer/src/i18n/ms-MY/settings.json index d92dd0d15..a44d2cf4c 100644 --- a/src/renderer/src/i18n/ms-MY/settings.json +++ b/src/renderer/src/i18n/ms-MY/settings.json @@ -130,8 +130,8 @@ "permissionFullAccess": "Dayakan semua", "permissionDefault": "Keizinan lalai", "subagentsTitle": "subagent", - "subagentsDescription": "Konfigurasikan slot sesi `subagent_orchestrator` boleh guna semula subagent.", - "subagentsEnabled": "Dayakan subagent", + "subagentsDescription": "Benarkan Agent ini mewakilkan subtugas bebas. Model menentukan masa slot yang dikonfigurasi digunakan; pewakilan menggunakan token, masa dan sumber sistem tambahan.", + "subagentsEnabled": "Benarkan Agent ini mewakilkan subtugas", "subagentTargetType": "jenis sasaran", "subagentTargetSelf": "ejen semasa", "subagentTargetAgent": "Nyatakan ejen", diff --git a/src/renderer/src/i18n/pl-PL/settings.json b/src/renderer/src/i18n/pl-PL/settings.json index af029e381..1b9b02cd8 100644 --- a/src/renderer/src/i18n/pl-PL/settings.json +++ b/src/renderer/src/i18n/pl-PL/settings.json @@ -130,8 +130,8 @@ "permissionFullAccess": "Pełny dostęp", "permissionDefault": "Domyślne uprawnienie", "subagentsTitle": "Subagenci", - "subagentsDescription": "Skonfiguruj gniazda sesji podrzędnych wielokrotnego użytku udostępnione subagent_orchestrator.", - "subagentsEnabled": "Włącz subagent", + "subagentsDescription": "Zezwól temu agentowi na delegowanie niezależnych podzadań. Model decyduje, kiedy użyć skonfigurowanych slotów; delegowanie zużywa dodatkowe tokeny, czas i zasoby systemowe.", + "subagentsEnabled": "Zezwól temu agentowi na delegowanie podzadań", "subagentTargetType": "Typ celu", "subagentTargetSelf": "Ja", "subagentTargetAgent": "Agent", diff --git a/src/renderer/src/i18n/pt-BR/settings.json b/src/renderer/src/i18n/pt-BR/settings.json index af3d87a73..0d1d62e47 100644 --- a/src/renderer/src/i18n/pt-BR/settings.json +++ b/src/renderer/src/i18n/pt-BR/settings.json @@ -2546,8 +2546,8 @@ "compactionRetainPairs": "Manter pares recentes", "deleteConfirm": "Excluir o agente \"{name}\"? O histórico dele voltará para o agente DeepChat integrado.", "subagentsTitle": "Subagentes", - "subagentsDescription": "Configure os slots reutilizáveis de sessões filhas expostos pelo `subagent_orchestrator`.", - "subagentsEnabled": "Ativar subagentes", + "subagentsDescription": "Permita que este agente delegue subtarefas independentes. O modelo decide quando usar os slots configurados; a delegação consome tokens, tempo e recursos do sistema adicionais.", + "subagentsEnabled": "Permitir que este agente delegue subtarefas", "subagentTargetType": "Tipo de destino", "subagentTargetSelf": "Este agente", "subagentTargetAgent": "Agente", diff --git a/src/renderer/src/i18n/ru-RU/settings.json b/src/renderer/src/i18n/ru-RU/settings.json index 47648c1a7..bc08803b5 100644 --- a/src/renderer/src/i18n/ru-RU/settings.json +++ b/src/renderer/src/i18n/ru-RU/settings.json @@ -2546,8 +2546,8 @@ "compactionRetainPairs": "Сохранять последние пары", "deleteConfirm": "Удалить агента «{name}»? Его история будет возвращена встроенному агенту DeepChat.", "subagentsTitle": "Субагенты", - "subagentsDescription": "Настройте переиспользуемые слоты дочерних сессий, доступные через `subagent_orchestrator`.", - "subagentsEnabled": "Включить субагентов", + "subagentsDescription": "Разрешите этому агенту делегировать независимые подзадачи. Модель сама решает, когда использовать настроенные слоты; делегирование требует дополнительных токенов, времени и системных ресурсов.", + "subagentsEnabled": "Разрешить этому агенту делегировать подзадачи", "subagentTargetType": "Тип цели", "subagentTargetSelf": "Этот агент", "subagentTargetAgent": "Агент", diff --git a/src/renderer/src/i18n/tr-TR/settings.json b/src/renderer/src/i18n/tr-TR/settings.json index 97159631d..071561bcb 100644 --- a/src/renderer/src/i18n/tr-TR/settings.json +++ b/src/renderer/src/i18n/tr-TR/settings.json @@ -130,8 +130,8 @@ "permissionFullAccess": "Tam erişim", "permissionDefault": "Varsayılan izin", "subagentsTitle": "Alt temsilciler", - "subagentsDescription": "Subagent_orchestrator'a sunulan yeniden kullanılabilir alt oturum yuvalarını yapılandırın.", - "subagentsEnabled": "subagents'yi etkinleştir", + "subagentsDescription": "Bu Agent'ın bağımsız alt görevleri devretmesine izin verin. Model, yapılandırılmış yuvaları ne zaman kullanacağına karar verir; devretme ek token, zaman ve sistem kaynağı kullanır.", + "subagentsEnabled": "Bu Agent'ın alt görevleri devretmesine izin ver", "subagentTargetType": "Hedef türü", "subagentTargetSelf": "öz", "subagentTargetAgent": "Agent", diff --git a/src/renderer/src/i18n/vi-VN/settings.json b/src/renderer/src/i18n/vi-VN/settings.json index c2d4eda31..7a5c4054b 100644 --- a/src/renderer/src/i18n/vi-VN/settings.json +++ b/src/renderer/src/i18n/vi-VN/settings.json @@ -130,8 +130,8 @@ "permissionFullAccess": "Toàn quyền truy cập", "permissionDefault": "Quyền mặc định", "subagentsTitle": "Chất phụ", - "subagentsDescription": "Định cấu hình các vị trí phiên con có thể tái sử dụng được hiển thị cho subagent_orchestrator.", - "subagentsEnabled": "Kích hoạt subagent", + "subagentsDescription": "Cho phép Agent này ủy quyền các tác vụ con độc lập. Mô hình quyết định khi nào dùng các vị trí đã cấu hình; việc ủy quyền tiêu tốn thêm token, thời gian và tài nguyên hệ thống.", + "subagentsEnabled": "Cho phép Agent này ủy quyền tác vụ con", "subagentTargetType": "Loại mục tiêu", "subagentTargetSelf": "bản thân", "subagentTargetAgent": "Agent", diff --git a/src/renderer/src/i18n/zh-CN/settings.json b/src/renderer/src/i18n/zh-CN/settings.json index dcfc4bf6e..01627c650 100644 --- a/src/renderer/src/i18n/zh-CN/settings.json +++ b/src/renderer/src/i18n/zh-CN/settings.json @@ -130,8 +130,8 @@ "permissionFullAccess": "全部启用", "permissionDefault": "默认权限", "subagentsTitle": "subagent", - "subagentsDescription": "配置 `subagent_orchestrator` 可复用的 subagent 会话槽位。", - "subagentsEnabled": "启用 subagent", + "subagentsDescription": "允许该智能体委派独立子任务。模型会自行判断何时使用已配置槽位;委派会消耗额外的 token、时间和系统资源。", + "subagentsEnabled": "允许该智能体委派子任务", "subagentTargetType": "目标类型", "subagentTargetSelf": "当前智能体", "subagentTargetAgent": "指定智能体", diff --git a/src/renderer/src/i18n/zh-HK/settings.json b/src/renderer/src/i18n/zh-HK/settings.json index ba2fac92b..95be0e40c 100644 --- a/src/renderer/src/i18n/zh-HK/settings.json +++ b/src/renderer/src/i18n/zh-HK/settings.json @@ -2546,8 +2546,8 @@ "compactionRetainPairs": "保留最近訊息對", "deleteConfirm": "確定要刪除智能體「{name}」嗎?它的歷史會話會自動回退到內建 DeepChat 智能體。", "subagentsTitle": "子代理", - "subagentsDescription": "設定 `subagent_orchestrator` 可重用的 subagent 會話槽位。", - "subagentsEnabled": "啟用 subagent", + "subagentsDescription": "允許此智能體委派獨立子任務。模型會自行判斷何時使用已設定的槽位;委派會消耗額外的 token、時間與系統資源。", + "subagentsEnabled": "允許此智能體委派子任務", "subagentTargetType": "目標類型", "subagentTargetSelf": "目前智能體", "subagentTargetAgent": "指定智能體", diff --git a/src/renderer/src/i18n/zh-TW/settings.json b/src/renderer/src/i18n/zh-TW/settings.json index 0f1af3c8b..7e83d3c10 100644 --- a/src/renderer/src/i18n/zh-TW/settings.json +++ b/src/renderer/src/i18n/zh-TW/settings.json @@ -2546,8 +2546,8 @@ "compactionRetainPairs": "保留最近訊息對", "deleteConfirm": "確定要刪除智能體「{name}」嗎?它的歷史會話會自動回退到內建 DeepChat 智能體。", "subagentsTitle": "subagent", - "subagentsDescription": "設定 `subagent_orchestrator` 可重用的 subagent 會話槽位。", - "subagentsEnabled": "啟用 subagent", + "subagentsDescription": "允許此智慧體委派獨立子任務。模型會自行判斷何時使用已設定的槽位;委派會消耗額外的 token、時間與系統資源。", + "subagentsEnabled": "允許此智慧體委派子任務", "subagentTargetType": "目標類型", "subagentTargetSelf": "目前智能體", "subagentTargetAgent": "指定智能體", diff --git a/src/shared/lib/deepchatSubagents.ts b/src/shared/lib/deepchatSubagents.ts index dfa3ee773..abe9b95bc 100644 --- a/src/shared/lib/deepchatSubagents.ts +++ b/src/shared/lib/deepchatSubagents.ts @@ -14,6 +14,15 @@ export const DEEPCHAT_REVIEWER_SUBAGENT_SLOT_ID = 'reviewer' export type { DeepChatSubagentCapability } from '@shared/types/agent-interface' +export const DEEPCHAT_SUBAGENT_MODEL_GUIDANCE = [ + 'Honor explicit user requests about Subagents: use them when requested and available, and never use them for a request that asks you not to.', + 'Delegate only genuinely independent, isolatable, or parallel work.', + 'Do not delegate simple, latency-sensitive, or strongly sequential tasks.', + 'Do not run write-heavy Subagents in parallel when their files may overlap.', + 'Use bounded task prompts and require concrete evidence or validation from each child.', + 'Delegation adds token usage, latency, and system resource cost.' +].join(' ') + export interface ResolveDeepChatSubagentCapabilityInput { agentType: AgentType | null sessionKind: SessionKind | null | undefined diff --git a/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts b/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts index bb56f43e4..3ddd2eaa9 100644 --- a/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts +++ b/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts @@ -6,6 +6,7 @@ import { app } from 'electron' import type { AssistantMessageBlock, ChatMessageRecord, + DeepChatAgentConfig, DeepChatSessionState } from '@shared/types/agent-interface' import { ApiEndpointType, ModelType } from '@shared/model' @@ -36,6 +37,7 @@ import type { AcpAgentDescriptor } from '@/agent/shared/agentDescriptors' import type { AcpAgentConfig } from '@shared/presenter' import type * as schema from '@agentclientprotocol/sdk/dist/schema/index.js' import { nanoid } from 'nanoid' +import { SubagentOrchestratorTool } from '@/presenter/toolPresenter/agentTools/subagentOrchestratorTool' vi.mock('nanoid', () => ({ nanoid: vi.fn(() => 'mock-msg-id') })) @@ -4093,6 +4095,70 @@ describe('AgentRuntimePresenter', () => { expect(callArgs.run.resources.toolDefinitions).toEqual(tools) }) + it('refreshes provider Subagent tool snapshots from Agent policy between turns', async () => { + let subagentConfig: DeepChatAgentConfig = { + subagentEnabled: true, + subagents: [ + { + id: 'reviewer', + targetType: 'self', + displayName: 'Reviewer', + description: 'Review the change.' + } + ] + } + const subagentTool = new SubagentOrchestratorTool({} as any) + + sqlitePresenter.newSessionsTable.get.mockReturnValue({ + id: 's1', + agent_id: 'deepchat', + session_kind: 'regular' + }) + configPresenter.resolveDeepChatAgentConfig.mockImplementation(async () => subagentConfig) + toolPresenter.getAllToolDefinitions.mockImplementation(async (context: any) => { + const definition = subagentTool.getToolDefinition(context.subagentCapability) + return definition ? [definition] : [] + }) + + await agent.initSession('s1', { providerId: 'openai', modelId: 'gpt-4' }) + await agent.processMessage('s1', 'First turn') + + const firstTools = (processStream as ReturnType).mock.calls[0][0].run.resources + .toolDefinitions + expect(firstTools.map((tool: any) => tool.function.name)).toEqual(['subagent_orchestrator']) + expect( + (firstTools[0].function.parameters as any).properties.tasks.items.properties.slotId.enum + ).toEqual(['reviewer']) + + subagentConfig = { ...subagentConfig, subagentEnabled: false } + await agent.processMessage('s1', 'Second turn') + + const secondTools = (processStream as ReturnType).mock.calls[1][0].run.resources + .toolDefinitions + expect(secondTools).toEqual([]) + + subagentConfig = { + subagentEnabled: true, + subagents: [ + { + id: 'explorer', + targetType: 'self', + displayName: 'Explorer', + description: 'Collect evidence.' + } + ] + } + await agent.processMessage('s1', 'Third turn') + + const thirdTools = (processStream as ReturnType).mock.calls[2][0].run.resources + .toolDefinitions + expect(thirdTools.map((tool: any) => tool.function.name)).toEqual(['subagent_orchestrator']) + expect( + (thirdTools[0].function.parameters as any).properties.tasks.items.properties.slotId.enum + ).toEqual(['explorer']) + expect(toolPresenter.getAllToolDefinitions).toHaveBeenCalledTimes(3) + }) + it('skips DeepChat runtime prompt layers and local tools for ACP-backed subagent sessions', async () => { sqlitePresenter.newSessionsTable.get.mockReturnValue({ id: 's-acp-subagent', diff --git a/test/main/presenter/configPresenter/systemPromptHelper.test.ts b/test/main/presenter/configPresenter/systemPromptHelper.test.ts new file mode 100644 index 000000000..3391b49e2 --- /dev/null +++ b/test/main/presenter/configPresenter/systemPromptHelper.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest' +import { DEFAULT_SYSTEM_PROMPT } from '@/presenter/configPresenter/systemPromptHelper' +import { DEEPCHAT_SUBAGENT_MODEL_GUIDANCE } from '@shared/lib/deepchatSubagents' + +describe('default system prompt', () => { + it('uses the shared conservative Subagent delegation guidance', () => { + expect(DEFAULT_SYSTEM_PROMPT).toContain(DEEPCHAT_SUBAGENT_MODEL_GUIDANCE) + expect(DEFAULT_SYSTEM_PROMPT).toContain('When `subagent_orchestrator` is available') + }) +}) diff --git a/test/main/presenter/toolPresenter/agentTools/agentToolManagerSettings.test.ts b/test/main/presenter/toolPresenter/agentTools/agentToolManagerSettings.test.ts index ad64ffe27..fbfb43e63 100644 --- a/test/main/presenter/toolPresenter/agentTools/agentToolManagerSettings.test.ts +++ b/test/main/presenter/toolPresenter/agentTools/agentToolManagerSettings.test.ts @@ -4,7 +4,10 @@ import { CHAT_SETTINGS_SKILL_NAME, CHAT_SETTINGS_TOOL_NAMES } from '@/presenter/toolPresenter/agentTools/chatSettingsTools' -import { resolveDeepChatSubagentCapability } from '@shared/lib/deepchatSubagents' +import { + DEEPCHAT_SUBAGENT_MODEL_GUIDANCE, + resolveDeepChatSubagentCapability +} from '@shared/lib/deepchatSubagents' vi.mock('electron', () => ({ app: { @@ -427,6 +430,7 @@ describe('AgentToolManager DeepChat settings tool gating', () => { expect(subagentDef?.function.description).toContain( 'inherits the same working directory as the parent session' ) + expect(subagentDef?.function.description).toContain(DEEPCHAT_SUBAGENT_MODEL_GUIDANCE) expect(promptSchema?.description).toContain( 'The child session uses the same working directory as the parent session' ) diff --git a/test/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.test.ts b/test/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.test.ts index 4d58ff79e..4fa3b6968 100644 --- a/test/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.test.ts +++ b/test/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.test.ts @@ -131,6 +131,70 @@ describe('SubagentOrchestratorTool', () => { expect(runtimePort.createSubagentSession).not.toHaveBeenCalled() }) + it('keeps an admitted run on its start-time capability snapshot', async () => { + let currentParent = buildSessionInfo() + const childSession = buildSessionInfo({ + sessionId: 'admitted-child', + sessionKind: 'subagent', + parentSessionId: currentParent.sessionId, + subagentCapability: childSubagentCapability + }) + let listener: ((update: DeepChatInternalSessionUpdate) => void) | null = null + const resolveConversationSessionInfo = vi.fn(async () => currentParent) + const sendConversationMessage = vi.fn().mockResolvedValue(undefined) + const cancelConversation = vi.fn().mockResolvedValue(undefined) + const runtimePort = buildRuntimePort(currentParent, { + resolveConversationSessionInfo, + createSubagentSession: vi.fn().mockResolvedValue(childSession), + sendConversationMessage, + cancelConversation, + subscribeDeepChatSessionUpdates: vi.fn((callback) => { + listener = callback + return () => { + listener = null + } + }) + }) + const tool = new SubagentOrchestratorTool(runtimePort as any) + + const running = tool.call( + { + mode: 'parallel', + tasks: [{ slotId: 'reviewer', title: 'Review', prompt: 'Review the change.' }] + }, + currentParent.sessionId + ) + await vi.waitFor(() => expect(sendConversationMessage).toHaveBeenCalled()) + + currentParent = buildSessionInfo({ + subagentCapability: resolveDeepChatSubagentCapability({ + agentType: 'deepchat', + sessionKind: 'regular', + agentPolicyEnabled: false, + slots: parentSubagentCapability.available ? parentSubagentCapability.slots : [] + }) + }) + listener?.({ + sessionId: childSession.sessionId, + kind: 'blocks', + updatedAt: Date.now(), + previewMarkdown: 'Review complete.', + responseMarkdown: 'Review complete.' + }) + listener?.({ + sessionId: childSession.sessionId, + kind: 'status', + updatedAt: Date.now() + 1, + status: 'idle' + }) + + await expect(running).resolves.toEqual( + expect.objectContaining({ content: expect.stringContaining('Review complete.') }) + ) + expect(resolveConversationSessionInfo).toHaveBeenCalledTimes(1) + expect(cancelConversation).not.toHaveBeenCalled() + }) + it('includes the parent session workdir in the child handoff', async () => { let listener: ((update: DeepChatInternalSessionUpdate) => void) | null = null let handoffMessage = '' From ce6c339b60fd2034c96eddc227766a63b459c3f4 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Thu, 16 Jul 2026 11:06:14 +0800 Subject: [PATCH 06/10] docs(agent): finalize subagent capability --- .../session-application-coordinators/spec.md | 9 +++++++- docs/architecture/session-management.md | 8 +++++++ .../subagent-capability-policy/spec.md | 19 +++++++++++++++- .../subagent-capability-policy/tasks.md | 22 +++++++++---------- docs/architecture/tool-system.md | 10 ++++++++- 5 files changed, 54 insertions(+), 14 deletions(-) diff --git a/docs/architecture/session-application-coordinators/spec.md b/docs/architecture/session-application-coordinators/spec.md index bc5248cb8..c3cea2e2b 100644 --- a/docs/architecture/session-application-coordinators/spec.md +++ b/docs/architecture/session-application-coordinators/spec.md @@ -136,10 +136,15 @@ Owns executable assignment and runtime-setting policy: - create/subagent/transfer assignment resolution; - transfer impact, batch move/delete, and single-session transfer; -- model, project, permission, generation settings, disabled tools, and subagent-enabled settings; +- model, project, permission, generation settings, and disabled tools; - ACP config options and commands; - subagent Tape link finalization. +Subagent availability is not Session assignment state. The DeepChat runtime derives it from the +current Agent delegation policy, normalized slots, and `sessionKind`; the retained +`new_sessions.subagent_enabled` column is physical compatibility data and is not an authorization +input. + Session deletion remains a lifecycle transaction. Assignment may call a required narrow lifecycle deletion port, but the composition graph must not use optional setters or circular construction. Assignment resolution shared with lifecycle may be implemented as a focused pure policy module; it @@ -253,6 +258,8 @@ defined as `Pick` and must not be grouped under a r - Project updates retain their current non-transactional order; no rollback is introduced. - ACP model lock, workdir requirement, permission modes, generation settings, disabled tools, and config/command behavior remain unchanged. +- Create and transfer do not copy or reinterpret the legacy `subagent_enabled` value. Existing + Sessions observe their current Agent delegation policy on the next tool-profile resolution. - Subagent parent/slot/agent validation, ACP forced runtime settings, and Tape link parent-child checks remain explicit at finalization. diff --git a/docs/architecture/session-management.md b/docs/architecture/session-management.md index 330f9f88d..315addebb 100644 --- a/docs/architecture/session-management.md +++ b/docs/architecture/session-management.md @@ -60,6 +60,11 @@ sequenceDiagram `new_sessions.agent_id` 对应的 strict `AgentDescriptor.kind`;unknown、disabled、malformed 或 cached identity mismatch 失败关闭,不 fallback。 +`new_sessions.subagent_enabled` 仅作为旧数据库兼容列保留,默认值仍为 `0`。当前 Session DTO、创建、 +transfer、remote、cron 与 renderer draft 都不读写或解释该列。Subagent 可用性只由当前 Agent 的 delegation +policy、正规化 slots 与 `sessionKind` 推导;因此已有 Session 会在下一次 tool-profile 解析时读取最新 Agent +配置,不需要重建或迁移 Session row。 + ## Typed handle 共同 handle 保留双方真实共有的: @@ -112,6 +117,9 @@ ACP-provider source 只清 compatibility binding,不被误判成 direct ACP。 - Subagent 与普通 session 共享 app/message schema,用 `sessionKind`、`parentSessionId`、`subagentMeta` 区分;child backend 仍由 manager 选择。子任务结算时父 session 记录 frozen-head Tape link;显式 cross-Tape View 只读 direct child,child entries 不复制进父 effective view。 +- regular DeepChat parent 只有在 Agent policy 开启且至少存在一个有效 slot 时才获得 + `subagent_orchestrator`;Subagent child 始终失败关闭,不能递归委派。模型是否实际调用由任务收益和用户 + 当轮指令决定,Agent Settings 是唯一持久化用户开关。 - Remote 通过四个 consumer-owned session ports 调用 coordinator;active-generation lookup/cancel 仍使用 `AgentManagerGenerationPort`,不扫描 presenter runtime maps。 - Cron 的 composition-owned starter 通过 Lifecycle 创建 detached app session、通过 Turn send/cancel; diff --git a/docs/architecture/subagent-capability-policy/spec.md b/docs/architecture/subagent-capability-policy/spec.md index f2a7ab8ff..18889f0c5 100644 --- a/docs/architecture/subagent-capability-policy/spec.md +++ b/docs/architecture/subagent-capability-policy/spec.md @@ -1,6 +1,6 @@ # Subagent Capability Policy - Specification -> Status: **planned** +> Status: **implemented and validated** ## Problem @@ -174,6 +174,23 @@ and does not create another persisted state source. 10. Existing run guardrails, host-policy isolation, Tape lineage, cross-Tape recall, ACP behavior, and child activity UI remain compatible. +## Validation Evidence + +The implementation was verified with targeted Agent migration/repository, tool catalog/runtime, +Session lifecycle/route, ACP, and renderer suites. The final branch validation also passed: + +- full main-process tests: 372 files passed, 16 skipped; 4,295 tests passed, 207 skipped; +- full renderer tests: 168 files and 1,301 tests passed; +- the four native-SQLite suites under Electron's ABI: 4 files and 179 tests passed; +- node and renderer typechecks, repository formatting and format check, i18n validation, lint, and + architecture guards. + +The exact `pnpm run test:main:native-sqlite` shell command could not load the installed native +module because the shell Node runtime requires module ABI 137 while the workspace dependency was +built for Electron ABI 143. The same forced-native suites passed through the repository's Electron +runtime. A full build was intentionally not run because this goal does not refresh provider or ACP +registries. + ## Non-goals - A global or enterprise-managed Subagent policy. diff --git a/docs/architecture/subagent-capability-policy/tasks.md b/docs/architecture/subagent-capability-policy/tasks.md index 93caf8be5..521c8f5c6 100644 --- a/docs/architecture/subagent-capability-policy/tasks.md +++ b/docs/architecture/subagent-capability-policy/tasks.md @@ -2,14 +2,14 @@ - [x] SCP-001: Specify the Agent policy, capability, migration, cache, compatibility, and security contracts. -- [ ] SCP-002: Add the default-on migration and enforce enabled-slot write invariants. -- [ ] SCP-003: Protect the enabled Settings form from empty slot configuration. -- [ ] SCP-004: Add a typed capability snapshot and canonical cache key. -- [ ] SCP-005: Build and execute `subagent_orchestrator` from the centralized capability. -- [ ] SCP-006: Reserve the orchestrator name and remove generic disabled-tool control. -- [ ] SCP-007: Remove Session/Draft capability inputs, state, route, and composer UI. -- [ ] SCP-008: Preserve legacy database/import compatibility without authorizing from Session data. -- [ ] SCP-009: Add conservative delegation guidance and Agent Settings cost copy. -- [ ] SCP-010: Cover migration, cache, runtime, Session, renderer, ACP, and active-run boundaries. -- [ ] SCP-011: Complete cumulative P0-P3 review and full validation. -- [ ] SCP-012: Update maintained architecture contracts with validated behavior. +- [x] SCP-002: Add the default-on migration and enforce enabled-slot write invariants. +- [x] SCP-003: Protect the enabled Settings form from empty slot configuration. +- [x] SCP-004: Add a typed capability snapshot and canonical cache key. +- [x] SCP-005: Build and execute `subagent_orchestrator` from the centralized capability. +- [x] SCP-006: Reserve the orchestrator name and remove generic disabled-tool control. +- [x] SCP-007: Remove Session/Draft capability inputs, state, route, and composer UI. +- [x] SCP-008: Preserve legacy database/import compatibility without authorizing from Session data. +- [x] SCP-009: Add conservative delegation guidance and Agent Settings cost copy. +- [x] SCP-010: Cover migration, cache, runtime, Session, renderer, ACP, and active-run boundaries. +- [x] SCP-011: Complete cumulative P0-P3 review and full validation. +- [x] SCP-012: Update maintained architecture contracts with validated behavior. diff --git a/docs/architecture/tool-system.md b/docs/architecture/tool-system.md index f3543e9dc..8494b1ce0 100644 --- a/docs/architecture/tool-system.md +++ b/docs/architecture/tool-system.md @@ -17,7 +17,7 @@ | `AgentBashHandler` | `src/main/presenter/toolPresenter/agentTools/agentBashHandler.ts` | 命令执行与后台 session | | `AgentFffSearchHandler` | `src/main/presenter/toolPresenter/agentTools/agentFffSearchHandler.ts` | FFF-backed `glob` / `grep` code search | | `chatSettingsTools` | `src/main/presenter/toolPresenter/agentTools/chatSettingsTools.ts` | chat/session settings 工具 | -| `SubagentOrchestratorTool` | `src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts` | subagent orchestration | +| `SubagentOrchestratorTool` | `src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts` | Agent-policy-gated subagent orchestration with call-time revalidation | | `AgentPlanTool` | `src/main/presenter/toolPresenter/agentTools/agentPlanTool.ts` | `agent-core/update_plan` | | `AgentTapeToolHandler` | `src/main/presenter/toolPresenter/agentTools/agentTapeTools.ts` | 模型可调用的原子 Tape recall pair:`tape_search` / `tape_context` | | `AgentImageGenerationTool` | `src/main/presenter/toolPresenter/agentTools/agentImageGenerationTool.ts` | image generation tool | @@ -64,6 +64,14 @@ catalog。它只返回 `user-configurable` Agent tools,不解析 MCP catalog conversation MCP access context 或 runtime cache。`tools.listDefinitions` 保持原 IPC name/wire shape,内部 读取该 configurable catalog。 +`subagent_orchestrator` 属于 `system-model` capability,不进入 configurable catalog,也不受 +`disabledAgentTools` 控制。其名称 reserved,MCP definition 不能 shadow 内建 orchestrator;持久化 +disabled-tool 正规化会移除遗留同名值。模型可见性由一个 closed capability snapshot 决定:regular +DeepChat parent 必须同时满足当前 Agent policy 开启和至少一个有效 slot,Subagent child 始终不可用。 +capability 的 canonical `cacheKey` 覆盖 policy 与所有 model-visible slot 字段,并进入 tool-profile +fingerprint,因此配置变化不依赖事件式 cache invalidation。definition 使用同一 snapshot 构建;真正 +调用前重新解析当前 capability 并失败关闭,而已经 admission 的 run 保留启动时 task/slot snapshot。 + Tape 的 exposure matrix 为: | Tool | Exposure | Model behavior | From 1cd4fcf1c59829712545e37e4e5bed8a4baaef10 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Thu, 16 Jul 2026 11:13:40 +0800 Subject: [PATCH 07/10] fix(agent): clarify delegation precedence --- .../subagent-capability-policy/spec.md | 14 ++++++++------ src/shared/lib/deepchatSubagents.ts | 4 ++-- .../configPresenter/systemPromptHelper.test.ts | 3 +++ .../agentTools/subagentOrchestratorTool.test.ts | 10 ++++++++++ 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/docs/architecture/subagent-capability-policy/spec.md b/docs/architecture/subagent-capability-policy/spec.md index 18889f0c5..5c3e6f1f7 100644 --- a/docs/architecture/subagent-capability-policy/spec.md +++ b/docs/architecture/subagent-capability-policy/spec.md @@ -130,12 +130,14 @@ need to be recreated. A legacy Session value of either `0` or `1` has no effect ## Model Delegation Policy -When available, the model may delegate only when the expected benefit exceeds the extra -coordination, token, latency, and resource cost. Tool and default system guidance must require: - -- respecting an explicit user request to use or avoid Subagents; -- using delegation for genuinely independent, isolatable, or parallel work; -- avoiding delegation for simple, latency-sensitive, or strongly sequential tasks; +When available, the model honors explicit user direction first. For proactive delegation, the +expected benefit must exceed the extra coordination, token, latency, and resource cost. Tool and +default system guidance must require: + +- using Subagents when explicitly requested and available, and never using them when explicitly + declined; +- limiting proactive delegation to genuinely independent, isolatable, or clearly parallel work; +- avoiding proactive delegation for simple, latency-sensitive, or strongly sequential tasks; - avoiding concurrent write-heavy tasks that can modify overlapping files; - preferring bounded prompts and observable validation results from each child. diff --git a/src/shared/lib/deepchatSubagents.ts b/src/shared/lib/deepchatSubagents.ts index abe9b95bc..1d4a2c685 100644 --- a/src/shared/lib/deepchatSubagents.ts +++ b/src/shared/lib/deepchatSubagents.ts @@ -16,8 +16,8 @@ export type { DeepChatSubagentCapability } from '@shared/types/agent-interface' export const DEEPCHAT_SUBAGENT_MODEL_GUIDANCE = [ 'Honor explicit user requests about Subagents: use them when requested and available, and never use them for a request that asks you not to.', - 'Delegate only genuinely independent, isolatable, or parallel work.', - 'Do not delegate simple, latency-sensitive, or strongly sequential tasks.', + 'For proactive delegation, choose only work with clear independent, isolated, or parallel benefit.', + 'Do not proactively delegate simple, latency-sensitive, or strongly sequential tasks.', 'Do not run write-heavy Subagents in parallel when their files may overlap.', 'Use bounded task prompts and require concrete evidence or validation from each child.', 'Delegation adds token usage, latency, and system resource cost.' diff --git a/test/main/presenter/configPresenter/systemPromptHelper.test.ts b/test/main/presenter/configPresenter/systemPromptHelper.test.ts index 3391b49e2..1d2184691 100644 --- a/test/main/presenter/configPresenter/systemPromptHelper.test.ts +++ b/test/main/presenter/configPresenter/systemPromptHelper.test.ts @@ -6,5 +6,8 @@ describe('default system prompt', () => { it('uses the shared conservative Subagent delegation guidance', () => { expect(DEFAULT_SYSTEM_PROMPT).toContain(DEEPCHAT_SUBAGENT_MODEL_GUIDANCE) expect(DEFAULT_SYSTEM_PROMPT).toContain('When `subagent_orchestrator` is available') + expect(DEEPCHAT_SUBAGENT_MODEL_GUIDANCE).toContain('use them when requested and available') + expect(DEEPCHAT_SUBAGENT_MODEL_GUIDANCE).toContain('For proactive delegation') + expect(DEEPCHAT_SUBAGENT_MODEL_GUIDANCE).toContain('Do not proactively delegate simple') }) }) diff --git a/test/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.test.ts b/test/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.test.ts index 4fa3b6968..1184960b5 100644 --- a/test/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.test.ts +++ b/test/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.test.ts @@ -106,6 +106,16 @@ const createDeferredPromise = () => { } describe('SubagentOrchestratorTool', () => { + it('distinguishes explicit requests from proactive delegation guidance', () => { + const tool = new SubagentOrchestratorTool(buildRuntimePort(buildSessionInfo()) as any) + const definition = tool.getToolDefinition(parentSubagentCapability) + const description = definition?.function.description ?? '' + + expect(description).toContain('use them when requested and available') + expect(description).toContain('For proactive delegation') + expect(description).toContain('Do not proactively delegate simple') + }) + it('fails closed when the Agent policy changes after tool definition', async () => { const currentParent = buildSessionInfo({ subagentCapability: resolveDeepChatSubagentCapability({ From 0f974864dc127cab3bc510deba158f7fd4fb2016 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Thu, 16 Jul 2026 11:16:11 +0800 Subject: [PATCH 08/10] docs(agent): update validation totals --- docs/architecture/subagent-capability-policy/spec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture/subagent-capability-policy/spec.md b/docs/architecture/subagent-capability-policy/spec.md index 5c3e6f1f7..720ae8737 100644 --- a/docs/architecture/subagent-capability-policy/spec.md +++ b/docs/architecture/subagent-capability-policy/spec.md @@ -181,7 +181,7 @@ and does not create another persisted state source. The implementation was verified with targeted Agent migration/repository, tool catalog/runtime, Session lifecycle/route, ACP, and renderer suites. The final branch validation also passed: -- full main-process tests: 372 files passed, 16 skipped; 4,295 tests passed, 207 skipped; +- full main-process tests: 372 files passed, 16 skipped; 4,296 tests passed, 207 skipped; - full renderer tests: 168 files and 1,301 tests passed; - the four native-SQLite suites under Electron's ABI: 4 files and 179 tests passed; - node and renderer typechecks, repository formatting and format check, i18n validation, lint, and From adced440bcd4799ed91bd62f41d824dc54625224 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Thu, 16 Jul 2026 11:39:03 +0800 Subject: [PATCH 09/10] fix(agent): enforce subagent policy ownership --- .../subagent-capability-policy/spec.md | 19 ++- .../subagent-capability-policy/tasks.md | 2 + .../agent/deepchat/deepChatAgentRepository.ts | 37 ++++-- .../agentRuntimePresenter/toolResolver.ts | 55 +++++--- .../deepchat/deepChatAgentRepository.test.ts | 59 +++++++++ .../agentRuntimePresenter.test.ts | 1 + .../toolResolver.test.ts | 119 +++++++++++++++++- .../runtimeIntegration.test.ts | 14 +++ 8 files changed, 277 insertions(+), 29 deletions(-) diff --git a/docs/architecture/subagent-capability-policy/spec.md b/docs/architecture/subagent-capability-policy/spec.md index 720ae8737..3325781dd 100644 --- a/docs/architecture/subagent-capability-policy/spec.md +++ b/docs/architecture/subagent-capability-policy/spec.md @@ -75,6 +75,13 @@ fields. Their meaning is Agent delegation policy and reusable child slots, not S New built-in and custom Agent configurations default to `subagentEnabled = true` with Explorer, Implementer, and Reviewer self-target slots. +A custom Agent with an absent config JSON keeps that implicit local Subagent default instead of +inheriting the built-in Agent's opt-out. Other unset configuration fields continue to inherit from +the built-in Agent, and raw catalog reads remain unchanged for compatibility. + +A present but unreadable config JSON does not receive those implicit slots. It resolves as enabled +with zero valid slots so the runtime reports `no_valid_slots` until the configuration is repaired. + The unified Agent configuration migration advances to version 3 with this matrix: | Stored configuration | Migration result | @@ -101,6 +108,10 @@ user-configurable Agent tool: - generic `disabledAgentTools` cannot create a second policy source; - it is absent from the generic tool toggle catalog; - its name is reserved so an MCP server cannot shadow the privileged built-in orchestrator; +- tool-catalog resolution checks the persisted Agent kind instead of assuming every compatibility + runtime Session is a DeepChat Agent; +- an Agent-kind lookup failure closes only the Subagent capability and does not suppress an + independently resolved Skill or MCP extension policy; - execution revalidates regular-parent ownership and the current Agent policy before admitting a new run. @@ -146,8 +157,8 @@ and does not create another persisted state source. ## Performance Constraints -- Capability resolution performs a bounded Agent-config read and one Session-kind lookup per tool - profile resolution; it does not scan Sessions or Agents. +- Capability resolution performs bounded Agent-kind/config point reads and one Session-kind lookup + per tool profile resolution; it does not scan Sessions or Agents. - Slot normalization remains bounded by `DEEPCHAT_SUBAGENT_SLOT_LIMIT`. - A stable capability cache key invalidates only when model-visible policy changes. - No event-only cache invalidation is relied upon for correctness. @@ -175,13 +186,15 @@ and does not create another persisted state source. policy with cost guidance. 10. Existing run guardrails, host-policy isolation, Tape lineage, cross-Tape recall, ACP behavior, and child activity UI remain compatible. +11. Configless custom Agents retain their own default-on Subagent policy, and non-DeepChat + compatibility Sessions never receive the orchestrator definition. ## Validation Evidence The implementation was verified with targeted Agent migration/repository, tool catalog/runtime, Session lifecycle/route, ACP, and renderer suites. The final branch validation also passed: -- full main-process tests: 372 files passed, 16 skipped; 4,296 tests passed, 207 skipped; +- full main-process tests: 372 files passed, 16 skipped; 4,300 tests passed, 207 skipped; - full renderer tests: 168 files and 1,301 tests passed; - the four native-SQLite suites under Electron's ABI: 4 files and 179 tests passed; - node and renderer typechecks, repository formatting and format check, i18n validation, lint, and diff --git a/docs/architecture/subagent-capability-policy/tasks.md b/docs/architecture/subagent-capability-policy/tasks.md index 521c8f5c6..b462c06a6 100644 --- a/docs/architecture/subagent-capability-policy/tasks.md +++ b/docs/architecture/subagent-capability-policy/tasks.md @@ -13,3 +13,5 @@ - [x] SCP-010: Cover migration, cache, runtime, Session, renderer, ACP, and active-run boundaries. - [x] SCP-011: Complete cumulative P0-P3 review and full validation. - [x] SCP-012: Update maintained architecture contracts with validated behavior. +- [x] SCP-013: Keep implicit custom defaults independent and enforce Agent kind at catalog + resolution. diff --git a/src/main/agent/deepchat/deepChatAgentRepository.ts b/src/main/agent/deepchat/deepChatAgentRepository.ts index de5a8bb0a..7022704b1 100644 --- a/src/main/agent/deepchat/deepChatAgentRepository.ts +++ b/src/main/agent/deepchat/deepChatAgentRepository.ts @@ -61,6 +61,27 @@ const prepareConfigWrite = (config: DeepChatAgentConfig): DeepChatAgentConfig => return normalized } +const createImplicitSubagentPolicyConfig = (): DeepChatAgentConfig => + normalizeDeepChatSubagentConfig({}) + +const createFailClosedSubagentPolicyConfig = (): DeepChatAgentConfig => + normalizeDeepChatSubagentConfig({ subagentEnabled: true, subagents: [] }) + +const parseDeepChatConfigRow = (row?: AgentRow): DeepChatAgentConfig | null => { + if (!row || row.agent_type !== 'deepchat') return null + const config = parseJson(row.config_json) + return config ? normalizeDeepChatSubagentConfig(config) : null +} + +const resolveDeepChatConfigRow = (row?: AgentRow): DeepChatAgentConfig => { + const config = parseDeepChatConfigRow(row) + if (config) return config + if (!row || row.agent_type !== 'deepchat') return {} + return row.config_json + ? createFailClosedSubagentPolicyConfig() + : createImplicitSubagentPolicyConfig() +} + const normalizeNullableStringList = ( value: string[] | null | undefined ): string[] | null | undefined => { @@ -218,24 +239,24 @@ export class DeepChatAgentRepository { } getConfig(agentId: string): DeepChatAgentConfig | null { - const row = this.dependencies.rows.get(agentId) - if (!row || row.agent_type !== 'deepchat') return null - const config = parseJson(row.config_json) - return config ? normalizeDeepChatSubagentConfig(config) : null + return parseDeepChatConfigRow(this.dependencies.rows.get(agentId)) } resolveConfig(agentId: string): DeepChatAgentConfig { - const builtin = this.getConfig(BUILTIN_DEEPCHAT_AGENT_ID) ?? {} + const { rows } = this.dependencies + const builtin = resolveDeepChatConfigRow(rows.get(BUILTIN_DEEPCHAT_AGENT_ID)) if (agentId === BUILTIN_DEEPCHAT_AGENT_ID) return mergeDeepChatConfig({}, builtin) - return mergeDeepChatConfig(builtin, this.getConfig(agentId) ?? {}) + + const agentRow = rows.get(agentId) + const override = resolveDeepChatConfigRow(agentRow) + return mergeDeepChatConfig(builtin, override) } listResolvedConfigs(): Array<{ agentId: string; config: DeepChatAgentConfig }> { const rows = this.dependencies.rows.list({ agentType: 'deepchat' }) const configByAgentId = new Map( rows.map((row) => { - const parsed = parseJson(row.config_json) - return [row.id, parsed ? normalizeDeepChatSubagentConfig(parsed) : null] as const + return [row.id, resolveDeepChatConfigRow(row)] as const }) ) const builtin = configByAgentId.get(BUILTIN_DEEPCHAT_AGENT_ID) ?? {} diff --git a/src/main/presenter/agentRuntimePresenter/toolResolver.ts b/src/main/presenter/agentRuntimePresenter/toolResolver.ts index 7083e671c..ec5a6e80c 100644 --- a/src/main/presenter/agentRuntimePresenter/toolResolver.ts +++ b/src/main/presenter/agentRuntimePresenter/toolResolver.ts @@ -1,7 +1,11 @@ import type { IConfigPresenter, ISkillPresenter } from '@shared/presenter' import type { MCPToolDefinition } from '@shared/types/core/mcp' import type { IToolPresenter } from '@shared/types/presenters/tool.presenter' -import type { DeepChatAgentConfig, DeepChatSessionState } from '@shared/types/agent-interface' +import type { + AgentType, + DeepChatAgentConfig, + DeepChatSessionState +} from '@shared/types/agent-interface' import type { SQLitePresenter } from '../sqlitePresenter' import type { DeepChatAgentInstance, @@ -213,30 +217,49 @@ export class DeepChatToolResolver { this.dependencies.getSessionAgentId(sessionId) || 'deepchat' const sessionRow = this.dependencies.sqlitePresenter.newSessionsTable?.get?.(sessionId) - const resolveCapability = (config?: DeepChatAgentConfig | null) => + const resolveCapability = (agentType: AgentType | null, config?: DeepChatAgentConfig | null) => resolveDeepChatSubagentCapability({ - agentType: 'deepchat', + agentType, sessionKind: sessionRow?.session_kind ?? null, agentPolicyEnabled: config?.subagentEnabled !== false, slots: config?.subagents }) - if (typeof this.dependencies.configPresenter.resolveDeepChatAgentConfig !== 'function') { - return { extensionPolicy: {}, subagentCapability: resolveCapability(null) } - } + const resolveConfig = this.dependencies.configPresenter.resolveDeepChatAgentConfig + const [agentTypeResult, configResult] = await Promise.allSettled([ + this.dependencies.configPresenter.getAgentType(agentId), + typeof resolveConfig === 'function' + ? this.dependencies.configPresenter.resolveDeepChatAgentConfig(agentId) + : Promise.resolve(null) + ]) + const agentType = agentTypeResult.status === 'fulfilled' ? agentTypeResult.value : null - try { - const config = await this.dependencies.configPresenter.resolveDeepChatAgentConfig(agentId) + if (agentTypeResult.status === 'rejected') { + console.warn( + `[DeepChatAgent] Failed to resolve Agent type for tool policy ${agentId}:`, + agentTypeResult.reason + ) + } + if (configResult.status === 'rejected') { + console.warn( + `[DeepChatAgent] Failed to resolve tool policy for agent ${agentId}:`, + configResult.reason + ) return { - extensionPolicy: { - enabledSkillNames: config.enabledSkillNames, - enabledMcpServerIds: config.enabledMcpServerIds - }, - subagentCapability: resolveCapability(config) + extensionPolicy: {}, + subagentCapability: resolveCapability(agentType, null) } - } catch (error) { - console.warn(`[DeepChatAgent] Failed to resolve tool policy for agent ${agentId}:`, error) - return { extensionPolicy: {}, subagentCapability: resolveCapability(null) } + } + + const config = configResult.value + return { + extensionPolicy: config + ? { + enabledSkillNames: config.enabledSkillNames, + enabledMcpServerIds: config.enabledMcpServerIds + } + : {}, + subagentCapability: resolveCapability(agentType, config) } } diff --git a/test/main/agent/deepchat/deepChatAgentRepository.test.ts b/test/main/agent/deepchat/deepChatAgentRepository.test.ts index 07172fd78..d8c2ce1ec 100644 --- a/test/main/agent/deepchat/deepChatAgentRepository.test.ts +++ b/test/main/agent/deepchat/deepChatAgentRepository.test.ts @@ -374,6 +374,65 @@ describe('DeepChatAgentRepository', () => { expect(config.subagents?.every((slot) => slot.targetType === 'self')).toBe(true) }) + it('keeps configless custom Agents on their own default Subagent policy', () => { + const { repository } = createMutableRepository() + repository.ensureBuiltin({ + config: { + systemPrompt: 'Builtin prompt', + subagentEnabled: false, + subagents: [ + { + id: 'builtin-reviewer', + targetType: 'self', + displayName: 'Builtin Reviewer', + description: '' + } + ] + } + }) + const created = repository.create({ name: 'Configless' }) + + expect(repository.getConfig(created.id)).toBeNull() + expect(repository.resolveConfig(created.id)).toMatchObject({ + systemPrompt: 'Builtin prompt', + subagentEnabled: true, + subagents: [ + expect.objectContaining({ id: 'explorer' }), + expect.objectContaining({ id: 'implementer' }), + expect.objectContaining({ id: 'reviewer' }) + ] + }) + expect(repository.listResolvedConfigs()).toContainEqual({ + agentId: created.id, + config: expect.objectContaining({ + systemPrompt: 'Builtin prompt', + subagentEnabled: true, + subagents: [ + expect.objectContaining({ id: 'explorer' }), + expect.objectContaining({ id: 'implementer' }), + expect.objectContaining({ id: 'reviewer' }) + ] + }) + }) + }) + + it('fails closed when a stored custom Agent config is unreadable', () => { + const { repository, rows } = createMutableRepository() + repository.ensureBuiltin({ config: { subagentEnabled: false } }) + const created = repository.create({ name: 'Unreadable' }) + rows.get(created.id).config_json = '{broken' + + expect(repository.getConfig(created.id)).toBeNull() + expect(repository.resolveConfig(created.id)).toMatchObject({ + subagentEnabled: true, + subagents: [] + }) + expect(repository.listResolvedConfigs()).toContainEqual({ + agentId: created.id, + config: expect.objectContaining({ subagentEnabled: true, subagents: [] }) + }) + }) + it('rejects enabled DeepChat Agent writes without a valid Subagent slot', () => { const { repository, rows } = createMutableRepository() diff --git a/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts b/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts index 3ddd2eaa9..d822be425 100644 --- a/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts +++ b/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts @@ -629,6 +629,7 @@ function createMockConfigPresenter() { apiType: providerApiTypes[providerId] ?? 'openai-compatible' })), isKnownModel: vi.fn().mockReturnValue(true), + getAgentType: vi.fn().mockResolvedValue('deepchat'), resolveDeepChatAgentConfig: vi.fn().mockResolvedValue({}), agentSupportsCapability: vi.fn().mockResolvedValue(true) } as any diff --git a/test/main/presenter/agentRuntimePresenter/toolResolver.test.ts b/test/main/presenter/agentRuntimePresenter/toolResolver.test.ts index f56d9f4f2..980db4af3 100644 --- a/test/main/presenter/agentRuntimePresenter/toolResolver.test.ts +++ b/test/main/presenter/agentRuntimePresenter/toolResolver.test.ts @@ -2,10 +2,10 @@ import { describe, expect, it, vi } from 'vitest' import { DeepChatToolResolver } from '@/presenter/agentRuntimePresenter/toolResolver' import type { DeepChatAgentConfig } from '@shared/types/agent-interface' -const createResourceInstance = () => { +const createResourceInstance = (agentId = 'deepchat') => { let cached: { profile: 'general'; fingerprint: string; tools: [] } | undefined return { - getAgentId: vi.fn(() => 'deepchat'), + getAgentId: vi.fn(() => agentId), getRuntimeState: vi.fn(() => ({ status: 'idle', providerId: 'openai', @@ -41,6 +41,7 @@ describe('DeepChatToolResolver Subagent capability', () => { const resolver = new DeepChatToolResolver({ configPresenter: { getSkillsEnabled: vi.fn(() => false), + getAgentType: vi.fn(async () => 'deepchat'), resolveDeepChatAgentConfig: vi.fn(async () => config) }, sqlitePresenter: { @@ -124,6 +125,7 @@ describe('DeepChatToolResolver Subagent capability', () => { const resolver = new DeepChatToolResolver({ configPresenter: { getSkillsEnabled: vi.fn(() => false), + getAgentType: vi.fn(async () => 'deepchat'), resolveDeepChatAgentConfig: vi.fn(async () => ({ subagentEnabled: true, subagents: [ @@ -169,6 +171,118 @@ describe('DeepChatToolResolver Subagent capability', () => { ) }) + it('does not expose Subagents to regular non-DeepChat compatibility sessions', async () => { + const getAllToolDefinitions = vi.fn().mockResolvedValue([]) + const resourceInstance = createResourceInstance('acp-reviewer') + const resolver = new DeepChatToolResolver({ + configPresenter: { + getSkillsEnabled: vi.fn(() => false), + getAgentType: vi.fn(async () => 'acp'), + resolveDeepChatAgentConfig: vi.fn(async () => ({ + subagentEnabled: true, + subagents: [ + { + id: 'reviewer', + targetType: 'self', + displayName: 'Reviewer', + description: '' + } + ] + })) + }, + sqlitePresenter: { + newSessionsTable: { + get: vi.fn(() => ({ session_kind: 'regular', subagent_enabled: 1 })), + getDisabledAgentTools: vi.fn(() => []) + } + }, + toolPresenter: { getAllToolDefinitions }, + deepChatRuntime: { getToolRegistryRevision: vi.fn(() => 1) }, + getDeepChatInstance: vi.fn(() => resourceInstance), + getSessionAgentId: vi.fn(() => 'acp-reviewer'), + getRuntimeState: vi.fn(), + assertCurrent: vi.fn(), + isAcpBackedSubagentSession: vi.fn(() => false), + isStaleInstanceError: vi.fn(() => false) + } as any) + + await resolver.loadToolDefinitionsForSession( + 'acp-session', + null, + undefined, + resourceInstance as any + ) + + expect(getAllToolDefinitions).toHaveBeenCalledWith( + expect.objectContaining({ + subagentCapability: expect.objectContaining({ + available: false, + reason: 'unsupported_session' + }) + }) + ) + }) + + it('keeps extension policy when Agent type resolution fails closed', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const getAllToolDefinitions = vi.fn().mockResolvedValue([]) + const resourceInstance = createResourceInstance('custom-agent') + const resolver = new DeepChatToolResolver({ + configPresenter: { + getSkillsEnabled: vi.fn(() => false), + getAgentType: vi.fn().mockRejectedValue(new Error('catalog unavailable')), + resolveDeepChatAgentConfig: vi.fn(async () => ({ + enabledMcpServerIds: ['server-a'], + subagentEnabled: true, + subagents: [ + { + id: 'reviewer', + targetType: 'self', + displayName: 'Reviewer', + description: '' + } + ] + })) + }, + sqlitePresenter: { + newSessionsTable: { + get: vi.fn(() => ({ session_kind: 'regular' })), + getDisabledAgentTools: vi.fn(() => []) + } + }, + toolPresenter: { getAllToolDefinitions }, + deepChatRuntime: { getToolRegistryRevision: vi.fn(() => 1) }, + getDeepChatInstance: vi.fn(() => resourceInstance), + getSessionAgentId: vi.fn(() => 'custom-agent'), + getRuntimeState: vi.fn(), + assertCurrent: vi.fn(), + isAcpBackedSubagentSession: vi.fn(() => false), + isStaleInstanceError: vi.fn(() => false) + } as any) + + await resolver.loadToolDefinitionsForSession( + 'session-1', + null, + undefined, + resourceInstance as any + ) + + expect(getAllToolDefinitions).toHaveBeenCalledWith( + expect.objectContaining({ + enabledMcpServerIds: ['server-a'], + subagentCapability: expect.objectContaining({ + available: false, + reason: 'unsupported_session' + }) + }) + ) + expect(warnSpy).toHaveBeenCalledWith( + '[DeepChatAgent] Failed to resolve Agent type for tool policy custom-agent:', + expect.any(Error) + ) + warnSpy.mockRestore() + }) + it('fails closed without misreporting config resolution failures as an explicit disable', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const getAllToolDefinitions = vi.fn().mockResolvedValue([]) @@ -176,6 +290,7 @@ describe('DeepChatToolResolver Subagent capability', () => { const resolver = new DeepChatToolResolver({ configPresenter: { getSkillsEnabled: vi.fn(() => false), + getAgentType: vi.fn(async () => 'deepchat'), resolveDeepChatAgentConfig: vi.fn().mockRejectedValue(new Error('config unavailable')) }, sqlitePresenter: { diff --git a/test/main/presenter/sessionApplication/runtimeIntegration.test.ts b/test/main/presenter/sessionApplication/runtimeIntegration.test.ts index 24af64790..d2039edd6 100644 --- a/test/main/presenter/sessionApplication/runtimeIntegration.test.ts +++ b/test/main/presenter/sessionApplication/runtimeIntegration.test.ts @@ -629,6 +629,18 @@ function createMockConfigPresenter() { getVerbosityDefault: vi.fn().mockReturnValue(undefined), getSkillsEnabled: vi.fn().mockReturnValue(false), getSetting: vi.fn().mockReturnValue(undefined), + getAgentType: vi.fn().mockResolvedValue('deepchat'), + resolveDeepChatAgentConfig: vi.fn().mockResolvedValue({ + subagentEnabled: true, + subagents: [ + { + id: 'reviewer', + targetType: 'self', + displayName: 'Reviewer', + description: 'Review an independent task.' + } + ] + }), getAcpAgents: vi.fn().mockResolvedValue([]) } as any } @@ -799,6 +811,8 @@ describe('Integration: createSession end-to-end', () => { ) expect(streamEndCalls.length).toBeGreaterThanOrEqual(1) expect(streamEndCalls[0][1].sessionId).toBe(session.id) + expect(configPresenter.getAgentType).toHaveBeenCalledWith('deepchat') + expect(configPresenter.resolveDeepChatAgentConfig).toHaveBeenCalledWith('deepchat') }) it('session list returns enriched sessions', async () => { From 668a9d47c27c8b6631f89fc617d9966f21ca60a0 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Thu, 16 Jul 2026 13:46:11 +0800 Subject: [PATCH 10/10] fix(agent): isolate subagent policy failures --- src/main/presenter/index.ts | 50 ++++++++++++-- src/shared/lib/deepchatSubagents.ts | 9 +-- test/main/presenter/index.test.ts | 65 ++++++++++++++++++- .../runtimeIntegration.test.ts | 22 ++++++- 4 files changed, 129 insertions(+), 17 deletions(-) diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index 3cf44e0ba..34a60c479 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -124,6 +124,12 @@ import { SessionHistorySearch } from '@/routes/sessions/sessionHistorySearch' import { SessionTranslation } from '@/routes/sessions/sessionTranslation' import { AgentSessionExportService } from './exporter/agentSessionExporter' import { createMemoryProviderBindings } from './memoryProviderBindings' +import type { + AgentType, + DeepChatAgentConfig, + DeepChatSubagentCapability, + SessionKind +} from '@shared/types/agent-interface' type MemoryMaintenanceConfigChangeTarget = Pick< MemoryPresenter, @@ -141,6 +147,39 @@ export const routeDeepChatAgentMemoryMaintenanceConfigChanged = ( } } +type ResolveConversationSubagentCapabilityInput = { + sessionId: string + agentId: string + agentType: AgentType | null + sessionKind: SessionKind + resolveConfig: (agentId: string) => Promise +} + +export const resolveConversationSubagentCapability = async ( + input: ResolveConversationSubagentCapabilityInput +): Promise => { + let config: DeepChatAgentConfig | null = null + + if (input.agentType === 'deepchat' && input.sessionKind === 'regular') { + try { + config = await input.resolveConfig(input.agentId) + } catch (error) { + console.warn('[Presenter] Failed to resolve Subagent policy:', { + sessionId: input.sessionId, + agentId: input.agentId, + error + }) + } + } + + return resolveDeepChatSubagentCapability({ + agentType: input.agentType, + sessionKind: input.sessionKind, + agentPolicyEnabled: config?.subagentEnabled !== false, + slots: config?.subagents + }) +} + // Coordinates presenters and owns main-process IPC wiring. export class Presenter implements IPresenter { private static instance: Presenter @@ -329,15 +368,12 @@ export class Presenter implements IPresenter { const disabledAgentTools = await this.sessionAgentAssignmentCoordinator.getSessionDisabledAgentTools(session.id) const activeSkills = await this.skillPresenter.getActiveSkills(session.id) - const subagentConfig = - agentType === 'deepchat' && session.sessionKind === 'regular' - ? await this.configPresenter.resolveDeepChatAgentConfig(session.agentId) - : null - const subagentCapability = resolveDeepChatSubagentCapability({ + const subagentCapability = await resolveConversationSubagentCapability({ + sessionId: session.id, + agentId: session.agentId, agentType, sessionKind: session.sessionKind, - agentPolicyEnabled: subagentConfig?.subagentEnabled !== false, - slots: subagentConfig?.subagents + resolveConfig: (agentId) => this.configPresenter.resolveDeepChatAgentConfig(agentId) }) return { diff --git a/src/shared/lib/deepchatSubagents.ts b/src/shared/lib/deepchatSubagents.ts index 1d4a2c685..a10f85366 100644 --- a/src/shared/lib/deepchatSubagents.ts +++ b/src/shared/lib/deepchatSubagents.ts @@ -166,9 +166,7 @@ export const resolveDeepChatSubagentCapability = ( return createUnavailableSubagentCapability('policy_disabled') } - const slots = normalizeDeepChatSubagentSlots(input.slots) - .map((slot) => ({ ...slot })) - .sort(compareSubagentSlots) + const slots = normalizeDeepChatSubagentSlots(input.slots).sort(compareSubagentSlots) if (slots.length === 0) { return createUnavailableSubagentCapability('no_valid_slots') } @@ -193,10 +191,7 @@ export const normalizeDeepChatSubagentConfig = ( export const assertDeepChatSubagentConfigInvariant = (config: DeepChatAgentConfig): void => { const normalized = normalizeDeepChatSubagentConfig(config) - if ( - normalized.subagentEnabled !== false && - normalizeDeepChatSubagentSlots(normalized.subagents).length === 0 - ) { + if (normalized.subagentEnabled !== false && (normalized.subagents?.length ?? 0) === 0) { throw new Error('Enabled DeepChat Subagents require at least one valid slot.') } } diff --git a/test/main/presenter/index.test.ts b/test/main/presenter/index.test.ts index 17e60561c..7467d8858 100644 --- a/test/main/presenter/index.test.ts +++ b/test/main/presenter/index.test.ts @@ -1,5 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Presenter, routeDeepChatAgentMemoryMaintenanceConfigChanged } from '@/presenter' +import { + Presenter, + resolveConversationSubagentCapability, + routeDeepChatAgentMemoryMaintenanceConfigChanged +} from '@/presenter' import { BUILTIN_DEEPCHAT_AGENT_ID } from '@/presenter/agentRepository' import logger from '@shared/logger' @@ -180,3 +184,62 @@ describe('DeepChat agent memory maintenance config routing', () => { expect(memoryPresenter.onBuiltinDeepChatMemoryMaintenanceConfigChanged).not.toHaveBeenCalled() }) }) + +describe('Conversation Subagent capability resolution', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('applies the current Agent policy to regular DeepChat parent sessions', async () => { + const resolveConfig = vi.fn().mockResolvedValue({ + subagentEnabled: true, + subagents: [ + { + id: 'reviewer', + targetType: 'self', + displayName: 'Reviewer', + description: 'Review an independent task.' + } + ] + }) + + await expect( + resolveConversationSubagentCapability({ + sessionId: 'session-1', + agentId: 'deepchat', + agentType: 'deepchat', + sessionKind: 'regular', + resolveConfig + }) + ).resolves.toMatchObject({ + available: true, + slots: [expect.objectContaining({ id: 'reviewer' })] + }) + expect(resolveConfig).toHaveBeenCalledWith('deepchat') + }) + + it('fails closed without rejecting when Agent policy resolution fails', async () => { + const error = new Error('config unavailable') + const resolveConfig = vi.fn().mockRejectedValue(error) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await expect( + resolveConversationSubagentCapability({ + sessionId: 'session-1', + agentId: 'deepchat', + agentType: 'deepchat', + sessionKind: 'regular', + resolveConfig + }) + ).resolves.toMatchObject({ + available: false, + reason: 'no_valid_slots' + }) + expect(resolveConfig).toHaveBeenCalledWith('deepchat') + expect(warn).toHaveBeenCalledWith('[Presenter] Failed to resolve Subagent policy:', { + sessionId: 'session-1', + agentId: 'deepchat', + error + }) + }) +}) diff --git a/test/main/presenter/sessionApplication/runtimeIntegration.test.ts b/test/main/presenter/sessionApplication/runtimeIntegration.test.ts index d2039edd6..3f0d5293f 100644 --- a/test/main/presenter/sessionApplication/runtimeIntegration.test.ts +++ b/test/main/presenter/sessionApplication/runtimeIntegration.test.ts @@ -119,7 +119,12 @@ function createMockSqlitePresenter() { agentId: string, title: string, projectDir: string | null, - options?: { isDraft?: boolean } + options?: { + isDraft?: boolean + sessionKind?: 'regular' | 'subagent' + parentSessionId?: string | null + subagentMetaJson?: string | null + } ) => { const now = Date.now() sessionsStore.set(id, { @@ -129,6 +134,9 @@ function createMockSqlitePresenter() { project_dir: projectDir, is_pinned: 0, is_draft: options?.isDraft ? 1 : 0, + session_kind: options?.sessionKind === 'subagent' ? 'subagent' : 'regular', + parent_session_id: options?.parentSessionId ?? null, + subagent_meta_json: options?.subagentMetaJson ?? null, created_at: now, updated_at: now }) @@ -687,6 +695,7 @@ describe('Integration: createSession end-to-end', () => { let sqlitePresenter: ReturnType let llmProvider: ReturnType let configPresenter: ReturnType + let toolPresenter: ReturnType let lifecycle: ReturnType['lifecycle'] let turn: ReturnType['turn'] let projection: ReturnType @@ -696,12 +705,13 @@ describe('Integration: createSession end-to-end', () => { sqlitePresenter = createMockSqlitePresenter() llmProvider = createMockLlmProviderPresenter() configPresenter = createMockConfigPresenter() + toolPresenter = createMockToolPresenter() const deepchatAgent = new AgentRuntimePresenter( llmProvider, configPresenter, sqlitePresenter, - createMockToolPresenter() + toolPresenter ) const agentManager = createDeepChatManager(deepchatAgent, sqlitePresenter) as any const appSessionService = new AppSessionService({ @@ -813,6 +823,14 @@ describe('Integration: createSession end-to-end', () => { expect(streamEndCalls[0][1].sessionId).toBe(session.id) expect(configPresenter.getAgentType).toHaveBeenCalledWith('deepchat') expect(configPresenter.resolveDeepChatAgentConfig).toHaveBeenCalledWith('deepchat') + expect(toolPresenter.getAllToolDefinitions).toHaveBeenCalledWith( + expect.objectContaining({ + subagentCapability: expect.objectContaining({ + available: true, + slots: [expect.objectContaining({ id: 'reviewer' })] + }) + }) + ) }) it('session list returns enriched sessions', async () => {