From 4c01c1058f72d8eb6a2a37ab530fd4dfe619c1bd Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 13 Jul 2026 17:20:45 +0800 Subject: [PATCH 01/29] docs(architecture): define session coordinators --- .../session-application-coordinators/plan.md | 310 +++++++++++++++++ .../session-application-coordinators/spec.md | 321 ++++++++++++++++++ .../session-application-coordinators/tasks.md | 87 +++++ 3 files changed, 718 insertions(+) create mode 100644 docs/architecture/session-application-coordinators/plan.md create mode 100644 docs/architecture/session-application-coordinators/spec.md create mode 100644 docs/architecture/session-application-coordinators/tasks.md diff --git a/docs/architecture/session-application-coordinators/plan.md b/docs/architecture/session-application-coordinators/plan.md new file mode 100644 index 000000000..3fe617b0a --- /dev/null +++ b/docs/architecture/session-application-coordinators/plan.md @@ -0,0 +1,310 @@ +# Session Application Coordinators — Implementation Plan + +## Approach + +The migration uses a strangler sequence inside the existing compatibility façade: + +1. characterize transaction ordering and consumer behavior; +2. define consumer-owned ports and coordinator-local dependencies; +3. extract Projection first because it owns shared status/window/event state; +4. extract Assignment and its focused policy seam; +5. extract Turn, then Lifecycle over narrow Assignment/Turn/Projection ports; +6. replace route, Remote, and Cron presenter dependencies; +7. reduce the presenter to forwarding, add guards, and update current docs. + +The implementation is performed on `task/session-application-coordinators`, based directly on +`dev@28e2a0e92`. It does not cherry-pick stage 1. + +## Planned Module Shape + +```text +src/main/presenter/sessionApplication/ +├── lifecycleCoordinator.ts +├── turnCoordinator.ts +├── agentAssignmentCoordinator.ts +├── agentAssignmentPolicy.ts # focused pure/shared resolution, if needed +├── projectionCoordinator.ts +└── ports.ts # coordinator dependency ports only + +src/main/routes/ +├── sessions/sessionService.ts # consumer-owned lifecycle/projection ports +├── chat/chatService.ts # consumer-owned turn/projection ports +└── hotPathPorts.ts # provider-only adapters; no session presenter adapter + +src/main/presenter/ +├── agentSessionPresenter/index.ts # compatibility forwarding + stage-1 foreign behavior on dev +├── remoteControlPresenter/ # four explicit remote session ports +└── cronJobs/ # starter factory over lifecycle + turn ports +``` + +Names may follow an existing local convention, but the ownership and dependency rules in the spec are +fixed. Do not create `SessionApplicationCoordinator`, `SessionApplicationServices`, or another object +that re-exports all four capabilities. + +## Module Contracts + +### Projection + +Projection is extracted first and constructed once. Its public surface is grouped by capability, not +by transport: + +```ts +interface SessionProjectionReadPort { + getSession(sessionId: string): Promise + listSessions(filters?: SessionListFilters): Promise + listLightweight(options?: SessionLightweightOptions): Promise + getLightweightByIds(sessionIds: string[]): Promise +} + +interface SessionWindowProjectionPort { + activate(webContentsId: number, sessionId: string): Promise + deactivate(webContentsId: number): Promise + getActive(webContentsId: number): Promise + getActiveId(webContentsId: number): string | null +} + +interface SessionProjectionMutationPort { + materialize(sessionId: string): Promise + notify(input: SessionProjectionUpdate): void + forgetStatus(sessionIds: string[]): void + scheduleTitleGeneration(input: TitleGenerationInput): void +} +``` + +Message, Tape, trace, replay, rename, and pin operations remain concrete methods on the coordinator; +consumers receive only the subsets they require. Full snapshot materialization continues through +typed backend handles and may hydrate runtime state. Lightweight projection remains non-hydrating. + +### Assignment + +Assignment separates resolution policy from commands so Lifecycle can consume policy without a +runtime construction cycle: + +```ts +interface SessionAssignmentPolicyPort { + resolveCreateAssignment(input: CreateAssignmentInput): Promise + resolveSubagentAssignment(input: SubagentAssignmentInput): Promise + resolveTransferTarget(input: TransferTargetInput): Promise +} +``` + +The policy may be a focused module created from the same config/catalog dependencies. The command +coordinator owns transfer and setting mutations. A required lifecycle deletion port is injected into +assignment commands through a concrete, initialized adapter; optional setters and late mutation of +dependencies are forbidden. + +### Turn + +Turn consumes typed handle resolution, app-session mutation, assignment workdir/runtime-setting +ports, transcript mutation, and projection mutation: + +```ts +interface SessionTurnPort { + sendMessage( + sessionId: string, + content: string | SendMessageInput, + options?: { maxProviderRounds?: number } + ): Promise + steerActiveTurn(sessionId: string, content: string | SendMessageInput): Promise + cancelGeneration(sessionId: string): Promise + respondToolInteraction(...args: ToolInteractionArgs): Promise +} +``` + +Pending, retry/edit/delete/clear, and compaction methods remain available to compatibility routes. +The internal initial-message method preserves current fire-and-forget create behavior without going +through `ChatService` request locks. + +### Lifecycle + +Lifecycle consumes assignment policy, the initial-turn port, projection mutation, app-session/runtime +ports, and the existing skill/permission owners. It owns a reusable required deletion transaction +port so Assignment can perform batch empty-draft deletion without duplicating cleanup ordering. + +Create methods continue to return materialized `SessionWithState`; no new public restore or close +operation is introduced. + +## Integration Enumeration + +Every real creation/call/injection relationship must be verified. Unit mocks do not satisfy these +integration tasks by themselves. + +| Producer/caller | Consumer | Required integration evidence | +| --- | --- | --- | +| `Presenter` composition root | four coordinators | each constructed exactly once with real narrow adapters | +| composition root | compatibility `AgentSessionPresenter` | forwarding reaches the same coordinator instances | +| Lifecycle | Assignment policy | create/draft/subagent resolve canonical kind/config through real policy | +| Lifecycle | Turn initial-message port | initial message is accepted after successful initialization and remains fire-and-forget | +| Lifecycle | Projection mutation | bind/materialize/notify/forget behavior uses the shared projection instance | +| Assignment | Lifecycle deletion port | batch deletion uses the real child-first transaction, not a stub implementation | +| Assignment/Turn | Projection mutation | post-mutation materialization/events use the shared projection instance | +| `createMainKernelRouteRuntime` | `SessionService` | real lifecycle/projection ports replace presenter adapter | +| `createMainKernelRouteRuntime` | `ChatService` | real turn/projection/permission ports replace presenter adapter/cast | +| `RemoteControlPresenter` | four remote ports | runner operations reach the real coordinator set; generation port remains separate | +| composition root | Cron starter | starter is installed before startup without route-runtime priming | +| Cron starter | Lifecycle + Turn | detached create, max-turn send, and cancel use real coordinators | +| compatibility routes/tools | façade | forwarding remains available for consumers deferred to stage 3 | + +## Slice 1 — Characterization and Port Contracts + +1. Inventory all core presenter methods, private helpers, state, and production/test callers. +2. Add missing characterization for: + - pending update/move/convert/steer/delete; + - retry/delete/edit message; + - fork and compaction failure paths; + - tool-interaction validation; + - lifecycle rollback/error precedence; + - assignment transfer partial-result and conservative checks; + - projection active binding, lightweight cache, malformed read data, and title CAS; + - Remote status/output truth table; + - Cron metadata/max-turn/output/status semantics. +3. Define consumer-owned SessionService, ChatService, Remote, and Cron ports using shared DTOs. +4. Do not define any port as `Pick`. + +## Slice 2 — Projection Coordinator + +1. Move `sessionStatusSnapshots`, session/message/Tape/trace read projection, active window operations, + rename/pin, event publication, UI refresh, materialization, lightweight mapping, and title generation + into `SessionProjectionCoordinator`. +2. Keep stage-1 history search and export code outside this coordinator on the `dev` baseline. +3. Add owner tests for full vs lightweight behavior, status cache, per-window binding, missing/malformed + reads, title generation, and update events. +4. Add forwarding methods in `AgentSessionPresenter` without duplicating policy/state. + +## Slice 3 — Assignment Coordinator + +1. Extract shared create/subagent/transfer resolution into the focused assignment policy seam. +2. Move transfer impact/batch/single commands, settings/ACP controls, and subagent Tape finalize into + `SessionAgentAssignmentCoordinator`. +3. Use Projection mutation for post-commit state/event publication. +4. Use the required lifecycle deletion transaction for empty-draft/bulk deletion. +5. Add owner tests for target validation, conservative failure handling, preflight-before-mutation, + partial transfer errors, setting mutation order, ACP restrictions, and subagent Tape validation. +6. Retain façade forwarding for deferred consumers. + +## Slice 4 — Turn Coordinator + +1. Move send/steer, pending operations, message mutation, clear/cancel, tool interaction, and compaction. +2. Preserve draft promotion and ACP workdir synchronization ordering. +3. Provide a narrow initial-turn operation for Lifecycle without routing through `ChatService`. +4. Add owner tests for method-specific missing-session behavior, queue metadata, cancellation ordering, + retry flags, ACP interaction validation, and compaction restrictions. +5. Retain façade forwarding for deferred consumers. + +## Slice 5 — Lifecycle Coordinator + +1. Move create/detached/subagent/draft/fork/delete and initialization/cleanup helpers. +2. Consume Assignment policy, Turn initial-message, and Projection mutation ports. +3. Preserve create precedence, transaction timing, retries, fire-and-forget initial send, rollback, + descriptor-independent deletion, and error precedence. +4. Add owner tests for all creation variants, failed initialization, ACP draft reuse, fork cleanup, + recursive deletion, and shared projection integration. +5. Retain façade forwarding for deferred consumers. + +## Slice 6 — SessionService and ChatService Integration + +1. Replace `SessionRepository`/`MessageRepository` presenter adaptation with explicit consumer-owned + lifecycle/projection ports. +2. Replace `ProviderExecutionPort` session methods with a Turn port; keep provider connection methods + in provider-specific wiring. +3. Inject the existing permission cleanup owner directly and remove the presenter intersection cast. +4. Delete unused message-list hot-path adapters. +5. Keep route schemas, scheduler timings, retries, locks, and response mapping unchanged. +6. Add service and dispatcher integration tests with independent coordinator doubles. + +## Slice 7 — Remote and Cron Integration + +### Remote + +1. Replace the full presenter in Remote interfaces, presenter construction, and runner construction + with four explicit ports. +2. Preserve `AgentManagerGenerationPort` for active-event lookup/cancel. +3. Remove optional `getSearchResults` capability probing; the required projection port is called and + its existing failure fallback remains local to Remote. +4. Replace untyped partial presenter fixtures with typed port stubs. + +### Cron + +1. Add a Cron-owned starter factory over Lifecycle and Turn ports. +2. Install the starter in the composition root before lifecycle startup. +3. Remove `setRunSessionStarter` side effects from route-runtime construction. +4. Remove route-runtime priming from `cronJobsStartHook`. +5. Preserve `CronJobRunExecutor` as the narrow consumer of `CronJobRunSessionStarter`. + +## Slice 8 — Façade Cleanup, Guards, and Documentation + +1. Remove migrated state, policy, private helpers, imports, and direct implementations from + `AgentSessionPresenter`; leave only forwarding for the four domains plus stage-1 foreign behavior + still present on `dev`. +2. Keep `IAgentSessionPresenter` public signatures unchanged in stage 2. +3. Move behavior tests to coordinator suites; presenter tests cover forwarding/compatibility only. +4. Add architecture guards that reject: + - `IAgentSessionPresenter` imports in SessionService/ChatService hot paths, Remote runner/interfaces, + Cron starter, and migrated composition helpers; + - duplicate coordinator construction outside the composition root/tests; + - coordinator imports of stage-1 foreign owners; + - a combined session application façade. +5. Update current architecture/session/flow/code-navigation docs. Do not rewrite historical BEFORE + sections or layered-runtime invariants that remain true. +6. Review generated architecture baseline changes; regenerate maintained outputs only when the diff is + intentional and the relevant tree is clean. + +## Test Strategy + +### Owner tests + +- Lifecycle: creation precedence, initialization, detached/subagent/draft/fork, rollback, recursive + deletion, error precedence. +- Turn: send/steer/pending/message mutation/clear/cancel/tool interaction/compaction. +- Assignment: policy resolution, transfer, settings, ACP controls, subagent Tape finalize. +- Projection: full/lightweight session state, message/Tape/trace projection, active binding, title, + status cache, events. + +### Consumer tests + +- `SessionService`: create/restore/list/page/activate/deactivate/get-active with exact timeout/retry + behavior. +- `ChatService`: send lock, unavailable session/agent, steer, stop-by-request, timeout cleanup, tool + interaction. +- Remote: command and status/output truth table over typed port stubs. +- Cron: starter metadata, ACP/pinned model snapshot, max-turn mapping, cancellation, executor output and + terminal fencing. + +### Integration tests + +- Composition identity proves one shared coordinator set reaches façade, routes, Remote, and Cron. +- Real coordinator chains cover Lifecycle → Assignment/Turn/Projection and Assignment/Turn → + Projection without mocks at those boundaries. +- Dispatcher tests prove unchanged route contracts while using independent coordinator doubles. +- Startup test proves Cron no longer primes route runtime. + +### Regression gates + +```text +pnpm run format +pnpm run i18n +pnpm run lint +pnpm run typecheck +pnpm run test:main +pnpm run lint:architecture +git diff --check +``` + +Run `pnpm run architecture:baseline` only after reviewing a temporary generated diff and only from a +clean relevant tree. + +## Compatibility and Rollback + +- No schema, route, event, or persisted-data migration is introduced; rollback is code-only. +- Each coordinator slice keeps façade forwarding, so later slices can be reverted independently. +- Do not remove a façade implementation until owner tests and all migrated consumers call the new + owner. +- Stage-1 integration requires a deliberate rebase. Preserve stage-1 foreign owner wiring and stage-2 + coordinator wiring file by file; never accept either side wholesale for presenter/composition/routes. + +## Exit Gate + +This goal is complete only when all spec acceptance criteria pass, all four coordinators are the sole +owners of their domain behavior, the four named consumer groups use narrow ports, the compatibility +façade contains forwarding rather than duplicated logic, architecture guards are active, current docs +are updated, and every task in `tasks.md` is closed. diff --git a/docs/architecture/session-application-coordinators/spec.md b/docs/architecture/session-application-coordinators/spec.md new file mode 100644 index 000000000..6f9398a01 --- /dev/null +++ b/docs/architecture/session-application-coordinators/spec.md @@ -0,0 +1,321 @@ +# Session Application Coordinators + +> Status: approved for implementation +> Base: `dev@28e2a0e92` +> Branch: `task/session-application-coordinators` + +## Context + +The layered runtime migration established the execution boundary: + +- `AppSessionService` owns the persisted app-session shell and window bindings; +- `AgentManager` resolves strict descriptors and typed DeepChat/direct-ACP handles; +- backend instances own runtime state and turn execution; +- transcript, Tape, permission, skill, and UI adapters retain their existing data ownership. + +`AgentSessionPresenter` still combines four application responsibilities above those owners: + +1. session lifecycle transactions; +2. turn commands and message mutations; +3. agent/runtime assignment policy and transfer; +4. renderer-facing session, message, Tape, title, status, and window projections. + +It is also the common dependency of typed session/chat services, Remote, Cron, subagent tooling, and +compatibility routes. Consumers therefore depend on a large presenter contract even when they need +only one or two operations. + +This goal is stage 2 of the session boundary cleanup sequence: + +1. foreign capability cleanup (`codex/session-boundary-cleanup`, complete but not merged into `dev`); +2. **session application coordinators** (this goal); +3. presenter retirement after all remaining compatibility consumers are rewired. + +This branch intentionally starts from `dev`, not from stage 1. Stage-1 capabilities remain untouched +here so both branches can be reviewed independently. Whichever branch merges second must preserve +both ownership splits while resolving composition-root, presenter, route, guard, test, and current-doc +conflicts. + +## Problem + +The current façade produces five concrete architecture failures: + +1. `SessionService` and `ChatService` reach application behavior through + `createPresenterHotPathPorts(IAgentSessionPresenter)` instead of explicit owners. +2. `RemoteConversationRunner` receives the complete `IAgentSessionPresenter` despite using a fixed, + narrow subset of lifecycle, turn, assignment, and projection operations. +3. Cron wiring is installed as a side effect of `createMainKernelRouteRuntime`, so startup must prime + the route runtime before `CronJobsService.start()`. +4. `AgentSessionPresenter` owns policy, transaction ordering, window/status caches, and title state, + making these invariants difficult to test without a 4,000-line fixture. +5. `ChatService` obtains permission cleanup through a presenter-only intersection cast instead of the + existing permission owner. + +## Goals + +1. Extract four composition-owned application coordinators: + `SessionLifecycleCoordinator`, `SessionTurnCoordinator`, + `SessionAgentAssignmentCoordinator`, and `SessionProjectionCoordinator`. +2. Move the corresponding policy, state, transaction ordering, private helpers, and tests out of + `AgentSessionPresenter`. +3. Keep `AgentSessionPresenter` as a compatibility façade that forwards existing public methods to + the four coordinators. Presenter retirement remains stage 3. +4. Make `SessionService`, `ChatService`, Remote, and Cron depend on consumer-owned narrow ports rather + than `IAgentSessionPresenter`. +5. Construct one shared coordinator set in the composition root and reuse it across the compatibility + façade, routes, Remote, Cron, tools, and other migrated consumers. +6. Remove Cron's route-runtime priming dependency and wire its existing + `CronJobRunSessionStarter` from the composition root. +7. Preserve route/event/data contracts and all DeepChat/direct-ACP behavior. +8. Add architecture enforcement that prevents the migrated consumers from regaining the presenter + dependency or an aggregate replacement façade. + +## Non-goals + +- Merging or modifying stage-1 history, import, migration, usage, RTK, export, translation, or + catalog ownership. Those methods remain on the compatibility façade on this `dev` baseline. +- Removing `AgentSessionPresenter`, deleting `IAgentSessionPresenter`, or rewiring every remaining + compatibility caller. That is stage 3. +- Moving runtime state out of `DeepChatAgentInstance`, `AcpAgentInstance`, or `LoopRun`. +- Reworking `AgentManager`, backend handles, transcript/Tape schemas, permission policy, skills, + providers, or ACP protocol behavior. +- Changing renderer route names, inputs, outputs, typed events, preload clients, database schemas, or + persisted formats. +- Changing `SessionService`/`ChatService` timeout, retry, locking, cancellation, or error semantics. +- Changing Remote commands, bindings, status/output projection, active-event cancellation, or media + delivery. +- Changing Cron scheduling, snapshot policy, detached-session metadata, completion detection, + timeout, output ordering, or delivery behavior. +- Introducing a generic service container, command bus, repository hierarchy, coordinator registry, + or combined `SessionApplicationServices` façade. +- GitHub issue synchronization. + +## Ownership Decisions + +### SessionLifecycleCoordinator + +Owns lifecycle transactions and their rollback/cleanup ordering: + +- create window-bound sessions; +- create detached sessions; +- create subagent sessions; +- ensure/reuse ACP draft sessions; +- fork sessions; +- delete a session tree; +- runtime initialization, ACP workdir synchronization, and failed-create cleanup. + +It consumes narrow assignment policy, initial-turn, projection mutation, app-session, runtime, +transcript, skill, and permission ports. It does not own window activation, title generation, +transfer policy, ordinary turns, or read projection. + +### SessionTurnCoordinator + +Owns turn commands and message mutation ordering: + +- send and steer; +- pending-input list/queue/update/move/convert/steer/delete; +- retry/delete/edit message; +- compaction state and manual compaction; +- clear session messages; +- cancel generation; +- respond to tool interactions; +- the narrow initial-message operation used by lifecycle creation. + +`ChatService` retains request-level locking, scheduler timeouts, stop-by-request lookup, and +all-settled permission/cancellation cleanup. + +### SessionAgentAssignmentCoordinator + +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; +- ACP config options and commands; +- subagent Tape merge/discard. + +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 +must not become a fifth aggregate coordinator. + +### SessionProjectionCoordinator + +Owns renderer-facing projection state and projection operations: + +- full and lightweight session materialization/listing; +- message/page/message-id/search-result/trace reads; +- Tape info/search/context/anchors/handoff/view-manifest/replay projection operations; +- active window binding and lookup; +- rename and pin updates; +- asynchronous title generation and compare-and-set protection; +- session status snapshot cache; +- `sessions.updated` publication and session UI refresh. + +It is a composition-owned singleton. Creating one instance per consumer is forbidden because window +bindings and status snapshots must remain coherent. + +History search and export remain stage-1 foreign capabilities on this base and must not be absorbed +into Projection. + +## Target Dependency Shape + +```text +Presenter composition root + ├─ SessionProjectionCoordinator (one instance) + ├─ SessionAgentAssignmentCoordinator (one instance) + ├─ SessionTurnCoordinator (one instance) + ├─ SessionLifecycleCoordinator (one instance) + └─ AgentSessionPresenter compatibility façade + └─ forwards existing core methods to the four coordinators + +typed routes + ├─ SessionService -> lifecycle + projection ports + └─ ChatService -> turn + projection + existing permission/catalog ports + +RemoteControlPresenter / RemoteConversationRunner + ├─ remote lifecycle port + ├─ remote turn port + ├─ remote assignment port + ├─ remote projection port + └─ existing AgentManagerGenerationPort + +CronJobsService + └─ CronJobRunSessionStarter + ├─ lifecycle.createDetachedSession + └─ turn.sendMessage / cancelGeneration +``` + +Ports are owned by the consumer that needs them and use shared route/domain DTOs. They must not be +defined as `Pick` and must not be grouped under a replacement aggregate. + +## Boundary Rules + +1. A coordinator may orchestrate existing owners but must not copy their state or persistence logic. +2. Coordinators depend on the smallest required typed ports. They must not receive the entire + `Presenter`, `IAgentSessionPresenter`, `AgentSharedDataPorts`, or concrete SQLite presenter. +3. The compatibility façade contains forwarding only for migrated core methods. Coordinator state, + policy, and private helpers must not remain duplicated there. +4. `SessionService`, `ChatService`, Remote runner/interfaces, Cron starter, and + `createPresenterHotPathPorts` must not import or accept `IAgentSessionPresenter` after migration. +5. `SessionService` continues to compose restore as projection lookup plus paged messages; there is no + new restore coordinator method. +6. `ChatService.activeControllers` remains in `ChatService`. +7. Remote generation stop continues through `AgentManagerGenerationPort.cancelGenerationByEventId`; + it must not be replaced with ordinary session cancellation. +8. Cron starter construction occurs in the composition root, not as a route-runtime side effect. +9. Required dependencies fail fast. No optional coordinator methods, capability probes, no-op + fallbacks, or `as unknown as` wiring are permitted. +10. Phase-1 foreign methods are left unchanged on this branch and are not imported by the new + coordinators. + +## Compatibility Invariants + +### Lifecycle + +- Create precedence, missing provider/model/workdir errors, permission normalization, active-skill + persistence, and direct-ACP initialization remain unchanged. +- `createSession(projectDir: null)` continues to suppress project fallback; detached blank/null input + keeps its current fallback behavior. +- Runtime initialization completes before window binding and `created` publication. +- Initial send remains fire-and-forget; its failure is logged and does not fail creation. +- Failed initialization removes the app row, best-effort clears ACP compatibility state/closes the + handle, and rethrows the original error. +- Subagent creation keeps at most two attempts with a fresh session ID per attempt and publishes only + after successful materialization. +- ACP draft transcript-read failure remains conservative: the draft is not reused. +- Delete remains descriptor-independent, child-first, and preserves backend/shared-data error + precedence and permission/skill/app-row/status cleanup ordering. +- Normal delete never becomes `resolveSessionHandle(...).close()`. + +### Turn + +- Send/steer/queue promote drafts before runtime work and do not roll back promotion on later failure. +- Queue source, project directory, `emitRefreshBeforeStream`, and `maxProviderRounds` forwarding remain + unchanged. +- Missing-session behavior remains method-specific: pending list returns `[]`, cancellation is a + no-op, and other mutations throw the existing error. +- Delete/clear cancel before mutation; edit does not cancel. +- Direct ACP tool interaction and DeepChat/manual compaction restrictions remain unchanged. +- Session/Chat routes retain existing scheduler timeout values, send lock, retry, stop, and + all-settled cleanup behavior. + +### Assignment + +- Transfer validates the target and all batch entries before mutation; ACP targets and DeepChat + targets whose default provider is ACP remain rejected. +- Conservative transcript/pending checks, active-generation blocking, sample limits, partial-transfer + error text, update order, and post-commit ACP cleanup remain unchanged. +- 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. +- Subagent parent/slot/agent validation, ACP forced runtime settings, and Tape merge/discard parent + checks remain unchanged. + +### Projection + +- Full session snapshots may hydrate the backend; restore must not become a pure database read. +- Lightweight lists do not hydrate, retain cached/default status, dedupe/order rules, cursor behavior, + and unavailable-agent skip behavior. +- Message/page/Tape/trace/search-result missing and malformed-data behavior remains unchanged. +- Active binding remains per-window; activation does not add session validation, and failed active + projection continues to unbind without emitting a new deactivation event. +- Title generation remains asynchronous, waits at most 30 seconds with 250 ms polling, uses the + assistant-model preference/fallback, performs both compare-and-set checks, normalizes to 80 + characters, and only warns on failure. +- Event ID trim/dedupe, reason defaults, active-session payloads, and UI refresh behavior remain + unchanged. + +### Remote + +- Deleted bindings are cleared; missing bindings and completed/no-response/pending-interaction status + projections keep their current payloads. +- `/open`, model selection, search-result fallback, old-event filtering, generated-image text, and + pending interaction behavior remain unchanged. +- Remote channel command/router public APIs remain unchanged. + +### Cron + +- Every run creates a new detached session with unchanged source/job/run/scheduled metadata. +- ACP routing, pinned-model and snapshot policy, system-instruction precedence, and max-turn mapping + remain unchanged. +- `messageId` remains the output message ID; completion remains event-driven. +- Output segment precedence/labels, concurrency skip/queue, timeout cleanup order, late-failure + fencing, and delivery behavior remain unchanged. + +## Acceptance Criteria + +1. One composition-owned instance exists for each of the four coordinators. +2. Core lifecycle, turn, assignment, and projection implementation/state/private helpers no longer + live in `AgentSessionPresenter`; its existing core public API forwards to coordinators. +3. `SessionService` uses explicit lifecycle/projection ports and `ChatService` uses explicit + turn/projection/permission/catalog ports without `IAgentSessionPresenter`. +4. `createPresenterHotPathPorts` no longer imports or adapts `IAgentSessionPresenter`; unused message + list adapters and the permission intersection cast are gone. +5. Remote presenter/runner receives separate lifecycle, turn, assignment, and projection ports and no + longer imports or accepts `IAgentSessionPresenter`. +6. Cron receives its existing starter from the composition root; route runtime no longer installs it, + and the Cron startup hook no longer primes route runtime. +7. `SessionService`, `ChatService`, Remote, and Cron route/command/output contracts and behavior remain + unchanged under characterization and integration tests. +8. Remaining non-target consumers either use a narrow coordinator port or the explicit compatibility + façade; no aggregate replacement is introduced. +9. Architecture guards reject presenter dependency reintroduction in migrated consumers, duplicate + coordinator construction, and coordinator imports of stage-1 foreign owners. +10. Current architecture, flow, session-management, and code-navigation docs describe the four + coordinators and narrow consumer ports without rewriting historical runtime invariants. +11. Formatting, i18n validation, lint, full typecheck, main tests, and architecture guards pass. + +## Risks + +- Moving 4,000 lines of interleaved behavior can silently change transaction ordering. Tests must be + relocated by owner before the façade helpers are deleted. +- Projection must remain singleton; duplicate caches would produce inconsistent status/window views. +- Lifecycle, Turn, and Assignment have real call chains. Breaking their construction with optional + setters would replace one service-locator problem with another. +- Full session projection intentionally hydrates runtime state. Treating Projection as a read-only + repository would break restore and Remote behavior. +- Remote and Cron often receive `{ requestId: null, messageId: null }` because send means accepted, + not completed. Coordinator extraction must not await generation completion. +- Stage 1 and stage 2 both touch the presenter, composition root, routes, tests, guards, and docs. + Integration requires a deliberate rebase/merge review; neither branch may overwrite the other's + owner wiring. diff --git a/docs/architecture/session-application-coordinators/tasks.md b/docs/architecture/session-application-coordinators/tasks.md new file mode 100644 index 000000000..9d3a5803b --- /dev/null +++ b/docs/architecture/session-application-coordinators/tasks.md @@ -0,0 +1,87 @@ +# Session Application Coordinators — Tasks + +## SDD and Inventory + +- [x] Audit Lifecycle, Turn, AgentAssignment, and Projection methods, state, dependencies, and tests. +- [x] Enumerate SessionService, ChatService, Remote, and Cron create/call/inject chains. +- [x] Resolve ownership of active-window state, title, draft, transfer, permission cleanup, and Cron + starter wiring. +- [x] Write the approved spec and implementation plan from `dev@28e2a0e92`. + +## 1. Characterization and Ports + +- [ ] Add missing lifecycle rollback and deletion error-precedence characterization. +- [ ] Add pending/message mutation, fork, compaction, and tool-interaction characterization. +- [ ] Lock assignment transfer, setting mutation, and subagent Tape behavior. +- [ ] Lock projection cache/window/title/read fallback behavior. +- [ ] Lock Remote status/output and Cron metadata/max-turn/output behavior. +- [ ] Define consumer-owned narrow ports without `Pick`. + +## 2. SessionProjectionCoordinator + +- [ ] Extract full and lightweight session materialization and status cache. +- [ ] Extract message, Tape, trace, manifest, replay, and search-result projection operations. +- [ ] Extract active-window binding, rename/pin, title generation, events, and UI refresh. +- [ ] Construct one composition-owned Projection instance. +- [ ] Rewire compatibility presenter forwarding and move owner tests. + +## 3. SessionAgentAssignmentCoordinator + +- [ ] Extract focused create/subagent/transfer assignment policy. +- [ ] Extract transfer impact, batch/single transfer, and agent-session deletion orchestration. +- [ ] Extract model/project/permission/generation/tools/subagent settings and ACP controls. +- [ ] Extract subagent Tape merge/discard. +- [ ] Use narrow lifecycle deletion and projection mutation ports without circular construction. +- [ ] Rewire compatibility presenter forwarding and move owner tests. + +## 4. SessionTurnCoordinator + +- [ ] Extract send, steer, and pending-input operations. +- [ ] Extract retry/delete/edit/clear message operations. +- [ ] Extract cancellation, tool-interaction response, and compaction. +- [ ] Add the narrow initial-turn operation used by Lifecycle. +- [ ] Rewire compatibility presenter forwarding and move owner tests. + +## 5. SessionLifecycleCoordinator + +- [ ] Extract create, detached, subagent, ACP draft, fork, and recursive delete transactions. +- [ ] Extract runtime initialization, workdir sync, and failed-create cleanup. +- [ ] Connect real Assignment policy, Turn initial-message, and Projection mutation owners. +- [ ] Rewire compatibility presenter forwarding and move owner tests. + +## 6. SessionService and ChatService + +- [ ] Inject Lifecycle/Projection ports into `SessionService`. +- [ ] Inject Turn/Projection and existing permission/catalog ports into `ChatService`. +- [ ] Remove the `IAgentSessionPresenter` hot-path adapter, unused message adapter, and permission cast. +- [ ] Preserve route schemas, timeout/retry/lock/cleanup semantics, and add integration tests. + +## 7. Remote and Cron + +- [ ] Inject separate Lifecycle, Turn, Assignment, and Projection ports into Remote. +- [ ] Keep Remote active-generation lookup/cancel on `AgentManagerGenerationPort`. +- [ ] Replace untyped Remote presenter fixtures with typed port stubs. +- [ ] Build the Cron starter from Lifecycle/Turn in the composition root. +- [ ] Remove route-runtime starter side effects and startup route-runtime priming. +- [ ] Preserve Cron metadata, max-turn, output, status, timeout, and delivery semantics. + +## 8. Façade and Enforcement + +- [ ] Remove migrated implementation state/helpers/imports from `AgentSessionPresenter`. +- [ ] Keep stage-2 compatibility signatures and forwarding; do not retire the façade. +- [ ] Exhaust production/test searches for presenter dependencies in migrated consumers. +- [ ] Add architecture guards for consumer imports, duplicate construction, foreign-owner imports, + and combined façade regression. +- [ ] Update current architecture, session management, flows, and code navigation. +- [ ] Review the dependency diff and regenerate maintained baselines only when intentional. + +## 9. Validation + +- [ ] Run focused coordinator, service, route, Remote, Cron, composition, and guard tests. +- [ ] Run `pnpm run format`. +- [ ] Run `pnpm run i18n`. +- [ ] Run `pnpm run lint`. +- [ ] Run `pnpm run typecheck`. +- [ ] Run `pnpm run test:main`. +- [ ] Run `pnpm run lint:architecture` and `git diff --check`. +- [ ] Confirm every acceptance criterion in `spec.md` and close this task list. From c164c71f504c0d8a45b38435919bf1cd372f7533 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 13 Jul 2026 17:30:55 +0800 Subject: [PATCH 02/29] test(session): lock coordinator invariants --- .../manager/directAcpAgentBackend.test.ts | 79 ++ .../agentSessionPresenter.test.ts | 790 ++++++++++++++++++ 2 files changed, 869 insertions(+) diff --git a/test/main/agent/manager/directAcpAgentBackend.test.ts b/test/main/agent/manager/directAcpAgentBackend.test.ts index ab2bca71d..73f3e9547 100644 --- a/test/main/agent/manager/directAcpAgentBackend.test.ts +++ b/test/main/agent/manager/directAcpAgentBackend.test.ts @@ -183,6 +183,85 @@ describe('direct ACP agent backend', () => { ) }) + it('rejects non-permission interactions before reading the transcript', async () => { + const harness = createHarness() + const handle = harness.backend.open(sessionId, descriptor) + + await expect( + handle.toolInteractions.respond('assistant', 'tool-call', { kind: 'question_other' }) + ).rejects.toThrow('Direct ACP sessions only accept permission interactions.') + + expect(harness.transcript.getMessage).not.toHaveBeenCalled() + expect(harness.instance.resolvePermissionRequest).not.toHaveBeenCalled() + }) + + it.each([ + ['a missing message', null], + [ + 'a message from another session', + { id: 'assistant', sessionId: toAppSessionId('other'), role: 'assistant', content: '[]' } + ], + ['a non-assistant message', { id: 'user', sessionId, role: 'user', content: '[]' }] + ])('rejects %s before resolving an ACP permission request', async (_caseName, message) => { + const harness = createHarness() + harness.transcript.getMessage.mockResolvedValueOnce(message) + const handle = harness.backend.open(sessionId, descriptor) + + await expect( + handle.toolInteractions.respond('assistant', 'tool-call', { + kind: 'permission', + granted: true + }) + ).rejects.toThrow('Assistant message not found: assistant') + + expect(harness.instance.resolvePermissionRequest).not.toHaveBeenCalled() + }) + + it('requires a matching persisted tool-call permission request', async () => { + const harness = createHarness() + harness.transcript.getMessage.mockResolvedValueOnce({ + id: 'assistant', + sessionId, + role: 'assistant', + content: JSON.stringify([ + { + type: 'action', + action_type: 'tool_call_permission', + tool_call: { id: 'different-tool' }, + extra: { permissionRequestId: 'permission-request' } + } + ]) + }) + const handle = harness.backend.open(sessionId, descriptor) + + await expect( + handle.toolInteractions.respond('assistant', 'tool-call', { + kind: 'permission', + granted: false + }) + ).rejects.toThrow('ACP permission request not found for tool call: tool-call') + + expect(harness.instance.resolvePermissionRequest).not.toHaveBeenCalled() + }) + + it('rejects permission requests that are no longer hydrated', async () => { + const harness = createHarness() + harness.instance.resolvePermissionRequest.mockReturnValueOnce(false) + const handle = harness.backend.open(sessionId, descriptor) + + await expect( + handle.toolInteractions.respond('assistant', 'tool-call', { + kind: 'permission', + granted: false + }) + ).rejects.toThrow('Unknown ACP permission request: permission-request') + + expect(harness.instance.resolvePermissionRequest).toHaveBeenCalledWith( + 'permission-request', + false + ) + }) + it('exposes direct transfer, subagent, generation, and close facets', async () => { const harness = createHarness() const handle = harness.backend.open(sessionId, descriptor) diff --git a/test/main/presenter/agentSessionPresenter/agentSessionPresenter.test.ts b/test/main/presenter/agentSessionPresenter/agentSessionPresenter.test.ts index 7288cd22f..2585e1886 100644 --- a/test/main/presenter/agentSessionPresenter/agentSessionPresenter.test.ts +++ b/test/main/presenter/agentSessionPresenter/agentSessionPresenter.test.ts @@ -407,10 +407,62 @@ function createMockSqlitePresenter() { deleteBySession: vi.fn(), searchFts: vi.fn().mockReturnValue([]), searchLike: vi.fn().mockReturnValue([]) + }, + deepchatSessionMetadataTable: { + get: vi.fn().mockReturnValue(null), + upsert: vi.fn(), + delete: vi.fn() + }, + deepchatMessageSearchResultsTable: { + listByMessageId: vi.fn().mockReturnValue([]) } } as any } +function installSessionStore(sqlitePresenter: ReturnType) { + const rows = new Map() + sqlitePresenter.newSessionsTable.create.mockImplementation( + ( + id: string, + agentId: string, + title: string, + projectDir: string | null, + options: Record = {} + ) => { + rows.set(id, { + id, + agent_id: agentId, + title, + project_dir: projectDir, + is_pinned: 0, + is_draft: options.isDraft ? 1 : 0, + session_kind: options.sessionKind ?? 'regular', + parent_session_id: options.parentSessionId ?? null, + subagent_enabled: options.subagentEnabled ? 1 : 0, + subagent_meta_json: options.subagentMetaJson ?? null, + created_at: Date.now(), + updated_at: Date.now() + }) + } + ) + sqlitePresenter.newSessionsTable.get.mockImplementation((id: string) => rows.get(id)) + sqlitePresenter.newSessionsTable.list.mockImplementation((filters: any) => + Array.from(rows.values()).filter( + (row) => + (!filters?.agentId || row.agent_id === filters.agentId) && + (filters?.parentSessionId === undefined || + row.parent_session_id === filters.parentSessionId) + ) + ) + sqlitePresenter.newSessionsTable.update.mockImplementation((id: string, fields: any) => { + const row = rows.get(id) + if (!row) return + rows.set(id, { ...row, ...fields, updated_at: Date.now() }) + }) + sqlitePresenter.newSessionsTable.delete.mockImplementation((id: string) => rows.delete(id)) + return rows +} + function createDescriptorIndependentDeleteHarness(options: { sessions: Array<{ id: string @@ -1698,6 +1750,114 @@ describe('AgentSessionPresenter', () => { } }) + it('does not start title generation after a manual rename wins the first CAS check', async () => { + const rows = installSessionStore(sqlitePresenter) + let resolveMessages: (messages: any[]) => void = () => undefined + deepChatAgent.getMessages.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveMessages = resolve + }) + ) + + await presenter.createSession({ agentId: 'deepchat', message: 'Original prompt' }, 1) + await presenter.renameSession('mock-session-id', 'Manual title') + resolveMessages([ + { + id: 'u1', + sessionId: 'mock-session-id', + orderSeq: 1, + role: 'user', + content: JSON.stringify({ text: 'Original prompt', files: [] }), + status: 'sent' + } + ]) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(llmProviderPresenter.summaryTitles).not.toHaveBeenCalled() + expect(rows.get('mock-session-id')?.title).toBe('Manual title') + }) + + it('does not overwrite a manual rename after title generation starts', async () => { + const rows = installSessionStore(sqlitePresenter) + deepChatAgent.getMessages.mockResolvedValueOnce([ + { + id: 'u1', + sessionId: 'mock-session-id', + orderSeq: 1, + role: 'user', + content: JSON.stringify({ text: 'Original prompt', files: [] }), + status: 'sent' + } + ]) + let resolveTitle: (title: string) => void = () => undefined + llmProviderPresenter.summaryTitles.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveTitle = resolve + }) + ) + + await presenter.createSession({ agentId: 'deepchat', message: 'Original prompt' }, 1) + await vi.waitFor(() => expect(llmProviderPresenter.summaryTitles).toHaveBeenCalledOnce()) + await presenter.renameSession('mock-session-id', 'Manual title') + resolveTitle('Generated title') + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(rows.get('mock-session-id')?.title).toBe('Manual title') + expect(sqlitePresenter.newSessionsTable.update).not.toHaveBeenCalledWith('mock-session-id', { + title: 'Generated title' + }) + }) + + it('falls back to the execution model when the preferred assistant model fails', async () => { + installSessionStore(sqlitePresenter) + configPresenter.resolveDeepChatAgentConfig.mockResolvedValue({ + assistantModel: { providerId: 'anthropic', modelId: 'claude-assistant' } + }) + deepChatAgent.getMessages.mockResolvedValueOnce([ + { + id: 'u1', + sessionId: 'mock-session-id', + orderSeq: 1, + role: 'user', + content: JSON.stringify({ text: 'Original prompt', files: [] }), + status: 'sent' + } + ]) + llmProviderPresenter.summaryTitles + .mockRejectedValueOnce(new Error('assistant unavailable')) + .mockResolvedValueOnce('Fallback title') + + await presenter.createSession( + { + agentId: 'deepchat', + message: 'Original prompt', + providerId: 'openai', + modelId: 'gpt-4' + }, + 1 + ) + await vi.waitFor(() => + expect(sqlitePresenter.newSessionsTable.update).toHaveBeenCalledWith('mock-session-id', { + title: 'Fallback title' + }) + ) + + expect(llmProviderPresenter.summaryTitles).toHaveBeenNthCalledWith( + 1, + expect.any(Array), + 'anthropic', + 'claude-assistant' + ) + expect(llmProviderPresenter.summaryTitles).toHaveBeenNthCalledWith( + 2, + expect.any(Array), + 'openai', + 'gpt-4' + ) + }) + it('syncs ACP-as-LLM workdir persistence before the first provider message runs', async () => { configPresenter.getAcpAgents.mockResolvedValue([ { id: 'acp-coder', name: 'ACP Coder', command: 'acp-coder' } @@ -1767,6 +1927,43 @@ describe('AgentSessionPresenter', () => { expect(sqlitePresenter.newSessionsTable.delete).toHaveBeenCalledWith('mock-session-id') expect(deepChatAgent.processMessage).not.toHaveBeenCalled() }) + + it('rethrows the initialization error after every rollback cleanup fails', async () => { + const initializationError = new Error('workdir initialization failed') + const clearError = new Error('compatibility cleanup failed') + const closeError = new Error('runtime close failed') + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + llmProviderPresenter.setAcpWorkdir.mockRejectedValueOnce(initializationError) + llmProviderPresenter.clearAcpSession.mockRejectedValueOnce(clearError) + deepChatAgent.destroySession.mockRejectedValueOnce(closeError) + + await expect( + presenter.createSession( + { + agentId: 'deepchat', + message: 'Hello ACP', + projectDir: '/tmp/workspace', + providerId: 'acp', + modelId: 'acp-coder' + }, + 1 + ) + ).rejects.toBe(initializationError) + + expect(llmProviderPresenter.clearAcpSession).toHaveBeenCalledWith('mock-session-id') + expect(deepChatAgent.destroySession).toHaveBeenCalledWith('mock-session-id') + expect(sqlitePresenter.newSessionsTable.delete).toHaveBeenCalledWith('mock-session-id') + expect(publishDeepchatEvent).not.toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalledWith( + '[AgentSessionPresenter] Failed to clear ACP session after initialization error mock-session-id:', + clearError + ) + expect(warnSpy).toHaveBeenCalledWith( + '[AgentSessionPresenter] Failed to cleanup session runtime after initialization error mock-session-id:', + closeError + ) + warnSpy.mockRestore() + }) }) describe('searchHistory', () => { @@ -2044,6 +2241,131 @@ describe('AgentSessionPresenter', () => { }) }) + describe('pending input mutations', () => { + beforeEach(() => { + sqlitePresenter.newSessionsTable.get.mockReturnValue({ + id: 's1', + agent_id: 'deepchat', + title: 'Test', + project_dir: '/tmp/workspace', + is_pinned: 0, + is_draft: 0, + created_at: 1000, + updated_at: 1000 + }) + }) + + it('delegates update, move, convert, steer, and delete with normalized inputs', async () => { + const updated = { id: 'pending-1', payload: { text: 'Updated', files: [] } } + const moved = [{ id: 'pending-1', queueOrder: 2 }] + const converted = { id: 'pending-1', mode: 'steer' } + const steered = { id: 'pending-1', state: 'claimed' } + deepChatAgent.updateQueuedInput.mockResolvedValueOnce(updated) + deepChatAgent.moveQueuedInput.mockResolvedValueOnce(moved) + deepChatAgent.convertPendingInputToSteer.mockResolvedValueOnce(converted) + deepChatAgent.steerPendingInput.mockResolvedValueOnce(steered) + + await expect(presenter.updateQueuedInput('s1', 'pending-1', 'Updated')).resolves.toBe(updated) + await expect(presenter.moveQueuedInput('s1', 'pending-1', 2)).resolves.toBe(moved) + await expect(presenter.convertPendingInputToSteer('s1', 'pending-1')).resolves.toBe(converted) + await expect(presenter.steerPendingInput('s1', 'pending-1')).resolves.toBe(steered) + await expect(presenter.deletePendingInput('s1', 'pending-1')).resolves.toBeUndefined() + + expect(deepChatAgent.updateQueuedInput).toHaveBeenCalledWith('s1', 'pending-1', { + text: 'Updated', + files: [] + }) + expect(deepChatAgent.moveQueuedInput).toHaveBeenCalledWith('s1', 'pending-1', 2) + expect(deepChatAgent.convertPendingInputToSteer).toHaveBeenCalledWith('s1', 'pending-1') + expect(deepChatAgent.steerPendingInput).toHaveBeenCalledWith('s1', 'pending-1') + expect(deepChatAgent.deletePendingInput).toHaveBeenCalledWith('s1', 'pending-1') + }) + + it.each([ + ['update', () => presenter.updateQueuedInput('missing', 'pending-1', 'Updated')], + ['move', () => presenter.moveQueuedInput('missing', 'pending-1', 1)], + ['convert', () => presenter.convertPendingInputToSteer('missing', 'pending-1')], + ['steer', () => presenter.steerPendingInput('missing', 'pending-1')], + ['delete', () => presenter.deletePendingInput('missing', 'pending-1')] + ])('rejects %s before resolving a handle for a missing session', async (_name, invoke) => { + sqlitePresenter.newSessionsTable.get.mockReturnValue(undefined) + + await expect(invoke()).rejects.toThrow('Session not found: missing') + + expect(agentManager.resolveSessionHandle).not.toHaveBeenCalled() + }) + + it('returns an empty pending list for a missing session without resolving a handle', async () => { + sqlitePresenter.newSessionsTable.get.mockReturnValue(undefined) + + await expect(presenter.listPendingInputs('missing')).resolves.toEqual([]) + + expect(agentManager.resolveSessionHandle).not.toHaveBeenCalled() + }) + }) + + describe('message mutations', () => { + beforeEach(() => { + sqlitePresenter.newSessionsTable.get.mockReturnValue({ + id: 's1', + agent_id: 'deepchat', + title: 'Test', + project_dir: '/tmp/workspace', + is_pinned: 0, + is_draft: 0, + created_at: 1000, + updated_at: 1000 + }) + }) + + it('prepares retry content before sending with refresh metadata', async () => { + deepChatAgent.prepareRetryMessage.mockResolvedValueOnce({ + content: { text: 'Retry body', files: [] }, + projectDir: '/retry/project' + }) + + await presenter.retryMessage('s1', 'message-1') + + expect(deepChatAgent.prepareRetryMessage).toHaveBeenCalledWith('s1', 'message-1') + expect(deepChatAgent.processMessage).toHaveBeenCalledWith( + 's1', + { text: 'Retry body', files: [] }, + { projectDir: '/retry/project', emitRefreshBeforeStream: true } + ) + expect(deepChatAgent.prepareRetryMessage.mock.invocationCallOrder[0]).toBeLessThan( + deepChatAgent.processMessage.mock.invocationCallOrder[0] + ) + expect(deepChatAgent.cancelGeneration).not.toHaveBeenCalled() + }) + + it('cancels before deleting and does not mutate when cancellation fails', async () => { + const cancelError = new Error('cancel failed') + deepChatAgent.cancelGeneration.mockRejectedValueOnce(cancelError) + + await expect(presenter.deleteMessage('s1', 'message-1')).rejects.toBe(cancelError) + + expect(deepChatAgent.deleteMessage).not.toHaveBeenCalled() + + deepChatAgent.cancelGeneration.mockResolvedValueOnce(undefined) + await presenter.deleteMessage('s1', 'message-1') + + expect(deepChatAgent.deleteMessage).toHaveBeenCalledWith('s1', 'message-1') + expect(deepChatAgent.cancelGeneration.mock.invocationCallOrder.at(-1)).toBeLessThan( + deepChatAgent.deleteMessage.mock.invocationCallOrder[0] + ) + }) + + it('edits without cancelling the active generation', async () => { + const edited = { id: 'message-1', sessionId: 's1', role: 'user', content: 'Edited' } + deepChatAgent.editUserMessage.mockResolvedValueOnce(edited) + + await expect(presenter.editUserMessage('s1', 'message-1', 'Edited')).resolves.toBe(edited) + + expect(deepChatAgent.editUserMessage).toHaveBeenCalledWith('s1', 'message-1', 'Edited') + expect(deepChatAgent.cancelGeneration).not.toHaveBeenCalled() + }) + }) + describe('setSessionProjectDir', () => { it('syncs workspace changes into the active agent runtime', async () => { const row = { @@ -2071,6 +2393,36 @@ describe('AgentSessionPresenter', () => { expect(deepChatAgent.setSessionProjectDir).toHaveBeenCalledWith('s1', '/tmp/workspace') expect(sqlitePresenter.newEnvironmentsTable.syncPath).toHaveBeenCalledWith('/tmp/workspace') }) + + it('keeps the persisted project update when runtime synchronization fails', async () => { + const row = { + id: 's1', + agent_id: 'deepchat', + title: 'Test', + project_dir: null as string | null, + is_pinned: 0, + is_draft: 0, + created_at: 1000, + updated_at: 1000 + } + sqlitePresenter.newSessionsTable.get.mockImplementation(() => row) + sqlitePresenter.newSessionsTable.update.mockImplementation((_: string, fields: any) => { + if (fields.project_dir !== undefined) row.project_dir = fields.project_dir + }) + const runtimeError = new Error('runtime project update failed') + deepChatAgent.setSessionProjectDir.mockRejectedValueOnce(runtimeError) + + await expect(presenter.setSessionProjectDir('s1', '/tmp/workspace')).rejects.toBe( + runtimeError + ) + + expect(row.project_dir).toBe('/tmp/workspace') + expect(sqlitePresenter.newSessionsTable.update).toHaveBeenCalledWith('s1', { + project_dir: '/tmp/workspace' + }) + expect(deepChatAgent.setSessionProjectDir).toHaveBeenCalledWith('s1', '/tmp/workspace') + expect(publishDeepchatEvent).not.toHaveBeenCalled() + }) }) describe('ensureAcpDraftSession', () => { @@ -2438,6 +2790,73 @@ describe('AgentSessionPresenter', () => { }) }) + describe('forkSession', () => { + it('deletes the target row and preserves the fork error when runtime cleanup fails', async () => { + const rows = installSessionStore(sqlitePresenter) + rows.set('source-session', { + id: 'source-session', + agent_id: 'deepchat', + title: 'Source title', + project_dir: '/repo', + is_pinned: 0, + is_draft: 0, + session_kind: 'regular', + parent_session_id: null, + subagent_enabled: 0, + subagent_meta_json: null, + created_at: 1000, + updated_at: 1000 + }) + deepChatAgent.getSessionState.mockResolvedValue({ + status: 'idle', + providerId: 'openai', + modelId: 'gpt-4', + permissionMode: 'default' + }) + deepChatAgent.getGenerationSettings.mockResolvedValue({ + systemPrompt: 'Keep this', + temperature: 0.2, + contextLength: 32000, + maxTokens: 2048 + }) + const forkError = new Error('fork transcript failed') + const closeError = new Error('fork runtime close failed') + deepChatAgent.forkSessionFromMessage.mockRejectedValueOnce(forkError) + deepChatAgent.destroySession.mockRejectedValueOnce(closeError) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await expect( + presenter.forkSession('source-session', 'message-1', 'Forked title') + ).rejects.toBe(forkError) + + expect(deepChatAgent.initSession).toHaveBeenCalledWith( + 'mock-session-id', + expect.objectContaining({ + agentId: 'deepchat', + providerId: 'openai', + modelId: 'gpt-4', + projectDir: '/repo', + permissionMode: 'default', + generationSettings: expect.objectContaining({ systemPrompt: 'Keep this' }) + }) + ) + expect(deepChatAgent.forkSessionFromMessage).toHaveBeenCalledWith( + 'source-session', + 'mock-session-id', + 'message-1' + ) + expect(deepChatAgent.destroySession).toHaveBeenCalledWith('mock-session-id') + expect(sqlitePresenter.newSessionsTable.delete).toHaveBeenCalledWith('mock-session-id') + expect(rows.has('mock-session-id')).toBe(false) + expect(publishDeepchatEvent).not.toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalledWith( + '[AgentSessionPresenter] Failed to cleanup forked session runtime mock-session-id:', + closeError + ) + warnSpy.mockRestore() + }) + }) + describe('getSessionList', () => { it('prefers lightweight session list state when available', async () => { sqlitePresenter.newSessionsTable.list.mockReturnValue([ @@ -2567,6 +2986,68 @@ describe('AgentSessionPresenter', () => { }) }) + describe('lightweight session projection', () => { + it('reuses the last projected status without hydrating a backend', async () => { + const row = { + id: 's1', + agent_id: 'deepchat', + title: 'Chat 1', + project_dir: null, + is_pinned: 0, + is_draft: 0, + session_kind: 'regular', + parent_session_id: null, + subagent_enabled: 0, + subagent_meta_json: null, + created_at: 1000, + updated_at: 2000 + } + sqlitePresenter.newSessionsTable.get.mockReturnValue(row) + sqlitePresenter.newSessionsTable.listPage.mockReturnValue({ rows: [row], hasMore: false }) + deepChatAgent.getSessionState.mockResolvedValueOnce({ + status: 'generating', + providerId: 'openai', + modelId: 'gpt-4', + permissionMode: 'full_access' + }) + + await expect(presenter.getSession('s1')).resolves.toMatchObject({ status: 'generating' }) + agentManager.resolveSessionHandle.mockClear() + + await expect(presenter.getLightweightSessionList()).resolves.toMatchObject({ + items: [expect.objectContaining({ id: 's1', status: 'generating' })], + nextCursor: null, + hasMore: false + }) + + expect(agentManager.resolveSessionHandle).not.toHaveBeenCalled() + expect(deepChatAgent.getSessionListState).not.toHaveBeenCalled() + }) + + it('defaults uncached lightweight rows to idle', async () => { + const row = { + id: 's1', + agent_id: 'deepchat', + title: 'Chat 1', + project_dir: null, + is_pinned: 0, + is_draft: 0, + session_kind: 'regular', + parent_session_id: null, + subagent_enabled: 0, + subagent_meta_json: null, + created_at: 1000, + updated_at: 2000 + } + sqlitePresenter.newSessionsTable.listPage.mockReturnValue({ rows: [row], hasMore: false }) + + const result = await presenter.getLightweightSessionList() + + expect(result.items).toEqual([expect.objectContaining({ id: 's1', status: 'idle' })]) + expect(agentManager.resolveSessionHandle).not.toHaveBeenCalled() + }) + }) + describe('getSession', () => { it('returns enriched session', async () => { sqlitePresenter.newSessionsTable.get.mockReturnValue({ @@ -2636,6 +3117,81 @@ describe('AgentSessionPresenter', () => { summaryUpdatedAt: 123 }) }) + + it('returns a fixed idle state for direct ACP without invoking DeepChat compaction', async () => { + sqlitePresenter.newSessionsTable.get.mockReturnValue({ + id: 's-acp', + agent_id: 'acp-coder', + title: 'ACP', + project_dir: '/repo', + is_pinned: 0, + created_at: 1000, + updated_at: 2000 + }) + + await expect(presenter.getSessionCompactionState('s-acp')).resolves.toEqual({ + status: 'idle', + cursorOrderSeq: 1, + summaryUpdatedAt: null + }) + + expect(deepChatAgent.getSessionCompactionState).not.toHaveBeenCalled() + }) + + it('rejects direct ACP and compatibility ACP manual compaction', async () => { + sqlitePresenter.newSessionsTable.get.mockReturnValue({ + id: 's-acp', + agent_id: 'acp-coder', + title: 'ACP', + project_dir: '/repo', + is_pinned: 0, + created_at: 1000, + updated_at: 2000 + }) + + await expect(presenter.compactSession('s-acp')).rejects.toThrow( + 'Agent acp-coder does not support manual compaction.' + ) + + sqlitePresenter.newSessionsTable.get.mockReturnValue({ + id: 's-compat', + agent_id: 'deepchat', + title: 'Compatibility ACP', + project_dir: '/repo', + is_pinned: 0, + created_at: 1000, + updated_at: 2000 + }) + deepChatAgent.getSessionState.mockResolvedValueOnce({ + status: 'idle', + providerId: 'acp', + modelId: 'acp-coder', + permissionMode: 'full_access' + }) + + await expect(presenter.compactSession('s-compat')).rejects.toThrow( + 'Manual compaction is only available for DeepChat agent sessions.' + ) + expect(deepChatAgent.compactSession).not.toHaveBeenCalled() + }) + + it('propagates DeepChat compaction failures unchanged', async () => { + const compactError = new Error('compaction failed') + sqlitePresenter.newSessionsTable.get.mockReturnValue({ + id: 's1', + agent_id: 'deepchat', + title: 'Test', + project_dir: null, + is_pinned: 0, + created_at: 1000, + updated_at: 2000 + }) + deepChatAgent.compactSession.mockRejectedValueOnce(compactError) + + await expect(presenter.compactSession('s1')).rejects.toBe(compactError) + + expect(deepChatAgent.compactSession).toHaveBeenCalledWith('s1') + }) }) describe('message traces', () => { @@ -2698,6 +3254,71 @@ describe('AgentSessionPresenter', () => { }) }) + describe('projection read fallbacks', () => { + it('skips malformed search rows and falls back to legacy unscoped results', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + sqlitePresenter.deepchatMessageSearchResultsTable.listByMessageId.mockReturnValue([ + { content: '{', rank: 1, search_id: 'search-new' }, + { + content: JSON.stringify({ title: 'Legacy result', url: 'https://example.com' }), + rank: 2, + search_id: null + } + ]) + + await expect(presenter.getSearchResults(' message-1 ', 'missing-search')).resolves.toEqual([ + expect.objectContaining({ + title: 'Legacy result', + url: 'https://example.com', + rank: 2, + searchId: undefined + }) + ]) + + expect( + sqlitePresenter.deepchatMessageSearchResultsTable.listByMessageId + ).toHaveBeenCalledWith('message-1') + expect(warnSpy).toHaveBeenCalledWith( + '[AgentSessionPresenter] Failed to parse search result row:', + expect.any(SyntaxError) + ) + warnSpy.mockRestore() + }) + + it('returns empty manifest and replay fallbacks when Tape reads fail', async () => { + sqlitePresenter.deepchatMessagesTable.get.mockReturnValue({ + id: 'message-1', + session_id: 's1' + }) + sqlitePresenter.newSessionsTable.get.mockReturnValue({ + id: 's1', + agent_id: 'deepchat', + title: 'Test', + project_dir: null, + is_pinned: 0, + is_draft: 0, + session_kind: 'regular', + parent_session_id: null, + subagent_enabled: 0, + subagent_meta_json: null, + created_at: 1000, + updated_at: 2000 + }) + deepChatAgent.listMessageViewManifests.mockRejectedValueOnce(new Error('manifest failed')) + deepChatAgent.exportMessageTapeReplaySlice.mockRejectedValueOnce(new Error('replay failed')) + + await expect(presenter.listMessageViewManifests('message-1')).resolves.toEqual([]) + await expect(presenter.exportMessageTapeReplaySlice('message-1')).resolves.toBeNull() + + expect(deepChatAgent.listMessageViewManifests).toHaveBeenCalledWith('s1', 'message-1') + expect(deepChatAgent.exportMessageTapeReplaySlice).toHaveBeenCalledWith( + 's1', + 'message-1', + undefined + ) + }) + }) + describe('activateSession', () => { it('binds window and publishes typed activated update', async () => { await presenter.activateSession(42, 's1') @@ -2707,6 +3328,8 @@ describe('AgentSessionPresenter', () => { reason: 'activated', activeSessionId: 's1' }) + expect(sqlitePresenter.newSessionsTable.get).not.toHaveBeenCalled() + expect(agentManager.resolveSessionHandle).not.toHaveBeenCalled() }) }) @@ -2795,6 +3418,50 @@ describe('AgentSessionPresenter', () => { expect(skillPresenter.clearNewAgentSessionSkills).not.toHaveBeenCalled() expect(sqlitePresenter.newSessionsTable.delete).not.toHaveBeenCalled() }) + + it('prefers backend cleanup errors when shared cleanup also fails', async () => { + const backendError = new Error('backend cleanup failed') + const sharedError = new Error('shared cleanup failed') + sqlitePresenter.newSessionsTable.get.mockReturnValue({ + id: 's1', + agent_id: 'deepchat', + title: 'Test', + project_dir: null, + is_pinned: 0, + created_at: 1000, + updated_at: 2000 + }) + agentManager.cleanupSessionBackends.mockRejectedValueOnce(backendError) + deepChatAgent.destroySession.mockRejectedValueOnce(sharedError) + + await expect(presenter.deleteSession('s1')).rejects.toBe(backendError) + + expect(deepChatAgent.destroySession).toHaveBeenCalledExactlyOnceWith('s1') + expect(skillPresenter.clearNewAgentSessionSkills).not.toHaveBeenCalled() + expect(sqlitePresenter.newSessionsTable.delete).not.toHaveBeenCalled() + expect(publishDeepchatEvent).not.toHaveBeenCalled() + }) + + it('propagates shared cleanup failure when backend cleanup succeeds', async () => { + const sharedError = new Error('shared cleanup failed') + sqlitePresenter.newSessionsTable.get.mockReturnValue({ + id: 's1', + agent_id: 'deepchat', + title: 'Test', + project_dir: null, + is_pinned: 0, + created_at: 1000, + updated_at: 2000 + }) + deepChatAgent.destroySession.mockRejectedValueOnce(sharedError) + + await expect(presenter.deleteSession('s1')).rejects.toBe(sharedError) + + expect(agentManager.cleanupSessionBackends).toHaveBeenCalledExactlyOnceWith('s1') + expect(skillPresenter.clearNewAgentSessionSkills).not.toHaveBeenCalled() + expect(sqlitePresenter.newSessionsTable.delete).not.toHaveBeenCalled() + expect(publishDeepchatEvent).not.toHaveBeenCalled() + }) }) describe('cancelGeneration', () => { @@ -2814,6 +3481,47 @@ describe('AgentSessionPresenter', () => { }) }) + describe('respondToolInteraction', () => { + it('validates the session before delegating the complete interaction identity', async () => { + sqlitePresenter.newSessionsTable.get.mockReturnValue({ + id: 's1', + agent_id: 'deepchat', + title: 'Test', + project_dir: null, + is_pinned: 0, + created_at: 1000, + updated_at: 1000 + }) + const response = { kind: 'permission' as const, granted: true } + deepChatAgent.respondToolInteraction.mockResolvedValueOnce({ resumed: true }) + + await expect( + presenter.respondToolInteraction('s1', 'message-1', 'tool-1', response) + ).resolves.toEqual({ resumed: true }) + + expect(deepChatAgent.respondToolInteraction).toHaveBeenCalledWith( + 's1', + 'message-1', + 'tool-1', + response + ) + }) + + it('does not resolve a handle for a missing session', async () => { + sqlitePresenter.newSessionsTable.get.mockReturnValue(undefined) + + await expect( + presenter.respondToolInteraction('missing', 'message-1', 'tool-1', { + kind: 'permission', + granted: true + }) + ).rejects.toThrow('Session not found: missing') + + expect(agentManager.resolveSessionHandle).not.toHaveBeenCalled() + expect(deepChatAgent.respondToolInteraction).not.toHaveBeenCalled() + }) + }) + describe('generation settings', () => { it('delegates getSessionGenerationSettings to agent', async () => { sqlitePresenter.newSessionsTable.get.mockReturnValue({ @@ -3267,11 +3975,82 @@ describe('AgentSessionPresenter', () => { expect(sqlitePresenter.newSessionsTable.updateDisabledAgentTools).toHaveBeenCalledWith('s1', [ 'agent_filesystem_read_file' ]) + expect(deepChatAgent.setSessionAgentContext.mock.invocationCallOrder[0]).toBeLessThan( + sqlitePresenter.newSessionsTable.updateAgentId.mock.invocationCallOrder[0] + ) + expect( + sqlitePresenter.newSessionsTable.updateAgentId.mock.invocationCallOrder[0] + ).toBeLessThan(sqlitePresenter.newSessionsTable.update.mock.invocationCallOrder[0]) + expect(sqlitePresenter.newSessionsTable.update.mock.invocationCallOrder[0]).toBeLessThan( + sqlitePresenter.newSessionsTable.updateDisabledAgentTools.mock.invocationCallOrder[0] + ) + expect( + sqlitePresenter.newSessionsTable.updateDisabledAgentTools.mock.invocationCallOrder[0] + ).toBeLessThan(deepChatAgent.getSessionState.mock.invocationCallOrder.at(-1)!) expect(updated.agentId).toBe('deepchat-coder') expect(updated.providerId).toBe('anthropic') expectSessionsUpdated({ reason: 'updated', sessionIds: ['s1'] }) }) + it('validates every batch session before applying the first mutation', async () => { + const rows = [ + { + id: 's-ready', + agent_id: 'deepchat-writer', + title: 'Ready', + project_dir: '/repo', + is_pinned: 0, + is_draft: 0, + session_kind: 'regular', + parent_session_id: null, + subagent_enabled: 0, + subagent_meta_json: null, + created_at: 1000, + updated_at: 1000 + }, + { + id: 's-active', + agent_id: 'deepchat-writer', + title: 'Active', + project_dir: '/repo', + is_pinned: 0, + is_draft: 0, + session_kind: 'regular', + parent_session_id: null, + subagent_enabled: 0, + subagent_meta_json: null, + created_at: 1000, + updated_at: 1000 + } + ] + sqlitePresenter.newSessionsTable.get.mockImplementation((id: string) => + rows.find((row) => row.id === id) + ) + sqlitePresenter.newSessionsTable.list.mockImplementation((filters: any) => + filters?.parentSessionId ? [] : rows + ) + configPresenter.resolveDeepChatAgentConfig.mockResolvedValue({ + defaultModelPreset: { providerId: 'openai', modelId: 'gpt-4.1' } + }) + deepChatAgent.hasMessages.mockResolvedValue(true) + deepChatAgent.getSessionState.mockImplementation(async (sessionId: string) => ({ + status: sessionId === 's-active' ? 'generating' : 'idle', + providerId: 'openai', + modelId: 'gpt-4.1', + permissionMode: 'full_access' + })) + + await expect( + presenter.moveAgentSessions('deepchat-writer', 'deepchat-coder') + ).rejects.toThrow('Session s-active cannot be moved: active') + + expect(deepChatAgent.setSessionAgentContext).not.toHaveBeenCalled() + expect(sqlitePresenter.newSessionsTable.updateAgentId).not.toHaveBeenCalled() + expect(sqlitePresenter.newSessionsTable.update).not.toHaveBeenCalled() + expect(sqlitePresenter.newSessionsTable.delete).not.toHaveBeenCalled() + expect(publishDeepchatEvent).not.toHaveBeenCalled() + }) + it('moves a direct ACP conversation to DeepChat without entering compatibility cleanup', async () => { const row = { id: 's-acp', @@ -3989,10 +4768,20 @@ describe('AgentSessionPresenter', () => { expect(session!.id).toBe('s1') }) + it('keeps bindings isolated by window', async () => { + await presenter.activateSession(1, 's1') + await presenter.activateSession(2, 's2') + await presenter.deactivateSession(1) + + expect(presenter.getActiveSessionId(1)).toBeNull() + expect(presenter.getActiveSessionId(2)).toBe('s2') + }) + it('returns null and clears binding when bound session becomes unavailable', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) await presenter.activateSession(1, 's-disabled') + vi.mocked(publishDeepchatEvent).mockClear() sqlitePresenter.newSessionsTable.get.mockReturnValueOnce({ id: 's-disabled', agent_id: 'disabled-agent', @@ -4004,6 +4793,7 @@ describe('AgentSessionPresenter', () => { }) await expect(presenter.getActiveSession(1)).resolves.toBeNull() + expect(publishDeepchatEvent).not.toHaveBeenCalled() sqlitePresenter.newSessionsTable.get.mockReturnValue({ id: 's-disabled', From 5036d1d363fc99eb8acda31188604878b8c84c54 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 13 Jul 2026 17:28:55 +0800 Subject: [PATCH 03/29] test(session): characterize consumers --- test/main/presenter/cronJobs.test.ts | 79 ++++++++- .../remoteConversationRunner.test.ts | 163 +++++++++++++++++- test/main/routes/chatService.test.ts | 159 +++++++++++++++++ test/main/routes/dispatcher.test.ts | 84 +++++++-- test/main/routes/sessionService.test.ts | 104 ++++++++++- 5 files changed, 572 insertions(+), 17 deletions(-) diff --git a/test/main/presenter/cronJobs.test.ts b/test/main/presenter/cronJobs.test.ts index b163f2da5..74b1542d8 100644 --- a/test/main/presenter/cronJobs.test.ts +++ b/test/main/presenter/cronJobs.test.ts @@ -280,6 +280,53 @@ describe('CronJobRunExecutor', () => { } }) + it('releases concurrency queue runs without creating or delivering a session', async () => { + const job = { + id: 'job-queue', + name: 'Overlap queue', + agentId: 'agent-1', + runtime: { + maxDurationMs: 3_600_000, + maxTurns: 20, + concurrencyPolicy: 'queue' + } + } as CronJob + const queuedRun = { + id: 'run-queue', + jobId: job.id, + status: 'queued' + } as CronJobRun + const repository = { + claimRun: vi.fn(() => queuedRun), + getRun: vi.fn(() => queuedRun), + countActiveRunsByJob: vi.fn(() => 1), + releaseRunQueued: vi.fn(() => queuedRun), + markRunCancelled: vi.fn() + } + const sessionStarter = { + createSessionForRun: vi.fn(), + startSessionRun: vi.fn() + } + const deliveryRouter = { + deliver: vi.fn() + } + const executor = new CronJobRunExecutor( + repository as never, + sessionStarter as never, + deliveryRouter as never + ) + + try { + await expect(executor.execute({ runId: queuedRun.id, job })).resolves.toEqual(queuedRun) + expect(repository.releaseRunQueued).toHaveBeenCalledWith(queuedRun.id) + expect(repository.markRunCancelled).not.toHaveBeenCalled() + expect(sessionStarter.createSessionForRun).not.toHaveBeenCalled() + expect(deliveryRouter.deliver).not.toHaveBeenCalled() + } finally { + executor.dispose() + } + }) + it('captures remote delivery segments as run output', async () => { const now = Date.parse('2026-07-04T00:00:00.000Z') const job: CronJob = { @@ -359,6 +406,20 @@ describe('CronJobRunExecutor', () => { try { await executor.execute({ runId: runningRun.id, job }) + emitDeepChatInternalSessionUpdate({ + sessionId: 'session-1', + kind: 'blocks', + messageId: 'message-1', + previewMarkdown: 'Preview answer', + responseMarkdown: 'Response answer', + waitingInteraction: null, + updatedAt: now + }) + expect(repository.updateRunOutput).toHaveBeenLastCalledWith(runningRun.id, { + outputMessageId: 'message-1', + outputPreview: 'Response answer' + }) + emitDeepChatInternalSessionUpdate({ sessionId: 'session-1', kind: 'blocks', @@ -373,7 +434,13 @@ describe('CronJobRunExecutor', () => { sourceMessageId: 'message-1' }, { - key: 'message-1:1:answer', + key: 'message-1:1:terminal', + kind: 'terminal', + text: 'Completed successfully', + sourceMessageId: 'message-1' + }, + { + key: 'message-1:2:answer', kind: 'answer', text: 'Final answer', sourceMessageId: 'message-1' @@ -385,7 +452,8 @@ describe('CronJobRunExecutor', () => { expect(repository.updateRunOutput).toHaveBeenLastCalledWith(runningRun.id, { outputMessageId: 'message-1', - outputPreview: 'Process\nread_file: "/tmp/a.md"\n\nAnswer\nFinal answer' + outputPreview: + 'Process\nread_file: "/tmp/a.md"\n\nStatus\nCompleted successfully\n\nAnswer\nFinal answer' }) } finally { executor.dispose() @@ -997,6 +1065,13 @@ describeIfSqlite('Cron Jobs persistence and service', () => { waitingInteraction: null, updatedAt: Date.now() }) + expect(new CronJobsRepositoryCtor(sqlitePresenter as never).getRun(result.run.id)).toEqual( + expect.objectContaining({ + status: 'running', + outputMessageId: 'message-1', + outputPreview: 'Finished cron session' + }) + ) emitDeepChatInternalSessionUpdate({ sessionId: `session-${result.run.id}`, kind: 'status', diff --git a/test/main/presenter/remoteControlPresenter/remoteConversationRunner.test.ts b/test/main/presenter/remoteControlPresenter/remoteConversationRunner.test.ts index 564a76c06..0d3f03630 100644 --- a/test/main/presenter/remoteControlPresenter/remoteConversationRunner.test.ts +++ b/test/main/presenter/remoteControlPresenter/remoteConversationRunner.test.ts @@ -1101,6 +1101,89 @@ describe('RemoteConversationRunner', () => { expect(show).toHaveBeenCalledWith(7, true) }) + it('reports the bound active event as the remote generation status', async () => { + const session = createSession({ status: 'generating' }) + const getBinding = vi + .fn() + .mockReturnValueOnce(null) + .mockReturnValue({ sessionId: 'session-1', updatedAt: 1 }) + const runner = new RemoteConversationRunner( + { + configPresenter: createConfigPresenter() as any, + agentSessionPresenter: { + getSession: vi.fn().mockResolvedValue(session), + getMessages: vi.fn().mockResolvedValue([]) + } as any, + agentManager: { + getActiveGeneration: vi.fn().mockReturnValue({ + eventId: 'manager-event', + runId: 'manager-run' + }) + } as any, + windowPresenter: {} as any, + tabPresenter: {} as any, + resolveDefaultAgentId: vi.fn() + }, + { + getBinding, + getActiveEvent: vi.fn().mockReturnValue('bound-event') + } as any + ) + + await expect(runner.getStatus('telegram:100:0')).resolves.toEqual({ + session: null, + activeEventId: null, + isGenerating: false, + pendingInteraction: null + }) + await expect(runner.getStatus('telegram:100:0')).resolves.toEqual({ + session, + activeEventId: 'bound-event', + isGenerating: true, + pendingInteraction: null + }) + }) + + it('clears deleted bindings and returns the fixed missing-session output', async () => { + const clearBinding = vi.fn() + const runner = new RemoteConversationRunner( + { + configPresenter: createConfigPresenter() as any, + agentSessionPresenter: { + getSession: vi.fn().mockResolvedValue(null) + } as any, + agentManager: { + getActiveGeneration: vi.fn().mockReturnValue(null) + } as any, + windowPresenter: {} as any, + tabPresenter: {} as any, + resolveDefaultAgentId: vi.fn() + }, + { clearBinding } as any + ) + + await expect( + (runner as any).getConversationSnapshot('telegram:100:0', 'deleted-session', { + afterOrderSeq: 0, + preferredMessageId: null, + ignoreMessageId: null + }) + ).resolves.toEqual({ + messageId: null, + text: 'The bound session no longer exists.', + traceText: '', + deliverySegments: [], + statusText: '', + finalText: 'The bound session no longer exists.', + draftText: '', + renderBlocks: [], + fullText: 'The bound session no longer exists.', + completed: true, + pendingInteraction: null + }) + expect(clearBinding).toHaveBeenCalledWith('telegram:100:0') + }) + it('does not fall back to the previous active assistant event while waiting for a new reply', async () => { vi.useFakeTimers() @@ -1240,7 +1323,9 @@ describe('RemoteConversationRunner', () => { } ]) } as any, - agentManager: {} as any, + agentManager: { + getActiveGeneration: vi.fn().mockReturnValue(null) + } as any, windowPresenter: {} as any, tabPresenter: {} as any, resolveDefaultAgentId: vi.fn().mockResolvedValue('deepchat') @@ -1249,11 +1334,12 @@ describe('RemoteConversationRunner', () => { getBinding: vi.fn().mockReturnValue({ sessionId: 'session-1', updatedAt: 1 - }) + }), + getActiveEvent: vi.fn().mockReturnValue(null) } as any ) - await expect(runner.getPendingInteraction('telegram:100:0')).resolves.toEqual({ + const pendingInteraction = { type: 'permission', messageId: 'assistant-1', toolCallId: 'tool-1', @@ -1270,6 +1356,16 @@ describe('RemoteConversationRunner', () => { suggestion: 'Confirm before pushing.' } } + } + + await expect(runner.getPendingInteraction('telegram:100:0')).resolves.toEqual( + pendingInteraction + ) + await expect(runner.getStatus('telegram:100:0')).resolves.toEqual({ + session: createSession(), + activeEventId: null, + isGenerating: false, + pendingInteraction }) }) @@ -1341,6 +1437,67 @@ describe('RemoteConversationRunner', () => { expect(snapshot.completed).toBe(false) }) + it('falls back to an empty stored-search result when projection lookup fails', async () => { + const searchError = new Error('search store unavailable') + const getSearchResults = vi.fn().mockRejectedValue(searchError) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const runner = new RemoteConversationRunner( + { + configPresenter: createConfigPresenter() as any, + agentSessionPresenter: { + getSession: vi.fn().mockResolvedValue(createSession()), + getMessage: vi.fn().mockResolvedValue({ + id: 'assistant-search', + role: 'assistant', + orderSeq: 2, + status: 'success', + content: JSON.stringify([ + { + id: 'search-1', + type: 'search', + content: '', + status: 'success', + timestamp: 1, + extra: { searchId: 'stored-search', label: 'web_search' } + } + ]) + }), + getSearchResults + } as any, + agentManager: { + getActiveGeneration: vi.fn().mockReturnValue(null) + } as any, + windowPresenter: {} as any, + tabPresenter: {} as any, + resolveDefaultAgentId: vi.fn() + }, + { + rememberActiveEvent: vi.fn(), + clearActiveEvent: vi.fn() + } as any + ) + + const snapshot = await (runner as any).getConversationSnapshot('telegram:100:0', 'session-1', { + afterOrderSeq: 0, + preferredMessageId: 'assistant-search', + ignoreMessageId: null + }) + + expect(getSearchResults).toHaveBeenCalledWith('assistant-search', 'stored-search') + expect(snapshot.renderBlocks).toEqual([ + expect.objectContaining({ + kind: 'search', + text: expect.stringContaining('No stored search results were found.') + }) + ]) + expect(snapshot.completed).toBe(true) + expect(warn).toHaveBeenCalledWith( + '[RemoteConversationRunner] Failed to load search results:', + expect.objectContaining({ error: searchError }) + ) + warn.mockRestore() + }) + it('creates a follow-up execution after responding to a pending interaction', async () => { const getMessage = vi.fn().mockResolvedValue({ id: 'assistant-2', diff --git a/test/main/routes/chatService.test.ts b/test/main/routes/chatService.test.ts index 54c9d0f8d..e662a48ca 100644 --- a/test/main/routes/chatService.test.ts +++ b/test/main/routes/chatService.test.ts @@ -57,6 +57,77 @@ describe('ChatService', () => { expect(providerExecutionPort.sendMessage).toHaveBeenCalledWith('session-1', 'hello') expect(messageRepository.listBySession).not.toHaveBeenCalled() expect(scheduler.timeout).toHaveBeenCalledTimes(3) + expect(scheduler.timeout).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + ms: 5_000, + reason: 'chat.sendMessage:session-1:session' + }) + ) + expect(scheduler.timeout).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + ms: 5_000, + reason: 'chat.sendMessage:session-1:agentType' + }) + ) + expect(scheduler.timeout).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + ms: 30 * 60 * 1_000, + reason: 'chat.sendMessage:session-1', + signal: expect.any(AbortSignal) + }) + ) + }) + + it('releases the send lock after missing session and agent type preflight failures', async () => { + const scheduler = createScheduler() + const sessionRepository = { + get: vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValue({ id: 'session-1', agentId: 'deepchat' }) + } + const providerExecutionPort = { + sendMessage: vi.fn().mockResolvedValue({ + requestId: 'request-1', + messageId: 'message-1' + }), + steerActiveTurn: vi.fn(), + cancelGeneration: vi.fn(), + respondToolInteraction: vi.fn() + } + const providerCatalogPort = { + getAgentType: vi.fn().mockResolvedValueOnce(null).mockResolvedValue('deepchat') + } + const service = new ChatService({ + sessionRepository: sessionRepository as any, + messageRepository: { listBySession: vi.fn(), get: vi.fn() } as any, + providerExecutionPort, + providerCatalogPort, + sessionPermissionPort: { clearSessionPermissions: vi.fn() }, + scheduler + }) + + await expect(service.sendMessage('session-1', 'missing session')).rejects.toThrow( + 'Session not found: session-1' + ) + expect(providerCatalogPort.getAgentType).not.toHaveBeenCalled() + expect(providerExecutionPort.sendMessage).not.toHaveBeenCalled() + + await expect(service.sendMessage('session-1', 'missing agent type')).rejects.toThrow( + 'Agent type not found: deepchat' + ) + expect(providerExecutionPort.sendMessage).not.toHaveBeenCalled() + + await expect(service.sendMessage('session-1', 'retry')).resolves.toEqual({ + accepted: true, + requestId: 'request-1', + messageId: 'message-1' + }) + expect(providerExecutionPort.sendMessage).toHaveBeenCalledOnce() + expect(providerExecutionPort.sendMessage).toHaveBeenCalledWith('session-1', 'retry') }) it('steers the active turn without claiming the normal send lock', async () => { @@ -143,6 +214,58 @@ describe('ChatService', () => { expect(messageRepository.get).toHaveBeenCalledWith('message-1') expect(sessionPermissionPort.clearSessionPermissions).toHaveBeenCalledWith('session-1') expect(providerExecutionPort.cancelGeneration).toHaveBeenCalledWith('session-1') + expect(scheduler.timeout).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + ms: 5_000, + reason: 'chat.stopStream:message-1:message' + }) + ) + expect(scheduler.timeout).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + ms: 5_000, + reason: 'chat.stopStream:session-1' + }) + ) + }) + + it('returns stopped false when a request id cannot be mapped to a session', async () => { + const scheduler = createScheduler() + const messageRepository = { + listBySession: vi.fn(), + get: vi.fn().mockResolvedValue(null) + } + const providerExecutionPort = { + sendMessage: vi.fn(), + steerActiveTurn: vi.fn(), + cancelGeneration: vi.fn(), + respondToolInteraction: vi.fn() + } + const sessionPermissionPort = { + clearSessionPermissions: vi.fn() + } + const service = new ChatService({ + sessionRepository: { get: vi.fn() } as any, + messageRepository: messageRepository as any, + providerExecutionPort, + providerCatalogPort: { getAgentType: vi.fn() } as any, + sessionPermissionPort, + scheduler + }) + + await expect(service.stopStream({ requestId: 'missing-message' })).resolves.toEqual({ + stopped: false + }) + expect(scheduler.timeout).toHaveBeenCalledOnce() + expect(scheduler.timeout).toHaveBeenCalledWith( + expect.objectContaining({ + ms: 5_000, + reason: 'chat.stopStream:missing-message:message' + }) + ) + expect(sessionPermissionPort.clearSessionPermissions).not.toHaveBeenCalled() + expect(providerExecutionPort.cancelGeneration).not.toHaveBeenCalled() }) it('attempts both stopStream cleanups even if clearing permissions fails', async () => { @@ -291,6 +414,42 @@ describe('ChatService', () => { expect(providerExecutionPort.cancelGeneration).toHaveBeenCalledWith('session-1') }) + it('releases the send lock after non-timeout failures without timeout cleanup', async () => { + const scheduler = createScheduler() + const sendError = new Error('provider failed') + const providerExecutionPort = { + sendMessage: vi + .fn() + .mockRejectedValueOnce(sendError) + .mockResolvedValueOnce({ requestId: 'request-2', messageId: 'message-2' }), + steerActiveTurn: vi.fn(), + cancelGeneration: vi.fn(), + respondToolInteraction: vi.fn() + } + const sessionPermissionPort = { + clearSessionPermissions: vi.fn() + } + const service = new ChatService({ + sessionRepository: { + get: vi.fn().mockResolvedValue({ id: 'session-1', agentId: 'deepchat' }) + } as any, + messageRepository: { listBySession: vi.fn(), get: vi.fn() } as any, + providerExecutionPort, + providerCatalogPort: { getAgentType: vi.fn().mockResolvedValue('deepchat') } as any, + sessionPermissionPort, + scheduler + }) + + await expect(service.sendMessage('session-1', 'first')).rejects.toBe(sendError) + await expect(service.sendMessage('session-1', 'second')).resolves.toEqual({ + accepted: true, + requestId: 'request-2', + messageId: 'message-2' + }) + expect(sessionPermissionPort.clearSessionPermissions).not.toHaveBeenCalled() + expect(providerExecutionPort.cancelGeneration).not.toHaveBeenCalled() + }) + it('aborts a pending send when stopStream races during preflight', async () => { const createAbortError = (reason: string) => { const error = new Error(reason) diff --git a/test/main/routes/dispatcher.test.ts b/test/main/routes/dispatcher.test.ts index a4261dd44..226cae7f1 100644 --- a/test/main/routes/dispatcher.test.ts +++ b/test/main/routes/dispatcher.test.ts @@ -1524,23 +1524,78 @@ describe('dispatchDeepchatRoute', () => { } as CronJobRun }) - expect(agentSessionPresenter.createDetachedSession).toHaveBeenCalledWith( - expect.objectContaining({ + expect(agentSessionPresenter.createDetachedSession).toHaveBeenCalledWith({ + agentId: 'deepchat', + title: 'Morning job', + metadata: { + source: 'cron_job', + cronJobId: 'cron-1', + cronJobRunId: 'run-1', + scheduledAt: 123 + } + }) + }) + + it('wires pinned Cron Job snapshots into detached sessions', async () => { + const { cronJobs, agentSessionPresenter } = createRuntime() + const starter = vi.mocked(cronJobs.setRunSessionStarter).mock.calls[0]?.[0] as + | CronJobRunSessionStarter + | undefined + + expect(starter).toBeDefined() + await starter!.createSessionForRun({ + job: { + id: 'cron-1', + name: 'Morning job', agentId: 'deepchat', - title: 'Morning job', - metadata: { - source: 'cron_job', - cronJobId: 'cron-1', - cronJobRunId: 'run-1', - scheduledAt: 123 - } - }) - ) + agentSnapshot: { + version: 1, + capturedAt: 100, + agent: { id: 'deepchat', name: 'DeepChat', type: 'deepchat' }, + config: { + defaultModelPreset: { providerId: 'anthropic', modelId: 'claude-sonnet' }, + permissionMode: 'full_access', + disabledAgentTools: ['write_file'], + subagentEnabled: false, + systemPrompt: 'Snapshot system prompt' + } + }, + modelPolicy: 'pin_current', + permissionPolicy: 'snapshot', + toolPolicy: 'snapshot', + taskSystemInstruction: ' Task-specific system prompt ' + } as CronJob, + run: { + id: 'run-1', + scheduledAt: 123 + } as CronJobRun + }) + + expect(agentSessionPresenter.createDetachedSession).toHaveBeenCalledWith({ + agentId: 'deepchat', + title: 'Morning job', + providerId: 'anthropic', + modelId: 'claude-sonnet', + permissionMode: 'full_access', + disabledAgentTools: ['write_file'], + subagentEnabled: false, + generationSettings: { systemPrompt: 'Task-specific system prompt' }, + metadata: { + source: 'cron_job', + cronJobId: 'cron-1', + cronJobRunId: 'run-1', + scheduledAt: 123 + } + }) }) it('routes ACP Cron Job prompts through the agent-session direct-routing facade', async () => { const { cronJobs, configPresenter, agentSessionPresenter } = createRuntime() vi.mocked(configPresenter.getAgentType).mockResolvedValue('acp') + vi.mocked(agentSessionPresenter.sendMessage).mockResolvedValueOnce({ + requestId: 'request-2', + messageId: 'message-2' + }) vi.mocked(agentSessionPresenter.createDetachedSession).mockResolvedValue({ id: 'acp-session-1', agentId: 'manual-acp', @@ -1594,6 +1649,13 @@ describe('dispatchDeepchatRoute', () => { 'Review the workspace', { maxProviderRounds: 7 } ) + await starter!.cancelSessionRun?.({ + job, + run, + sessionId: 'acp-session-1', + reason: 'Cron job exceeded max duration.' + }) + expect(agentSessionPresenter.cancelGeneration).toHaveBeenCalledWith('acp-session-1') }) it('reconciles Cron Jobs after agent mutation routes', async () => { diff --git a/test/main/routes/sessionService.test.ts b/test/main/routes/sessionService.test.ts index fe89f3512..f971bc5b7 100644 --- a/test/main/routes/sessionService.test.ts +++ b/test/main/routes/sessionService.test.ts @@ -37,8 +37,29 @@ describe('SessionService', () => { const result = await service.restoreSession('session-1') - expect(scheduler.retry).toHaveBeenCalledTimes(1) + expect(scheduler.retry).toHaveBeenCalledWith( + expect.objectContaining({ + maxAttempts: 2, + initialDelayMs: 25, + backoff: 1, + reason: 'sessions.restore:session-1' + }) + ) expect(scheduler.timeout).toHaveBeenCalledTimes(2) + expect(scheduler.timeout).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + ms: 5_000, + reason: 'sessions.restore:session-1:session' + }) + ) + expect(scheduler.timeout).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + ms: 5_000, + reason: 'sessions.restore:session-1:messages' + }) + ) expect(sessionRepository.get).toHaveBeenCalledWith('session-1') expect(messageRepository.listPageBySession).toHaveBeenCalledWith('session-1', { limit: 100 @@ -51,6 +72,34 @@ describe('SessionService', () => { }) }) + it('uses an explicit restore message limit when provided', async () => { + const scheduler = createScheduler() + const sessionRepository = { + create: vi.fn(), + get: vi.fn().mockResolvedValue({ id: 'session-1' }), + list: vi.fn(), + activate: vi.fn(), + deactivate: vi.fn(), + getActive: vi.fn() + } + const messageRepository = { + listBySession: vi.fn(), + listPageBySession: vi.fn().mockResolvedValue({ + messages: [], + nextCursor: null, + hasMore: false + }), + get: vi.fn() + } + const service = new SessionService({ sessionRepository, messageRepository, scheduler }) + + await service.restoreSession('session-1', 25) + + expect(messageRepository.listPageBySession).toHaveBeenCalledWith('session-1', { + limit: 25 + }) + }) + it('returns an empty restore payload when the session no longer exists', async () => { const scheduler = createScheduler() const sessionRepository = { @@ -81,4 +130,57 @@ describe('SessionService', () => { }) expect(messageRepository.listPageBySession).not.toHaveBeenCalled() }) + + it('routes session lifecycle operations through five-second scheduler boundaries', async () => { + const scheduler = createScheduler() + const session = { id: 'session-1' } + const sessionRepository = { + create: vi.fn().mockResolvedValue(session), + get: vi.fn(), + list: vi.fn().mockResolvedValue([session]), + activate: vi.fn().mockResolvedValue(undefined), + deactivate: vi.fn().mockResolvedValue(undefined), + getActive: vi.fn().mockResolvedValue(session) + } + const messageRepository = { + listBySession: vi.fn(), + listPageBySession: vi.fn().mockResolvedValue({ + messages: [], + nextCursor: null, + hasMore: false + }), + get: vi.fn() + } + const service = new SessionService({ sessionRepository, messageRepository, scheduler }) + const context = { webContentsId: 42, windowId: 7 } + const input = { agentId: 'deepchat', message: 'hello' } + const filters = { agentId: 'deepchat' } + const pageOptions = { limit: 20, cursor: null } + + await expect(service.createSession(input, context)).resolves.toEqual(session) + await expect(service.listSessions(filters)).resolves.toEqual([session]) + await expect(service.listMessagesPage('session-1', pageOptions)).resolves.toEqual({ + messages: [], + nextCursor: null, + hasMore: false + }) + await expect(service.activateSession(context, 'session-1')).resolves.toBeUndefined() + await expect(service.deactivateSession(context)).resolves.toBeUndefined() + await expect(service.getActiveSession(context)).resolves.toEqual(session) + + expect(sessionRepository.create).toHaveBeenCalledWith(input, 42) + expect(sessionRepository.list).toHaveBeenCalledWith(filters) + expect(messageRepository.listPageBySession).toHaveBeenCalledWith('session-1', pageOptions) + expect(sessionRepository.activate).toHaveBeenCalledWith(42, 'session-1') + expect(sessionRepository.deactivate).toHaveBeenCalledWith(42) + expect(sessionRepository.getActive).toHaveBeenCalledWith(42) + expect(scheduler.timeout.mock.calls.map(([options]) => [options.ms, options.reason])).toEqual([ + [5_000, 'sessions.create'], + [5_000, 'sessions.list'], + [5_000, 'sessions.listMessagesPage:session-1'], + [5_000, 'sessions.activate:session-1'], + [5_000, 'sessions.deactivate'], + [5_000, 'sessions.getActive'] + ]) + }) }) From db1bf4655596800817112a8d7d8f0629ec11b3d4 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 13 Jul 2026 17:45:34 +0800 Subject: [PATCH 04/29] test(session): cover coordinator edge order --- .../agentSessionPresenter.test.ts | 154 +++++++++++++++++- 1 file changed, 153 insertions(+), 1 deletion(-) diff --git a/test/main/presenter/agentSessionPresenter/agentSessionPresenter.test.ts b/test/main/presenter/agentSessionPresenter/agentSessionPresenter.test.ts index 2585e1886..23cb4d503 100644 --- a/test/main/presenter/agentSessionPresenter/agentSessionPresenter.test.ts +++ b/test/main/presenter/agentSessionPresenter/agentSessionPresenter.test.ts @@ -609,6 +609,7 @@ describe('AgentSessionPresenter', () => { let closeDirectAcpSession: ReturnType let closeDirectAcpRuntime: ReturnType let directAcpControl: ReturnType + let sessionUiPort: { refreshSessionUi: ReturnType } let agentManager: { resolveBackend: ReturnType resolveSessionBackend: ReturnType @@ -630,6 +631,7 @@ describe('AgentSessionPresenter', () => { closeDirectAcpSession = vi.fn().mockResolvedValue(undefined) closeDirectAcpRuntime = vi.fn().mockResolvedValue(undefined) directAcpControl = createMockDirectAcpControl() + sessionUiPort = { refreshSessionUi: vi.fn() } const backend = createDeepChatAgentBackendFixture(deepChatAgent as never) const resolveBackend = vi.fn((agentId: string) => { if (agentId === 'disabled-agent') throw new Error(`Agent not found: ${agentId}`) @@ -713,7 +715,7 @@ describe('AgentSessionPresenter', () => { tape: deepChatAgent } as any, skillPresenter, - { acpAsLlmProviderSessionControl: llmProviderPresenter } + { acpAsLlmProviderSessionControl: llmProviderPresenter, sessionUiPort } ) }) @@ -1953,7 +1955,15 @@ describe('AgentSessionPresenter', () => { expect(llmProviderPresenter.clearAcpSession).toHaveBeenCalledWith('mock-session-id') expect(deepChatAgent.destroySession).toHaveBeenCalledWith('mock-session-id') expect(sqlitePresenter.newSessionsTable.delete).toHaveBeenCalledWith('mock-session-id') + expect(llmProviderPresenter.clearAcpSession.mock.invocationCallOrder[0]).toBeLessThan( + deepChatAgent.destroySession.mock.invocationCallOrder[0] + ) + expect(deepChatAgent.destroySession.mock.invocationCallOrder[0]).toBeLessThan( + sqlitePresenter.newSessionsTable.delete.mock.invocationCallOrder[0] + ) + expect(presenter.getActiveSessionId(1)).toBeNull() expect(publishDeepchatEvent).not.toHaveBeenCalled() + expect(sessionUiPort.refreshSessionUi).not.toHaveBeenCalled() expect(warnSpy).toHaveBeenCalledWith( '[AgentSessionPresenter] Failed to clear ACP session after initialization error mock-session-id:', clearError @@ -2090,6 +2100,47 @@ describe('AgentSessionPresenter', () => { }) }) + describe('draft turn promotion failures', () => { + it.each([ + ['send', () => presenter.sendMessage('s-draft', 'New prompt')], + ['steer', () => presenter.steerActiveTurn('s-draft', 'New prompt')], + ['queue', () => presenter.queuePendingInput('s-draft', 'New prompt')] + ])('does not roll back draft promotion when %s fails later', async (_name, invoke) => { + const row = { + id: 's-draft', + agent_id: 'deepchat', + title: 'New Chat', + project_dir: '/repo', + is_pinned: 0, + is_draft: 1, + session_kind: 'regular', + parent_session_id: null, + subagent_enabled: 0, + subagent_meta_json: null, + created_at: 1000, + updated_at: 1000 + } + sqlitePresenter.newSessionsTable.get.mockImplementation(() => row) + sqlitePresenter.newSessionsTable.update.mockImplementation((_: string, fields: any) => { + if (fields.title !== undefined) row.title = fields.title + if (fields.is_draft !== undefined) row.is_draft = fields.is_draft + }) + const runtimeError = new Error(`${_name} runtime failed`) + deepChatAgent.getSessionState.mockRejectedValueOnce(runtimeError) + + await expect(invoke()).rejects.toBe(runtimeError) + + expect(row).toMatchObject({ is_draft: 0, title: 'New prompt' }) + expect(sqlitePresenter.newSessionsTable.update).toHaveBeenCalledWith('s-draft', { + is_draft: 0, + title: 'New prompt' + }) + expectSessionsUpdated({ sessionIds: ['s-draft'], reason: 'updated' }) + expect(deepChatAgent.queuePendingInput).not.toHaveBeenCalled() + expect(deepChatAgent.steerActiveTurn).not.toHaveBeenCalled() + }) + }) + describe('sendMessage', () => { it('promotes draft session before first message', async () => { configPresenter.getAcpAgents.mockResolvedValue([ @@ -2166,6 +2217,40 @@ describe('AgentSessionPresenter', () => { expect(deepChatAgent.getMessageIds).not.toHaveBeenCalled() }) + it('forwards maxProviderRounds through the turn handle context', async () => { + sqlitePresenter.newSessionsTable.get.mockReturnValue({ + id: 's1', + agent_id: 'deepchat', + title: 'Test', + project_dir: '/repo', + is_pinned: 0, + is_draft: 0, + created_at: 1000, + updated_at: 1000 + }) + deepChatAgent.hasMessages.mockResolvedValue(true) + const send = vi.fn().mockResolvedValue({ requestId: null, messageId: null }) + agentManager.resolveSessionHandle.mockReturnValueOnce({ + handle: { + kind: 'deepchat', + snapshot: vi.fn().mockResolvedValue({ + status: 'idle', + providerId: 'openai', + modelId: 'gpt-4' + }), + send + } + }) + + await presenter.sendMessage('s1', 'Scheduled prompt', { maxProviderRounds: 4 }) + + expect(send).toHaveBeenCalledWith({ + content: { text: 'Scheduled prompt', files: [] }, + context: { projectDir: '/repo', maxProviderRounds: 4 }, + queue: { source: 'send', projectDir: '/repo' } + }) + }) + it('routes active generation submissions to queue', async () => { sqlitePresenter.newSessionsTable.get.mockReturnValue({ id: 's1', @@ -2847,6 +2932,9 @@ describe('AgentSessionPresenter', () => { ) expect(deepChatAgent.destroySession).toHaveBeenCalledWith('mock-session-id') expect(sqlitePresenter.newSessionsTable.delete).toHaveBeenCalledWith('mock-session-id') + expect(deepChatAgent.destroySession.mock.invocationCallOrder[0]).toBeLessThan( + sqlitePresenter.newSessionsTable.delete.mock.invocationCallOrder[0] + ) expect(rows.has('mock-session-id')).toBe(false) expect(publishDeepchatEvent).not.toHaveBeenCalled() expect(warnSpy).toHaveBeenCalledWith( @@ -3319,6 +3407,36 @@ describe('AgentSessionPresenter', () => { }) }) + describe('session update publication', () => { + it('trims and dedupes ids, applies reason defaults, and refreshes session UI', () => { + const emit = Reflect.get(presenter, 'emitSessionListUpdated') as (options?: { + sessionIds?: string[] + }) => void + + emit.call(presenter, { sessionIds: [' s1 ', 's1', ' ', 's2', 's2'] }) + + expect(publishDeepchatEvent).toHaveBeenCalledWith('sessions.updated', { + sessionIds: ['s1', 's2'], + reason: 'updated', + activeSessionId: undefined, + webContentsId: undefined + }) + expect(sessionUiPort.refreshSessionUi).toHaveBeenCalledOnce() + + vi.mocked(publishDeepchatEvent).mockClear() + sessionUiPort.refreshSessionUi.mockClear() + emit.call(presenter) + + expect(publishDeepchatEvent).toHaveBeenCalledWith('sessions.updated', { + sessionIds: [], + reason: 'list-refreshed', + activeSessionId: undefined, + webContentsId: undefined + }) + expect(sessionUiPort.refreshSessionUi).toHaveBeenCalledOnce() + }) + }) + describe('activateSession', () => { it('binds window and publishes typed activated update', async () => { await presenter.activateSession(42, 's1') @@ -3330,6 +3448,7 @@ describe('AgentSessionPresenter', () => { }) expect(sqlitePresenter.newSessionsTable.get).not.toHaveBeenCalled() expect(agentManager.resolveSessionHandle).not.toHaveBeenCalled() + expect(sessionUiPort.refreshSessionUi).not.toHaveBeenCalled() }) }) @@ -3342,6 +3461,7 @@ describe('AgentSessionPresenter', () => { activeSessionId: null, webContentsId: 42 }) + expect(sessionUiPort.refreshSessionUi).not.toHaveBeenCalled() }) }) @@ -3479,6 +3599,15 @@ describe('AgentSessionPresenter', () => { await presenter.cancelGeneration('s1') expect(deepChatAgent.cancelGeneration).toHaveBeenCalledWith('s1') }) + + it('is a no-op for a missing session', async () => { + sqlitePresenter.newSessionsTable.get.mockReturnValue(undefined) + + await expect(presenter.cancelGeneration('missing')).resolves.toBeUndefined() + + expect(agentManager.resolveSessionHandle).not.toHaveBeenCalled() + expect(deepChatAgent.cancelGeneration).not.toHaveBeenCalled() + }) }) describe('respondToolInteraction', () => { @@ -4479,10 +4608,33 @@ describe('AgentSessionPresenter', () => { await presenter.clearSessionMessages('s1') expect(deepChatAgent.clearMessages).toHaveBeenCalledWith('s1') + expect(deepChatAgent.cancelGeneration.mock.invocationCallOrder[0]).toBeLessThan( + deepChatAgent.clearMessages.mock.invocationCallOrder[0] + ) expect(sqlitePresenter.newSessionsTable.delete).not.toHaveBeenCalled() expectSessionsUpdated({ reason: 'updated', sessionIds: ['s1'] }) }) + it('does not clear messages or publish when cancellation fails', async () => { + sqlitePresenter.newSessionsTable.get.mockReturnValue({ + id: 's1', + agent_id: 'deepchat', + title: 'Test', + project_dir: null, + is_pinned: 0, + created_at: 1000, + updated_at: 1000 + }) + const cancelError = new Error('cancel failed') + deepChatAgent.cancelGeneration.mockRejectedValueOnce(cancelError) + + await expect(presenter.clearSessionMessages('s1')).rejects.toBe(cancelError) + + expect(deepChatAgent.clearMessages).not.toHaveBeenCalled() + expect(publishDeepchatEvent).not.toHaveBeenCalled() + expect(sessionUiPort.refreshSessionUi).not.toHaveBeenCalled() + }) + it('exports session in all supported formats', async () => { const now = Date.now() sqlitePresenter.newSessionsTable.get.mockReturnValue({ From 71325c70d00e40957e244e8903e60c277915adfb Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 13 Jul 2026 17:47:57 +0800 Subject: [PATCH 05/29] docs(architecture): record session characterization --- .../session-application-coordinators/tasks.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/architecture/session-application-coordinators/tasks.md b/docs/architecture/session-application-coordinators/tasks.md index 9d3a5803b..c568728f2 100644 --- a/docs/architecture/session-application-coordinators/tasks.md +++ b/docs/architecture/session-application-coordinators/tasks.md @@ -10,11 +10,11 @@ ## 1. Characterization and Ports -- [ ] Add missing lifecycle rollback and deletion error-precedence characterization. -- [ ] Add pending/message mutation, fork, compaction, and tool-interaction characterization. -- [ ] Lock assignment transfer, setting mutation, and subagent Tape behavior. -- [ ] Lock projection cache/window/title/read fallback behavior. -- [ ] Lock Remote status/output and Cron metadata/max-turn/output behavior. +- [x] Add missing lifecycle rollback and deletion error-precedence characterization. +- [x] Add pending/message mutation, fork, compaction, and tool-interaction characterization. +- [x] Lock assignment transfer, setting mutation, and subagent Tape behavior. +- [x] Lock projection cache/window/title/read fallback behavior. +- [x] Lock Remote status/output and Cron metadata/max-turn/output behavior. - [ ] Define consumer-owned narrow ports without `Pick`. ## 2. SessionProjectionCoordinator From bff3ac9da9cdaa6099224118fe83753c42575927 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 13 Jul 2026 18:07:14 +0800 Subject: [PATCH 06/29] refactor(session): extract projection --- .../session-application-coordinators/tasks.md | 10 +- .../presenter/agentSessionPresenter/index.ts | 597 ++---------------- src/main/presenter/index.ts | 34 +- .../presenter/sessionApplication/ports.ts | 152 +++++ .../projectionCoordinator.ts | 585 +++++++++++++++++ .../agentSessionPresenter.test.ts | 105 ++- .../agentSessionPresenter/integration.test.ts | 112 ++-- .../projectionCoordinatorFixture.ts | 50 ++ .../usageDashboard.test.ts | 53 +- .../projectionCoordinator.test.ts | 424 +++++++++++++ 10 files changed, 1491 insertions(+), 631 deletions(-) create mode 100644 src/main/presenter/sessionApplication/ports.ts create mode 100644 src/main/presenter/sessionApplication/projectionCoordinator.ts create mode 100644 test/main/presenter/agentSessionPresenter/projectionCoordinatorFixture.ts create mode 100644 test/main/presenter/sessionApplication/projectionCoordinator.test.ts diff --git a/docs/architecture/session-application-coordinators/tasks.md b/docs/architecture/session-application-coordinators/tasks.md index c568728f2..6173cc80c 100644 --- a/docs/architecture/session-application-coordinators/tasks.md +++ b/docs/architecture/session-application-coordinators/tasks.md @@ -19,11 +19,11 @@ ## 2. SessionProjectionCoordinator -- [ ] Extract full and lightweight session materialization and status cache. -- [ ] Extract message, Tape, trace, manifest, replay, and search-result projection operations. -- [ ] Extract active-window binding, rename/pin, title generation, events, and UI refresh. -- [ ] Construct one composition-owned Projection instance. -- [ ] Rewire compatibility presenter forwarding and move owner tests. +- [x] Extract full and lightweight session materialization and status cache. +- [x] Extract message, Tape, trace, manifest, replay, and search-result projection operations. +- [x] Extract active-window binding, rename/pin, title generation, events, and UI refresh. +- [x] Construct one composition-owned Projection instance. +- [x] Rewire compatibility presenter forwarding and move owner tests. ## 3. SessionAgentAssignmentCoordinator diff --git a/src/main/presenter/agentSessionPresenter/index.ts b/src/main/presenter/agentSessionPresenter/index.ts index e25984b0a..7c560e064 100644 --- a/src/main/presenter/agentSessionPresenter/index.ts +++ b/src/main/presenter/agentSessionPresenter/index.ts @@ -68,7 +68,7 @@ import { AppSessionService } from '@/agent/shared/appSessionService' import type { AgentSharedDataPorts } from '@/agent/shared/agentSharedData' import { toAppSessionId } from '@/agent/shared/agentSessionIds' import { LegacyChatImportService } from './legacyImportService' -import { publishDeepchatEvent } from '@/routes/publishDeepchatEvent' +import { SessionProjectionCoordinator } from '../sessionApplication/projectionCoordinator' import { buildConversationExportContent, generateExportFilename, @@ -88,11 +88,7 @@ import { } from '../usageStats' import { rtkRuntimeService } from '@/agent/shared/process/rtkRuntimeService' import { resolveAcpAgentAlias } from '@shared/utils/acpAgentAlias' -import type { - AcpAsLlmProviderSessionControlPort, - SessionPermissionPort, - SessionUiPort -} from '../runtimePorts' +import type { AcpAsLlmProviderSessionControlPort, SessionPermissionPort } from '../runtimePorts' type SearchableSessionRow = { id: string @@ -271,14 +267,13 @@ export class AgentSessionPresenter { private configPresenter: IConfigPresenter private sharedData: AgentSharedDataPorts private legacyImportService: LegacyChatImportService + private sessionProjection: SessionProjectionCoordinator private skillPresenter?: Pick private acpAsLlmProviderSessionControl?: AcpAsLlmProviderSessionControlPort private sessionPermissionPort?: SessionPermissionPort - private sessionUiPort?: SessionUiPort private usageStatsBackfillPromise: Promise | null = null private mainlineNormalizationPromise: Promise | null = null private disabledSearchToolCleanupPromise: Promise | null = null - private readonly sessionStatusSnapshots = new Map() constructor( agentManager: AgentManager, @@ -287,11 +282,11 @@ export class AgentSessionPresenter { configPresenter: IConfigPresenter, sqlitePresenter: SQLitePresenter, sharedData: AgentSharedDataPorts, + sessionProjection: SessionProjectionCoordinator, skillPresenter?: Pick, runtimePorts?: { acpAsLlmProviderSessionControl?: AcpAsLlmProviderSessionControlPort sessionPermissionPort?: SessionPermissionPort - sessionUiPort?: SessionUiPort } ) { this.agentManager = agentManager @@ -299,12 +294,12 @@ export class AgentSessionPresenter { this.llmProviderPresenter = llmProviderPresenter this.configPresenter = configPresenter this.sharedData = sharedData + this.sessionProjection = sessionProjection this.skillPresenter = skillPresenter this.sessionManager = appSessionService this.legacyImportService = new LegacyChatImportService(sqlitePresenter) this.acpAsLlmProviderSessionControl = runtimePorts?.acpAsLlmProviderSessionControl this.sessionPermissionPort = runtimePorts?.sessionPermissionPort - this.sessionUiPort = runtimePorts?.sessionUiPort } // ---- IPC-facing methods ---- @@ -402,9 +397,9 @@ export class AgentSessionPresenter { } logger.info(`[AgentSessionPresenter] agent.initSession done`) - // Bind to window and emit activated - this.sessionManager.bindWindow(webContentsId, toAppSessionId(sessionId)) - this.emitSessionListUpdated({ + // Bind to the window and publish the created session projection. + this.sessionProjection.bindWindow(webContentsId, sessionId) + this.sessionProjection.notify({ sessionIds: [sessionId], reason: 'created', activeSessionId: sessionId, @@ -446,7 +441,12 @@ export class AgentSessionPresenter { .catch((err) => { console.error('[AgentSessionPresenter] initial send failed:', err) }) - void this.generateSessionTitle(sessionId, title, providerId, modelId) + this.sessionProjection.scheduleTitleGeneration({ + sessionId, + initialTitle: title, + fallbackProviderId: providerId, + fallbackModelId: modelId + }) } return sessionResult @@ -530,7 +530,7 @@ export class AgentSessionPresenter { await this.skillPresenter.setActiveSkills(sessionId, input.activeSkills) } - this.emitSessionListUpdated({ + this.sessionProjection.notify({ sessionIds: [sessionId], reason: 'created' }) @@ -628,8 +628,8 @@ export class AgentSessionPresenter { throw new Error(`Subagent session not found after creation: ${sessionId}`) } - const session = (await this.buildSessionWithState(record)) as SessionWithState - this.emitSessionListUpdated({ + const session = await this.sessionProjection.materializeRequired(sessionId) + this.sessionProjection.notify({ sessionIds: [session.id], reason: 'created' }) @@ -712,7 +712,7 @@ export class AgentSessionPresenter { const handle = this.requireDirectAcpHandle(record.id) await handle.acp.prepare() - this.emitSessionListUpdated({ + this.sessionProjection.notify({ sessionIds: [record.id], reason: createdDraftSession ? 'created' : 'updated' }) @@ -741,7 +741,7 @@ export class AgentSessionPresenter { if (session.isDraft) { const title = normalizedInput.text.trim().slice(0, 50) || 'New Chat' this.sessionManager.update(sessionId, { isDraft: false, title }) - this.emitSessionListUpdated({ + this.sessionProjection.notify({ sessionIds: [sessionId], reason: 'updated' }) @@ -773,7 +773,12 @@ export class AgentSessionPresenter { } }) if (!hadMessages && !wasDraft) { - void this.generateSessionTitle(sessionId, session.title, providerId, state?.modelId ?? '') + this.sessionProjection.scheduleTitleGeneration({ + sessionId, + initialTitle: session.title, + fallbackProviderId: providerId, + fallbackModelId: state?.modelId ?? '' + }) } return result } @@ -786,7 +791,7 @@ export class AgentSessionPresenter { if (session.isDraft) { const title = normalizedInput.text.trim().slice(0, 50) || 'New Chat' this.sessionManager.update(sessionId, { isDraft: false, title }) - this.emitSessionListUpdated({ + this.sessionProjection.notify({ sessionIds: [sessionId], reason: 'updated' }) @@ -830,7 +835,7 @@ export class AgentSessionPresenter { if (currentSession.isDraft) { const title = normalizedInput.text.trim().slice(0, 50) || 'New Chat' this.sessionManager.update(sessionId, { isDraft: false, title }) - this.emitSessionListUpdated({ + this.sessionProjection.notify({ sessionIds: [sessionId], reason: 'updated' }) @@ -995,7 +1000,7 @@ export class AgentSessionPresenter { throw error } - this.emitSessionListUpdated({ + this.sessionProjection.notify({ sessionIds: [targetSessionId], reason: 'created' }) @@ -1022,17 +1027,7 @@ export class AgentSessionPresenter { includeSubagents?: boolean parentSessionId?: string }): Promise { - const records = this.sessionManager.list(filters) - const enriched: SessionWithState[] = [] - - for (const record of records) { - const session = await this.tryBuildSessionWithState(record, 'list') - if (session) { - enriched.push(session) - } - } - - return enriched + return await this.sessionProjection.listSessions(filters) } async getLightweightSessionList(options?: { @@ -1042,51 +1037,19 @@ export class AgentSessionPresenter { agentId?: string prioritizeSessionId?: string }): Promise { - const page = this.sessionManager.listPage({ - limit: options?.limit, - cursor: options?.cursor, - agentId: options?.agentId, - includeSubagents: options?.includeSubagents - }) - const items = page.records.map((record) => this.mapSessionRecordToListItem(record)) - - const prioritizeSessionId = options?.prioritizeSessionId?.trim() - if (prioritizeSessionId) { - const prioritizedRecord = this.sessionManager.get(prioritizeSessionId) - if (prioritizedRecord && this.matchesLightweightFilter(prioritizedRecord, options)) { - items.unshift(this.mapSessionRecordToListItem(prioritizedRecord)) - } - } - - const deduped = this.dedupeAndSortSessionListItems(items) - return { - items: deduped, - nextCursor: page.nextCursor, - hasMore: page.hasMore - } + return await this.sessionProjection.listLightweight(options) } async getLightweightSessionsByIds(sessionIds: string[]): Promise { - const dedupedIds = Array.from( - new Set(sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean)) - ) - return this.dedupeAndSortSessionListItems( - this.sessionManager - .getMany(dedupedIds) - .map((record) => this.mapSessionRecordToListItem(record)) - ) + return await this.sessionProjection.getLightweightByIds(sessionIds) } async getSession(sessionId: string): Promise { - const record = this.sessionManager.get(sessionId) - if (!record) return null - return await this.tryBuildSessionWithState(record) + return await this.sessionProjection.getSession(sessionId) } async getMessages(sessionId: string): Promise { - const session = this.sessionManager.get(sessionId) - if (!session) throw new Error(`Session not found: ${sessionId}`) - return await this.sharedData.transcript.getMessages(sessionId) + return await this.sessionProjection.getMessages(sessionId) } async listMessagesPage( @@ -1096,12 +1059,7 @@ export class AgentSessionPresenter { cursor?: MessagePageCursor | null } ): Promise { - const session = this.sessionManager.get(sessionId) - if (!session) { - throw new Error(`Session not found: ${sessionId}`) - } - - return await this.sharedData.transcript.listMessagesPage(sessionId, options) + return await this.sessionProjection.listMessagesPage(sessionId, options) } async searchHistory(query: string, options?: HistorySearchOptions): Promise { @@ -1307,12 +1265,7 @@ export class AgentSessionPresenter { } async getTapeInfo(sessionId: string): Promise { - const session = this.sessionManager.get(sessionId) - if (!session) { - throw new Error(`Session not found: ${sessionId}`) - } - - return await this.sharedData.tape.getTapeInfo(sessionId) + return await this.sessionProjection.getTapeInfo(sessionId) } async searchTape( @@ -1320,12 +1273,7 @@ export class AgentSessionPresenter { query: string, options?: AgentTapeSearchOptions ): Promise { - const session = this.sessionManager.get(sessionId) - if (!session) { - throw new Error(`Session not found: ${sessionId}`) - } - - return await this.sharedData.tape.searchTape(sessionId, query, options) + return await this.sessionProjection.searchTape(sessionId, query, options) } async getTapeContext( @@ -1333,24 +1281,14 @@ export class AgentSessionPresenter { entryIds: number[], options?: AgentTapeContextOptions ): Promise { - const session = this.sessionManager.get(sessionId) - if (!session) { - throw new Error(`Session not found: ${sessionId}`) - } - - return await this.sharedData.tape.getTapeContext(sessionId, entryIds, options) + return await this.sessionProjection.getTapeContext(sessionId, entryIds, options) } async listTapeAnchors( sessionId: string, options?: AgentTapeAnchorsOptions ): Promise { - const session = this.sessionManager.get(sessionId) - if (!session) { - throw new Error(`Session not found: ${sessionId}`) - } - - return await this.sharedData.tape.listTapeAnchors(sessionId, options) + return await this.sessionProjection.listTapeAnchors(sessionId, options) } async handoffTape( @@ -1358,64 +1296,18 @@ export class AgentSessionPresenter { name: string, state: Record = {} ): Promise { - const session = this.sessionManager.get(sessionId) - if (!session) { - throw new Error(`Session not found: ${sessionId}`) - } - - return await this.sharedData.tape.handoffTape(sessionId, name, state) + return await this.sessionProjection.handoffTape(sessionId, name, state) } async listMessageViewManifests(messageId: string): Promise { - const normalizedMessageId = messageId?.trim() - if (!normalizedMessageId) return [] - - const message = this.sqlitePresenter.deepchatMessagesTable.get(normalizedMessageId) - if (!message) return [] - - const session = this.sessionManager.get(message.session_id) - if (!session) return [] - - try { - return await this.sharedData.tape.listMessageViewManifests( - message.session_id, - normalizedMessageId - ) - } catch (error) { - logger.warn('[AgentSessionPresenter] Failed to list message view manifests', { - messageId: normalizedMessageId, - error - }) - return [] - } + return await this.sessionProjection.listMessageViewManifests(messageId) } async exportMessageTapeReplaySlice( messageId: string, options?: DeepChatTapeReplayExportOptions ): Promise { - const normalizedMessageId = messageId?.trim() - if (!normalizedMessageId) return null - - const message = this.sqlitePresenter.deepchatMessagesTable.get(normalizedMessageId) - if (!message) return null - - const session = this.sessionManager.get(message.session_id) - if (!session) return null - - try { - return await this.sharedData.tape.exportMessageTapeReplaySlice( - message.session_id, - normalizedMessageId, - options - ) - } catch (error) { - logger.warn('[AgentSessionPresenter] Failed to export tape replay slice', { - messageId: normalizedMessageId, - error - }) - return null - } + return await this.sessionProjection.exportMessageTapeReplaySlice(messageId, options) } async mergeSubagentTape( @@ -1493,38 +1385,7 @@ export class AgentSessionPresenter { } async getSearchResults(messageId: string, searchId?: string): Promise { - const normalizedMessageId = messageId?.trim() - if (!normalizedMessageId) { - return [] - } - const parsed: SearchResult[] = [] - const rows = - this.sqlitePresenter.deepchatMessageSearchResultsTable.listByMessageId(normalizedMessageId) - for (const row of rows) { - try { - const result = JSON.parse(row.content) as SearchResult - parsed.push({ - ...result, - rank: typeof result.rank === 'number' ? result.rank : (row.rank ?? undefined), - searchId: result.searchId ?? row.search_id ?? undefined - }) - } catch (error) { - console.warn('[AgentSessionPresenter] Failed to parse search result row:', error) - } - } - - if (searchId) { - const filtered = parsed.filter((item) => item.searchId === searchId) - if (filtered.length > 0) { - return filtered - } - const legacy = parsed.filter((item) => !item.searchId) - if (legacy.length > 0) { - return legacy - } - } - - return parsed + return await this.sessionProjection.getSearchResults(messageId, searchId) } async getLegacyImportStatus(): Promise { @@ -1712,38 +1573,19 @@ export class AgentSessionPresenter { } async listMessageTraces(messageId: string): Promise { - if (!messageId?.trim()) return [] - return this.sqlitePresenter.deepchatMessageTracesTable - .listByMessageId(messageId) - .map((row) => ({ - id: row.id, - messageId: row.message_id, - sessionId: row.session_id, - providerId: row.provider_id, - modelId: row.model_id, - requestSeq: row.request_seq, - endpoint: row.endpoint, - headersJson: row.headers_json, - bodyJson: row.body_json, - truncated: row.truncated === 1, - createdAt: row.created_at - })) + return await this.sessionProjection.listMessageTraces(messageId) } async getMessageTraceCount(messageId: string): Promise { - const normalizedMessageId = messageId?.trim() - if (!normalizedMessageId) return 0 - return this.sqlitePresenter.deepchatMessageTracesTable.countByMessageId(normalizedMessageId) + return await this.sessionProjection.getMessageTraceCount(messageId) } async getMessageIds(sessionId: string): Promise { - const session = this.sessionManager.get(sessionId) - if (!session) throw new Error(`Session not found: ${sessionId}`) - return await this.sharedData.transcript.getMessageIds(sessionId) + return await this.sessionProjection.getMessageIds(sessionId) } async getMessage(messageId: string): Promise { - return await this.sharedData.transcript.getMessage(messageId) + return await this.sessionProjection.getMessage(messageId) } async translateText(text: string, locale?: string, agentId?: string): Promise { @@ -1787,37 +1629,19 @@ export class AgentSessionPresenter { } async activateSession(webContentsId: number, sessionId: string): Promise { - this.sessionManager.bindWindow(webContentsId, toAppSessionId(sessionId)) - publishDeepchatEvent('sessions.updated', { - sessionIds: [sessionId], - reason: 'activated', - activeSessionId: sessionId, - webContentsId - }) + await this.sessionProjection.activate(webContentsId, sessionId) } async deactivateSession(webContentsId: number): Promise { - this.sessionManager.unbindWindow(webContentsId) - publishDeepchatEvent('sessions.updated', { - sessionIds: [], - reason: 'deactivated', - activeSessionId: null, - webContentsId - }) + await this.sessionProjection.deactivate(webContentsId) } async getActiveSession(webContentsId: number): Promise { - const sessionId = this.sessionManager.getActiveSessionId(webContentsId) - if (!sessionId) return null - const session = await this.getSession(sessionId) - if (!session) { - this.sessionManager.unbindWindow(webContentsId) - } - return session + return await this.sessionProjection.getActive(webContentsId) } getActiveSessionId(webContentsId: number): string | null { - return this.sessionManager.getActiveSessionId(webContentsId) + return this.sessionProjection.getActiveId(webContentsId) } async getAgents(): Promise { @@ -1830,34 +1654,11 @@ export class AgentSessionPresenter { } async renameSession(sessionId: string, title: string): Promise { - const session = this.sessionManager.get(sessionId) - if (!session) { - throw new Error(`Session not found: ${sessionId}`) - } - - const normalized = title.trim() - if (!normalized) { - throw new Error('Session title cannot be empty.') - } - - this.sessionManager.update(sessionId, { title: normalized }) - this.emitSessionListUpdated({ - sessionIds: [sessionId], - reason: 'updated' - }) + await this.sessionProjection.renameSession(sessionId, title) } async toggleSessionPinned(sessionId: string, pinned: boolean): Promise { - const session = this.sessionManager.get(sessionId) - if (!session) { - throw new Error(`Session not found: ${sessionId}`) - } - - this.sessionManager.update(sessionId, { isPinned: pinned }) - this.emitSessionListUpdated({ - sessionIds: [sessionId], - reason: 'updated' - }) + await this.sessionProjection.toggleSessionPinned(sessionId, pinned) } async clearSessionMessages(sessionId: string): Promise { @@ -1868,7 +1669,7 @@ export class AgentSessionPresenter { await this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle.cancel() await this.sharedData.transcriptMutation.clearMessages(sessionId) - this.emitSessionListUpdated({ + this.sessionProjection.notify({ sessionIds: [sessionId], reason: 'updated' }) @@ -1908,7 +1709,7 @@ export class AgentSessionPresenter { async deleteSession(sessionId: string): Promise { const deletedSessionIds = await this.deleteSessionInternal(sessionId) - this.emitSessionListUpdated({ + this.sessionProjection.notify({ sessionIds: deletedSessionIds, reason: 'deleted' }) @@ -2035,13 +1836,13 @@ export class AgentSessionPresenter { if (partialCounts.length > 0) { if (movedSessionIds.length > 0) { - this.emitSessionListUpdated({ + this.sessionProjection.notify({ sessionIds: movedSessionIds, reason: 'updated' }) } if (deletedSessionIds.length > 0) { - this.emitSessionListUpdated({ + this.sessionProjection.notify({ sessionIds: deletedSessionIds, reason: 'deleted' }) @@ -2052,13 +1853,13 @@ export class AgentSessionPresenter { } if (movedSessionIds.length > 0) { - this.emitSessionListUpdated({ + this.sessionProjection.notify({ sessionIds: movedSessionIds, reason: 'updated' }) } if (deletedSessionIds.length > 0) { - this.emitSessionListUpdated({ + this.sessionProjection.notify({ sessionIds: deletedSessionIds, reason: 'deleted' }) @@ -2097,7 +1898,7 @@ export class AgentSessionPresenter { } if (deletedSessionIds.length > 0) { - this.emitSessionListUpdated({ + this.sessionProjection.notify({ sessionIds: deletedSessionIds, reason: 'deleted' }) @@ -2108,7 +1909,7 @@ export class AgentSessionPresenter { async moveSessionToAgent(sessionId: string, toAgentId: string): Promise { const updated = await this.moveSessionToAgentInternal(sessionId, toAgentId) - this.emitSessionListUpdated({ + this.sessionProjection.notify({ sessionIds: [sessionId], reason: 'updated' }) @@ -2238,11 +2039,11 @@ export class AgentSessionPresenter { throw new Error(`Session not found after update: ${sessionId}`) } - this.emitSessionListUpdated({ + this.sessionProjection.notify({ sessionIds: [sessionId], reason: 'updated' }) - const sessionWithState = await this.tryBuildSessionWithState(updated) + const sessionWithState = await this.sessionProjection.materialize(sessionId) if (!sessionWithState) { throw new Error(`Failed to build session state for sessionId: ${sessionId}`) } @@ -2278,7 +2079,7 @@ export class AgentSessionPresenter { providerId: state?.providerId ?? nextProviderId, modelId: state?.modelId ?? nextModelId } - this.emitSessionListUpdated({ + this.sessionProjection.notify({ sessionIds: [sessionId], reason: 'updated' }) @@ -2315,11 +2116,11 @@ export class AgentSessionPresenter { throw new Error(`Session not found after update: ${sessionId}`) } - this.emitSessionListUpdated({ + this.sessionProjection.notify({ sessionIds: [sessionId], reason: 'updated' }) - const sessionWithState = await this.tryBuildSessionWithState(updated) + const sessionWithState = await this.sessionProjection.materialize(sessionId) if (!sessionWithState) { throw new Error(`Failed to build session state after project update: ${sessionId}`) } @@ -2376,208 +2177,6 @@ export class AgentSessionPresenter { .handle.settings.updateGenerationSettings(settings) } - private async generateSessionTitle( - sessionId: string, - initialTitle: string, - fallbackProviderId: string, - fallbackModelId: string - ): Promise { - try { - const titleMessages = await this.waitForSessionTitleMessages(sessionId) - if (!titleMessages) return - - const currentSession = this.sessionManager.get(sessionId) - if (!currentSession) return - if (currentSession.title !== initialTitle) return - - const assistantSelection = await this.resolveAssistantModelSelection( - currentSession.agentId, - fallbackProviderId, - fallbackModelId - ) - const preferredProviderId = assistantSelection.providerId - const preferredModelId = assistantSelection.modelId - - let generatedTitle: string - try { - generatedTitle = await this.llmProviderPresenter.summaryTitles( - titleMessages, - preferredProviderId, - preferredModelId - ) - } catch (error) { - const shouldFallback = - preferredProviderId !== fallbackProviderId || preferredModelId !== fallbackModelId - if (!shouldFallback) throw error - generatedTitle = await this.llmProviderPresenter.summaryTitles( - titleMessages, - fallbackProviderId, - fallbackModelId - ) - } - - const normalized = this.normalizeGeneratedTitle(generatedTitle) - if (!normalized || normalized === initialTitle) return - - const latest = this.sessionManager.get(sessionId) - if (!latest) return - if (latest.title !== initialTitle) return - - this.sessionManager.update(sessionId, { title: normalized }) - this.emitSessionListUpdated({ - sessionIds: [sessionId], - reason: 'updated' - }) - } catch (error) { - console.warn( - `[AgentSessionPresenter] title generation skipped for session=${sessionId}:`, - error - ) - } - } - - private emitSessionListUpdated( - options: { - sessionIds?: string[] - reason?: 'created' | 'updated' | 'deleted' | 'list-refreshed' - activeSessionId?: string | null - webContentsId?: number - } = {} - ): void { - const sessionIds = Array.from( - new Set(options.sessionIds?.map((sessionId) => sessionId.trim()).filter(Boolean) ?? []) - ) - const reason = options.reason ?? (sessionIds.length > 0 ? 'updated' : 'list-refreshed') - - publishDeepchatEvent('sessions.updated', { - sessionIds, - reason, - activeSessionId: options.activeSessionId, - webContentsId: options.webContentsId - }) - this.sessionUiPort?.refreshSessionUi() - } - - private async waitForSessionTitleMessages( - sessionId: string - ): Promise | null> { - const MAX_WAIT_MS = 30000 - const POLL_MS = 250 - const startedAt = Date.now() - const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) - const readTitleMessages = async () => { - const titleMessages = this.buildTitleMessages( - await this.sharedData.transcript.getMessages(sessionId) - ) - return titleMessages.length > 0 ? titleMessages : null - } - - while (Date.now() - startedAt < MAX_WAIT_MS) { - const session = this.sessionManager.get(sessionId) - if (!session) return null - - const handle = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle - const state = await handle.snapshot() - if (!state) return null - if (state.status === 'error') return null - if (state.status === 'idle') { - const titleMessages = await readTitleMessages() - if (titleMessages) { - return titleMessages - } - } - - const remainingMs = MAX_WAIT_MS - (Date.now() - startedAt) - const ready = await handle.waitForFirstTurnReady({ - timeoutMs: Math.min(POLL_MS, Math.max(0, remainingMs)) - }) - if (!ready) { - continue - } - - const titleMessages = await readTitleMessages() - if (titleMessages) { - return titleMessages - } - - await sleep(POLL_MS) - } - - return null - } - - private async buildSessionWithState( - record: SessionRecord, - mode: 'full' | 'list' = 'full' - ): Promise { - const state = await this.agentManager - .resolveSessionHandle(toAppSessionId(record.id)) - .handle.snapshot({ lightweight: mode === 'list' }) - const status = state?.status ?? 'idle' - this.sessionStatusSnapshots.set(record.id, status) - return { - ...record, - status, - providerId: state?.providerId ?? '', - modelId: state?.modelId ?? '' - } - } - - private mapSessionRecordToListItem(record: SessionRecord): SessionListItem { - return { - ...record, - status: this.sessionStatusSnapshots.get(record.id) ?? 'idle' - } - } - - private dedupeAndSortSessionListItems(items: SessionListItem[]): SessionListItem[] { - const sessionMap = new Map() - for (const item of items) { - sessionMap.set(item.id, item) - } - - return Array.from(sessionMap.values()).sort((left, right) => { - if (right.updatedAt !== left.updatedAt) { - return right.updatedAt - left.updatedAt - } - - return right.id.localeCompare(left.id) - }) - } - - private matchesLightweightFilter( - record: SessionRecord, - options?: { - includeSubagents?: boolean - agentId?: string - } - ): boolean { - if (options?.agentId && record.agentId !== options.agentId) { - return false - } - - if (options?.includeSubagents !== true && record.sessionKind === 'subagent') { - return false - } - - return true - } - - private async tryBuildSessionWithState( - record: SessionRecord, - mode: 'full' | 'list' = 'full' - ): Promise { - try { - return await this.buildSessionWithState(record, mode) - } catch (error) { - console.warn( - `[AgentSessionPresenter] Skipping unavailable session id=${record.id} agent=${record.agentId}:`, - error - ) - return null as unknown as SessionWithState - } - } - private requireDirectAcpHandle(sessionId: string): DirectAcpSessionHandle { const handle = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle if (handle.kind !== 'acp') { @@ -2841,7 +2440,7 @@ export class AgentSessionPresenter { throw new Error(`Session not found after transfer: ${sessionId}`) } - const sessionWithState = await this.tryBuildSessionWithState(updated) + const sessionWithState = await this.sessionProjection.materialize(sessionId) if (!sessionWithState) { throw new Error(`Failed to build session state after transfer: ${sessionId}`) } @@ -2950,7 +2549,7 @@ export class AgentSessionPresenter { this.sessionPermissionPort?.clearSessionPermissions(sessionId) await this.skillPresenter?.clearNewAgentSessionSkills?.(sessionId) this.sessionManager.delete(sessionId) - this.sessionStatusSnapshots.delete(sessionId) + this.sessionProjection.forgetStatus([sessionId]) deletedSessionIds.push(sessionId) return deletedSessionIds @@ -3834,68 +3433,6 @@ export class AgentSessionPresenter { await new Promise((resolve) => setTimeout(resolve, 0)) } - private buildTitleMessages( - records: ChatMessageRecord[] - ): Array<{ role: 'system' | 'user' | 'assistant'; content: string }> { - const sorted = [...records].sort((a, b) => a.orderSeq - b.orderSeq) - const messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> = [] - - for (const record of sorted) { - if (record.role === 'user') { - const text = this.extractUserText(record.content) - if (text) { - messages.push({ role: 'user', content: text }) - } - continue - } - - if (record.role === 'assistant') { - const text = this.extractAssistantText(record.content) - if (text) { - messages.push({ role: 'assistant', content: text }) - } - } - } - - return messages.slice(0, 6) - } - - private extractUserText(content: string): string { - try { - const parsed = JSON.parse(content) as UserMessageContent | string - if (typeof parsed === 'string') return parsed.trim() - return typeof parsed.text === 'string' ? parsed.text.trim() : '' - } catch { - return content.trim() - } - } - - private extractAssistantText(content: string): string { - try { - const parsed = JSON.parse(content) as AssistantMessageBlock[] | string - if (typeof parsed === 'string') return parsed.trim() - if (!Array.isArray(parsed)) return '' - return parsed - .filter((block) => block.type === 'content') - .map((block) => block.content) - .join('\n') - .trim() - } catch { - return content.trim() - } - } - - private normalizeGeneratedTitle(rawTitle: string): string { - if (!rawTitle) return '' - let cleaned = rawTitle.replace(/.*?<\/think>/gs, '').trim() - cleaned = cleaned.replace(/^/, '').trim() - cleaned = cleaned.replace(/^["'`]+|["'`]+$/g, '').trim() - if (cleaned.length > 80) { - cleaned = cleaned.slice(0, 80).trim() - } - return cleaned - } - private buildForkTitle(sourceTitle: string, customTitle?: string): string { const normalizedCustom = customTitle?.trim() if (normalizedCustom) { diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index cc5b5f011..f31b5ea08 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -76,6 +76,7 @@ import { toAppSessionId } from '@/agent/shared/agentSessionIds' import { AgentUnavailableError } from '@/agent/shared/agentCatalogCodec' import { resolveAcpAgentAlias } from '@shared/utils/acpAgentAlias' import { AgentSessionPresenter } from './agentSessionPresenter' +import { SessionProjectionCoordinator } from './sessionApplication/projectionCoordinator' import { AgentRuntimePresenter } from './agentRuntimePresenter' import { AcpAgentRuntime } from '@/agent/acp/instance' import type { @@ -157,6 +158,7 @@ export class Presenter implements IPresenter { skillPresenter: ISkillPresenter skillSyncPresenter: ISkillSyncPresenter agentSessionPresenter: IAgentSessionPresenter + sessionProjectionCoordinator: SessionProjectionCoordinator agentManager: AgentManager acpAgentRuntime: AcpAgentRuntime memoryPresenter: MemoryPresenter @@ -719,6 +721,34 @@ export class Presenter implements IPresenter { } }) }) + this.sessionProjectionCoordinator = new SessionProjectionCoordinator({ + sessions: appSessionService, + runtime: { + getAgentKind: (agentId) => this.agentManager.resolveBackend(agentId).kind, + snapshot: async (sessionId, options) => + await this.agentManager + .resolveSessionHandle(toAppSessionId(sessionId)) + .handle.snapshot(options), + waitForFirstTurnReady: async (sessionId, options) => + await this.agentManager + .resolveSessionHandle(toAppSessionId(sessionId)) + .handle.waitForFirstTurnReady(options) + }, + transcript: agentSharedData.transcript, + tape: agentSharedData.tape, + messages: sqlitePresenter.deepchatMessagesTable, + searchResults: sqlitePresenter.deepchatMessageSearchResultsTable, + traces: sqlitePresenter.deepchatMessageTracesTable, + titles: this.llmproviderPresenter, + agentConfig: { + getAssistantModel: async (agentId) => + (await this.configPresenter.resolveDeepChatAgentConfig(agentId))?.assistantModel ?? null + }, + events: { + publish: (payload) => publishDeepchatEvent('sessions.updated', payload) + }, + ui: sessionUiPort + }) this.agentSessionPresenter = new AgentSessionPresenter( this.agentManager, appSessionService, @@ -726,11 +756,11 @@ export class Presenter implements IPresenter { this.configPresenter, this.sqlitePresenter as unknown as import('./sqlitePresenter').SQLitePresenter, agentSharedData, + this.sessionProjectionCoordinator, this.skillPresenter, { acpAsLlmProviderSessionControl: this.acpAsLlmProviderSessionControl, - sessionPermissionPort, - sessionUiPort + sessionPermissionPort } ) this.projectPresenter = new ProjectPresenter( diff --git a/src/main/presenter/sessionApplication/ports.ts b/src/main/presenter/sessionApplication/ports.ts new file mode 100644 index 000000000..4d44a9f92 --- /dev/null +++ b/src/main/presenter/sessionApplication/ports.ts @@ -0,0 +1,152 @@ +import type { AppSessionId } from '@/agent/shared/agentSessionIds' +import type { AgentTapePort, AgentTranscriptReadPort } from '@/agent/shared/agentSharedData' +import type { + DeepChatSessionState, + SessionLightweightListResult, + SessionListItem, + SessionPageCursor, + SessionRecord, + SessionWithState +} from '@shared/types/agent-interface' +import type { DeepChatMessageRow } from '../sqlitePresenter/tables/deepchatMessages' +import type { DeepChatMessageSearchResultRow } from '../sqlitePresenter/tables/deepchatMessageSearchResults' +import type { DeepChatMessageTraceRow } from '../sqlitePresenter/tables/deepchatMessageTraces' + +export interface SessionProjectionStorePort { + get(sessionId: string): SessionRecord | null + getMany(sessionIds: string[]): SessionRecord[] + list(filters?: SessionListFilters): SessionRecord[] + listPage(options?: SessionLightweightOptions): { + records: SessionRecord[] + nextCursor: SessionPageCursor | null + hasMore: boolean + } + update(sessionId: string, fields: Partial>): void + bindWindow(webContentsId: number, sessionId: AppSessionId): void + unbindWindow(webContentsId: number): void + getActiveSessionId(webContentsId: number): AppSessionId | null +} + +export interface SessionProjectionRuntimePort { + getAgentKind(agentId: string): 'deepchat' | 'acp' + snapshot( + sessionId: string, + options?: { lightweight?: boolean } + ): Promise + waitForFirstTurnReady(sessionId: string, options: { timeoutMs: number }): Promise +} + +export type SessionProjectionTranscriptPort = Pick< + AgentTranscriptReadPort, + 'getMessages' | 'listMessagesPage' | 'getMessageIds' | 'getMessage' +> + +export type SessionProjectionTapePort = Pick< + AgentTapePort, + | 'getTapeInfo' + | 'searchTape' + | 'getTapeContext' + | 'listTapeAnchors' + | 'handoffTape' + | 'listMessageViewManifests' + | 'exportMessageTapeReplaySlice' +> + +export interface SessionProjectionMessageLookupPort { + get(messageId: string): Pick | null | undefined +} + +export interface SessionProjectionSearchResultStorePort { + listByMessageId(messageId: string): DeepChatMessageSearchResultRow[] +} + +export interface SessionProjectionTraceStorePort { + listByMessageId(messageId: string): DeepChatMessageTraceRow[] + countByMessageId(messageId: string): number +} + +export interface SessionProjectionTitlePort { + summaryTitles( + messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>, + providerId: string, + modelId: string + ): Promise +} + +export interface SessionProjectionAgentConfigPort { + getAssistantModel( + agentId: string + ): Promise<{ providerId?: string | null; modelId?: string | null } | null> +} + +export type SessionProjectionEventReason = + | 'created' + | 'updated' + | 'deleted' + | 'list-refreshed' + | 'activated' + | 'deactivated' + +export interface SessionProjectionEventPort { + publish(payload: { + sessionIds: string[] + reason: SessionProjectionEventReason + activeSessionId?: string | null + webContentsId?: number + }): void +} + +export interface SessionProjectionUiPort { + refreshSessionUi(): void +} + +export interface SessionListFilters { + agentId?: string + projectDir?: string + includeSubagents?: boolean + parentSessionId?: string +} + +export interface SessionLightweightOptions { + limit?: number + cursor?: SessionPageCursor | null + includeSubagents?: boolean + agentId?: string + prioritizeSessionId?: string +} + +export interface SessionProjectionUpdate { + sessionIds?: string[] + reason?: 'created' | 'updated' | 'deleted' | 'list-refreshed' + activeSessionId?: string | null + webContentsId?: number +} + +export interface TitleGenerationInput { + sessionId: string + initialTitle: string + fallbackProviderId: string + fallbackModelId: string +} + +export interface SessionProjectionReadPort { + getSession(sessionId: string): Promise + listSessions(filters?: SessionListFilters): Promise + listLightweight(options?: SessionLightweightOptions): Promise + getLightweightByIds(sessionIds: string[]): Promise +} + +export interface SessionWindowProjectionPort { + activate(webContentsId: number, sessionId: string): Promise + deactivate(webContentsId: number): Promise + getActive(webContentsId: number): Promise + getActiveId(webContentsId: number): string | null +} + +export interface SessionProjectionMutationPort { + bindWindow(webContentsId: number, sessionId: string): void + materialize(sessionId: string): Promise + notify(input?: SessionProjectionUpdate): void + forgetStatus(sessionIds: string[]): void + scheduleTitleGeneration(input: TitleGenerationInput): void +} diff --git a/src/main/presenter/sessionApplication/projectionCoordinator.ts b/src/main/presenter/sessionApplication/projectionCoordinator.ts new file mode 100644 index 000000000..61faf4a5c --- /dev/null +++ b/src/main/presenter/sessionApplication/projectionCoordinator.ts @@ -0,0 +1,585 @@ +import logger from '@shared/logger' +import { toAppSessionId } from '@/agent/shared/agentSessionIds' +import type { + AgentTapeAnchorResult, + AgentTapeAnchorsOptions, + AgentTapeContextOptions, + AgentTapeContextResult, + AgentTapeInfo, + AgentTapeSearchOptions, + AgentTapeSearchResult, + AssistantMessageBlock, + ChatMessagePageResult, + ChatMessageRecord, + MessagePageCursor, + MessageTraceRecord, + SessionLightweightListResult, + SessionListItem, + SessionRecord, + SessionWithState, + UserMessageContent +} from '@shared/types/agent-interface' +import type { SearchResult } from '@shared/types/core/search' +import type { DeepChatTapeViewManifestRecord } from '@shared/types/tape-view-manifest' +import type { + DeepChatTapeReplayExportOptions, + DeepChatTapeReplaySlice +} from '@shared/types/tape-replay' +import type { + SessionLightweightOptions, + SessionListFilters, + SessionProjectionAgentConfigPort, + SessionProjectionEventPort, + SessionProjectionMessageLookupPort, + SessionProjectionMutationPort, + SessionProjectionReadPort, + SessionProjectionRuntimePort, + SessionProjectionSearchResultStorePort, + SessionProjectionStorePort, + SessionProjectionTapePort, + SessionProjectionTitlePort, + SessionProjectionTraceStorePort, + SessionProjectionTranscriptPort, + SessionProjectionUiPort, + SessionProjectionUpdate, + SessionWindowProjectionPort, + TitleGenerationInput +} from './ports' + +export interface SessionProjectionCoordinatorDependencies { + sessions: SessionProjectionStorePort + runtime: SessionProjectionRuntimePort + transcript: SessionProjectionTranscriptPort + tape: SessionProjectionTapePort + messages: SessionProjectionMessageLookupPort + searchResults: SessionProjectionSearchResultStorePort + traces: SessionProjectionTraceStorePort + titles: SessionProjectionTitlePort + agentConfig: SessionProjectionAgentConfigPort + events: SessionProjectionEventPort + ui: SessionProjectionUiPort +} + +export class SessionProjectionCoordinator + implements SessionProjectionReadPort, SessionWindowProjectionPort, SessionProjectionMutationPort +{ + private readonly sessionStatusSnapshots = new Map() + + constructor(private readonly dependencies: SessionProjectionCoordinatorDependencies) {} + + async listSessions(filters?: SessionListFilters): Promise { + const records = this.dependencies.sessions.list(filters) + const enriched: SessionWithState[] = [] + + for (const record of records) { + const session = await this.tryMaterializeRecord(record, 'list') + if (session) enriched.push(session) + } + + return enriched + } + + async listLightweight( + options?: SessionLightweightOptions + ): Promise { + const page = this.dependencies.sessions.listPage({ + limit: options?.limit, + cursor: options?.cursor, + agentId: options?.agentId, + includeSubagents: options?.includeSubagents + }) + const items = page.records.map((record) => this.mapSessionRecordToListItem(record)) + const prioritizeSessionId = options?.prioritizeSessionId?.trim() + + if (prioritizeSessionId) { + const prioritizedRecord = this.dependencies.sessions.get(prioritizeSessionId) + if (prioritizedRecord && this.matchesLightweightFilter(prioritizedRecord, options)) { + items.unshift(this.mapSessionRecordToListItem(prioritizedRecord)) + } + } + + return { + items: this.dedupeAndSortSessionListItems(items), + nextCursor: page.nextCursor, + hasMore: page.hasMore + } + } + + async getLightweightByIds(sessionIds: string[]): Promise { + const dedupedIds = Array.from( + new Set(sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean)) + ) + return this.dedupeAndSortSessionListItems( + this.dependencies.sessions + .getMany(dedupedIds) + .map((record) => this.mapSessionRecordToListItem(record)) + ) + } + + async getSession(sessionId: string): Promise { + return await this.materialize(sessionId) + } + + async materialize(sessionId: string): Promise { + const record = this.dependencies.sessions.get(sessionId) + if (!record) return null + return await this.tryMaterializeRecord(record) + } + + async materializeRequired(sessionId: string): Promise { + const record = this.dependencies.sessions.get(sessionId) + if (!record) throw new Error(`Session not found: ${sessionId}`) + return await this.materializeRecord(record) + } + + async getMessages(sessionId: string): Promise { + this.requireSession(sessionId) + return await this.dependencies.transcript.getMessages(sessionId) + } + + async listMessagesPage( + sessionId: string, + options?: { limit?: number; cursor?: MessagePageCursor | null } + ): Promise { + this.requireSession(sessionId) + return await this.dependencies.transcript.listMessagesPage(sessionId, options) + } + + async getTapeInfo(sessionId: string): Promise { + this.requireSession(sessionId) + return await this.dependencies.tape.getTapeInfo(sessionId) + } + + async searchTape( + sessionId: string, + query: string, + options?: AgentTapeSearchOptions + ): Promise { + this.requireSession(sessionId) + return await this.dependencies.tape.searchTape(sessionId, query, options) + } + + async getTapeContext( + sessionId: string, + entryIds: number[], + options?: AgentTapeContextOptions + ): Promise { + this.requireSession(sessionId) + return await this.dependencies.tape.getTapeContext(sessionId, entryIds, options) + } + + async listTapeAnchors( + sessionId: string, + options?: AgentTapeAnchorsOptions + ): Promise { + this.requireSession(sessionId) + return await this.dependencies.tape.listTapeAnchors(sessionId, options) + } + + async handoffTape( + sessionId: string, + name: string, + state: Record = {} + ): Promise { + this.requireSession(sessionId) + return await this.dependencies.tape.handoffTape(sessionId, name, state) + } + + async listMessageViewManifests(messageId: string): Promise { + const normalizedMessageId = messageId?.trim() + if (!normalizedMessageId) return [] + + const message = this.dependencies.messages.get(normalizedMessageId) + if (!message || !this.dependencies.sessions.get(message.session_id)) return [] + + try { + return await this.dependencies.tape.listMessageViewManifests( + message.session_id, + normalizedMessageId + ) + } catch (error) { + logger.warn('[SessionProjectionCoordinator] Failed to list message view manifests', { + messageId: normalizedMessageId, + error + }) + return [] + } + } + + async exportMessageTapeReplaySlice( + messageId: string, + options?: DeepChatTapeReplayExportOptions + ): Promise { + const normalizedMessageId = messageId?.trim() + if (!normalizedMessageId) return null + + const message = this.dependencies.messages.get(normalizedMessageId) + if (!message || !this.dependencies.sessions.get(message.session_id)) return null + + try { + return await this.dependencies.tape.exportMessageTapeReplaySlice( + message.session_id, + normalizedMessageId, + options + ) + } catch (error) { + logger.warn('[SessionProjectionCoordinator] Failed to export tape replay slice', { + messageId: normalizedMessageId, + error + }) + return null + } + } + + async getSearchResults(messageId: string, searchId?: string): Promise { + const normalizedMessageId = messageId?.trim() + if (!normalizedMessageId) return [] + + const parsed: SearchResult[] = [] + for (const row of this.dependencies.searchResults.listByMessageId(normalizedMessageId)) { + try { + const result = JSON.parse(row.content) as SearchResult + parsed.push({ + ...result, + rank: typeof result.rank === 'number' ? result.rank : (row.rank ?? undefined), + searchId: result.searchId ?? row.search_id ?? undefined + }) + } catch (error) { + console.warn('[SessionProjectionCoordinator] Failed to parse search result row:', error) + } + } + + if (searchId) { + const filtered = parsed.filter((item) => item.searchId === searchId) + if (filtered.length > 0) return filtered + const legacy = parsed.filter((item) => !item.searchId) + if (legacy.length > 0) return legacy + } + + return parsed + } + + async listMessageTraces(messageId: string): Promise { + if (!messageId?.trim()) return [] + return this.dependencies.traces.listByMessageId(messageId).map((row) => ({ + id: row.id, + messageId: row.message_id, + sessionId: row.session_id, + providerId: row.provider_id, + modelId: row.model_id, + requestSeq: row.request_seq, + endpoint: row.endpoint, + headersJson: row.headers_json, + bodyJson: row.body_json, + truncated: row.truncated === 1, + createdAt: row.created_at + })) + } + + async getMessageTraceCount(messageId: string): Promise { + const normalizedMessageId = messageId?.trim() + if (!normalizedMessageId) return 0 + return this.dependencies.traces.countByMessageId(normalizedMessageId) + } + + async getMessageIds(sessionId: string): Promise { + this.requireSession(sessionId) + return await this.dependencies.transcript.getMessageIds(sessionId) + } + + async getMessage(messageId: string): Promise { + return await this.dependencies.transcript.getMessage(messageId) + } + + bindWindow(webContentsId: number, sessionId: string): void { + this.dependencies.sessions.bindWindow(webContentsId, toAppSessionId(sessionId)) + } + + async activate(webContentsId: number, sessionId: string): Promise { + this.bindWindow(webContentsId, sessionId) + this.dependencies.events.publish({ + sessionIds: [sessionId], + reason: 'activated', + activeSessionId: sessionId, + webContentsId + }) + } + + async deactivate(webContentsId: number): Promise { + this.dependencies.sessions.unbindWindow(webContentsId) + this.dependencies.events.publish({ + sessionIds: [], + reason: 'deactivated', + activeSessionId: null, + webContentsId + }) + } + + async getActive(webContentsId: number): Promise { + const sessionId = this.dependencies.sessions.getActiveSessionId(webContentsId) + if (!sessionId) return null + + const session = await this.getSession(sessionId) + if (!session) this.dependencies.sessions.unbindWindow(webContentsId) + return session + } + + getActiveId(webContentsId: number): string | null { + return this.dependencies.sessions.getActiveSessionId(webContentsId) + } + + async renameSession(sessionId: string, title: string): Promise { + this.requireSession(sessionId) + const normalized = title.trim() + if (!normalized) throw new Error('Session title cannot be empty.') + + this.dependencies.sessions.update(sessionId, { title: normalized }) + this.notify({ sessionIds: [sessionId], reason: 'updated' }) + } + + async toggleSessionPinned(sessionId: string, pinned: boolean): Promise { + this.requireSession(sessionId) + this.dependencies.sessions.update(sessionId, { isPinned: pinned }) + this.notify({ sessionIds: [sessionId], reason: 'updated' }) + } + + notify(options: SessionProjectionUpdate = {}): void { + const sessionIds = Array.from( + new Set(options.sessionIds?.map((sessionId) => sessionId.trim()).filter(Boolean) ?? []) + ) + const reason = options.reason ?? (sessionIds.length > 0 ? 'updated' : 'list-refreshed') + + this.dependencies.events.publish({ + sessionIds, + reason, + activeSessionId: options.activeSessionId, + webContentsId: options.webContentsId + }) + this.dependencies.ui.refreshSessionUi() + } + + forgetStatus(sessionIds: string[]): void { + for (const sessionId of sessionIds) this.sessionStatusSnapshots.delete(sessionId) + } + + scheduleTitleGeneration(input: TitleGenerationInput): void { + void this.generateSessionTitle(input) + } + + private requireSession(sessionId: string): SessionRecord { + const session = this.dependencies.sessions.get(sessionId) + if (!session) throw new Error(`Session not found: ${sessionId}`) + return session + } + + private async materializeRecord( + record: SessionRecord, + mode: 'full' | 'list' = 'full' + ): Promise { + const state = await this.dependencies.runtime.snapshot(record.id, { + lightweight: mode === 'list' + }) + const status = state?.status ?? 'idle' + this.sessionStatusSnapshots.set(record.id, status) + return { + ...record, + status, + providerId: state?.providerId ?? '', + modelId: state?.modelId ?? '' + } + } + + private async tryMaterializeRecord( + record: SessionRecord, + mode: 'full' | 'list' = 'full' + ): Promise { + try { + return await this.materializeRecord(record, mode) + } catch (error) { + console.warn( + `[SessionProjectionCoordinator] Skipping unavailable session id=${record.id} agent=${record.agentId}:`, + error + ) + return null + } + } + + private mapSessionRecordToListItem(record: SessionRecord): SessionListItem { + return { + ...record, + status: this.sessionStatusSnapshots.get(record.id) ?? 'idle' + } + } + + private dedupeAndSortSessionListItems(items: SessionListItem[]): SessionListItem[] { + const sessionMap = new Map() + for (const item of items) sessionMap.set(item.id, item) + + return Array.from(sessionMap.values()).sort((left, right) => { + if (right.updatedAt !== left.updatedAt) return right.updatedAt - left.updatedAt + return right.id.localeCompare(left.id) + }) + } + + private matchesLightweightFilter( + record: SessionRecord, + options?: Pick + ): boolean { + if (options?.agentId && record.agentId !== options.agentId) return false + return options?.includeSubagents === true || record.sessionKind !== 'subagent' + } + + private async generateSessionTitle(input: TitleGenerationInput): Promise { + const { sessionId, initialTitle, fallbackProviderId, fallbackModelId } = input + try { + const titleMessages = await this.waitForSessionTitleMessages(sessionId) + if (!titleMessages) return + + const currentSession = this.dependencies.sessions.get(sessionId) + if (!currentSession || currentSession.title !== initialTitle) return + + const assistantSelection = await this.resolveAssistantModelSelection( + currentSession.agentId, + fallbackProviderId, + fallbackModelId + ) + + let generatedTitle: string + try { + generatedTitle = await this.dependencies.titles.summaryTitles( + titleMessages, + assistantSelection.providerId, + assistantSelection.modelId + ) + } catch (error) { + const shouldFallback = + assistantSelection.providerId !== fallbackProviderId || + assistantSelection.modelId !== fallbackModelId + if (!shouldFallback) throw error + generatedTitle = await this.dependencies.titles.summaryTitles( + titleMessages, + fallbackProviderId, + fallbackModelId + ) + } + + const normalized = this.normalizeGeneratedTitle(generatedTitle) + if (!normalized || normalized === initialTitle) return + + const latest = this.dependencies.sessions.get(sessionId) + if (!latest || latest.title !== initialTitle) return + + this.dependencies.sessions.update(sessionId, { title: normalized }) + this.notify({ sessionIds: [sessionId], reason: 'updated' }) + } catch (error) { + console.warn( + `[SessionProjectionCoordinator] title generation skipped for session=${sessionId}:`, + error + ) + } + } + + private async resolveAssistantModelSelection( + agentId: string, + fallbackProviderId: string, + fallbackModelId: string + ): Promise<{ providerId: string; modelId: string }> { + if (this.dependencies.runtime.getAgentKind(agentId) === 'deepchat') { + const assistantModel = await this.dependencies.agentConfig.getAssistantModel(agentId) + const providerId = assistantModel?.providerId?.trim() + const modelId = assistantModel?.modelId?.trim() + if (providerId && modelId) return { providerId, modelId } + } + + return { providerId: fallbackProviderId, modelId: fallbackModelId } + } + + private async waitForSessionTitleMessages( + sessionId: string + ): Promise | null> { + const maxWaitMs = 30000 + const pollMs = 250 + const startedAt = Date.now() + const readTitleMessages = async () => { + const titleMessages = this.buildTitleMessages( + await this.dependencies.transcript.getMessages(sessionId) + ) + return titleMessages.length > 0 ? titleMessages : null + } + + while (Date.now() - startedAt < maxWaitMs) { + if (!this.dependencies.sessions.get(sessionId)) return null + + const state = await this.dependencies.runtime.snapshot(sessionId) + if (!state || state.status === 'error') return null + if (state.status === 'idle') { + const titleMessages = await readTitleMessages() + if (titleMessages) return titleMessages + } + + const remainingMs = maxWaitMs - (Date.now() - startedAt) + const ready = await this.dependencies.runtime.waitForFirstTurnReady(sessionId, { + timeoutMs: Math.min(pollMs, Math.max(0, remainingMs)) + }) + if (!ready) continue + + const titleMessages = await readTitleMessages() + if (titleMessages) return titleMessages + await new Promise((resolve) => setTimeout(resolve, pollMs)) + } + + return null + } + + private buildTitleMessages( + records: ChatMessageRecord[] + ): Array<{ role: 'system' | 'user' | 'assistant'; content: string }> { + const sorted = [...records].sort((left, right) => left.orderSeq - right.orderSeq) + const messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> = [] + + for (const record of sorted) { + if (record.role === 'user') { + const text = this.extractUserText(record.content) + if (text) messages.push({ role: 'user', content: text }) + } else if (record.role === 'assistant') { + const text = this.extractAssistantText(record.content) + if (text) messages.push({ role: 'assistant', content: text }) + } + } + + return messages.slice(0, 6) + } + + private extractUserText(content: string): string { + try { + const parsed = JSON.parse(content) as UserMessageContent | string + if (typeof parsed === 'string') return parsed.trim() + return typeof parsed.text === 'string' ? parsed.text.trim() : '' + } catch { + return content.trim() + } + } + + private extractAssistantText(content: string): string { + try { + const parsed = JSON.parse(content) as AssistantMessageBlock[] | string + if (typeof parsed === 'string') return parsed.trim() + if (!Array.isArray(parsed)) return '' + return parsed + .filter((block) => block.type === 'content') + .map((block) => block.content) + .join('\n') + .trim() + } catch { + return content.trim() + } + } + + private normalizeGeneratedTitle(rawTitle: string): string { + if (!rawTitle) return '' + let cleaned = rawTitle.replace(/.*?<\/think>/gs, '').trim() + cleaned = cleaned.replace(/^/, '').trim() + cleaned = cleaned.replace(/^["'`]+|["'`]+$/g, '').trim() + return cleaned.length > 80 ? cleaned.slice(0, 80).trim() : cleaned + } +} diff --git a/test/main/presenter/agentSessionPresenter/agentSessionPresenter.test.ts b/test/main/presenter/agentSessionPresenter/agentSessionPresenter.test.ts index 23cb4d503..531bb4d90 100644 --- a/test/main/presenter/agentSessionPresenter/agentSessionPresenter.test.ts +++ b/test/main/presenter/agentSessionPresenter/agentSessionPresenter.test.ts @@ -10,6 +10,7 @@ import { AgentRepository } from '@/presenter/agentRepository' import { AgentSessionPresenter } from '@/presenter/agentSessionPresenter/index' import { resolveAcpAgentAlias } from '@shared/utils/acpAgentAlias' import { createDeepChatAgentBackendFixture } from '../../agent/manager/deepChatAgentBackendFixture' +import { createProjectionCoordinatorFixture } from './projectionCoordinatorFixture' vi.mock('nanoid', () => ({ nanoid: vi.fn(() => 'mock-session-id') })) @@ -569,18 +570,29 @@ function createDescriptorIndependentDeleteHarness(options: { clearSessionPermissions: vi.fn(), approvePermission: vi.fn().mockResolvedValue(undefined) } + const llmProviderPresenter = createMockLlmProviderPresenter() + const configPresenter = createMockConfigPresenter() + const sharedData = { + sessionState: deepchatImplementation, + transcript: deepchatImplementation, + transcriptMutation: deepchatImplementation, + tape: deepchatImplementation + } as any const presenter = new AgentSessionPresenter( manager, appSessionService, - createMockLlmProviderPresenter(), - createMockConfigPresenter(), + llmProviderPresenter, + configPresenter, sqliteWithAgents, - { - sessionState: deepchatImplementation, - transcript: deepchatImplementation, - transcriptMutation: deepchatImplementation, - tape: deepchatImplementation - } as any, + sharedData, + createProjectionCoordinatorFixture({ + agentManager: manager, + appSessionService, + llmProviderPresenter, + configPresenter, + sqlitePresenter: sqliteWithAgents, + sharedData + }), skillPresenter, { sessionPermissionPort } ) @@ -697,25 +709,36 @@ describe('AgentSessionPresenter', () => { await closeDirectAcpSession(sessionId) }) } + const appSessionService = new AppSessionService({ + newSessionsTable: sqlitePresenter.newSessionsTable, + deepchatSessionMetadataTable: sqlitePresenter.deepchatSessionMetadataTable, + deepchatSearchDocumentsTable: sqlitePresenter.deepchatSearchDocumentsTable, + newEnvironmentsTable: sqlitePresenter.newEnvironmentsTable + }) + const sharedData = { + sessionState: deepChatAgent, + transcript: deepChatAgent, + transcriptMutation: deepChatAgent, + tape: deepChatAgent + } as any presenter = new AgentSessionPresenter( agentManager as any, - new AppSessionService({ - newSessionsTable: sqlitePresenter.newSessionsTable, - deepchatSessionMetadataTable: sqlitePresenter.deepchatSessionMetadataTable, - deepchatSearchDocumentsTable: sqlitePresenter.deepchatSearchDocumentsTable, - newEnvironmentsTable: sqlitePresenter.newEnvironmentsTable - }), + appSessionService, llmProviderPresenter, configPresenter, sqlitePresenter, - { - sessionState: deepChatAgent, - transcript: deepChatAgent, - transcriptMutation: deepChatAgent, - tape: deepChatAgent - } as any, + sharedData, + createProjectionCoordinatorFixture({ + agentManager: agentManager as any, + appSessionService, + llmProviderPresenter, + configPresenter, + sqlitePresenter, + sharedData, + sessionUiPort + }), skillPresenter, - { acpAsLlmProviderSessionControl: llmProviderPresenter, sessionUiPort } + { acpAsLlmProviderSessionControl: llmProviderPresenter } ) }) @@ -939,18 +962,27 @@ describe('AgentSessionPresenter', () => { resolveInput: resolveDirectAcpInput }) }) + const integratedSharedData = { + sessionState: deepchatImplementation, + transcript: deepchatImplementation, + transcriptMutation: deepchatImplementation, + tape: deepchatImplementation + } as any const integratedPresenter = new AgentSessionPresenter( realManager, appSessionService, llmProviderPresenter, configPresenter, sqliteWithAgents, - { - sessionState: deepchatImplementation, - transcript: deepchatImplementation, - transcriptMutation: deepchatImplementation, - tape: deepchatImplementation - } as any, + integratedSharedData, + createProjectionCoordinatorFixture({ + agentManager: realManager, + appSessionService, + llmProviderPresenter, + configPresenter, + sqlitePresenter: sqliteWithAgents, + sharedData: integratedSharedData + }), skillPresenter ) @@ -3022,7 +3054,7 @@ describe('AgentSessionPresenter', () => { expect(sessions).toHaveLength(1) expect(sessions[0].id).toBe('s1') expect(warnSpy).toHaveBeenCalledWith( - '[AgentSessionPresenter] Skipping unavailable session id=missing-agent agent=disabled-agent:', + '[SessionProjectionCoordinator] Skipping unavailable session id=missing-agent agent=disabled-agent:', expect.any(Error) ) warnSpy.mockRestore() @@ -3067,7 +3099,7 @@ describe('AgentSessionPresenter', () => { expect(sessions).toHaveLength(1) expect(sessions[0].id).toBe('healthy-state') expect(warnSpy).toHaveBeenCalledWith( - '[AgentSessionPresenter] Skipping unavailable session id=broken-state agent=deepchat:', + '[SessionProjectionCoordinator] Skipping unavailable session id=broken-state agent=deepchat:', expect.any(Error) ) warnSpy.mockRestore() @@ -3172,7 +3204,7 @@ describe('AgentSessionPresenter', () => { expect(await presenter.getSession('s-disabled')).toBeNull() expect(warnSpy).toHaveBeenCalledWith( - '[AgentSessionPresenter] Skipping unavailable session id=s-disabled agent=disabled-agent:', + '[SessionProjectionCoordinator] Skipping unavailable session id=s-disabled agent=disabled-agent:', expect.any(Error) ) warnSpy.mockRestore() @@ -3367,7 +3399,7 @@ describe('AgentSessionPresenter', () => { sqlitePresenter.deepchatMessageSearchResultsTable.listByMessageId ).toHaveBeenCalledWith('message-1') expect(warnSpy).toHaveBeenCalledWith( - '[AgentSessionPresenter] Failed to parse search result row:', + '[SessionProjectionCoordinator] Failed to parse search result row:', expect.any(SyntaxError) ) warnSpy.mockRestore() @@ -3409,11 +3441,14 @@ describe('AgentSessionPresenter', () => { describe('session update publication', () => { it('trims and dedupes ids, applies reason defaults, and refreshes session UI', () => { - const emit = Reflect.get(presenter, 'emitSessionListUpdated') as (options?: { + const projection = Reflect.get(presenter, 'sessionProjection') as { + notify(options?: { sessionIds?: string[] }): void + } + const emit = projection.notify.bind(projection) as (options?: { sessionIds?: string[] }) => void - emit.call(presenter, { sessionIds: [' s1 ', 's1', ' ', 's2', 's2'] }) + emit({ sessionIds: [' s1 ', 's1', ' ', 's2', 's2'] }) expect(publishDeepchatEvent).toHaveBeenCalledWith('sessions.updated', { sessionIds: ['s1', 's2'], @@ -3425,7 +3460,7 @@ describe('AgentSessionPresenter', () => { vi.mocked(publishDeepchatEvent).mockClear() sessionUiPort.refreshSessionUi.mockClear() - emit.call(presenter) + emit() expect(publishDeepchatEvent).toHaveBeenCalledWith('sessions.updated', { sessionIds: [], @@ -4958,7 +4993,7 @@ describe('AgentSessionPresenter', () => { }) await expect(presenter.getActiveSession(1)).resolves.toBeNull() expect(warnSpy).toHaveBeenCalledWith( - '[AgentSessionPresenter] Skipping unavailable session id=s-disabled agent=disabled-agent:', + '[SessionProjectionCoordinator] Skipping unavailable session id=s-disabled agent=disabled-agent:', expect.any(Error) ) warnSpy.mockRestore() diff --git a/test/main/presenter/agentSessionPresenter/integration.test.ts b/test/main/presenter/agentSessionPresenter/integration.test.ts index 2354aa7c2..348253f40 100644 --- a/test/main/presenter/agentSessionPresenter/integration.test.ts +++ b/test/main/presenter/agentSessionPresenter/integration.test.ts @@ -10,6 +10,7 @@ import logger from '@shared/logger' import { toAppSessionId } from '@/agent/shared/agentSessionIds' import type { DeepChatActiveGeneration } from '@/agent/deepchat/instance/deepChatAgentInstance' import { createDeepChatAgentBackendFixture } from '../../agent/manager/deepChatAgentBackendFixture' +import { createProjectionCoordinatorFixture } from './projectionCoordinatorFixture' vi.mock('nanoid', () => { let counter = 0 @@ -681,23 +682,34 @@ describe('Integration: createSession end-to-end', () => { sqlitePresenter, createMockToolPresenter() ) + const agentManager = createDeepChatManager(deepchatAgent, sqlitePresenter) as any + const appSessionService = new AppSessionService({ + newSessionsTable: sqlitePresenter.newSessionsTable, + deepchatSessionMetadataTable: sqlitePresenter.deepchatSessionMetadataTable, + deepchatSearchDocumentsTable: sqlitePresenter.deepchatSearchDocumentsTable, + newEnvironmentsTable: sqlitePresenter.newEnvironmentsTable + }) + const sharedData = { + sessionState: deepchatAgent, + transcript: deepchatAgent, + transcriptMutation: deepchatAgent, + tape: deepchatAgent + } agentPresenter = new AgentSessionPresenter( - createDeepChatManager(deepchatAgent, sqlitePresenter) as any, - new AppSessionService({ - newSessionsTable: sqlitePresenter.newSessionsTable, - deepchatSessionMetadataTable: sqlitePresenter.deepchatSessionMetadataTable, - deepchatSearchDocumentsTable: sqlitePresenter.deepchatSearchDocumentsTable, - newEnvironmentsTable: sqlitePresenter.newEnvironmentsTable - }), + agentManager, + appSessionService, llmProvider, configPresenter, sqlitePresenter, - { - sessionState: deepchatAgent, - transcript: deepchatAgent, - transcriptMutation: deepchatAgent, - tape: deepchatAgent - } + sharedData, + createProjectionCoordinatorFixture({ + agentManager, + appSessionService, + llmProviderPresenter: llmProvider, + configPresenter, + sqlitePresenter, + sharedData + }) ) }) @@ -844,23 +856,34 @@ describe('Integration: ACP hooks bridge', () => { createMockToolPresenter(), new NewSessionHooksBridge(hookDispatcher) ) + const agentManager = createDeepChatManager(deepchatAgent, sqlitePresenter) as any + const appSessionService = new AppSessionService({ + newSessionsTable: sqlitePresenter.newSessionsTable, + deepchatSessionMetadataTable: sqlitePresenter.deepchatSessionMetadataTable, + deepchatSearchDocumentsTable: sqlitePresenter.deepchatSearchDocumentsTable, + newEnvironmentsTable: sqlitePresenter.newEnvironmentsTable + }) + const sharedData = { + sessionState: deepchatAgent, + transcript: deepchatAgent, + transcriptMutation: deepchatAgent, + tape: deepchatAgent + } agentPresenter = new AgentSessionPresenter( - createDeepChatManager(deepchatAgent, sqlitePresenter) as any, - new AppSessionService({ - newSessionsTable: sqlitePresenter.newSessionsTable, - deepchatSessionMetadataTable: sqlitePresenter.deepchatSessionMetadataTable, - deepchatSearchDocumentsTable: sqlitePresenter.deepchatSearchDocumentsTable, - newEnvironmentsTable: sqlitePresenter.newEnvironmentsTable - }), + agentManager, + appSessionService, llmProvider, configPresenter, sqlitePresenter, - { - sessionState: deepchatAgent, - transcript: deepchatAgent, - transcriptMutation: deepchatAgent, - tape: deepchatAgent - }, + sharedData, + createProjectionCoordinatorFixture({ + agentManager, + appSessionService, + llmProviderPresenter: llmProvider, + configPresenter, + sqlitePresenter, + sharedData + }), undefined, { acpAsLlmProviderSessionControl: llmProvider } ) @@ -937,23 +960,34 @@ describe('Integration: multi-turn context', () => { sqlitePresenter, createMockToolPresenter() ) + const agentManager = createDeepChatManager(deepchatAgent, sqlitePresenter) as any + const appSessionService = new AppSessionService({ + newSessionsTable: sqlitePresenter.newSessionsTable, + deepchatSessionMetadataTable: sqlitePresenter.deepchatSessionMetadataTable, + deepchatSearchDocumentsTable: sqlitePresenter.deepchatSearchDocumentsTable, + newEnvironmentsTable: sqlitePresenter.newEnvironmentsTable + }) + const sharedData = { + sessionState: deepchatAgent, + transcript: deepchatAgent, + transcriptMutation: deepchatAgent, + tape: deepchatAgent + } agentPresenter = new AgentSessionPresenter( - createDeepChatManager(deepchatAgent, sqlitePresenter) as any, - new AppSessionService({ - newSessionsTable: sqlitePresenter.newSessionsTable, - deepchatSessionMetadataTable: sqlitePresenter.deepchatSessionMetadataTable, - deepchatSearchDocumentsTable: sqlitePresenter.deepchatSearchDocumentsTable, - newEnvironmentsTable: sqlitePresenter.newEnvironmentsTable - }), + agentManager, + appSessionService, llmProvider, configPresenter, sqlitePresenter, - { - sessionState: deepchatAgent, - transcript: deepchatAgent, - transcriptMutation: deepchatAgent, - tape: deepchatAgent - } + sharedData, + createProjectionCoordinatorFixture({ + agentManager, + appSessionService, + llmProviderPresenter: llmProvider, + configPresenter, + sqlitePresenter, + sharedData + }) ) }) diff --git a/test/main/presenter/agentSessionPresenter/projectionCoordinatorFixture.ts b/test/main/presenter/agentSessionPresenter/projectionCoordinatorFixture.ts new file mode 100644 index 000000000..ac003953f --- /dev/null +++ b/test/main/presenter/agentSessionPresenter/projectionCoordinatorFixture.ts @@ -0,0 +1,50 @@ +import type { AgentManager } from '@/agent/manager/agentManager' +import type { AgentSharedDataPorts } from '@/agent/shared/agentSharedData' +import type { AppSessionService } from '@/agent/shared/appSessionService' +import { toAppSessionId } from '@/agent/shared/agentSessionIds' +import type { IConfigPresenter, ILlmProviderPresenter } from '@shared/presenter' +import { publishDeepchatEvent } from '@/routes/publishDeepchatEvent' +import type { SQLitePresenter } from '@/presenter/sqlitePresenter' +import { SessionProjectionCoordinator } from '@/presenter/sessionApplication/projectionCoordinator' + +export const createProjectionCoordinatorFixture = (input: { + agentManager: AgentManager + appSessionService: AppSessionService + llmProviderPresenter: ILlmProviderPresenter + configPresenter: IConfigPresenter + sqlitePresenter: SQLitePresenter + sharedData: AgentSharedDataPorts + sessionUiPort?: { refreshSessionUi(): void } +}): SessionProjectionCoordinator => + new SessionProjectionCoordinator({ + sessions: input.appSessionService, + runtime: { + getAgentKind: (agentId) => input.agentManager.resolveBackend(agentId).kind, + snapshot: async (sessionId, options) => + await input.agentManager + .resolveSessionHandle(toAppSessionId(sessionId)) + .handle.snapshot(options), + waitForFirstTurnReady: async (sessionId, options) => + await input.agentManager + .resolveSessionHandle(toAppSessionId(sessionId)) + .handle.waitForFirstTurnReady(options) + }, + transcript: input.sharedData.transcript, + tape: input.sharedData.tape, + messages: input.sqlitePresenter.deepchatMessagesTable, + searchResults: input.sqlitePresenter.deepchatMessageSearchResultsTable, + traces: input.sqlitePresenter.deepchatMessageTracesTable, + titles: input.llmProviderPresenter, + agentConfig: { + getAssistantModel: async (agentId) => { + if (typeof input.configPresenter.resolveDeepChatAgentConfig !== 'function') return null + return ( + (await input.configPresenter.resolveDeepChatAgentConfig(agentId))?.assistantModel ?? null + ) + } + }, + events: { + publish: (payload) => publishDeepchatEvent('sessions.updated', payload) + }, + ui: input.sessionUiPort ?? { refreshSessionUi: () => undefined } + }) diff --git a/test/main/presenter/agentSessionPresenter/usageDashboard.test.ts b/test/main/presenter/agentSessionPresenter/usageDashboard.test.ts index 012b000d9..803eb28c6 100644 --- a/test/main/presenter/agentSessionPresenter/usageDashboard.test.ts +++ b/test/main/presenter/agentSessionPresenter/usageDashboard.test.ts @@ -4,6 +4,7 @@ import { AgentSessionPresenter } from '@/presenter/agentSessionPresenter/index' import { DeepChatMessageStore } from '@/presenter/agentRuntimePresenter/messageStore' import { DASHBOARD_STATS_BACKFILL_KEY, type UsageStatsRecordInput } from '@/presenter/usageStats' import { createDeepChatAgentBackendFixture } from '../../agent/manager/deepChatAgentBackendFixture' +import { createProjectionCoordinatorFixture } from './projectionCoordinatorFixture' vi.mock('@/eventbus', () => ({ eventBus: { sendToMain: vi.fn(), on: vi.fn() } @@ -447,29 +448,41 @@ describe('AgentSessionPresenter usage dashboard', () => { const sqlitePresenter = createMockSqlitePresenter() const configPresenter = createMockConfigPresenter() const deepChatAgent = createMockDeepChatAgent() + const agentManager = { + resolveBackend: () => ({ + kind: 'deepchat', + descriptor: { id: 'deepchat', kind: 'deepchat', source: 'builtin', config: {} }, + backend: createDeepChatAgentBackendFixture(deepChatAgent as never) + }) + } as any + const appSessionService = new AppSessionService({ + newSessionsTable: sqlitePresenter.newSessionsTable, + deepchatSessionMetadataTable: sqlitePresenter.deepchatSessionMetadataTable, + deepchatSearchDocumentsTable: sqlitePresenter.deepchatSearchDocumentsTable, + newEnvironmentsTable: sqlitePresenter.newEnvironmentsTable + }) + const llmProviderPresenter = createMockLlmProviderPresenter() as any + const sharedData = { + sessionState: deepChatAgent, + transcript: deepChatAgent, + transcriptMutation: deepChatAgent, + tape: deepChatAgent + } as any const presenter = new AgentSessionPresenter( - { - resolveBackend: () => ({ - kind: 'deepchat', - descriptor: { id: 'deepchat', kind: 'deepchat', source: 'builtin', config: {} }, - backend: createDeepChatAgentBackendFixture(deepChatAgent as never) - }) - } as any, - new AppSessionService({ - newSessionsTable: sqlitePresenter.newSessionsTable, - deepchatSessionMetadataTable: sqlitePresenter.deepchatSessionMetadataTable, - deepchatSearchDocumentsTable: sqlitePresenter.deepchatSearchDocumentsTable, - newEnvironmentsTable: sqlitePresenter.newEnvironmentsTable - }), - createMockLlmProviderPresenter() as any, + agentManager, + appSessionService, + llmProviderPresenter, configPresenter as any, sqlitePresenter, - { - sessionState: deepChatAgent, - transcript: deepChatAgent, - transcriptMutation: deepChatAgent, - tape: deepChatAgent - } as any + sharedData, + createProjectionCoordinatorFixture({ + agentManager, + appSessionService, + llmProviderPresenter, + configPresenter: configPresenter as any, + sqlitePresenter, + sharedData + }) ) return { diff --git a/test/main/presenter/sessionApplication/projectionCoordinator.test.ts b/test/main/presenter/sessionApplication/projectionCoordinator.test.ts new file mode 100644 index 000000000..512a21e92 --- /dev/null +++ b/test/main/presenter/sessionApplication/projectionCoordinator.test.ts @@ -0,0 +1,424 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ChatMessageRecord, SessionRecord } from '@shared/types/agent-interface' +import { + SessionProjectionCoordinator, + type SessionProjectionCoordinatorDependencies +} from '@/presenter/sessionApplication/projectionCoordinator' + +const createSessionRecord = (overrides: Partial = {}): SessionRecord => ({ + id: 's1', + agentId: 'deepchat', + title: 'Original title', + projectDir: null, + isPinned: false, + isDraft: false, + sessionKind: 'regular', + parentSessionId: null, + subagentEnabled: false, + subagentMeta: null, + createdAt: 100, + updatedAt: 200, + ...overrides +}) + +const createMessage = (overrides: Partial = {}): ChatMessageRecord => ({ + id: 'm1', + sessionId: 's1', + orderSeq: 1, + role: 'user', + content: JSON.stringify({ text: 'Summarize this session', files: [] }), + status: 'sent', + isContextEdge: 0, + metadata: '{}', + createdAt: 100, + updatedAt: 100, + ...overrides +}) + +function createHarness() { + const records = new Map([['s1', createSessionRecord()]]) + const bindings = new Map() + const sessions = { + get: vi.fn((sessionId: string) => records.get(sessionId) ?? null), + getMany: vi.fn((sessionIds: string[]) => + sessionIds.flatMap((sessionId) => { + const record = records.get(sessionId) + return record ? [record] : [] + }) + ), + list: vi.fn(() => [...records.values()]), + listPage: vi.fn(() => ({ + records: [...records.values()], + nextCursor: null, + hasMore: false + })), + update: vi.fn((sessionId: string, fields: Partial) => { + const record = records.get(sessionId) + if (record) records.set(sessionId, { ...record, ...fields }) + }), + bindWindow: vi.fn((webContentsId: number, sessionId: string) => { + bindings.set(webContentsId, sessionId) + }), + unbindWindow: vi.fn((webContentsId: number) => { + bindings.set(webContentsId, null) + }), + getActiveSessionId: vi.fn((webContentsId: number) => bindings.get(webContentsId) ?? null) + } + const runtime = { + getAgentKind: vi.fn(() => 'deepchat' as const), + snapshot: vi.fn().mockResolvedValue({ + status: 'idle', + providerId: 'openai', + modelId: 'gpt-4', + permissionMode: 'full_access' + }), + waitForFirstTurnReady: vi.fn().mockResolvedValue(true) + } + const transcript = { + getMessages: vi.fn().mockResolvedValue([]), + listMessagesPage: vi.fn().mockResolvedValue({ + messages: [], + nextCursor: null, + hasMore: false + }), + getMessageIds: vi.fn().mockResolvedValue([]), + getMessage: vi.fn().mockResolvedValue(null) + } + const tape = { + getTapeInfo: vi.fn().mockResolvedValue({}), + searchTape: vi.fn().mockResolvedValue([]), + getTapeContext: vi.fn().mockResolvedValue({}), + listTapeAnchors: vi.fn().mockResolvedValue([]), + handoffTape: vi.fn().mockResolvedValue({}), + listMessageViewManifests: vi.fn().mockResolvedValue([]), + exportMessageTapeReplaySlice: vi.fn().mockResolvedValue(null) + } + const messages = { get: vi.fn() } + const searchResults = { listByMessageId: vi.fn().mockReturnValue([]) } + const traces = { + listByMessageId: vi.fn().mockReturnValue([]), + countByMessageId: vi.fn().mockReturnValue(0) + } + const titles = { summaryTitles: vi.fn().mockResolvedValue('Generated title') } + const agentConfig = { getAssistantModel: vi.fn().mockResolvedValue(null) } + const events = { publish: vi.fn() } + const ui = { refreshSessionUi: vi.fn() } + const dependencies = { + sessions, + runtime, + transcript, + tape, + messages, + searchResults, + traces, + titles, + agentConfig, + events, + ui + } as unknown as SessionProjectionCoordinatorDependencies + + return { + coordinator: new SessionProjectionCoordinator(dependencies), + records, + bindings, + sessions, + runtime, + transcript, + tape, + messages, + searchResults, + traces, + titles, + agentConfig, + events, + ui + } +} + +describe('SessionProjectionCoordinator', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('materializes full/list state and keeps lightweight reads non-hydrating', async () => { + const harness = createHarness() + harness.runtime.snapshot.mockResolvedValueOnce({ + status: 'generating', + providerId: 'anthropic', + modelId: 'claude', + permissionMode: 'full_access' + }) + + await expect(harness.coordinator.getSession('s1')).resolves.toMatchObject({ + status: 'generating', + providerId: 'anthropic', + modelId: 'claude' + }) + expect(harness.runtime.snapshot).toHaveBeenCalledWith('s1', { lightweight: false }) + + harness.runtime.snapshot.mockClear() + await expect(harness.coordinator.listLightweight()).resolves.toMatchObject({ + items: [expect.objectContaining({ id: 's1', status: 'generating' })] + }) + expect(harness.runtime.snapshot).not.toHaveBeenCalled() + + harness.coordinator.forgetStatus(['s1']) + await expect(harness.coordinator.getLightweightByIds([' s1 ', 's1', ' '])).resolves.toEqual([ + expect.objectContaining({ id: 's1', status: 'idle' }) + ]) + expect(harness.sessions.getMany).toHaveBeenCalledWith(['s1']) + + await harness.coordinator.listSessions() + expect(harness.runtime.snapshot).toHaveBeenCalledWith('s1', { lightweight: true }) + }) + + it('skips unavailable full projections without poisoning lightweight status', async () => { + const harness = createHarness() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + harness.runtime.snapshot.mockRejectedValueOnce(new Error('backend unavailable')) + + await expect(harness.coordinator.listSessions()).resolves.toEqual([]) + await expect(harness.coordinator.listLightweight()).resolves.toMatchObject({ + items: [expect.objectContaining({ id: 's1', status: 'idle' })] + }) + expect(warn).toHaveBeenCalledWith( + '[SessionProjectionCoordinator] Skipping unavailable session id=s1 agent=deepchat:', + expect.any(Error) + ) + warn.mockRestore() + }) + + it('dedupes and orders lightweight rows while filtering prioritized subagents', async () => { + const harness = createHarness() + const newer = createSessionRecord({ id: 's2', updatedAt: 300 }) + const child = createSessionRecord({ id: 'child', sessionKind: 'subagent', updatedAt: 400 }) + harness.records.set('s2', newer) + harness.records.set('child', child) + harness.sessions.listPage.mockReturnValue({ + records: [createSessionRecord(), newer], + nextCursor: { updatedAt: 200, id: 's1' }, + hasMore: true + }) + + await expect( + harness.coordinator.listLightweight({ prioritizeSessionId: 'child' }) + ).resolves.toEqual({ + items: [expect.objectContaining({ id: 's2' }), expect.objectContaining({ id: 's1' })], + nextCursor: { updatedAt: 200, id: 's1' }, + hasMore: true + }) + }) + + it('owns message, Tape, search-result, trace, manifest, and replay reads', async () => { + const harness = createHarness() + const message = createMessage() + harness.transcript.getMessages.mockResolvedValue([message]) + harness.transcript.getMessageIds.mockResolvedValue(['m1']) + harness.transcript.getMessage.mockResolvedValue(message) + harness.messages.get.mockReturnValue({ session_id: 's1' }) + harness.tape.getTapeInfo.mockResolvedValue({ sessionId: 's1' }) + harness.tape.searchTape.mockResolvedValue([{ entryId: 1 }]) + harness.tape.getTapeContext.mockResolvedValue({ sessionId: 's1' }) + harness.tape.listTapeAnchors.mockResolvedValue([{ name: 'checkpoint' }]) + harness.tape.handoffTape.mockResolvedValue({ name: 'handoff' }) + harness.tape.listMessageViewManifests.mockResolvedValue([{ id: 'view-1' }]) + harness.tape.exportMessageTapeReplaySlice.mockResolvedValue({ version: 1 }) + harness.searchResults.listByMessageId.mockReturnValue([ + { content: '{', rank: 1, search_id: 'new' }, + { + content: JSON.stringify({ title: 'Legacy', url: 'https://example.com' }), + rank: 2, + search_id: null + } + ]) + harness.traces.listByMessageId.mockReturnValue([ + { + id: 'trace-1', + message_id: 'm1', + session_id: 's1', + provider_id: 'openai', + model_id: 'gpt-4', + request_seq: 2, + endpoint: '/responses', + headers_json: '{}', + body_json: '{}', + truncated: 1, + created_at: 123 + } + ]) + harness.traces.countByMessageId.mockReturnValue(1) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await expect(harness.coordinator.getMessages('s1')).resolves.toEqual([message]) + await harness.coordinator.listMessagesPage('s1', { limit: 10 }) + await expect(harness.coordinator.getMessageIds('s1')).resolves.toEqual(['m1']) + await expect(harness.coordinator.getMessage('m1')).resolves.toBe(message) + await expect(harness.coordinator.getTapeInfo('s1')).resolves.toEqual({ sessionId: 's1' }) + await harness.coordinator.searchTape('s1', 'needle') + await harness.coordinator.getTapeContext('s1', [1]) + await harness.coordinator.listTapeAnchors('s1') + await harness.coordinator.handoffTape('s1', 'handoff') + await expect(harness.coordinator.listMessageViewManifests(' m1 ')).resolves.toEqual([ + { id: 'view-1' } + ]) + await expect(harness.coordinator.exportMessageTapeReplaySlice('m1')).resolves.toEqual({ + version: 1 + }) + await expect(harness.coordinator.getSearchResults(' m1 ', 'missing')).resolves.toEqual([ + expect.objectContaining({ title: 'Legacy', rank: 2, searchId: undefined }) + ]) + await expect(harness.coordinator.listMessageTraces('m1')).resolves.toEqual([ + expect.objectContaining({ id: 'trace-1', truncated: true, requestSeq: 2 }) + ]) + await expect(harness.coordinator.getMessageTraceCount(' m1 ')).resolves.toBe(1) + await expect(harness.coordinator.getMessages('missing')).rejects.toThrow( + 'Session not found: missing' + ) + expect(warn).toHaveBeenCalledWith( + '[SessionProjectionCoordinator] Failed to parse search result row:', + expect.any(SyntaxError) + ) + warn.mockRestore() + }) + + it('falls back when manifest and replay projections fail or lose their session', async () => { + const harness = createHarness() + harness.messages.get.mockReturnValue({ session_id: 's1' }) + harness.tape.listMessageViewManifests.mockRejectedValue(new Error('manifest failed')) + harness.tape.exportMessageTapeReplaySlice.mockRejectedValue(new Error('replay failed')) + + await expect(harness.coordinator.listMessageViewManifests('m1')).resolves.toEqual([]) + await expect(harness.coordinator.exportMessageTapeReplaySlice('m1')).resolves.toBeNull() + + harness.records.delete('s1') + harness.tape.listMessageViewManifests.mockClear() + await expect(harness.coordinator.listMessageViewManifests('m1')).resolves.toEqual([]) + expect(harness.tape.listMessageViewManifests).not.toHaveBeenCalled() + }) + + it('keeps active bindings per window and silently unbinds failed projections', async () => { + const harness = createHarness() + + await harness.coordinator.activate(1, 'missing') + await harness.coordinator.activate(2, 's1') + expect(harness.coordinator.getActiveId(1)).toBe('missing') + expect(harness.coordinator.getActiveId(2)).toBe('s1') + expect(harness.sessions.get).not.toHaveBeenCalled() + expect(harness.ui.refreshSessionUi).not.toHaveBeenCalled() + + harness.events.publish.mockClear() + await expect(harness.coordinator.getActive(1)).resolves.toBeNull() + expect(harness.coordinator.getActiveId(1)).toBeNull() + expect(harness.events.publish).not.toHaveBeenCalled() + + await harness.coordinator.deactivate(2) + expect(harness.events.publish).toHaveBeenCalledWith({ + sessionIds: [], + reason: 'deactivated', + activeSessionId: null, + webContentsId: 2 + }) + }) + + it('owns rename, pin, normalized updates, and UI refresh', async () => { + const harness = createHarness() + + await harness.coordinator.renameSession('s1', ' Renamed ') + await harness.coordinator.toggleSessionPinned('s1', true) + expect(harness.records.get('s1')).toMatchObject({ title: 'Renamed', isPinned: true }) + + harness.events.publish.mockClear() + harness.ui.refreshSessionUi.mockClear() + harness.coordinator.notify({ sessionIds: [' s1 ', 's1', ' ', 's2'] }) + expect(harness.events.publish).toHaveBeenCalledWith({ + sessionIds: ['s1', 's2'], + reason: 'updated', + activeSessionId: undefined, + webContentsId: undefined + }) + expect(harness.ui.refreshSessionUi).toHaveBeenCalledOnce() + + harness.coordinator.notify() + expect(harness.events.publish).toHaveBeenLastCalledWith({ + sessionIds: [], + reason: 'list-refreshed', + activeSessionId: undefined, + webContentsId: undefined + }) + }) + + it('uses the preferred title model, falls back, normalizes, and checks both CAS points', async () => { + const harness = createHarness() + harness.transcript.getMessages.mockResolvedValue([createMessage()]) + harness.agentConfig.getAssistantModel.mockResolvedValue({ + providerId: 'anthropic', + modelId: 'claude-assistant' + }) + harness.titles.summaryTitles + .mockRejectedValueOnce(new Error('assistant unavailable')) + .mockResolvedValueOnce(`"${'x'.repeat(100)}"`) + + harness.coordinator.scheduleTitleGeneration({ + sessionId: 's1', + initialTitle: 'Original title', + fallbackProviderId: 'openai', + fallbackModelId: 'gpt-4' + }) + + await vi.waitFor(() => expect(harness.records.get('s1')?.title).toBe('x'.repeat(80))) + expect(harness.titles.summaryTitles).toHaveBeenNthCalledWith( + 1, + expect.any(Array), + 'anthropic', + 'claude-assistant' + ) + expect(harness.titles.summaryTitles).toHaveBeenNthCalledWith( + 2, + expect.any(Array), + 'openai', + 'gpt-4' + ) + + const firstCas = createHarness() + let resolveMessages: (messages: ChatMessageRecord[]) => void = () => undefined + firstCas.transcript.getMessages.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveMessages = resolve + }) + ) + firstCas.coordinator.scheduleTitleGeneration({ + sessionId: 's1', + initialTitle: 'Original title', + fallbackProviderId: 'openai', + fallbackModelId: 'gpt-4' + }) + firstCas.records.set('s1', createSessionRecord({ title: 'Manual title' })) + resolveMessages([createMessage()]) + await vi.waitFor(() => expect(firstCas.transcript.getMessages).toHaveBeenCalled()) + expect(firstCas.titles.summaryTitles).not.toHaveBeenCalled() + + const secondCas = createHarness() + secondCas.transcript.getMessages.mockResolvedValue([createMessage()]) + let resolveTitle: (title: string) => void = () => undefined + secondCas.titles.summaryTitles.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveTitle = resolve + }) + ) + secondCas.coordinator.scheduleTitleGeneration({ + sessionId: 's1', + initialTitle: 'Original title', + fallbackProviderId: 'openai', + fallbackModelId: 'gpt-4' + }) + await vi.waitFor(() => expect(secondCas.titles.summaryTitles).toHaveBeenCalledOnce()) + secondCas.records.set('s1', createSessionRecord({ title: 'Manual title' })) + resolveTitle('Generated title') + await vi.waitFor(() => expect(secondCas.records.get('s1')?.title).toBe('Manual title')) + expect(secondCas.sessions.update).not.toHaveBeenCalledWith('s1', { + title: 'Generated title' + }) + }) +}) From cf610d510fa9e99ed708ea6da8c124e0c8018e4a Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 13 Jul 2026 18:31:20 +0800 Subject: [PATCH 07/29] refactor(session): extract assignment --- .../session-application-coordinators/tasks.md | 12 +- .../presenter/agentSessionPresenter/index.ts | 1133 ++--------------- src/main/presenter/index.ts | 55 +- .../agentAssignmentCoordinator.ts | 597 +++++++++ .../agentAssignmentPolicy.ts | 240 ++++ .../lifecycleDeletionTransaction.ts | 59 + .../presenter/sessionApplication/ports.ts | 169 ++- .../agentSessionPresenter.test.ts | 96 +- .../assignmentCoordinatorFixture.ts | 82 ++ .../agentSessionPresenter/integration.test.ts | 90 +- .../usageDashboard.test.ts | 30 +- .../agentAssignmentCoordinator.test.ts | 362 ++++++ .../agentAssignmentPolicy.test.ts | 164 +++ .../lifecycleDeletionTransaction.test.ts | 98 ++ 14 files changed, 2103 insertions(+), 1084 deletions(-) create mode 100644 src/main/presenter/sessionApplication/agentAssignmentCoordinator.ts create mode 100644 src/main/presenter/sessionApplication/agentAssignmentPolicy.ts create mode 100644 src/main/presenter/sessionApplication/lifecycleDeletionTransaction.ts create mode 100644 test/main/presenter/agentSessionPresenter/assignmentCoordinatorFixture.ts create mode 100644 test/main/presenter/sessionApplication/agentAssignmentCoordinator.test.ts create mode 100644 test/main/presenter/sessionApplication/agentAssignmentPolicy.test.ts create mode 100644 test/main/presenter/sessionApplication/lifecycleDeletionTransaction.test.ts diff --git a/docs/architecture/session-application-coordinators/tasks.md b/docs/architecture/session-application-coordinators/tasks.md index 6173cc80c..95440f5b4 100644 --- a/docs/architecture/session-application-coordinators/tasks.md +++ b/docs/architecture/session-application-coordinators/tasks.md @@ -27,12 +27,12 @@ ## 3. SessionAgentAssignmentCoordinator -- [ ] Extract focused create/subagent/transfer assignment policy. -- [ ] Extract transfer impact, batch/single transfer, and agent-session deletion orchestration. -- [ ] Extract model/project/permission/generation/tools/subagent settings and ACP controls. -- [ ] Extract subagent Tape merge/discard. -- [ ] Use narrow lifecycle deletion and projection mutation ports without circular construction. -- [ ] Rewire compatibility presenter forwarding and move owner tests. +- [x] Extract focused create/subagent/transfer assignment policy. +- [x] Extract transfer impact, batch/single transfer, and agent-session deletion orchestration. +- [x] Extract model/project/permission/generation/tools/subagent settings and ACP controls. +- [x] Extract subagent Tape merge/discard. +- [x] Use narrow lifecycle deletion and projection mutation ports without circular construction. +- [x] Rewire compatibility presenter forwarding and move owner tests. ## 4. SessionTurnCoordinator diff --git a/src/main/presenter/agentSessionPresenter/index.ts b/src/main/presenter/agentSessionPresenter/index.ts index 7c560e064..d8fd79ede 100644 --- a/src/main/presenter/agentSessionPresenter/index.ts +++ b/src/main/presenter/agentSessionPresenter/index.ts @@ -1,6 +1,5 @@ import logger from '@shared/logger' import type { AgentManager } from '@/agent/manager/agentManager' -import type { DirectAcpSessionHandle } from '@/agent/manager/sessionHandles' import type { Agent, AgentTapeAnchorResult, @@ -10,9 +9,7 @@ import type { AgentTapeInfo, AgentTapeSearchOptions, AgentTapeSearchResult, - AgentTransferBlockReason, AgentTransferImpact, - AgentTransferImpactSample, ChatMessagePageResult, SessionListItem, SessionLightweightListResult, @@ -69,6 +66,11 @@ import type { AgentSharedDataPorts } from '@/agent/shared/agentSharedData' import { toAppSessionId } from '@/agent/shared/agentSessionIds' import { LegacyChatImportService } from './legacyImportService' import { SessionProjectionCoordinator } from '../sessionApplication/projectionCoordinator' +import { SessionAgentAssignmentCoordinator } from '../sessionApplication/agentAssignmentCoordinator' +import type { + SessionAssignmentPolicyPort, + SessionLifecycleDeletionPort +} from '../sessionApplication/ports' import { buildConversationExportContent, generateExportFilename, @@ -87,8 +89,7 @@ import { resolveUsageProviderId } from '../usageStats' import { rtkRuntimeService } from '@/agent/shared/process/rtkRuntimeService' -import { resolveAcpAgentAlias } from '@shared/utils/acpAgentAlias' -import type { AcpAsLlmProviderSessionControlPort, SessionPermissionPort } from '../runtimePorts' +import type { SessionPermissionPort } from '../runtimePorts' type SearchableSessionRow = { id: string @@ -106,34 +107,10 @@ type SearchableMessageRow = { updatedAt: number } -type AgentTransferTargetContext = { - agentId: string - agentType: 'deepchat' - providerId: string - modelId: string - projectDir: string | null - permissionMode: PermissionMode - generationSettings?: Partial - disabledAgentTools: string[] - subagentEnabled: boolean -} - const SUBAGENT_SESSION_INIT_MAX_ATTEMPTS = 2 const SQLITE_MAINLINE_NORMALIZATION_KEY = 'sqlite-mainline-normalization-v1' const DISABLED_SEARCH_TOOL_CLEANUP_KEY = 'agent-disabled-search-tool-cleanup-v1' -function normalizePermissionMode(mode: PermissionMode | null | undefined): PermissionMode { - return mode === 'default' || mode === 'auto_approve' ? mode : 'full_access' -} - -const RETIRED_DEFAULT_AGENT_TOOLS = new Set(['find', 'ls']) -const LEGACY_PERSISTED_DISABLED_AGENT_TOOLS = new Set(['find', 'grep', 'ls']) -const LEGACY_AGENT_TOOL_NAME_MAP: Record = { - yo_browser_cdp_send: 'cdp_send', - yo_browser_window_open: 'load_url', - yo_browser_window_list: 'get_browser_status' -} - const clampHistorySearchLimit = (value: number | undefined): number => { if (typeof value !== 'number' || Number.isNaN(value)) { return 12 @@ -268,8 +245,10 @@ export class AgentSessionPresenter { private sharedData: AgentSharedDataPorts private legacyImportService: LegacyChatImportService private sessionProjection: SessionProjectionCoordinator + private sessionAssignmentPolicy: SessionAssignmentPolicyPort + private sessionAssignment: SessionAgentAssignmentCoordinator + private sessionDeletion: SessionLifecycleDeletionPort private skillPresenter?: Pick - private acpAsLlmProviderSessionControl?: AcpAsLlmProviderSessionControlPort private sessionPermissionPort?: SessionPermissionPort private usageStatsBackfillPromise: Promise | null = null private mainlineNormalizationPromise: Promise | null = null @@ -283,9 +262,11 @@ export class AgentSessionPresenter { sqlitePresenter: SQLitePresenter, sharedData: AgentSharedDataPorts, sessionProjection: SessionProjectionCoordinator, + sessionAssignmentPolicy: SessionAssignmentPolicyPort, + sessionAssignment: SessionAgentAssignmentCoordinator, + sessionDeletion: SessionLifecycleDeletionPort, skillPresenter?: Pick, runtimePorts?: { - acpAsLlmProviderSessionControl?: AcpAsLlmProviderSessionControlPort sessionPermissionPort?: SessionPermissionPort } ) { @@ -295,10 +276,12 @@ export class AgentSessionPresenter { this.configPresenter = configPresenter this.sharedData = sharedData this.sessionProjection = sessionProjection + this.sessionAssignmentPolicy = sessionAssignmentPolicy + this.sessionAssignment = sessionAssignment + this.sessionDeletion = sessionDeletion this.skillPresenter = skillPresenter this.sessionManager = appSessionService this.legacyImportService = new LegacyChatImportService(sqlitePresenter) - this.acpAsLlmProviderSessionControl = runtimePorts?.acpAsLlmProviderSessionControl this.sessionPermissionPort = runtimePorts?.sessionPermissionPort } @@ -306,62 +289,33 @@ export class AgentSessionPresenter { async createSession(input: CreateSessionInput, webContentsId: number): Promise { const requestedAgentId = input.agentId || 'deepchat' - const resolvedAgent = this.agentManager.resolveBackend(requestedAgentId) - const agentId = resolvedAgent.descriptor.id + const assignment = await this.sessionAssignmentPolicy.resolveCreateAssignment({ + agentId: requestedAgentId, + providerId: input.providerId, + modelId: input.modelId, + projectDir: input.projectDir, + permissionMode: input.permissionMode, + generationSettings: input.generationSettings, + disabledAgentTools: input.disabledAgentTools, + subagentEnabled: input.subagentEnabled, + preserveExplicitNullProjectDir: true + }) + const { + agentId, + providerId, + modelId, + projectDir, + permissionMode, + generationSettings, + disabledAgentTools, + subagentEnabled + } = assignment logger.info( `[AgentSessionPresenter] createSession agent=${agentId} webContentsId=${webContentsId}` ) const normalizedInput = this.normalizeCreateSessionInput(input) - const agentType = resolvedAgent.kind - const deepChatAgentConfig = - agentType === 'deepchat' ? await this.resolveDeepChatAgentConfigCompat(agentId) : null - const projectDir = this.resolveCreateSessionProjectDir( - input.projectDir, - deepChatAgentConfig?.defaultProjectPath - ) - const disabledAgentTools = - agentType === 'deepchat' - ? this.normalizeDisabledAgentTools( - input.disabledAgentTools ?? deepChatAgentConfig?.disabledAgentTools - ) - : [] - const subagentEnabled = this.resolveSessionSubagentEnabled( - agentType, - input.subagentEnabled, - deepChatAgentConfig?.subagentEnabled - ) - - // Resolve provider/model - const defaultModel = this.configPresenter.getDefaultModel() - const providerId = - agentType === 'acp' - ? 'acp' - : (input.providerId ?? - deepChatAgentConfig?.defaultModelPreset?.providerId ?? - defaultModel?.providerId ?? - '') - const modelId = - agentType === 'acp' - ? agentId - : (input.modelId ?? - deepChatAgentConfig?.defaultModelPreset?.modelId ?? - defaultModel?.modelId ?? - '') - const permissionMode = - input.permissionMode !== undefined - ? normalizePermissionMode(input.permissionMode) - : normalizePermissionMode(deepChatAgentConfig?.permissionMode) - const generationSettings = this.mergeDeepChatDefaultGenerationSettings( - deepChatAgentConfig, - input.generationSettings - ) logger.info(`[AgentSessionPresenter] resolved provider=${providerId} model=${modelId}`) - if (!providerId || !modelId) { - throw new Error('No provider or model configured. Please set a default model in settings.') - } - this.assertAcpSessionHasWorkdir(providerId, projectDir) - // Create session record const title = normalizedInput.text.slice(0, 50) || 'New Chat' const sessionId = this.sessionManager.create(agentId, title, projectDir, { @@ -454,56 +408,27 @@ export class AgentSessionPresenter { async createDetachedSession(input: CreateDetachedSessionInput): Promise { const requestedAgentId = input.agentId?.trim() || 'deepchat' - const resolvedAgent = this.agentManager.resolveBackend(requestedAgentId) - const agentId = resolvedAgent.descriptor.id const title = input.title?.trim() || 'New Chat' - const agentType = resolvedAgent.kind - const deepChatAgentConfig = - agentType === 'deepchat' ? await this.resolveDeepChatAgentConfigCompat(agentId) : null - const projectDir = - input.projectDir?.trim() || - deepChatAgentConfig?.defaultProjectPath?.trim() || - this.getDefaultProjectPathCompat() || - null - const disabledAgentTools = - agentType === 'deepchat' - ? this.normalizeDisabledAgentTools( - input.disabledAgentTools ?? deepChatAgentConfig?.disabledAgentTools - ) - : [] - const subagentEnabled = this.resolveSessionSubagentEnabled( - agentType, - input.subagentEnabled, - deepChatAgentConfig?.subagentEnabled - ) - const defaultModel = this.configPresenter.getDefaultModel() - const providerId = - agentType === 'acp' - ? 'acp' - : (input.providerId ?? - deepChatAgentConfig?.defaultModelPreset?.providerId ?? - defaultModel?.providerId ?? - '') - const modelId = - agentType === 'acp' - ? agentId - : (input.modelId ?? - deepChatAgentConfig?.defaultModelPreset?.modelId ?? - defaultModel?.modelId ?? - '') - const permissionMode = - input.permissionMode !== undefined - ? normalizePermissionMode(input.permissionMode) - : normalizePermissionMode(deepChatAgentConfig?.permissionMode) - const generationSettings = this.mergeDeepChatDefaultGenerationSettings( - deepChatAgentConfig, - input.generationSettings - ) - - if (!providerId || !modelId) { - throw new Error('No provider or model configured. Please set a default model in settings.') - } - this.assertAcpSessionHasWorkdir(providerId, projectDir) + const { + agentId, + providerId, + modelId, + projectDir, + permissionMode, + generationSettings, + disabledAgentTools, + subagentEnabled + } = await this.sessionAssignmentPolicy.resolveCreateAssignment({ + agentId: requestedAgentId, + providerId: input.providerId, + modelId: input.modelId, + projectDir: input.projectDir, + permissionMode: input.permissionMode, + generationSettings: input.generationSettings, + disabledAgentTools: input.disabledAgentTools, + subagentEnabled: input.subagentEnabled, + preserveExplicitNullProjectDir: false + }) const sessionId = this.sessionManager.create(agentId, title, projectDir, { isDraft: false, @@ -588,15 +513,22 @@ export class AgentSessionPresenter { throw new Error('Subagent session requires an agentId.') } - const runtimeConfig = await this.resolveSubagentSessionRuntimeConfig(input) const projectDir = input.projectDir?.trim() || null + const runtimeConfig = await this.sessionAssignmentPolicy.resolveSubagentAssignment({ + agentId, + targetAgentId: input.targetAgentId, + projectDir, + providerId: input.providerId, + modelId: input.modelId, + generationSettings: input.generationSettings, + disabledAgentTools: input.disabledAgentTools, + activeSkills: input.activeSkills + }) const subagentMeta: DeepChatSubagentMeta = { slotId, displayName, targetAgentId: runtimeConfig.targetAgentId || null } - this.assertAcpSessionHasWorkdir(runtimeConfig.providerId, projectDir) - let lastError: unknown = null for (let attempt = 1; attempt <= SUBAGENT_SESSION_INIT_MAX_ATTEMPTS; attempt += 1) { @@ -669,12 +601,8 @@ export class AgentSessionPresenter { throw new Error('ACP draft session requires a non-empty projectDir.') } - const resolvedAgent = this.agentManager.resolveBackend(agentId) - if (resolvedAgent.kind !== 'acp') { - throw new Error(`Agent ${agentId} is not an ACP agent.`) - } - const canonicalAgentId = resolvedAgent.descriptor.id - const permissionMode = normalizePermissionMode(input.permissionMode) + const { agentId: canonicalAgentId, permissionMode } = + this.sessionAssignmentPolicy.resolveAcpDraftAssignment(agentId, input.permissionMode) let record = await this.findReusableDraftSession(canonicalAgentId, projectDir) let createdDraftSession = false @@ -710,8 +638,7 @@ export class AgentSessionPresenter { }) } - const handle = this.requireDirectAcpHandle(record.id) - await handle.acp.prepare() + await this.sessionAssignment.prepareDirectAcpSession(record.id) this.sessionProjection.notify({ sessionIds: [record.id], reason: createdDraftSession ? 'created' : 'updated' @@ -754,8 +681,8 @@ export class AgentSessionPresenter { const hadMessages = await this.sharedData.transcript.hasMessages(sessionId) let providerId = state?.providerId ?? '' if (!providerId && handle.kind === 'acp') providerId = 'acp' - this.assertAcpSessionHasWorkdir(providerId, session.projectDir ?? null) - await this.syncAcpSessionWorkdir( + this.sessionAssignment.assertAcpSessionHasWorkdir(providerId, session.projectDir ?? null) + await this.sessionAssignment.syncAcpSessionWorkdir( providerId, sessionId, session.agentId, @@ -803,8 +730,8 @@ export class AgentSessionPresenter { const state = await handle.snapshot() let providerId = state?.providerId ?? '' if (!providerId && handle.kind === 'acp') providerId = 'acp' - this.assertAcpSessionHasWorkdir(providerId, session.projectDir ?? null) - await this.syncAcpSessionWorkdir( + this.sessionAssignment.assertAcpSessionHasWorkdir(providerId, session.projectDir ?? null) + await this.sessionAssignment.syncAcpSessionWorkdir( providerId, sessionId, session.agentId, @@ -845,8 +772,8 @@ export class AgentSessionPresenter { const { handle } = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)) let providerId = (await handle.snapshot())?.providerId ?? '' if (!providerId && handle.kind === 'acp') providerId = 'acp' - this.assertAcpSessionHasWorkdir(providerId, currentSession.projectDir ?? null) - await this.syncAcpSessionWorkdir( + this.sessionAssignment.assertAcpSessionHasWorkdir(providerId, currentSession.projectDir ?? null) + await this.sessionAssignment.syncAcpSessionWorkdir( providerId, sessionId, currentSession.agentId, @@ -1315,36 +1242,7 @@ export class AgentSessionPresenter { childSessionId: string, meta: Record = {} ): Promise { - const parentSession = this.sessionManager.get(parentSessionId) - if (!parentSession) { - throw new Error(`Session not found: ${parentSessionId}`) - } - - const childSession = this.sessionManager.get(childSessionId) - if (!childSession) { - throw new Error(`Session not found: ${childSessionId}`) - } - if (childSession.parentSessionId !== parentSessionId) { - throw new Error(`Session ${childSessionId} is not a child of ${parentSessionId}.`) - } - - const resolved = this.agentManager.resolveSubagentFacet(toAppSessionId(parentSessionId)) - switch (resolved.kind) { - case 'deepchat': - await resolved.facet.mergeTape( - toAppSessionId(parentSessionId), - toAppSessionId(childSessionId), - meta - ) - break - case 'acp': - await resolved.facet.mergeTape( - toAppSessionId(parentSessionId), - toAppSessionId(childSessionId), - meta - ) - break - } + await this.sessionAssignment.mergeSubagentTape(parentSessionId, childSessionId, meta) } async discardSubagentTape( @@ -1352,36 +1250,7 @@ export class AgentSessionPresenter { childSessionId: string, meta: Record = {} ): Promise { - const parentSession = this.sessionManager.get(parentSessionId) - if (!parentSession) { - throw new Error(`Session not found: ${parentSessionId}`) - } - - const childSession = this.sessionManager.get(childSessionId) - if (!childSession) { - throw new Error(`Session not found: ${childSessionId}`) - } - if (childSession.parentSessionId !== parentSessionId) { - throw new Error(`Session ${childSessionId} is not a child of ${parentSessionId}.`) - } - - const resolved = this.agentManager.resolveSubagentFacet(toAppSessionId(parentSessionId)) - switch (resolved.kind) { - case 'deepchat': - await resolved.facet.discardTape( - toAppSessionId(parentSessionId), - toAppSessionId(childSessionId), - meta - ) - break - case 'acp': - await resolved.facet.discardTape( - toAppSessionId(parentSessionId), - toAppSessionId(childSessionId), - meta - ) - break - } + await this.sessionAssignment.discardSubagentTape(parentSessionId, childSessionId, meta) } async getSearchResults(messageId: string, searchId?: string): Promise { @@ -1708,7 +1577,7 @@ export class AgentSessionPresenter { } async deleteSession(sessionId: string): Promise { - const deletedSessionIds = await this.deleteSessionInternal(sessionId) + const deletedSessionIds = await this.sessionDeletion.deleteSessionTree(sessionId) this.sessionProjection.notify({ sessionIds: deletedSessionIds, reason: 'deleted' @@ -1716,204 +1585,22 @@ export class AgentSessionPresenter { } async getAgentTransferImpact(agentId: string): Promise { - const normalizedAgentId = agentId.trim() - if (!normalizedAgentId) { - throw new Error('Agent id is required.') - } - - const sessions = this.sessionManager.list({ - agentId: normalizedAgentId, - includeSubagents: true - }) - const samples: AgentTransferImpactSample[] = [] - let emptyDrafts = 0 - let movableSessions = 0 - let blockedSessions = 0 - - for (const session of sessions) { - const assessment = await this.assessTransferSession(session) - if (assessment.isEmptyDraft) { - emptyDrafts += 1 - } - if (assessment.blockReason) { - blockedSessions += 1 - } else if (!assessment.isEmptyDraft) { - movableSessions += 1 - } - - if (samples.length < 6 && (!assessment.isEmptyDraft || assessment.blockReason)) { - samples.push({ - id: session.id, - title: session.title, - sessionKind: session.sessionKind, - isDraft: Boolean(session.isDraft), - projectDir: session.projectDir, - status: assessment.status, - blockReason: assessment.blockReason - }) - } - } - - return { - agentId: normalizedAgentId, - totalSessions: sessions.length, - regularSessions: sessions.filter((session) => session.sessionKind === 'regular').length, - subagentSessions: sessions.filter((session) => session.sessionKind === 'subagent').length, - emptyDrafts, - movableSessions, - blockedSessions, - samples - } + return await this.sessionAssignment.getAgentTransferImpact(agentId) } async moveAgentSessions( fromAgentId: string, toAgentId: string ): Promise<{ movedSessionIds: string[]; deletedSessionIds: string[] }> { - const sourceAgentId = fromAgentId.trim() - const targetAgentId = toAgentId.trim() - if (!sourceAgentId || !targetAgentId) { - throw new Error('Source and target agent ids are required.') - } - if (sourceAgentId === targetAgentId) { - throw new Error('Source and target agents cannot be the same.') - } - await this.resolveTransferTargetContext(targetAgentId, null) - - const sessions = this.sessionManager.list({ - agentId: sourceAgentId, - includeSubagents: true - }) - const transferSessionIds: string[] = [] - const emptyDraftSessionIds: string[] = [] - const movedSessionIds: string[] = [] - const deletedSessionIds: string[] = [] - const deletedSessionIdSet = new Set() - - for (const session of sessions) { - const assessment = await this.assessTransferSession(session) - if (assessment.blockReason) { - throw new Error(`Session ${session.id} cannot be moved: ${assessment.blockReason}`) - } - if (assessment.isEmptyDraft) { - emptyDraftSessionIds.push(session.id) - continue - } - - await this.resolveTransferTargetContext(targetAgentId, session.projectDir) - transferSessionIds.push(session.id) - } - - try { - for (const sessionId of transferSessionIds) { - if (deletedSessionIdSet.has(sessionId)) { - continue - } - if (!this.sessionManager.get(sessionId)) { - throw new Error(`Session ${sessionId} is no longer available.`) - } - await this.moveSessionToAgentInternal(sessionId, targetAgentId, true) - movedSessionIds.push(sessionId) - } - - for (const sessionId of emptyDraftSessionIds) { - if (deletedSessionIdSet.has(sessionId)) { - continue - } - if (!this.sessionManager.get(sessionId)) { - throw new Error(`Session ${sessionId} is no longer available.`) - } - const deleted = await this.deleteSessionInternal(sessionId) - deleted.forEach((deletedSessionId) => deletedSessionIdSet.add(deletedSessionId)) - deletedSessionIds.push(...deleted) - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - const partialCounts = [ - movedSessionIds.length > 0 ? `${movedSessionIds.length} moved` : '', - deletedSessionIds.length > 0 ? `${deletedSessionIds.length} deleted` : '' - ].filter(Boolean) - - if (partialCounts.length > 0) { - if (movedSessionIds.length > 0) { - this.sessionProjection.notify({ - sessionIds: movedSessionIds, - reason: 'updated' - }) - } - if (deletedSessionIds.length > 0) { - this.sessionProjection.notify({ - sessionIds: deletedSessionIds, - reason: 'deleted' - }) - } - throw new Error(`${message} Partial transfer completed: ${partialCounts.join(', ')}.`) - } - throw error - } - - if (movedSessionIds.length > 0) { - this.sessionProjection.notify({ - sessionIds: movedSessionIds, - reason: 'updated' - }) - } - if (deletedSessionIds.length > 0) { - this.sessionProjection.notify({ - sessionIds: deletedSessionIds, - reason: 'deleted' - }) - } - - return { movedSessionIds, deletedSessionIds } + return await this.sessionAssignment.moveAgentSessions(fromAgentId, toAgentId) } async deleteAgentSessions(agentId: string): Promise { - const normalizedAgentId = agentId.trim() - if (!normalizedAgentId) { - throw new Error('Agent id is required.') - } - - const sessions = this.sessionManager.list({ - agentId: normalizedAgentId, - includeSubagents: true - }) - const deletedSessionIds: string[] = [] - const deletedSessionIdSet = new Set() - - for (const session of sessions) { - const assessment = await this.assessTransferSession(session) - if (assessment.blockReason) { - throw new Error(`Session ${session.id} cannot be deleted: ${assessment.blockReason}`) - } - } - - for (const session of sessions) { - if (deletedSessionIdSet.has(session.id) || !this.sessionManager.get(session.id)) { - continue - } - const deleted = await this.deleteSessionInternal(session.id) - deleted.forEach((sessionId) => deletedSessionIdSet.add(sessionId)) - deletedSessionIds.push(...deleted) - } - - if (deletedSessionIds.length > 0) { - this.sessionProjection.notify({ - sessionIds: deletedSessionIds, - reason: 'deleted' - }) - } - - return deletedSessionIds + return await this.sessionAssignment.deleteAgentSessions(agentId) } async moveSessionToAgent(sessionId: string, toAgentId: string): Promise { - const updated = await this.moveSessionToAgentInternal(sessionId, toAgentId) - this.sessionProjection.notify({ - sessionIds: [sessionId], - reason: 'updated' - }) - return updated + return await this.sessionAssignment.moveSessionToAgent(sessionId, toAgentId) } async cancelGeneration(sessionId: string): Promise { @@ -1948,31 +1635,11 @@ export class AgentSessionPresenter { input?: { hint: string } | null }> > { - const session = this.sessionManager.get(sessionId) - if (!session) return [] - const handle = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle - if (handle.kind === 'acp') { - return await handle.acp.getCommands() - } - if ((await handle.snapshot())?.providerId !== 'acp') { - return [] - } - return await this.requireAcpAsLlmProviderSessionControl().getAcpSessionCommands(sessionId) + return await this.sessionAssignment.getAcpSessionCommands(sessionId) } async getAcpSessionConfigOptions(sessionId: string): Promise { - const session = this.sessionManager.get(sessionId) - if (!session) { - return null - } - const handle = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle - if (handle.kind === 'acp') { - return await handle.acp.getConfigOptions() - } - if ((await handle.snapshot())?.providerId !== 'acp') { - return null - } - return await this.requireAcpAsLlmProviderSessionControl().getAcpSessionConfigOptions(sessionId) + return await this.sessionAssignment.getAcpSessionConfigOptions(sessionId) } async setAcpSessionConfigOption( @@ -1980,75 +1647,19 @@ export class AgentSessionPresenter { configId: string, value: string | boolean ): Promise { - const session = this.sessionManager.get(sessionId) - if (!session) { - throw new Error(`Session not found: ${sessionId}`) - } - const handle = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle - if (handle.kind === 'acp') { - return await handle.acp.setConfigOption(configId, value) - } - if ((await handle.snapshot())?.providerId !== 'acp') { - throw new Error('ACP session config options are only available for ACP sessions.') - } - return await this.requireAcpAsLlmProviderSessionControl().setAcpSessionConfigOption( - sessionId, - configId, - value - ) + return await this.sessionAssignment.setAcpSessionConfigOption(sessionId, configId, value) } async getPermissionMode(sessionId: string): Promise { - const session = this.sessionManager.get(sessionId) - if (!session) { - throw new Error(`Session not found: ${sessionId}`) - } - return await this.agentManager - .resolveSessionHandle(toAppSessionId(sessionId)) - .handle.settings.getPermissionMode() + return await this.sessionAssignment.getPermissionMode(sessionId) } async setPermissionMode(sessionId: string, mode: PermissionMode): Promise { - const session = this.sessionManager.get(sessionId) - if (!session) { - throw new Error(`Session not found: ${sessionId}`) - } - await this.agentManager - .resolveSessionHandle(toAppSessionId(sessionId)) - .handle.settings.setPermissionMode(mode) + await this.sessionAssignment.setPermissionMode(sessionId, mode) } async setSessionSubagentEnabled(sessionId: string, enabled: boolean): Promise { - const session = this.sessionManager.get(sessionId) - if (!session) { - throw new Error(`Session not found: ${sessionId}`) - } - - if (session.sessionKind !== 'regular') { - throw new Error('Only regular sessions can change subagent state.') - } - - const { descriptor } = this.agentManager.resolveSessionBackend(toAppSessionId(sessionId)) - if (descriptor.kind !== 'deepchat') { - throw new Error('Only DeepChat sessions can change subagent state.') - } - - this.sessionManager.update(sessionId, { subagentEnabled: enabled }) - const updated = this.sessionManager.get(sessionId) - if (!updated) { - throw new Error(`Session not found after update: ${sessionId}`) - } - - this.sessionProjection.notify({ - sessionIds: [sessionId], - reason: 'updated' - }) - const sessionWithState = await this.sessionProjection.materialize(sessionId) - if (!sessionWithState) { - throw new Error(`Failed to build session state for sessionId: ${sessionId}`) - } - - return sessionWithState + return await this.sessionAssignment.setSessionSubagentEnabled(sessionId, enabled) } async setSessionModel( @@ -2056,140 +1667,39 @@ export class AgentSessionPresenter { providerId: string, modelId: string ): Promise { - const session = this.sessionManager.get(sessionId) - if (!session) { - throw new Error(`Session not found: ${sessionId}`) - } - - const nextProviderId = providerId?.trim() - const nextModelId = modelId?.trim() - if (!nextProviderId || !nextModelId) { - throw new Error('setSessionModel requires providerId and modelId.') - } - - const handle = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle - if (handle.kind !== 'deepchat') { - throw new Error('ACP session model is locked.') - } - await handle.deepchat.setModel(nextProviderId, nextModelId) - const state = await handle.snapshot() - const updated: SessionWithState = { - ...session, - status: state?.status ?? 'idle', - providerId: state?.providerId ?? nextProviderId, - modelId: state?.modelId ?? nextModelId - } - this.sessionProjection.notify({ - sessionIds: [sessionId], - reason: 'updated' - }) - return updated + return await this.sessionAssignment.setSessionModel(sessionId, providerId, modelId) } async setSessionProjectDir( sessionId: string, projectDir: string | null ): Promise { - const session = this.sessionManager.get(sessionId) - if (!session) { - throw new Error(`Session not found: ${sessionId}`) - } - - const handle = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle - const state = await handle.snapshot() - const providerId = state?.providerId?.trim() || (handle.kind === 'acp' ? 'acp' : '') - const normalizedProjectDir = projectDir?.trim() || null - this.assertAcpSessionHasWorkdir(providerId, normalizedProjectDir) - - this.sessionManager.update(sessionId, { projectDir: normalizedProjectDir }) - - // Sync environment for new project dir - if (normalizedProjectDir) { - this.sqlitePresenter.newEnvironmentsTable.syncPath(normalizedProjectDir) - } - - await handle.settings.setProjectDir(normalizedProjectDir) - await this.syncAcpSessionWorkdir(providerId, sessionId, session.agentId, normalizedProjectDir) - - const updated = this.sessionManager.get(sessionId) - if (!updated) { - throw new Error(`Session not found after update: ${sessionId}`) - } - - this.sessionProjection.notify({ - sessionIds: [sessionId], - reason: 'updated' - }) - const sessionWithState = await this.sessionProjection.materialize(sessionId) - if (!sessionWithState) { - throw new Error(`Failed to build session state after project update: ${sessionId}`) - } - return sessionWithState + return await this.sessionAssignment.setSessionProjectDir(sessionId, projectDir) } async getSessionGenerationSettings(sessionId: string): Promise { - const session = this.sessionManager.get(sessionId) - if (!session) { - throw new Error(`Session not found: ${sessionId}`) - } - return await this.agentManager - .resolveSessionHandle(toAppSessionId(sessionId)) - .handle.settings.getGenerationSettings() + return await this.sessionAssignment.getSessionGenerationSettings(sessionId) } async getSessionDisabledAgentTools(sessionId: string): Promise { - const session = this.sessionManager.get(sessionId) - if (!session) { - throw new Error(`Session not found: ${sessionId}`) - } - - return this.sessionManager.getDisabledAgentTools(sessionId) + return await this.sessionAssignment.getSessionDisabledAgentTools(sessionId) } async updateSessionDisabledAgentTools( sessionId: string, disabledAgentTools: string[] ): Promise { - const session = this.sessionManager.get(sessionId) - if (!session) { - throw new Error(`Session not found: ${sessionId}`) - } - - const normalized = this.normalizeDisabledAgentTools(disabledAgentTools) - this.sessionManager.updateDisabledAgentTools(sessionId, normalized) - - const handle = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle - if (handle.kind === 'deepchat') handle.deepchat.invalidateSystemPromptCache() - - return normalized + return await this.sessionAssignment.updateSessionDisabledAgentTools( + sessionId, + disabledAgentTools + ) } async updateSessionGenerationSettings( sessionId: string, settings: Partial ): Promise { - const session = this.sessionManager.get(sessionId) - if (!session) { - throw new Error(`Session not found: ${sessionId}`) - } - return await this.agentManager - .resolveSessionHandle(toAppSessionId(sessionId)) - .handle.settings.updateGenerationSettings(settings) - } - - private requireDirectAcpHandle(sessionId: string): DirectAcpSessionHandle { - const handle = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle - if (handle.kind !== 'acp') { - throw new Error(`Session ${sessionId} is not a direct ACP session.`) - } - return handle - } - - private requireAcpAsLlmProviderSessionControl(): AcpAsLlmProviderSessionControlPort { - if (this.acpAsLlmProviderSessionControl) { - return this.acpAsLlmProviderSessionControl - } - throw new Error('ACP-as-LLM provider session control is not available.') + return await this.sessionAssignment.updateSessionGenerationSettings(sessionId, settings) } private async resolveDeepChatAgentConfigCompat( @@ -2202,30 +1712,6 @@ export class AgentSessionPresenter { return await this.configPresenter.resolveDeepChatAgentConfig(agentId) } - private getDefaultProjectPathCompat(): string | null { - if (typeof this.configPresenter.getDefaultProjectPath !== 'function') { - return null - } - - return this.configPresenter.getDefaultProjectPath() ?? null - } - - private resolveCreateSessionProjectDir( - inputProjectDir: string | null | undefined, - agentDefaultProjectDir: string | null | undefined - ): string | null { - if (inputProjectDir === null) { - return null - } - - return ( - inputProjectDir?.trim() || - agentDefaultProjectDir?.trim() || - this.getDefaultProjectPathCompat() || - null - ) - } - private async resolveAssistantModelSelection( agentId: string, fallbackProviderId: string, @@ -2249,312 +1735,6 @@ export class AgentSessionPresenter { } } - private mergeDeepChatDefaultGenerationSettings( - config: Awaited> | null, - overrides?: Partial - ): Partial | undefined { - const defaults: Partial = {} - - if (typeof config?.systemPrompt === 'string') { - defaults.systemPrompt = config.systemPrompt - } - - const merged = { - ...defaults, - ...overrides - } - - return Object.keys(merged).length > 0 ? merged : undefined - } - - private resolveSessionSubagentEnabled( - agentType: 'deepchat' | 'acp' | null, - inputEnabled?: boolean, - configEnabled?: boolean - ): boolean { - if (agentType !== 'deepchat') { - return false - } - - if (typeof inputEnabled === 'boolean') { - return inputEnabled - } - - return configEnabled === true - } - - private async resolveSubagentSessionRuntimeConfig(input: { - agentId: string - targetAgentId?: string | null - providerId: string - modelId: string - generationSettings?: Partial - disabledAgentTools?: string[] - activeSkills?: string[] - }): Promise<{ - agentId: string - targetAgentId: string | null - providerId: string - modelId: string - generationSettings?: Partial - disabledAgentTools: string[] - activeSkills: string[] - }> { - const trimmedAgentId = input.agentId.trim() - const resolvedAgentId = resolveAcpAgentAlias(trimmedAgentId) - let descriptor - try { - descriptor = this.agentManager.resolveBackend(resolvedAgentId).descriptor - } catch { - throw new Error(`Agent ${input.agentId} is not a valid subagent target.`) - } - - if (descriptor.kind === 'acp') { - return { - agentId: descriptor.id, - targetAgentId: input.targetAgentId?.trim() ? descriptor.id : null, - providerId: 'acp', - modelId: descriptor.id, - generationSettings: { - systemPrompt: '' - }, - disabledAgentTools: [], - activeSkills: [] - } - } - - return { - agentId: descriptor.id, - targetAgentId: input.targetAgentId?.trim() ? descriptor.id : null, - providerId: input.providerId, - modelId: input.modelId, - generationSettings: input.generationSettings, - disabledAgentTools: this.normalizeDisabledAgentTools(input.disabledAgentTools), - activeSkills: this.normalizeActiveSkills(input.activeSkills) - } - } - - private async assessTransferSession(session: SessionRecord): Promise<{ - status: SessionWithState['status'] - isEmptyDraft: boolean - blockReason?: AgentTransferBlockReason - }> { - const { handle, facet } = this.agentManager.resolveTransferSource(toAppSessionId(session.id)) - const state = await handle.snapshot() - const status = state?.status ?? 'idle' - let hasMessages = true - try { - hasMessages = await facet.hasMessages(toAppSessionId(session.id)) - } catch (error) { - console.warn( - `[AgentSessionPresenter] Failed to inspect messages for session=${session.id}:`, - error - ) - } - let hasPendingInput = false - try { - hasPendingInput = (await facet.listPendingInputs(toAppSessionId(session.id))).length > 0 - } catch (error) { - console.warn( - `[AgentSessionPresenter] Failed to inspect pending input for session=${session.id}:`, - error - ) - hasPendingInput = true - } - const hasSubagentChildren = - session.sessionKind === 'regular' && - this.sessionManager.list({ includeSubagents: true, parentSessionId: session.id }).length > 0 - const isEmptyDraft = Boolean(session.isDraft) && !hasMessages && !hasSubagentChildren - - if (status === 'generating') { - return { status, isEmptyDraft, blockReason: 'active' } - } - if (hasPendingInput) { - return { status, isEmptyDraft, blockReason: 'pending-input' } - } - - return { status, isEmptyDraft } - } - - private async moveSessionToAgentInternal( - sessionId: string, - toAgentId: string, - allowSubagent: boolean = false - ): Promise { - const session = this.sessionManager.get(sessionId) - if (!session) { - throw new Error(`Session not found: ${sessionId}`) - } - if (!allowSubagent && session.sessionKind !== 'regular') { - throw new Error('Only regular conversations can be moved from the conversation menu.') - } - - const targetAgentId = toAgentId.trim() - if (!targetAgentId) { - throw new Error('Target agent id is required.') - } - if (session.agentId === targetAgentId) { - throw new Error('Conversation is already assigned to the selected agent.') - } - - const assessment = await this.assessTransferSession(session) - if (assessment.blockReason) { - throw new Error(`Session ${sessionId} cannot be moved: ${assessment.blockReason}`) - } - - const targetContext = await this.resolveTransferTargetContext(targetAgentId, session.projectDir) - const source = this.agentManager.resolveTransferSource(toAppSessionId(sessionId)) - const sourceState = await source.handle.snapshot() - const previousDirectAcp = source.handle.kind === 'acp' - const previousCompatibilityAcp = - source.handle.kind === 'deepchat' && sourceState?.providerId === 'acp' - const { facet: transferTarget } = this.agentManager.resolveDeepChatTransferTarget( - targetContext.agentId - ) - - await transferTarget.setSessionAgentContext(toAppSessionId(sessionId), { - agentId: targetContext.agentId, - providerId: targetContext.providerId, - modelId: targetContext.modelId, - projectDir: targetContext.projectDir, - permissionMode: targetContext.permissionMode, - generationSettings: targetContext.generationSettings - }) - - this.sessionManager.updateAgentId(sessionId, targetContext.agentId) - this.sessionManager.update(sessionId, { - projectDir: targetContext.projectDir, - subagentEnabled: session.sessionKind === 'regular' ? targetContext.subagentEnabled : false - }) - this.sessionManager.updateDisabledAgentTools(sessionId, targetContext.disabledAgentTools) - - await this.syncAcpSessionWorkdir( - targetContext.providerId, - sessionId, - targetContext.agentId, - targetContext.projectDir - ) - - const updated = this.sessionManager.get(sessionId) - if (!updated) { - throw new Error(`Session not found after transfer: ${sessionId}`) - } - - const sessionWithState = await this.sessionProjection.materialize(sessionId) - if (!sessionWithState) { - throw new Error(`Failed to build session state after transfer: ${sessionId}`) - } - - if (previousDirectAcp && source.closeRuntime) { - try { - await source.closeRuntime() - } catch (error) { - console.warn( - `[AgentSessionPresenter] Failed to close direct ACP runtime after transfer ${sessionId}:`, - error - ) - } - } else if (previousCompatibilityAcp) { - try { - await this.requireAcpAsLlmProviderSessionControl().clearAcpSession(sessionId) - } catch (error) { - console.warn( - `[AgentSessionPresenter] Failed to clear stale ACP binding after transfer ${sessionId}:`, - error - ) - } - } - - return sessionWithState - } - - private async resolveTransferTargetContext( - targetAgentId: string, - currentProjectDir: string | null - ): Promise { - const resolvedAgentId = resolveAcpAgentAlias(targetAgentId.trim()) - let descriptor - try { - descriptor = this.agentManager.resolveBackend(resolvedAgentId).descriptor - } catch { - throw new Error(`Target agent not found: ${targetAgentId}`) - } - if (descriptor.kind === 'acp') { - throw new Error('Conversation history cannot be moved to ACP agents.') - } - - const currentProject = currentProjectDir?.trim() || null - const config = await this.resolveDeepChatAgentConfigCompat(descriptor.id) - const defaultModel = this.configPresenter.getDefaultModel() - const providerId = - config?.defaultModelPreset?.providerId?.trim() || defaultModel?.providerId?.trim() || '' - const modelId = - config?.defaultModelPreset?.modelId?.trim() || defaultModel?.modelId?.trim() || '' - if (!providerId || !modelId) { - throw new Error('Target DeepChat agent does not have a default model.') - } - if (providerId.toLowerCase() === 'acp') { - throw new Error('Conversation history cannot be moved to ACP agents.') - } - - return { - agentId: descriptor.id, - agentType: 'deepchat', - providerId, - modelId, - projectDir: - currentProject || - config?.defaultProjectPath?.trim() || - this.getDefaultProjectPathCompat() || - null, - permissionMode: normalizePermissionMode(config?.permissionMode), - generationSettings: this.mergeDeepChatDefaultGenerationSettings(config), - disabledAgentTools: this.normalizeDisabledAgentTools(config?.disabledAgentTools), - subagentEnabled: this.resolveSessionSubagentEnabled( - 'deepchat', - undefined, - config?.subagentEnabled - ) - } - } - - private async deleteSessionInternal(sessionId: string): Promise { - const session = this.sessionManager.get(sessionId) - if (!session) return [] - - const deletedSessionIds: string[] = [] - - if (session.sessionKind === 'regular') { - const children = this.sessionManager.list({ - includeSubagents: true, - parentSessionId: sessionId - }) - for (const child of children) { - deletedSessionIds.push(...(await this.deleteSessionInternal(child.id))) - } - } - - let backendCleanupError: unknown - try { - await this.agentManager.cleanupSessionBackends(toAppSessionId(sessionId)) - } catch (error) { - backendCleanupError = error - } - try { - await this.sharedData.sessionState.destroySession(sessionId) - } catch (error) { - if (!backendCleanupError) throw error - } - if (backendCleanupError) throw backendCleanupError - this.sessionPermissionPort?.clearSessionPermissions(sessionId) - await this.skillPresenter?.clearNewAgentSessionSkills?.(sessionId) - this.sessionManager.delete(sessionId) - this.sessionProjection.forgetStatus([sessionId]) - deletedSessionIds.push(sessionId) - - return deletedSessionIds - } - private async findReusableDraftSession( agentId: string, projectDir: string @@ -2604,7 +1784,7 @@ export class AgentSessionPresenter { await handle.settings.setPermissionMode(config.permissionMode) } - await this.syncAcpSessionWorkdir( + await this.sessionAssignment.syncAcpSessionWorkdir( config.providerId, sessionId, config.agentId ?? config.modelId, @@ -2626,7 +1806,7 @@ export class AgentSessionPresenter { await this.agentManager .resolveSessionHandle(toAppSessionId(sessionId)) .handle.lifecycle.initialize(config) - await this.syncAcpSessionWorkdir( + await this.sessionAssignment.syncAcpSessionWorkdir( config.providerId, sessionId, config.agentId ?? config.modelId, @@ -2634,43 +1814,6 @@ export class AgentSessionPresenter { ) } - private async syncAcpSessionWorkdir( - providerId: string, - conversationId: string, - agentId: string, - projectDir?: string | null - ): Promise { - if (providerId !== 'acp') { - return - } - - const normalizedProjectDir = projectDir?.trim() - if (!normalizedProjectDir) { - return - } - - try { - const handle = this.agentManager.resolveSessionHandle(toAppSessionId(conversationId)).handle - if (handle.kind === 'acp') { - await handle.acp.updateWorkdir(normalizedProjectDir) - return - } - await this.requireAcpAsLlmProviderSessionControl().setAcpWorkdir( - conversationId, - resolveAcpAgentAlias(agentId), - normalizedProjectDir - ) - } catch (error) { - console.warn('[AgentSessionPresenter] Failed to sync ACP workdir for session:', { - conversationId, - agentId, - projectDir: normalizedProjectDir, - error - }) - throw error - } - } - private async cleanupFailedSessionInitialization( sessionId: string, providerId?: string @@ -2678,7 +1821,7 @@ export class AgentSessionPresenter { const handle = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle if (providerId === 'acp' && handle.kind !== 'acp') { try { - await this.requireAcpAsLlmProviderSessionControl().clearAcpSession(sessionId) + await this.sessionAssignment.clearCompatibilityAcpSession(sessionId) } catch (error) { console.warn( `[AgentSessionPresenter] Failed to clear ACP session after initialization error ${sessionId}:`, @@ -3091,9 +2234,12 @@ export class AgentSessionPresenter { const disabledAgentTools = this.sqlitePresenter.newSessionsTable.getDisabledAgentTools( sessionRow.id ) - const normalized = this.normalizeDisabledAgentTools(disabledAgentTools, { - dropLegacySearchTools: true - }) + const normalized = this.sessionAssignmentPolicy.normalizeDisabledAgentTools( + disabledAgentTools, + { + dropLegacySearchTools: true + } + ) if (!this.areStringArraysEqual(disabledAgentTools, normalized)) { this.sessionManager.updateDisabledAgentTools(sessionRow.id, normalized) @@ -3143,9 +2289,12 @@ export class AgentSessionPresenter { continue } - const normalized = this.normalizeDisabledAgentTools(config.disabledAgentTools, { - dropLegacySearchTools: true - }) + const normalized = this.sessionAssignmentPolicy.normalizeDisabledAgentTools( + config.disabledAgentTools, + { + dropLegacySearchTools: true + } + ) if (this.areStringArraysEqual(config.disabledAgentTools, normalized)) { continue } @@ -3226,7 +2375,7 @@ export class AgentSessionPresenter { : [], search: parsed.search === true, think: parsed.think === true, - activeSkills: this.normalizeActiveSkills(parsed.activeSkills) + activeSkills: this.sessionAssignmentPolicy.normalizeActiveSkills(parsed.activeSkills) } } catch { return null @@ -3505,16 +2654,6 @@ export class AgentSessionPresenter { return 'English' } - private assertAcpSessionHasWorkdir(providerId: string, projectDir: string | null): void { - if (providerId !== 'acp') { - return - } - if (projectDir?.trim()) { - return - } - throw new Error('ACP agent requires selecting a workdir before sending messages.') - } - private normalizeSendMessageInput(content: string | SendMessageInput): SendMessageInput { if (typeof content === 'string') { return { text: content, files: [] } @@ -3528,7 +2667,7 @@ export class AgentSessionPresenter { const files = Array.isArray(content.files) ? content.files.filter((file): file is MessageFile => Boolean(file)) : [] - const activeSkills = this.normalizeActiveSkills(content.activeSkills) + const activeSkills = this.sessionAssignmentPolicy.normalizeActiveSkills(content.activeSkills) const inlineItems = Array.isArray(content.inlineItems) ? content.inlineItems : [] return { text, @@ -3558,57 +2697,19 @@ export class AgentSessionPresenter { input: SendMessageInput, activeSkills?: string[] ): SendMessageInput { - const normalizedActiveSkills = this.normalizeActiveSkills(activeSkills ?? input.activeSkills) + const normalizedActiveSkills = this.sessionAssignmentPolicy.normalizeActiveSkills( + activeSkills ?? input.activeSkills + ) return { ...input, ...(normalizedActiveSkills.length > 0 ? { activeSkills: normalizedActiveSkills } : {}) } } - private normalizeDisabledAgentTools( - disabledAgentTools?: string[], - options?: { - dropLegacySearchTools?: boolean - } - ): string[] { - if (!Array.isArray(disabledAgentTools)) { - return [] - } - - const retiredTools = options?.dropLegacySearchTools - ? LEGACY_PERSISTED_DISABLED_AGENT_TOOLS - : RETIRED_DEFAULT_AGENT_TOOLS - - return Array.from( - new Set( - disabledAgentTools - .filter((item): item is string => typeof item === 'string') - .map((item) => item.trim()) - .map((item) => LEGACY_AGENT_TOOL_NAME_MAP[item] ?? item) - .filter((item) => Boolean(item) && !retiredTools.has(item)) - ) - ).sort((left, right) => left.localeCompare(right)) - } - private areStringArraysEqual(left: string[], right: string[]): boolean { if (left.length !== right.length) { return false } return left.every((item, index) => item === right[index]) } - - private normalizeActiveSkills(activeSkills?: string[]): string[] { - if (!Array.isArray(activeSkills)) { - return [] - } - - return Array.from( - new Set( - activeSkills - .filter((item): item is string => typeof item === 'string') - .map((item) => item.trim()) - .filter(Boolean) - ) - ) - } } diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index f31b5ea08..768f3978a 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -77,6 +77,9 @@ import { AgentUnavailableError } from '@/agent/shared/agentCatalogCodec' import { resolveAcpAgentAlias } from '@shared/utils/acpAgentAlias' import { AgentSessionPresenter } from './agentSessionPresenter' import { SessionProjectionCoordinator } from './sessionApplication/projectionCoordinator' +import { SessionAgentAssignmentPolicy } from './sessionApplication/agentAssignmentPolicy' +import { SessionAgentAssignmentCoordinator } from './sessionApplication/agentAssignmentCoordinator' +import { SessionDeletionTransaction } from './sessionApplication/lifecycleDeletionTransaction' import { AgentRuntimePresenter } from './agentRuntimePresenter' import { AcpAgentRuntime } from '@/agent/acp/instance' import type { @@ -159,6 +162,9 @@ export class Presenter implements IPresenter { skillSyncPresenter: ISkillSyncPresenter agentSessionPresenter: IAgentSessionPresenter sessionProjectionCoordinator: SessionProjectionCoordinator + sessionAgentAssignmentPolicy: SessionAgentAssignmentPolicy + sessionAgentAssignmentCoordinator: SessionAgentAssignmentCoordinator + sessionDeletionTransaction: SessionDeletionTransaction agentManager: AgentManager acpAgentRuntime: AcpAgentRuntime memoryPresenter: MemoryPresenter @@ -749,6 +755,51 @@ export class Presenter implements IPresenter { }, ui: sessionUiPort }) + this.sessionAgentAssignmentPolicy = new SessionAgentAssignmentPolicy( + { + resolveAgent: (agentId) => { + const descriptor = this.agentManager.resolveBackend(agentId).descriptor + return { id: descriptor.id, kind: descriptor.kind } + } + }, + { + getDefaultModel: () => this.configPresenter.getDefaultModel(), + getDefaultProjectPath: () => this.configPresenter.getDefaultProjectPath(), + resolveDeepChatAgentConfig: async (agentId) => + await this.configPresenter.resolveDeepChatAgentConfig(agentId) + } + ) + this.sessionDeletionTransaction = new SessionDeletionTransaction({ + sessions: appSessionService, + runtime: { + cleanupSessionBackends: async (sessionId) => + await this.agentManager.cleanupSessionBackends(sessionId) + }, + state: agentSharedData.sessionState, + permissions: sessionPermissionPort, + skills: { + clearNewAgentSessionSkills: async (sessionId) => + await this.skillPresenter.clearNewAgentSessionSkills?.(sessionId) + }, + projection: this.sessionProjectionCoordinator + }) + this.sessionAgentAssignmentCoordinator = new SessionAgentAssignmentCoordinator({ + sessions: appSessionService, + runtime: { + resolveSession: (sessionId) => this.agentManager.resolveSessionHandle(sessionId), + resolveTransferSource: (sessionId) => this.agentManager.resolveTransferSource(sessionId), + resolveDeepChatTransferTarget: (agentId) => + this.agentManager.resolveDeepChatTransferTarget(agentId), + resolveSubagentFacet: (sessionId) => this.agentManager.resolveSubagentFacet(sessionId) + }, + policy: this.sessionAgentAssignmentPolicy, + projection: this.sessionProjectionCoordinator, + deletion: this.sessionDeletionTransaction, + environment: { + syncPath: (projectDir) => sqlitePresenter.newEnvironmentsTable.syncPath(projectDir) + }, + acp: this.acpAsLlmProviderSessionControl + }) this.agentSessionPresenter = new AgentSessionPresenter( this.agentManager, appSessionService, @@ -757,9 +808,11 @@ export class Presenter implements IPresenter { this.sqlitePresenter as unknown as import('./sqlitePresenter').SQLitePresenter, agentSharedData, this.sessionProjectionCoordinator, + this.sessionAgentAssignmentPolicy, + this.sessionAgentAssignmentCoordinator, + this.sessionDeletionTransaction, this.skillPresenter, { - acpAsLlmProviderSessionControl: this.acpAsLlmProviderSessionControl, sessionPermissionPort } ) diff --git a/src/main/presenter/sessionApplication/agentAssignmentCoordinator.ts b/src/main/presenter/sessionApplication/agentAssignmentCoordinator.ts new file mode 100644 index 000000000..e68389c32 --- /dev/null +++ b/src/main/presenter/sessionApplication/agentAssignmentCoordinator.ts @@ -0,0 +1,597 @@ +import { toAppSessionId } from '@/agent/shared/agentSessionIds' +import type { + AgentTransferBlockReason, + AgentTransferImpact, + AgentTransferImpactSample, + PermissionMode, + SessionGenerationSettings, + SessionRecord, + SessionWithState +} from '@shared/types/agent-interface' +import type { AcpConfigState } from '@shared/presenter' +import { resolveAcpAgentAlias } from '@shared/utils/acpAgentAlias' +import type { + SessionAssignmentAcpControlPort, + SessionAssignmentEnvironmentPort, + SessionAssignmentPolicyPort, + SessionAssignmentProjectionPort, + SessionAssignmentRuntimePort, + SessionAssignmentStorePort, + SessionAssignmentWorkdirPort, + SessionLifecycleDeletionPort +} from './ports' + +export interface SessionAgentAssignmentDependencies { + sessions: SessionAssignmentStorePort + runtime: SessionAssignmentRuntimePort + policy: SessionAssignmentPolicyPort + projection: SessionAssignmentProjectionPort + deletion: SessionLifecycleDeletionPort + environment: SessionAssignmentEnvironmentPort + acp: SessionAssignmentAcpControlPort +} + +export class SessionAgentAssignmentCoordinator implements SessionAssignmentWorkdirPort { + constructor(private readonly dependencies: SessionAgentAssignmentDependencies) {} + + async mergeSubagentTape( + parentSessionId: string, + childSessionId: string, + meta: Record = {} + ): Promise { + this.requireChildSession(parentSessionId, childSessionId) + const resolved = this.dependencies.runtime.resolveSubagentFacet(toAppSessionId(parentSessionId)) + await resolved.facet.mergeTape( + toAppSessionId(parentSessionId), + toAppSessionId(childSessionId), + meta + ) + } + + async discardSubagentTape( + parentSessionId: string, + childSessionId: string, + meta: Record = {} + ): Promise { + this.requireChildSession(parentSessionId, childSessionId) + const resolved = this.dependencies.runtime.resolveSubagentFacet(toAppSessionId(parentSessionId)) + await resolved.facet.discardTape( + toAppSessionId(parentSessionId), + toAppSessionId(childSessionId), + meta + ) + } + + async getAgentTransferImpact(agentId: string): Promise { + const normalizedAgentId = agentId.trim() + if (!normalizedAgentId) { + throw new Error('Agent id is required.') + } + + const sessions = this.dependencies.sessions.list({ + agentId: normalizedAgentId, + includeSubagents: true + }) + const samples: AgentTransferImpactSample[] = [] + let emptyDrafts = 0 + let movableSessions = 0 + let blockedSessions = 0 + + for (const session of sessions) { + const assessment = await this.assessTransferSession(session) + if (assessment.isEmptyDraft) emptyDrafts += 1 + if (assessment.blockReason) { + blockedSessions += 1 + } else if (!assessment.isEmptyDraft) { + movableSessions += 1 + } + + if (samples.length < 6 && (!assessment.isEmptyDraft || assessment.blockReason)) { + samples.push({ + id: session.id, + title: session.title, + sessionKind: session.sessionKind, + isDraft: Boolean(session.isDraft), + projectDir: session.projectDir, + status: assessment.status, + blockReason: assessment.blockReason + }) + } + } + + return { + agentId: normalizedAgentId, + totalSessions: sessions.length, + regularSessions: sessions.filter((session) => session.sessionKind === 'regular').length, + subagentSessions: sessions.filter((session) => session.sessionKind === 'subagent').length, + emptyDrafts, + movableSessions, + blockedSessions, + samples + } + } + + async moveAgentSessions( + fromAgentId: string, + toAgentId: string + ): Promise<{ movedSessionIds: string[]; deletedSessionIds: string[] }> { + const sourceAgentId = fromAgentId.trim() + const targetAgentId = toAgentId.trim() + if (!sourceAgentId || !targetAgentId) { + throw new Error('Source and target agent ids are required.') + } + if (sourceAgentId === targetAgentId) { + throw new Error('Source and target agents cannot be the same.') + } + await this.dependencies.policy.resolveTransferTarget(targetAgentId, null) + + const sessions = this.dependencies.sessions.list({ + agentId: sourceAgentId, + includeSubagents: true + }) + const transferSessionIds: string[] = [] + const emptyDraftSessionIds: string[] = [] + const movedSessionIds: string[] = [] + const deletedSessionIds: string[] = [] + const deletedSessionIdSet = new Set() + + for (const session of sessions) { + const assessment = await this.assessTransferSession(session) + if (assessment.blockReason) { + throw new Error(`Session ${session.id} cannot be moved: ${assessment.blockReason}`) + } + if (assessment.isEmptyDraft) { + emptyDraftSessionIds.push(session.id) + continue + } + + await this.dependencies.policy.resolveTransferTarget(targetAgentId, session.projectDir) + transferSessionIds.push(session.id) + } + + try { + for (const sessionId of transferSessionIds) { + if (deletedSessionIdSet.has(sessionId)) continue + if (!this.dependencies.sessions.get(sessionId)) { + throw new Error(`Session ${sessionId} is no longer available.`) + } + await this.moveSessionToAgentInternal(sessionId, targetAgentId, true) + movedSessionIds.push(sessionId) + } + + for (const sessionId of emptyDraftSessionIds) { + if (deletedSessionIdSet.has(sessionId)) continue + if (!this.dependencies.sessions.get(sessionId)) { + throw new Error(`Session ${sessionId} is no longer available.`) + } + const deleted = await this.dependencies.deletion.deleteSessionTree(sessionId) + deleted.forEach((deletedSessionId) => deletedSessionIdSet.add(deletedSessionId)) + deletedSessionIds.push(...deleted) + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + const partialCounts = [ + movedSessionIds.length > 0 ? `${movedSessionIds.length} moved` : '', + deletedSessionIds.length > 0 ? `${deletedSessionIds.length} deleted` : '' + ].filter(Boolean) + + if (partialCounts.length > 0) { + this.notifyTransferResults(movedSessionIds, deletedSessionIds) + throw new Error(`${message} Partial transfer completed: ${partialCounts.join(', ')}.`) + } + throw error + } + + this.notifyTransferResults(movedSessionIds, deletedSessionIds) + return { movedSessionIds, deletedSessionIds } + } + + async deleteAgentSessions(agentId: string): Promise { + const normalizedAgentId = agentId.trim() + if (!normalizedAgentId) { + throw new Error('Agent id is required.') + } + + const sessions = this.dependencies.sessions.list({ + agentId: normalizedAgentId, + includeSubagents: true + }) + const deletedSessionIds: string[] = [] + const deletedSessionIdSet = new Set() + + for (const session of sessions) { + const assessment = await this.assessTransferSession(session) + if (assessment.blockReason) { + throw new Error(`Session ${session.id} cannot be deleted: ${assessment.blockReason}`) + } + } + + for (const session of sessions) { + if (deletedSessionIdSet.has(session.id) || !this.dependencies.sessions.get(session.id)) { + continue + } + const deleted = await this.dependencies.deletion.deleteSessionTree(session.id) + deleted.forEach((sessionId) => deletedSessionIdSet.add(sessionId)) + deletedSessionIds.push(...deleted) + } + + if (deletedSessionIds.length > 0) { + this.dependencies.projection.notify({ + sessionIds: deletedSessionIds, + reason: 'deleted' + }) + } + return deletedSessionIds + } + + async moveSessionToAgent(sessionId: string, toAgentId: string): Promise { + const updated = await this.moveSessionToAgentInternal(sessionId, toAgentId) + this.dependencies.projection.notify({ sessionIds: [sessionId], reason: 'updated' }) + return updated + } + + async getAcpSessionCommands(sessionId: string): Promise< + Array<{ + name: string + description: string + input?: { hint: string } | null + }> + > { + if (!this.dependencies.sessions.get(sessionId)) return [] + const { handle } = this.dependencies.runtime.resolveSession(toAppSessionId(sessionId)) + if (handle.kind === 'acp') return await handle.acp.getCommands() + if ((await handle.snapshot())?.providerId !== 'acp') return [] + return await this.dependencies.acp.getAcpSessionCommands(sessionId) + } + + async getAcpSessionConfigOptions(sessionId: string): Promise { + if (!this.dependencies.sessions.get(sessionId)) return null + const { handle } = this.dependencies.runtime.resolveSession(toAppSessionId(sessionId)) + if (handle.kind === 'acp') return await handle.acp.getConfigOptions() + if ((await handle.snapshot())?.providerId !== 'acp') return null + return await this.dependencies.acp.getAcpSessionConfigOptions(sessionId) + } + + async setAcpSessionConfigOption( + sessionId: string, + configId: string, + value: string | boolean + ): Promise { + if (!this.dependencies.sessions.get(sessionId)) { + throw new Error(`Session not found: ${sessionId}`) + } + const { handle } = this.dependencies.runtime.resolveSession(toAppSessionId(sessionId)) + if (handle.kind === 'acp') return await handle.acp.setConfigOption(configId, value) + if ((await handle.snapshot())?.providerId !== 'acp') { + throw new Error('ACP session config options are only available for ACP sessions.') + } + return await this.dependencies.acp.setAcpSessionConfigOption(sessionId, configId, value) + } + + async getPermissionMode(sessionId: string): Promise { + this.requireSession(sessionId) + return await this.dependencies.runtime + .resolveSession(toAppSessionId(sessionId)) + .handle.settings.getPermissionMode() + } + + async setPermissionMode(sessionId: string, mode: PermissionMode): Promise { + this.requireSession(sessionId) + await this.dependencies.runtime + .resolveSession(toAppSessionId(sessionId)) + .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.') + } + + const resolved = this.dependencies.runtime.resolveSession(toAppSessionId(sessionId)) + if (resolved.descriptor.kind !== '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, + modelId: string + ): Promise { + this.requireSession(sessionId) + const nextProviderId = providerId?.trim() + const nextModelId = modelId?.trim() + if (!nextProviderId || !nextModelId) { + throw new Error('setSessionModel requires providerId and modelId.') + } + + const { handle } = this.dependencies.runtime.resolveSession(toAppSessionId(sessionId)) + if (handle.kind !== 'deepchat') throw new Error('ACP session model is locked.') + await handle.deepchat.setModel(nextProviderId, nextModelId) + + 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 after model update: ${sessionId}`) + } + return materialized + } + + async setSessionProjectDir( + sessionId: string, + projectDir: string | null + ): Promise { + const session = this.requireSession(sessionId) + const { handle } = this.dependencies.runtime.resolveSession(toAppSessionId(sessionId)) + const state = await handle.snapshot() + const providerId = state?.providerId?.trim() || (handle.kind === 'acp' ? 'acp' : '') + const normalizedProjectDir = projectDir?.trim() || null + this.assertAcpSessionHasWorkdir(providerId, normalizedProjectDir) + + this.dependencies.sessions.update(sessionId, { projectDir: normalizedProjectDir }) + if (normalizedProjectDir) this.dependencies.environment.syncPath(normalizedProjectDir) + await handle.settings.setProjectDir(normalizedProjectDir) + await this.syncAcpSessionWorkdir(providerId, sessionId, session.agentId, normalizedProjectDir) + + 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 after project update: ${sessionId}`) + } + return materialized + } + + async getSessionGenerationSettings(sessionId: string): Promise { + this.requireSession(sessionId) + return await this.dependencies.runtime + .resolveSession(toAppSessionId(sessionId)) + .handle.settings.getGenerationSettings() + } + + async getSessionDisabledAgentTools(sessionId: string): Promise { + this.requireSession(sessionId) + return this.dependencies.sessions.getDisabledAgentTools(sessionId) + } + + async updateSessionDisabledAgentTools( + sessionId: string, + disabledAgentTools: string[] + ): Promise { + this.requireSession(sessionId) + const normalized = this.dependencies.policy.normalizeDisabledAgentTools(disabledAgentTools) + this.dependencies.sessions.updateDisabledAgentTools(sessionId, normalized) + + const { handle } = this.dependencies.runtime.resolveSession(toAppSessionId(sessionId)) + if (handle.kind === 'deepchat') handle.deepchat.invalidateSystemPromptCache() + return normalized + } + + async updateSessionGenerationSettings( + sessionId: string, + settings: Partial + ): Promise { + this.requireSession(sessionId) + return await this.dependencies.runtime + .resolveSession(toAppSessionId(sessionId)) + .handle.settings.updateGenerationSettings(settings) + } + + assertAcpSessionHasWorkdir(providerId: string, projectDir: string | null): void { + this.dependencies.policy.assertAcpSessionHasWorkdir(providerId, projectDir) + } + + async syncAcpSessionWorkdir( + providerId: string, + sessionId: string, + agentId: string, + projectDir?: string | null + ): Promise { + if (providerId !== 'acp') return + const normalizedProjectDir = projectDir?.trim() + if (!normalizedProjectDir) return + + try { + const { handle } = this.dependencies.runtime.resolveSession(toAppSessionId(sessionId)) + if (handle.kind === 'acp') { + await handle.acp.updateWorkdir(normalizedProjectDir) + return + } + await this.dependencies.acp.setAcpWorkdir( + sessionId, + resolveAcpAgentAlias(agentId), + normalizedProjectDir + ) + } catch (error) { + console.warn('[SessionAgentAssignmentCoordinator] Failed to sync ACP workdir:', { + sessionId, + agentId, + projectDir: normalizedProjectDir, + error + }) + throw error + } + } + + async prepareDirectAcpSession(sessionId: string): Promise { + const { handle } = this.dependencies.runtime.resolveSession(toAppSessionId(sessionId)) + if (handle.kind !== 'acp') { + throw new Error(`Session ${sessionId} is not a direct ACP session.`) + } + await handle.acp.prepare() + } + + async clearCompatibilityAcpSession(sessionId: string): Promise { + await this.dependencies.acp.clearAcpSession(sessionId) + } + + private async assessTransferSession(session: SessionRecord): Promise<{ + status: SessionWithState['status'] + isEmptyDraft: boolean + blockReason?: AgentTransferBlockReason + }> { + const { handle, facet } = this.dependencies.runtime.resolveTransferSource( + toAppSessionId(session.id) + ) + const state = await handle.snapshot() + const status = state?.status ?? 'idle' + let hasMessages = true + try { + hasMessages = await facet.hasMessages(toAppSessionId(session.id)) + } catch (error) { + console.warn( + `[SessionAgentAssignmentCoordinator] Failed to inspect messages for session=${session.id}:`, + error + ) + } + + let hasPendingInput = false + try { + hasPendingInput = (await facet.listPendingInputs(toAppSessionId(session.id))).length > 0 + } catch (error) { + console.warn( + `[SessionAgentAssignmentCoordinator] Failed to inspect pending input for session=${session.id}:`, + error + ) + hasPendingInput = true + } + + const hasSubagentChildren = + session.sessionKind === 'regular' && + this.dependencies.sessions.list({ + includeSubagents: true, + parentSessionId: session.id + }).length > 0 + const isEmptyDraft = Boolean(session.isDraft) && !hasMessages && !hasSubagentChildren + + if (status === 'generating') return { status, isEmptyDraft, blockReason: 'active' } + if (hasPendingInput) return { status, isEmptyDraft, blockReason: 'pending-input' } + return { status, isEmptyDraft } + } + + private async moveSessionToAgentInternal( + sessionId: string, + toAgentId: string, + allowSubagent: boolean = false + ): Promise { + const session = this.requireSession(sessionId) + if (!allowSubagent && session.sessionKind !== 'regular') { + throw new Error('Only regular conversations can be moved from the conversation menu.') + } + + const targetAgentId = toAgentId.trim() + if (!targetAgentId) throw new Error('Target agent id is required.') + if (session.agentId === targetAgentId) { + throw new Error('Conversation is already assigned to the selected agent.') + } + + const assessment = await this.assessTransferSession(session) + if (assessment.blockReason) { + throw new Error(`Session ${sessionId} cannot be moved: ${assessment.blockReason}`) + } + + const target = await this.dependencies.policy.resolveTransferTarget( + targetAgentId, + session.projectDir + ) + const source = this.dependencies.runtime.resolveTransferSource(toAppSessionId(sessionId)) + const sourceState = await source.handle.snapshot() + const previousDirectAcp = source.handle.kind === 'acp' + const previousCompatibilityAcp = + source.handle.kind === 'deepchat' && sourceState?.providerId === 'acp' + const { facet: transferTarget } = this.dependencies.runtime.resolveDeepChatTransferTarget( + target.agentId + ) + + await transferTarget.setSessionAgentContext(toAppSessionId(sessionId), { + agentId: target.agentId, + providerId: target.providerId, + modelId: target.modelId, + projectDir: target.projectDir, + permissionMode: target.permissionMode, + generationSettings: target.generationSettings + }) + + this.dependencies.sessions.updateAgentId(sessionId, target.agentId) + this.dependencies.sessions.update(sessionId, { + projectDir: target.projectDir, + subagentEnabled: session.sessionKind === 'regular' ? target.subagentEnabled : false + }) + this.dependencies.sessions.updateDisabledAgentTools(sessionId, target.disabledAgentTools) + await this.syncAcpSessionWorkdir( + target.providerId, + sessionId, + target.agentId, + target.projectDir + ) + + if (!this.dependencies.sessions.get(sessionId)) { + throw new Error(`Session not found after transfer: ${sessionId}`) + } + const materialized = await this.dependencies.projection.materialize(sessionId) + if (!materialized) { + throw new Error(`Failed to build session state after transfer: ${sessionId}`) + } + + if (previousDirectAcp && source.closeRuntime) { + try { + await source.closeRuntime() + } catch (error) { + console.warn( + `[SessionAgentAssignmentCoordinator] Failed to close direct ACP runtime after transfer ${sessionId}:`, + error + ) + } + } else if (previousCompatibilityAcp) { + try { + await this.clearCompatibilityAcpSession(sessionId) + } catch (error) { + console.warn( + `[SessionAgentAssignmentCoordinator] Failed to clear stale ACP binding after transfer ${sessionId}:`, + error + ) + } + } + + return materialized + } + + private requireSession(sessionId: string): SessionRecord { + const session = this.dependencies.sessions.get(sessionId) + if (!session) throw new Error(`Session not found: ${sessionId}`) + return session + } + + private requireChildSession(parentSessionId: string, childSessionId: string): void { + this.requireSession(parentSessionId) + const child = this.requireSession(childSessionId) + if (child.parentSessionId !== parentSessionId) { + throw new Error(`Session ${childSessionId} is not a child of ${parentSessionId}.`) + } + } + + private notifyTransferResults(movedSessionIds: string[], deletedSessionIds: string[]): void { + if (movedSessionIds.length > 0) { + this.dependencies.projection.notify({ sessionIds: movedSessionIds, reason: 'updated' }) + } + if (deletedSessionIds.length > 0) { + this.dependencies.projection.notify({ sessionIds: deletedSessionIds, reason: 'deleted' }) + } + } +} diff --git a/src/main/presenter/sessionApplication/agentAssignmentPolicy.ts b/src/main/presenter/sessionApplication/agentAssignmentPolicy.ts new file mode 100644 index 000000000..c05b5b94d --- /dev/null +++ b/src/main/presenter/sessionApplication/agentAssignmentPolicy.ts @@ -0,0 +1,240 @@ +import { resolveAcpAgentAlias } from '@shared/utils/acpAgentAlias' +import type { + DeepChatAgentConfig, + PermissionMode, + SessionGenerationSettings +} from '@shared/types/agent-interface' +import type { + CreateAssignmentInput, + ResolvedSessionAssignment, + ResolvedSubagentAssignment, + ResolvedTransferTarget, + SessionAssignmentCatalogPort, + SessionAssignmentConfigPort, + SessionAssignmentPolicyPort, + SubagentAssignmentInput +} from './ports' + +const RETIRED_DEFAULT_AGENT_TOOLS = new Set(['find', 'ls']) +const LEGACY_PERSISTED_DISABLED_AGENT_TOOLS = new Set(['find', 'grep', 'ls']) +const LEGACY_AGENT_TOOL_NAME_MAP: Record = { + yo_browser_cdp_send: 'cdp_send', + yo_browser_window_open: 'load_url', + yo_browser_window_list: 'get_browser_status' +} + +const normalizePermissionMode = (mode: PermissionMode | null | undefined): PermissionMode => + mode === 'default' || mode === 'auto_approve' ? mode : 'full_access' + +export class SessionAgentAssignmentPolicy implements SessionAssignmentPolicyPort { + constructor( + private readonly catalog: SessionAssignmentCatalogPort, + private readonly config: SessionAssignmentConfigPort + ) {} + + async resolveCreateAssignment(input: CreateAssignmentInput): Promise { + const descriptor = this.catalog.resolveAgent(input.agentId) + const agentConfig = + descriptor.kind === 'deepchat' + ? await this.config.resolveDeepChatAgentConfig(descriptor.id) + : null + const projectDir = this.resolveProjectDir(input, agentConfig?.defaultProjectPath) + const defaultModel = this.config.getDefaultModel() + const providerId = + descriptor.kind === 'acp' + ? 'acp' + : (input.providerId ?? + agentConfig?.defaultModelPreset?.providerId ?? + defaultModel?.providerId ?? + '') + const modelId = + descriptor.kind === 'acp' + ? descriptor.id + : (input.modelId ?? agentConfig?.defaultModelPreset?.modelId ?? defaultModel?.modelId ?? '') + + if (!providerId || !modelId) { + throw new Error('No provider or model configured. Please set a default model in settings.') + } + this.assertAcpSessionHasWorkdir(providerId, projectDir) + + return { + agentId: descriptor.id, + agentType: descriptor.kind, + providerId, + modelId, + projectDir, + permissionMode: + input.permissionMode !== undefined + ? normalizePermissionMode(input.permissionMode) + : normalizePermissionMode(agentConfig?.permissionMode), + generationSettings: this.mergeDefaultGenerationSettings( + agentConfig, + input.generationSettings + ), + disabledAgentTools: + descriptor.kind === 'deepchat' + ? this.normalizeDisabledAgentTools( + input.disabledAgentTools ?? agentConfig?.disabledAgentTools + ) + : [], + subagentEnabled: + descriptor.kind === 'deepchat' + ? (input.subagentEnabled ?? agentConfig?.subagentEnabled ?? false) + : false + } + } + + resolveAcpDraftAssignment( + agentId: string, + permissionMode?: PermissionMode + ): { agentId: string; permissionMode: PermissionMode } { + const descriptor = this.catalog.resolveAgent(agentId) + if (descriptor.kind !== 'acp') { + throw new Error(`Agent ${agentId} is not an ACP agent.`) + } + return { + agentId: descriptor.id, + permissionMode: normalizePermissionMode(permissionMode) + } + } + + async resolveSubagentAssignment( + input: SubagentAssignmentInput + ): Promise { + let descriptor: { id: string; kind: 'deepchat' | 'acp' } + try { + descriptor = this.catalog.resolveAgent(resolveAcpAgentAlias(input.agentId.trim())) + } catch { + throw new Error(`Agent ${input.agentId} is not a valid subagent target.`) + } + + if (descriptor.kind === 'acp') { + this.assertAcpSessionHasWorkdir('acp', input.projectDir) + return { + agentId: descriptor.id, + targetAgentId: input.targetAgentId?.trim() ? descriptor.id : null, + providerId: 'acp', + modelId: descriptor.id, + generationSettings: { systemPrompt: '' }, + disabledAgentTools: [], + activeSkills: [] + } + } + + this.assertAcpSessionHasWorkdir(input.providerId, input.projectDir) + + return { + agentId: descriptor.id, + targetAgentId: input.targetAgentId?.trim() ? descriptor.id : null, + providerId: input.providerId, + modelId: input.modelId, + generationSettings: input.generationSettings, + disabledAgentTools: this.normalizeDisabledAgentTools(input.disabledAgentTools), + activeSkills: this.normalizeActiveSkills(input.activeSkills) + } + } + + async resolveTransferTarget( + targetAgentId: string, + currentProjectDir: string | null + ): Promise { + let descriptor: { id: string; kind: 'deepchat' | 'acp' } + try { + descriptor = this.catalog.resolveAgent(resolveAcpAgentAlias(targetAgentId.trim())) + } catch { + throw new Error(`Target agent not found: ${targetAgentId}`) + } + if (descriptor.kind === 'acp') { + throw new Error('Conversation history cannot be moved to ACP agents.') + } + + const agentConfig = await this.config.resolveDeepChatAgentConfig(descriptor.id) + const defaultModel = this.config.getDefaultModel() + const providerId = + agentConfig?.defaultModelPreset?.providerId?.trim() || defaultModel?.providerId?.trim() || '' + const modelId = + agentConfig?.defaultModelPreset?.modelId?.trim() || defaultModel?.modelId?.trim() || '' + if (!providerId || !modelId) { + throw new Error('Target DeepChat agent does not have a default model.') + } + if (providerId.toLowerCase() === 'acp') { + throw new Error('Conversation history cannot be moved to ACP agents.') + } + + return { + agentId: descriptor.id, + providerId, + modelId, + projectDir: + currentProjectDir?.trim() || + agentConfig?.defaultProjectPath?.trim() || + this.config.getDefaultProjectPath()?.trim() || + null, + permissionMode: normalizePermissionMode(agentConfig?.permissionMode), + generationSettings: this.mergeDefaultGenerationSettings(agentConfig), + disabledAgentTools: this.normalizeDisabledAgentTools(agentConfig?.disabledAgentTools), + subagentEnabled: agentConfig?.subagentEnabled === true + } + } + + assertAcpSessionHasWorkdir(providerId: string, projectDir: string | null): void { + if (providerId === 'acp' && !projectDir?.trim()) { + throw new Error('ACP agent requires selecting a workdir before sending messages.') + } + } + + normalizeDisabledAgentTools( + disabledAgentTools?: string[], + options?: { dropLegacySearchTools?: boolean } + ): string[] { + if (!Array.isArray(disabledAgentTools)) return [] + const retiredTools = options?.dropLegacySearchTools + ? LEGACY_PERSISTED_DISABLED_AGENT_TOOLS + : RETIRED_DEFAULT_AGENT_TOOLS + + return Array.from( + new Set( + disabledAgentTools + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .map((item) => LEGACY_AGENT_TOOL_NAME_MAP[item] ?? item) + .filter((item) => Boolean(item) && !retiredTools.has(item)) + ) + ).sort((left, right) => left.localeCompare(right)) + } + + normalizeActiveSkills(activeSkills?: string[]): string[] { + if (!Array.isArray(activeSkills)) return [] + return Array.from( + new Set( + activeSkills + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter(Boolean) + ) + ) + } + + private resolveProjectDir( + input: CreateAssignmentInput, + agentDefaultProjectDir: string | null | undefined + ): string | null { + if (input.preserveExplicitNullProjectDir && input.projectDir === null) return null + return ( + input.projectDir?.trim() || + agentDefaultProjectDir?.trim() || + this.config.getDefaultProjectPath()?.trim() || + null + ) + } + + private mergeDefaultGenerationSettings( + config: DeepChatAgentConfig | null, + overrides?: Partial + ): Partial | undefined { + const defaults: Partial = {} + if (typeof config?.systemPrompt === 'string') defaults.systemPrompt = config.systemPrompt + const merged = { ...defaults, ...overrides } + return Object.keys(merged).length > 0 ? merged : undefined + } +} diff --git a/src/main/presenter/sessionApplication/lifecycleDeletionTransaction.ts b/src/main/presenter/sessionApplication/lifecycleDeletionTransaction.ts new file mode 100644 index 000000000..1e3a06820 --- /dev/null +++ b/src/main/presenter/sessionApplication/lifecycleDeletionTransaction.ts @@ -0,0 +1,59 @@ +import { toAppSessionId } from '@/agent/shared/agentSessionIds' +import type { + SessionDeletionPermissionPort, + SessionDeletionProjectionPort, + SessionDeletionRuntimePort, + SessionDeletionSkillPort, + SessionDeletionStatePort, + SessionDeletionStorePort, + SessionLifecycleDeletionPort +} from './ports' + +export interface SessionDeletionTransactionDependencies { + sessions: SessionDeletionStorePort + runtime: SessionDeletionRuntimePort + state: SessionDeletionStatePort + permissions: SessionDeletionPermissionPort + skills: SessionDeletionSkillPort + projection: SessionDeletionProjectionPort +} + +export class SessionDeletionTransaction implements SessionLifecycleDeletionPort { + constructor(private readonly dependencies: SessionDeletionTransactionDependencies) {} + + async deleteSessionTree(sessionId: string): Promise { + const session = this.dependencies.sessions.get(sessionId) + if (!session) return [] + + const deletedSessionIds: string[] = [] + if (session.sessionKind === 'regular') { + const children = this.dependencies.sessions.list({ + includeSubagents: true, + parentSessionId: sessionId + }) + for (const child of children) { + deletedSessionIds.push(...(await this.deleteSessionTree(child.id))) + } + } + + let backendCleanupError: unknown + try { + await this.dependencies.runtime.cleanupSessionBackends(toAppSessionId(sessionId)) + } catch (error) { + backendCleanupError = error + } + try { + await this.dependencies.state.destroySession(sessionId) + } catch (error) { + if (!backendCleanupError) throw error + } + if (backendCleanupError) throw backendCleanupError + + this.dependencies.permissions.clearSessionPermissions(sessionId) + await this.dependencies.skills.clearNewAgentSessionSkills(sessionId) + this.dependencies.sessions.delete(sessionId) + this.dependencies.projection.forgetStatus([sessionId]) + deletedSessionIds.push(sessionId) + return deletedSessionIds + } +} diff --git a/src/main/presenter/sessionApplication/ports.ts b/src/main/presenter/sessionApplication/ports.ts index 4d44a9f92..c98131609 100644 --- a/src/main/presenter/sessionApplication/ports.ts +++ b/src/main/presenter/sessionApplication/ports.ts @@ -1,13 +1,27 @@ import type { AppSessionId } from '@/agent/shared/agentSessionIds' -import type { AgentTapePort, AgentTranscriptReadPort } from '@/agent/shared/agentSharedData' import type { + AgentSessionStatePort, + AgentTapePort, + AgentTranscriptReadPort +} from '@/agent/shared/agentSharedData' +import type { + ResolvedAgentSession, + ResolvedDeepChatTransferTarget, + ResolvedSubagentFacet, + ResolvedTransferSource +} from '@/agent/manager/agentManager' +import type { + DeepChatAgentConfig, DeepChatSessionState, + PermissionMode, + SessionGenerationSettings, SessionLightweightListResult, SessionListItem, SessionPageCursor, SessionRecord, SessionWithState } from '@shared/types/agent-interface' +import type { AcpAsLlmProviderSessionControlPort } from '../runtimePorts' import type { DeepChatMessageRow } from '../sqlitePresenter/tables/deepchatMessages' import type { DeepChatMessageSearchResultRow } from '../sqlitePresenter/tables/deepchatMessageSearchResults' import type { DeepChatMessageTraceRow } from '../sqlitePresenter/tables/deepchatMessageTraces' @@ -150,3 +164,156 @@ export interface SessionProjectionMutationPort { forgetStatus(sessionIds: string[]): void scheduleTitleGeneration(input: TitleGenerationInput): void } + +export interface SessionAssignmentCatalogPort { + resolveAgent(agentId: string): { id: string; kind: 'deepchat' | 'acp' } +} + +export interface SessionAssignmentConfigPort { + getDefaultModel(): { providerId: string; modelId: string } | null | undefined + getDefaultProjectPath(): string | null + resolveDeepChatAgentConfig(agentId: string): Promise +} + +export interface CreateAssignmentInput { + agentId: string + providerId?: string + modelId?: string + projectDir?: string | null + permissionMode?: PermissionMode + generationSettings?: Partial + disabledAgentTools?: string[] + subagentEnabled?: boolean + preserveExplicitNullProjectDir: boolean +} + +export interface ResolvedSessionAssignment { + agentId: string + agentType: 'deepchat' | 'acp' + providerId: string + modelId: string + projectDir: string | null + permissionMode: PermissionMode + generationSettings?: Partial + disabledAgentTools: string[] + subagentEnabled: boolean +} + +export interface SubagentAssignmentInput { + agentId: string + targetAgentId?: string | null + projectDir: string | null + providerId: string + modelId: string + generationSettings?: Partial + disabledAgentTools?: string[] + activeSkills?: string[] +} + +export interface ResolvedSubagentAssignment { + agentId: string + targetAgentId: string | null + providerId: string + modelId: string + generationSettings?: Partial + disabledAgentTools: string[] + activeSkills: string[] +} + +export interface ResolvedTransferTarget { + agentId: string + providerId: string + modelId: string + projectDir: string | null + permissionMode: PermissionMode + generationSettings?: Partial + disabledAgentTools: string[] + subagentEnabled: boolean +} + +export interface SessionAssignmentPolicyPort { + resolveCreateAssignment(input: CreateAssignmentInput): Promise + resolveAcpDraftAssignment( + agentId: string, + permissionMode?: PermissionMode + ): { agentId: string; permissionMode: PermissionMode } + resolveSubagentAssignment(input: SubagentAssignmentInput): Promise + resolveTransferTarget( + targetAgentId: string, + currentProjectDir: string | null + ): Promise + assertAcpSessionHasWorkdir(providerId: string, projectDir: string | null): void + normalizeDisabledAgentTools( + disabledAgentTools?: string[], + options?: { dropLegacySearchTools?: boolean } + ): string[] + normalizeActiveSkills(activeSkills?: string[]): string[] +} + +export interface SessionAssignmentStorePort { + get(sessionId: string): SessionRecord | null + list(filters?: SessionListFilters): SessionRecord[] + update( + sessionId: string, + fields: Partial> + ): void + updateAgentId(sessionId: string, agentId: string): void + getDisabledAgentTools(sessionId: string): string[] + updateDisabledAgentTools(sessionId: string, disabledAgentTools: string[]): void +} + +export interface SessionAssignmentRuntimePort { + resolveSession(sessionId: AppSessionId): ResolvedAgentSession + resolveTransferSource(sessionId: AppSessionId): ResolvedTransferSource + resolveDeepChatTransferTarget(agentId: string): ResolvedDeepChatTransferTarget + resolveSubagentFacet(sessionId: AppSessionId): ResolvedSubagentFacet +} + +export interface SessionAssignmentEnvironmentPort { + syncPath(projectDir: string): void +} + +export type SessionAssignmentProjectionPort = Pick< + SessionProjectionMutationPort, + 'materialize' | 'notify' +> + +export interface SessionLifecycleDeletionPort { + deleteSessionTree(sessionId: string): Promise +} + +export interface SessionAssignmentWorkdirPort { + assertAcpSessionHasWorkdir(providerId: string, projectDir: string | null): void + syncAcpSessionWorkdir( + providerId: string, + sessionId: string, + agentId: string, + projectDir?: string | null + ): Promise + prepareDirectAcpSession(sessionId: string): Promise + clearCompatibilityAcpSession(sessionId: string): Promise +} + +export interface SessionDeletionStorePort { + get(sessionId: string): SessionRecord | null + list(filters?: SessionListFilters): SessionRecord[] + delete(sessionId: string): void +} + +export interface SessionDeletionRuntimePort { + cleanupSessionBackends(sessionId: AppSessionId): Promise +} + +export type SessionDeletionStatePort = Pick + +export interface SessionDeletionPermissionPort { + clearSessionPermissions(sessionId: string): void +} + +export interface SessionDeletionSkillPort { + clearNewAgentSessionSkills(sessionId: string): Promise +} + +export type SessionDeletionProjectionPort = Pick + +export type SessionAssignmentAcpControlPort = AcpAsLlmProviderSessionControlPort diff --git a/test/main/presenter/agentSessionPresenter/agentSessionPresenter.test.ts b/test/main/presenter/agentSessionPresenter/agentSessionPresenter.test.ts index 531bb4d90..8f0a982e7 100644 --- a/test/main/presenter/agentSessionPresenter/agentSessionPresenter.test.ts +++ b/test/main/presenter/agentSessionPresenter/agentSessionPresenter.test.ts @@ -11,6 +11,7 @@ import { AgentSessionPresenter } from '@/presenter/agentSessionPresenter/index' import { resolveAcpAgentAlias } from '@shared/utils/acpAgentAlias' import { createDeepChatAgentBackendFixture } from '../../agent/manager/deepChatAgentBackendFixture' import { createProjectionCoordinatorFixture } from './projectionCoordinatorFixture' +import { createAssignmentCoordinatorFixture } from './assignmentCoordinatorFixture' vi.mock('nanoid', () => ({ nanoid: vi.fn(() => 'mock-session-id') })) @@ -578,6 +579,25 @@ function createDescriptorIndependentDeleteHarness(options: { transcriptMutation: deepchatImplementation, tape: deepchatImplementation } as any + const projection = createProjectionCoordinatorFixture({ + agentManager: manager, + appSessionService, + llmProviderPresenter, + configPresenter, + sqlitePresenter: sqliteWithAgents, + sharedData + }) + const sessionApplications = createAssignmentCoordinatorFixture({ + agentManager: manager, + appSessionService, + configPresenter, + sqlitePresenter: sqliteWithAgents, + sharedData, + projection, + acp: llmProviderPresenter, + skillPresenter, + sessionPermissionPort + }) const presenter = new AgentSessionPresenter( manager, appSessionService, @@ -585,14 +605,10 @@ function createDescriptorIndependentDeleteHarness(options: { configPresenter, sqliteWithAgents, sharedData, - createProjectionCoordinatorFixture({ - agentManager: manager, - appSessionService, - llmProviderPresenter, - configPresenter, - sqlitePresenter: sqliteWithAgents, - sharedData - }), + projection, + sessionApplications.policy, + sessionApplications.assignment, + sessionApplications.deletion, skillPresenter, { sessionPermissionPort } ) @@ -721,6 +737,25 @@ describe('AgentSessionPresenter', () => { transcriptMutation: deepChatAgent, tape: deepChatAgent } as any + const projection = createProjectionCoordinatorFixture({ + agentManager: agentManager as any, + appSessionService, + llmProviderPresenter, + configPresenter, + sqlitePresenter, + sharedData, + sessionUiPort + }) + const sessionApplications = createAssignmentCoordinatorFixture({ + agentManager: agentManager as any, + appSessionService, + configPresenter, + sqlitePresenter, + sharedData, + projection, + acp: llmProviderPresenter, + skillPresenter + }) presenter = new AgentSessionPresenter( agentManager as any, appSessionService, @@ -728,17 +763,12 @@ describe('AgentSessionPresenter', () => { configPresenter, sqlitePresenter, sharedData, - createProjectionCoordinatorFixture({ - agentManager: agentManager as any, - appSessionService, - llmProviderPresenter, - configPresenter, - sqlitePresenter, - sharedData, - sessionUiPort - }), + projection, + sessionApplications.policy, + sessionApplications.assignment, + sessionApplications.deletion, skillPresenter, - { acpAsLlmProviderSessionControl: llmProviderPresenter } + undefined ) }) @@ -968,6 +998,24 @@ describe('AgentSessionPresenter', () => { transcriptMutation: deepchatImplementation, tape: deepchatImplementation } as any + const projection = createProjectionCoordinatorFixture({ + agentManager: realManager, + appSessionService, + llmProviderPresenter, + configPresenter, + sqlitePresenter: sqliteWithAgents, + sharedData: integratedSharedData + }) + const sessionApplications = createAssignmentCoordinatorFixture({ + agentManager: realManager, + appSessionService, + configPresenter, + sqlitePresenter: sqliteWithAgents, + sharedData: integratedSharedData, + projection, + acp: llmProviderPresenter, + skillPresenter + }) const integratedPresenter = new AgentSessionPresenter( realManager, appSessionService, @@ -975,14 +1023,10 @@ describe('AgentSessionPresenter', () => { configPresenter, sqliteWithAgents, integratedSharedData, - createProjectionCoordinatorFixture({ - agentManager: realManager, - appSessionService, - llmProviderPresenter, - configPresenter, - sqlitePresenter: sqliteWithAgents, - sharedData: integratedSharedData - }), + projection, + sessionApplications.policy, + sessionApplications.assignment, + sessionApplications.deletion, skillPresenter ) diff --git a/test/main/presenter/agentSessionPresenter/assignmentCoordinatorFixture.ts b/test/main/presenter/agentSessionPresenter/assignmentCoordinatorFixture.ts new file mode 100644 index 000000000..20a8fb6bb --- /dev/null +++ b/test/main/presenter/agentSessionPresenter/assignmentCoordinatorFixture.ts @@ -0,0 +1,82 @@ +import type { AgentManager } from '@/agent/manager/agentManager' +import type { AgentSharedDataPorts } from '@/agent/shared/agentSharedData' +import type { AppSessionService } from '@/agent/shared/appSessionService' +import type { IConfigPresenter, ISkillPresenter } from '@shared/presenter' +import type { SQLitePresenter } from '@/presenter/sqlitePresenter' +import type { + AcpAsLlmProviderSessionControlPort, + SessionPermissionPort +} from '@/presenter/runtimePorts' +import { SessionAgentAssignmentPolicy } from '@/presenter/sessionApplication/agentAssignmentPolicy' +import { SessionAgentAssignmentCoordinator } from '@/presenter/sessionApplication/agentAssignmentCoordinator' +import { SessionDeletionTransaction } from '@/presenter/sessionApplication/lifecycleDeletionTransaction' +import type { SessionProjectionCoordinator } from '@/presenter/sessionApplication/projectionCoordinator' + +export const createAssignmentCoordinatorFixture = (input: { + agentManager: AgentManager + appSessionService: AppSessionService + configPresenter: IConfigPresenter + sqlitePresenter: SQLitePresenter + sharedData: AgentSharedDataPorts + projection: SessionProjectionCoordinator + acp: AcpAsLlmProviderSessionControlPort + skillPresenter?: Pick + sessionPermissionPort?: Pick +}): { + policy: SessionAgentAssignmentPolicy + assignment: SessionAgentAssignmentCoordinator + deletion: SessionDeletionTransaction +} => { + const policy = new SessionAgentAssignmentPolicy( + { + resolveAgent: (agentId) => { + const descriptor = input.agentManager.resolveBackend(agentId).descriptor + return { id: descriptor.id, kind: descriptor.kind } + } + }, + { + getDefaultModel: () => input.configPresenter.getDefaultModel(), + getDefaultProjectPath: () => input.configPresenter.getDefaultProjectPath?.() ?? null, + resolveDeepChatAgentConfig: async (agentId) => { + if (typeof input.configPresenter.resolveDeepChatAgentConfig !== 'function') return null + return await input.configPresenter.resolveDeepChatAgentConfig(agentId) + } + } + ) + const deletion = new SessionDeletionTransaction({ + sessions: input.appSessionService, + runtime: { + cleanupSessionBackends: async (sessionId) => + await input.agentManager.cleanupSessionBackends(sessionId) + }, + state: input.sharedData.sessionState, + permissions: { + clearSessionPermissions: (sessionId) => + input.sessionPermissionPort?.clearSessionPermissions(sessionId) + }, + skills: { + clearNewAgentSessionSkills: async (sessionId) => + await input.skillPresenter?.clearNewAgentSessionSkills?.(sessionId) + }, + projection: input.projection + }) + const assignment = new SessionAgentAssignmentCoordinator({ + sessions: input.appSessionService, + runtime: { + resolveSession: (sessionId) => input.agentManager.resolveSessionHandle(sessionId), + resolveTransferSource: (sessionId) => input.agentManager.resolveTransferSource(sessionId), + resolveDeepChatTransferTarget: (agentId) => + input.agentManager.resolveDeepChatTransferTarget(agentId), + resolveSubagentFacet: (sessionId) => input.agentManager.resolveSubagentFacet(sessionId) + }, + policy, + projection: input.projection, + deletion, + environment: { + syncPath: (projectDir) => input.sqlitePresenter.newEnvironmentsTable.syncPath(projectDir) + }, + acp: input.acp + }) + + return { policy, assignment, deletion } +} diff --git a/test/main/presenter/agentSessionPresenter/integration.test.ts b/test/main/presenter/agentSessionPresenter/integration.test.ts index 348253f40..7dece8da0 100644 --- a/test/main/presenter/agentSessionPresenter/integration.test.ts +++ b/test/main/presenter/agentSessionPresenter/integration.test.ts @@ -11,6 +11,7 @@ import { toAppSessionId } from '@/agent/shared/agentSessionIds' import type { DeepChatActiveGeneration } from '@/agent/deepchat/instance/deepChatAgentInstance' import { createDeepChatAgentBackendFixture } from '../../agent/manager/deepChatAgentBackendFixture' import { createProjectionCoordinatorFixture } from './projectionCoordinatorFixture' +import { createAssignmentCoordinatorFixture } from './assignmentCoordinatorFixture' vi.mock('nanoid', () => { let counter = 0 @@ -695,6 +696,23 @@ describe('Integration: createSession end-to-end', () => { transcriptMutation: deepchatAgent, tape: deepchatAgent } + const projection = createProjectionCoordinatorFixture({ + agentManager, + appSessionService, + llmProviderPresenter: llmProvider, + configPresenter, + sqlitePresenter, + sharedData + }) + const sessionApplications = createAssignmentCoordinatorFixture({ + agentManager, + appSessionService, + configPresenter, + sqlitePresenter, + sharedData, + projection, + acp: llmProvider + }) agentPresenter = new AgentSessionPresenter( agentManager, appSessionService, @@ -702,14 +720,10 @@ describe('Integration: createSession end-to-end', () => { configPresenter, sqlitePresenter, sharedData, - createProjectionCoordinatorFixture({ - agentManager, - appSessionService, - llmProviderPresenter: llmProvider, - configPresenter, - sqlitePresenter, - sharedData - }) + projection, + sessionApplications.policy, + sessionApplications.assignment, + sessionApplications.deletion ) }) @@ -869,6 +883,23 @@ describe('Integration: ACP hooks bridge', () => { transcriptMutation: deepchatAgent, tape: deepchatAgent } + const projection = createProjectionCoordinatorFixture({ + agentManager, + appSessionService, + llmProviderPresenter: llmProvider, + configPresenter, + sqlitePresenter, + sharedData + }) + const sessionApplications = createAssignmentCoordinatorFixture({ + agentManager, + appSessionService, + configPresenter, + sqlitePresenter, + sharedData, + projection, + acp: llmProvider + }) agentPresenter = new AgentSessionPresenter( agentManager, appSessionService, @@ -876,16 +907,10 @@ describe('Integration: ACP hooks bridge', () => { configPresenter, sqlitePresenter, sharedData, - createProjectionCoordinatorFixture({ - agentManager, - appSessionService, - llmProviderPresenter: llmProvider, - configPresenter, - sqlitePresenter, - sharedData - }), - undefined, - { acpAsLlmProviderSessionControl: llmProvider } + projection, + sessionApplications.policy, + sessionApplications.assignment, + sessionApplications.deletion ) }) @@ -973,6 +998,23 @@ describe('Integration: multi-turn context', () => { transcriptMutation: deepchatAgent, tape: deepchatAgent } + const projection = createProjectionCoordinatorFixture({ + agentManager, + appSessionService, + llmProviderPresenter: llmProvider, + configPresenter, + sqlitePresenter, + sharedData + }) + const sessionApplications = createAssignmentCoordinatorFixture({ + agentManager, + appSessionService, + configPresenter, + sqlitePresenter, + sharedData, + projection, + acp: llmProvider + }) agentPresenter = new AgentSessionPresenter( agentManager, appSessionService, @@ -980,14 +1022,10 @@ describe('Integration: multi-turn context', () => { configPresenter, sqlitePresenter, sharedData, - createProjectionCoordinatorFixture({ - agentManager, - appSessionService, - llmProviderPresenter: llmProvider, - configPresenter, - sqlitePresenter, - sharedData - }) + projection, + sessionApplications.policy, + sessionApplications.assignment, + sessionApplications.deletion ) }) diff --git a/test/main/presenter/agentSessionPresenter/usageDashboard.test.ts b/test/main/presenter/agentSessionPresenter/usageDashboard.test.ts index 803eb28c6..5cd37af27 100644 --- a/test/main/presenter/agentSessionPresenter/usageDashboard.test.ts +++ b/test/main/presenter/agentSessionPresenter/usageDashboard.test.ts @@ -5,6 +5,7 @@ import { DeepChatMessageStore } from '@/presenter/agentRuntimePresenter/messageS import { DASHBOARD_STATS_BACKFILL_KEY, type UsageStatsRecordInput } from '@/presenter/usageStats' import { createDeepChatAgentBackendFixture } from '../../agent/manager/deepChatAgentBackendFixture' import { createProjectionCoordinatorFixture } from './projectionCoordinatorFixture' +import { createAssignmentCoordinatorFixture } from './assignmentCoordinatorFixture' vi.mock('@/eventbus', () => ({ eventBus: { sendToMain: vi.fn(), on: vi.fn() } @@ -468,6 +469,23 @@ describe('AgentSessionPresenter usage dashboard', () => { transcriptMutation: deepChatAgent, tape: deepChatAgent } as any + const projection = createProjectionCoordinatorFixture({ + agentManager, + appSessionService, + llmProviderPresenter, + configPresenter: configPresenter as any, + sqlitePresenter, + sharedData + }) + const sessionApplications = createAssignmentCoordinatorFixture({ + agentManager, + appSessionService, + configPresenter: configPresenter as any, + sqlitePresenter, + sharedData, + projection, + acp: llmProviderPresenter + }) const presenter = new AgentSessionPresenter( agentManager, appSessionService, @@ -475,14 +493,10 @@ describe('AgentSessionPresenter usage dashboard', () => { configPresenter as any, sqlitePresenter, sharedData, - createProjectionCoordinatorFixture({ - agentManager, - appSessionService, - llmProviderPresenter, - configPresenter: configPresenter as any, - sqlitePresenter, - sharedData - }) + projection, + sessionApplications.policy, + sessionApplications.assignment, + sessionApplications.deletion ) return { diff --git a/test/main/presenter/sessionApplication/agentAssignmentCoordinator.test.ts b/test/main/presenter/sessionApplication/agentAssignmentCoordinator.test.ts new file mode 100644 index 000000000..f873e9172 --- /dev/null +++ b/test/main/presenter/sessionApplication/agentAssignmentCoordinator.test.ts @@ -0,0 +1,362 @@ +import { describe, expect, it, vi } from 'vitest' +import type { SessionRecord, SessionWithState } from '@shared/types/agent-interface' +import { + SessionAgentAssignmentCoordinator, + type SessionAgentAssignmentDependencies +} from '@/presenter/sessionApplication/agentAssignmentCoordinator' + +const createSession = (overrides: Partial = {}): SessionRecord => ({ + id: 's1', + agentId: 'source', + title: 'Session', + projectDir: '/source', + isPinned: false, + isDraft: false, + sessionKind: 'regular', + parentSessionId: null, + subagentEnabled: false, + subagentMeta: null, + createdAt: 100, + updatedAt: 200, + ...overrides +}) + +const materialize = (session: SessionRecord): SessionWithState => ({ + ...session, + status: 'idle', + providerId: 'openai', + modelId: 'gpt-4' +}) + +function createHarness(initialSessions: SessionRecord[] = [createSession()]) { + const records = new Map(initialSessions.map((session) => [session.id, session])) + const hasMessages = new Map(initialSessions.map((session) => [session.id, true])) + const pendingInputs = new Map(initialSessions.map((session) => [session.id, [] as unknown[]])) + const settings = { + getPermissionMode: vi.fn().mockResolvedValue('full_access'), + setPermissionMode: vi.fn().mockResolvedValue(undefined), + getGenerationSettings: vi.fn().mockResolvedValue(null), + updateGenerationSettings: vi.fn().mockResolvedValue({ temperature: 0.2 }), + setProjectDir: vi.fn().mockResolvedValue(undefined) + } + const deepchat = { + setSessionAgentContext: vi.fn().mockResolvedValue(undefined), + setModel: vi.fn().mockResolvedValue(undefined), + getCompactionState: vi.fn(), + compact: vi.fn(), + invalidateSystemPromptCache: vi.fn() + } + const acpFacet = { + prepare: vi.fn().mockResolvedValue(undefined), + updateWorkdir: vi.fn().mockResolvedValue('/repo'), + getModes: vi.fn(), + setMode: vi.fn(), + getConfigOptions: vi.fn().mockResolvedValue({ options: [] }), + setConfigOption: vi.fn().mockResolvedValue({ options: [] }), + getCommands: vi.fn().mockResolvedValue([{ name: 'test', description: 'Test' }]), + closeRuntime: vi.fn().mockResolvedValue(undefined) + } + const deepchatHandle = { + kind: 'deepchat', + snapshot: vi.fn().mockResolvedValue({ + status: 'idle', + providerId: 'openai', + modelId: 'gpt-4' + }), + settings, + deepchat + } + const acpHandle = { + kind: 'acp', + snapshot: vi.fn().mockResolvedValue({ + status: 'idle', + providerId: 'acp', + modelId: 'claude-acp' + }), + settings, + acp: acpFacet + } + const sessions = { + get: vi.fn((sessionId: string) => records.get(sessionId) ?? null), + list: vi.fn( + (filters?: { agentId?: string; parentSessionId?: string; includeSubagents?: boolean }) => + [...records.values()].filter((session) => { + if (filters?.agentId && session.agentId !== filters.agentId) return false + if (filters?.parentSessionId && session.parentSessionId !== filters.parentSessionId) { + return false + } + return filters?.includeSubagents || session.sessionKind === 'regular' + }) + ), + update: vi.fn((sessionId: string, fields: Partial) => { + const session = records.get(sessionId) + if (session) records.set(sessionId, { ...session, ...fields }) + }), + updateAgentId: vi.fn((sessionId: string, agentId: string) => { + const session = records.get(sessionId) + if (session) records.set(sessionId, { ...session, agentId }) + }), + getDisabledAgentTools: vi.fn(() => []), + updateDisabledAgentTools: vi.fn() + } + const runtime = { + resolveSession: vi.fn((sessionId: string) => { + const session = records.get(sessionId) + const isAcp = session?.agentId.includes('acp') + return isAcp + ? { + kind: 'acp', + descriptor: { id: session.agentId, kind: 'acp', source: 'manual' }, + handle: acpHandle + } + : { + kind: 'deepchat', + descriptor: { + id: session?.agentId ?? 'source', + kind: 'deepchat', + source: 'manual', + config: {} + }, + handle: deepchatHandle + } + }), + resolveTransferSource: vi.fn((sessionId: string) => ({ + descriptor: { id: records.get(sessionId)?.agentId ?? 'source', kind: 'deepchat' }, + handle: deepchatHandle, + facet: { + hasMessages: vi.fn(async () => hasMessages.get(sessionId) ?? true), + listPendingInputs: vi.fn(async () => pendingInputs.get(sessionId) ?? []) + } + })), + resolveDeepChatTransferTarget: vi.fn(() => ({ + descriptor: { id: 'target', kind: 'deepchat' }, + facet: { setSessionAgentContext: deepchat.setSessionAgentContext } + })), + resolveSubagentFacet: vi.fn(() => ({ + kind: 'deepchat', + descriptor: { id: 'source', kind: 'deepchat' }, + facet: { + mergeTape: vi.fn().mockResolvedValue(undefined), + discardTape: vi.fn().mockResolvedValue(undefined) + } + })) + } + const policy = { + resolveCreateAssignment: vi.fn(), + resolveAcpDraftAssignment: vi.fn(), + resolveSubagentAssignment: vi.fn(), + resolveTransferTarget: vi.fn(async (_agentId: string, projectDir: string | null) => ({ + agentId: 'target', + providerId: 'openai', + modelId: 'gpt-4', + projectDir: projectDir ?? '/target', + permissionMode: 'full_access', + disabledAgentTools: ['write'], + subagentEnabled: true + })), + assertAcpSessionHasWorkdir: vi.fn((providerId: string, projectDir: string | null) => { + if (providerId === 'acp' && !projectDir) throw new Error('workdir required') + }), + normalizeDisabledAgentTools: vi.fn((tools?: string[]) => tools ?? []), + normalizeActiveSkills: vi.fn((skills?: string[]) => skills ?? []) + } + const projection = { + materialize: vi.fn(async (sessionId: string) => { + const session = records.get(sessionId) + return session ? materialize(session) : null + }), + notify: vi.fn() + } + const deletion = { deleteSessionTree: vi.fn().mockResolvedValue([]) } + const environment = { syncPath: vi.fn() } + const acp = { + setAcpWorkdir: vi.fn().mockResolvedValue(undefined), + getAcpSessionConfigOptions: vi.fn().mockResolvedValue({ options: [] }), + setAcpSessionConfigOption: vi.fn().mockResolvedValue({ options: [] }), + getAcpSessionCommands: vi.fn().mockResolvedValue([{ name: 'compat', description: 'Compat' }]), + clearAcpSession: vi.fn().mockResolvedValue(undefined) + } + const dependencies = { + sessions, + runtime, + policy, + projection, + deletion, + environment, + acp + } as unknown as SessionAgentAssignmentDependencies + + return { + coordinator: new SessionAgentAssignmentCoordinator(dependencies), + records, + hasMessages, + pendingInputs, + sessions, + runtime, + policy, + projection, + deletion, + environment, + acp, + settings, + deepchat, + deepchatHandle, + acpHandle, + acpFacet + } +} + +describe('SessionAgentAssignmentCoordinator', () => { + it('keeps failed transcript and pending checks conservative', async () => { + const harness = createHarness([ + createSession({ id: 'draft', isDraft: true }), + createSession({ id: 'pending', isDraft: true }) + ]) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + harness.runtime.resolveTransferSource.mockImplementation((sessionId: string) => ({ + handle: harness.deepchatHandle, + facet: { + hasMessages: + sessionId === 'draft' + ? vi.fn().mockRejectedValue(new Error('transcript failed')) + : vi.fn().mockResolvedValue(false), + listPendingInputs: + sessionId === 'pending' + ? vi.fn().mockRejectedValue(new Error('pending failed')) + : vi.fn().mockResolvedValue([]) + } + })) + + await expect(harness.coordinator.getAgentTransferImpact('source')).resolves.toMatchObject({ + emptyDrafts: 1, + movableSessions: 1, + blockedSessions: 1, + samples: [ + expect.objectContaining({ id: 'draft', blockReason: undefined }), + expect.objectContaining({ id: 'pending', blockReason: 'pending-input' }) + ] + }) + expect(warn).toHaveBeenCalledTimes(2) + warn.mockRestore() + }) + + it('preflights every batch entry before the first transfer mutation', async () => { + const harness = createHarness([createSession({ id: 's1' }), createSession({ id: 's2' })]) + harness.policy.resolveTransferTarget.mockImplementation( + async (_agentId: string, projectDir: string | null) => { + if (projectDir === '/blocked') throw new Error('blocked project') + if ( + projectDir === '/source' && + harness.policy.resolveTransferTarget.mock.calls.length > 2 + ) { + throw new Error('blocked project') + } + return { + agentId: 'target', + providerId: 'openai', + modelId: 'gpt-4', + projectDir, + permissionMode: 'full_access', + disabledAgentTools: [], + subagentEnabled: false + } + } + ) + + await expect(harness.coordinator.moveAgentSessions('source', 'target')).rejects.toThrow( + 'blocked project' + ) + expect(harness.deepchat.setSessionAgentContext).not.toHaveBeenCalled() + expect(harness.sessions.updateAgentId).not.toHaveBeenCalled() + }) + + it('reports and publishes completed work when a later transfer fails', async () => { + const harness = createHarness([createSession({ id: 's1' }), createSession({ id: 's2' })]) + harness.projection.materialize + .mockImplementationOnce(async () => materialize(harness.records.get('s1')!)) + .mockResolvedValueOnce(null) + + await expect(harness.coordinator.moveAgentSessions('source', 'target')).rejects.toThrow( + 'Failed to build session state after transfer: s2 Partial transfer completed: 1 moved.' + ) + expect(harness.projection.notify).toHaveBeenCalledWith({ + sessionIds: ['s1'], + reason: 'updated' + }) + }) + + it('uses the required deletion port for bulk deletion and publishes once', async () => { + const harness = createHarness([createSession({ id: 'parent' })]) + harness.deletion.deleteSessionTree.mockResolvedValue(['child', 'parent']) + + await expect(harness.coordinator.deleteAgentSessions('source')).resolves.toEqual([ + 'child', + 'parent' + ]) + expect(harness.deletion.deleteSessionTree).toHaveBeenCalledWith('parent') + expect(harness.projection.notify).toHaveBeenCalledWith({ + sessionIds: ['child', 'parent'], + reason: 'deleted' + }) + }) + + it('preserves the non-transactional project update order', async () => { + const harness = createHarness([createSession({ agentId: 'claude-acp' })]) + const order: string[] = [] + harness.sessions.update.mockImplementation( + (sessionId: string, fields: Partial) => { + order.push('store') + const session = harness.records.get(sessionId) + if (session) harness.records.set(sessionId, { ...session, ...fields }) + } + ) + harness.environment.syncPath.mockImplementation(() => order.push('environment')) + harness.settings.setProjectDir.mockImplementation(async () => { + order.push('runtime-setting') + }) + harness.acpFacet.updateWorkdir.mockImplementation(async () => { + order.push('acp-workdir') + return '/next' + }) + + await harness.coordinator.setSessionProjectDir('s1', ' /next ') + expect(order).toEqual(['store', 'environment', 'runtime-setting', 'acp-workdir']) + }) + + it('routes direct and compatibility ACP commands through their narrow controls', async () => { + const harness = createHarness([ + createSession({ id: 'direct', agentId: 'claude-acp' }), + createSession({ id: 'compat', agentId: 'source' }) + ]) + harness.deepchatHandle.snapshot.mockResolvedValue({ + status: 'idle', + providerId: 'acp', + modelId: 'claude-acp' + }) + + await expect(harness.coordinator.getAcpSessionCommands('direct')).resolves.toEqual([ + { name: 'test', description: 'Test' } + ]) + await expect(harness.coordinator.getAcpSessionCommands('compat')).resolves.toEqual([ + { name: 'compat', description: 'Compat' } + ]) + expect(harness.acpFacet.getCommands).toHaveBeenCalledOnce() + expect(harness.acp.getAcpSessionCommands).toHaveBeenCalledWith('compat') + }) + + it('validates Tape parentage before resolving the runtime facet', async () => { + const harness = createHarness([ + createSession({ id: 'parent' }), + createSession({ + id: 'child', + sessionKind: 'subagent', + parentSessionId: 'different-parent' + }) + ]) + + await expect(harness.coordinator.mergeSubagentTape('parent', 'child')).rejects.toThrow( + 'Session child is not a child of parent.' + ) + expect(harness.runtime.resolveSubagentFacet).not.toHaveBeenCalled() + }) +}) diff --git a/test/main/presenter/sessionApplication/agentAssignmentPolicy.test.ts b/test/main/presenter/sessionApplication/agentAssignmentPolicy.test.ts new file mode 100644 index 000000000..02fad8d99 --- /dev/null +++ b/test/main/presenter/sessionApplication/agentAssignmentPolicy.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it, vi } from 'vitest' +import { SessionAgentAssignmentPolicy } from '@/presenter/sessionApplication/agentAssignmentPolicy' + +function createHarness() { + const agents = new Map([ + ['deepchat', { id: 'deepchat', kind: 'deepchat' as const }], + ['reviewer', { id: 'reviewer', kind: 'deepchat' as const }], + ['claude-acp', { id: 'claude-acp', kind: 'acp' as const }] + ]) + const catalog = { + resolveAgent: vi.fn((agentId: string) => { + const descriptor = agents.get(agentId) + if (!descriptor) throw new Error(`Agent not found: ${agentId}`) + return descriptor + }) + } + const configs = new Map([ + [ + 'deepchat', + { + defaultModelPreset: { providerId: 'anthropic', modelId: 'claude' }, + defaultProjectPath: '/agent-project', + permissionMode: 'auto_approve', + systemPrompt: 'Agent prompt', + disabledAgentTools: ['find', 'write', 'write'], + subagentEnabled: true + } + ], + ['reviewer', {}] + ]) + const config = { + getDefaultModel: vi.fn(() => ({ providerId: 'openai', modelId: 'gpt-4' })), + getDefaultProjectPath: vi.fn(() => '/global-project'), + resolveDeepChatAgentConfig: vi.fn(async (agentId: string) => configs.get(agentId) ?? null) + } + return { policy: new SessionAgentAssignmentPolicy(catalog, config), catalog, config, configs } +} + +describe('SessionAgentAssignmentPolicy', () => { + it('resolves DeepChat creation precedence and normalizes persisted settings', async () => { + const { policy } = createHarness() + + await expect( + policy.resolveCreateAssignment({ + agentId: 'deepchat', + providerId: 'openrouter', + modelId: 'custom-model', + generationSettings: { temperature: 0.2 }, + preserveExplicitNullProjectDir: false + }) + ).resolves.toEqual({ + agentId: 'deepchat', + agentType: 'deepchat', + providerId: 'openrouter', + modelId: 'custom-model', + projectDir: '/agent-project', + permissionMode: 'auto_approve', + generationSettings: { systemPrompt: 'Agent prompt', temperature: 0.2 }, + disabledAgentTools: ['write'], + subagentEnabled: true + }) + }) + + it('preserves explicit null only for attached creation', async () => { + const { policy } = createHarness() + + const attached = await policy.resolveCreateAssignment({ + agentId: 'deepchat', + projectDir: null, + preserveExplicitNullProjectDir: true + }) + const detached = await policy.resolveCreateAssignment({ + agentId: 'deepchat', + projectDir: null, + preserveExplicitNullProjectDir: false + }) + + expect(attached.projectDir).toBeNull() + expect(detached.projectDir).toBe('/agent-project') + }) + + it('forces ACP identity and requires a workdir', async () => { + const { policy } = createHarness() + + await expect( + policy.resolveCreateAssignment({ + agentId: 'claude-acp', + projectDir: null, + preserveExplicitNullProjectDir: true + }) + ).rejects.toThrow('ACP agent requires selecting a workdir') + + await expect( + policy.resolveCreateAssignment({ + agentId: 'claude-acp', + providerId: 'ignored', + modelId: 'ignored', + projectDir: '/repo', + disabledAgentTools: ['write'], + subagentEnabled: true, + preserveExplicitNullProjectDir: true + }) + ).resolves.toMatchObject({ + agentId: 'claude-acp', + agentType: 'acp', + providerId: 'acp', + modelId: 'claude-acp', + disabledAgentTools: [], + subagentEnabled: false + }) + }) + + it('canonicalizes ACP subagents and rejects ACP transfer targets', async () => { + const { policy, catalog } = createHarness() + + await expect( + policy.resolveSubagentAssignment({ + agentId: 'claude-code-acp', + targetAgentId: 'legacy-slot', + projectDir: '/repo', + providerId: 'openai', + modelId: 'gpt-4', + activeSkills: ['ignored'] + }) + ).resolves.toEqual({ + agentId: 'claude-acp', + targetAgentId: 'claude-acp', + providerId: 'acp', + modelId: 'claude-acp', + generationSettings: { systemPrompt: '' }, + disabledAgentTools: [], + activeSkills: [] + }) + expect(catalog.resolveAgent).toHaveBeenCalledWith('claude-acp') + await expect(policy.resolveTransferTarget('claude-acp', null)).rejects.toThrow( + 'Conversation history cannot be moved to ACP agents.' + ) + }) + + it('rejects DeepChat transfer targets backed by ACP defaults', async () => { + const { policy, configs } = createHarness() + configs.set('reviewer', { + defaultModelPreset: { providerId: 'acp', modelId: 'claude-acp' } + }) + + await expect(policy.resolveTransferTarget('reviewer', null)).rejects.toThrow( + 'Conversation history cannot be moved to ACP agents.' + ) + }) + + it('normalizes legacy tool and skill values deterministically', () => { + const { policy } = createHarness() + + expect( + policy.normalizeDisabledAgentTools([' yo_browser_cdp_send ', 'grep', 'ls', 'cdp_send'], { + dropLegacySearchTools: true + }) + ).toEqual(['cdp_send']) + expect(policy.normalizeActiveSkills([' review ', '', 'review', 'test'])).toEqual([ + 'review', + 'test' + ]) + }) +}) diff --git a/test/main/presenter/sessionApplication/lifecycleDeletionTransaction.test.ts b/test/main/presenter/sessionApplication/lifecycleDeletionTransaction.test.ts new file mode 100644 index 000000000..e07b140f1 --- /dev/null +++ b/test/main/presenter/sessionApplication/lifecycleDeletionTransaction.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest' +import type { SessionRecord } from '@shared/types/agent-interface' +import { + SessionDeletionTransaction, + type SessionDeletionTransactionDependencies +} from '@/presenter/sessionApplication/lifecycleDeletionTransaction' + +const createSession = (overrides: Partial = {}): SessionRecord => ({ + id: 'parent', + agentId: 'deepchat', + title: 'Session', + projectDir: null, + isPinned: false, + isDraft: false, + sessionKind: 'regular', + parentSessionId: null, + subagentEnabled: false, + subagentMeta: null, + createdAt: 100, + updatedAt: 200, + ...overrides +}) + +function createHarness() { + const records = new Map([ + ['parent', createSession()], + ['child', createSession({ id: 'child', sessionKind: 'subagent', parentSessionId: 'parent' })] + ]) + const order: string[] = [] + const dependencies = { + sessions: { + get: vi.fn((sessionId: string) => records.get(sessionId) ?? null), + list: vi.fn((filters?: { parentSessionId?: string }) => + [...records.values()].filter( + (session) => session.parentSessionId === filters?.parentSessionId + ) + ), + delete: vi.fn((sessionId: string) => { + order.push(`delete:${sessionId}`) + records.delete(sessionId) + }) + }, + runtime: { + cleanupSessionBackends: vi.fn(async (sessionId: string) => { + order.push(`runtime:${sessionId}`) + }) + }, + state: { + destroySession: vi.fn(async (sessionId: string) => { + order.push(`state:${sessionId}`) + }) + }, + permissions: { clearSessionPermissions: vi.fn() }, + skills: { clearNewAgentSessionSkills: vi.fn().mockResolvedValue(undefined) }, + projection: { forgetStatus: vi.fn() } + } as unknown as SessionDeletionTransactionDependencies + return { + transaction: new SessionDeletionTransaction(dependencies), + dependencies, + records, + order + } +} + +describe('SessionDeletionTransaction', () => { + it('deletes children first and clears every narrow owner before each row', async () => { + const harness = createHarness() + + await expect(harness.transaction.deleteSessionTree('parent')).resolves.toEqual([ + 'child', + 'parent' + ]) + expect(harness.order).toEqual([ + 'runtime:child', + 'state:child', + 'delete:child', + 'runtime:parent', + 'state:parent', + 'delete:parent' + ]) + expect(harness.dependencies.permissions.clearSessionPermissions).toHaveBeenCalledTimes(2) + expect(harness.dependencies.skills.clearNewAgentSessionSkills).toHaveBeenCalledTimes(2) + expect(harness.dependencies.projection.forgetStatus).toHaveBeenNthCalledWith(1, ['child']) + expect(harness.dependencies.projection.forgetStatus).toHaveBeenNthCalledWith(2, ['parent']) + }) + + it('preserves backend cleanup error precedence over shared-state cleanup', async () => { + const harness = createHarness() + harness.records.delete('child') + const backendError = new Error('backend failed') + harness.dependencies.runtime.cleanupSessionBackends.mockRejectedValue(backendError) + harness.dependencies.state.destroySession.mockRejectedValue(new Error('state failed')) + + await expect(harness.transaction.deleteSessionTree('parent')).rejects.toBe(backendError) + expect(harness.dependencies.state.destroySession).toHaveBeenCalledWith('parent') + expect(harness.dependencies.sessions.delete).not.toHaveBeenCalled() + }) +}) From e39f09301f09bd9445d99d9e7dfb291056f6bbea Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 13 Jul 2026 18:44:02 +0800 Subject: [PATCH 08/29] fix(session): preserve assignment behavior --- .../agent/shared/agentSessionNormalization.ts | 39 +++++ .../presenter/agentSessionPresenter/index.ts | 63 ++++---- src/main/presenter/index.ts | 9 +- .../agentAssignmentCoordinator.ts | 27 ++-- .../agentAssignmentPolicy.ts | 54 +------ .../presenter/sessionApplication/ports.ts | 56 ++++++- .../agentSessionPresenter.test.ts | 3 + .../assignmentCoordinatorFixture.ts | 2 + .../agentSessionPresenter/integration.test.ts | 3 + .../usageDashboard.test.ts | 1 + .../agentAssignmentCoordinator.test.ts | 148 +++++++++++++++++- .../agentAssignmentPolicy.test.ts | 13 +- 12 files changed, 314 insertions(+), 104 deletions(-) create mode 100644 src/main/agent/shared/agentSessionNormalization.ts diff --git a/src/main/agent/shared/agentSessionNormalization.ts b/src/main/agent/shared/agentSessionNormalization.ts new file mode 100644 index 000000000..a0afb9601 --- /dev/null +++ b/src/main/agent/shared/agentSessionNormalization.ts @@ -0,0 +1,39 @@ +const RETIRED_DEFAULT_AGENT_TOOLS = new Set(['find', 'ls']) +const LEGACY_PERSISTED_DISABLED_AGENT_TOOLS = new Set(['find', 'grep', 'ls']) +const LEGACY_AGENT_TOOL_NAME_MAP: Record = { + yo_browser_cdp_send: 'cdp_send', + yo_browser_window_open: 'load_url', + yo_browser_window_list: 'get_browser_status' +} + +export const normalizeDisabledAgentTools = ( + disabledAgentTools?: string[], + options?: { dropLegacySearchTools?: boolean } +): string[] => { + if (!Array.isArray(disabledAgentTools)) return [] + const retiredTools = options?.dropLegacySearchTools + ? LEGACY_PERSISTED_DISABLED_AGENT_TOOLS + : RETIRED_DEFAULT_AGENT_TOOLS + + return Array.from( + new Set( + disabledAgentTools + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .map((item) => LEGACY_AGENT_TOOL_NAME_MAP[item] ?? item) + .filter((item) => Boolean(item) && !retiredTools.has(item)) + ) + ).sort((left, right) => left.localeCompare(right)) +} + +export const normalizeActiveSkills = (activeSkills?: string[]): string[] => { + if (!Array.isArray(activeSkills)) return [] + return Array.from( + new Set( + activeSkills + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter(Boolean) + ) + ) +} diff --git a/src/main/presenter/agentSessionPresenter/index.ts b/src/main/presenter/agentSessionPresenter/index.ts index d8fd79ede..2405db897 100644 --- a/src/main/presenter/agentSessionPresenter/index.ts +++ b/src/main/presenter/agentSessionPresenter/index.ts @@ -66,11 +66,16 @@ import type { AgentSharedDataPorts } from '@/agent/shared/agentSharedData' import { toAppSessionId } from '@/agent/shared/agentSessionIds' import { LegacyChatImportService } from './legacyImportService' import { SessionProjectionCoordinator } from '../sessionApplication/projectionCoordinator' -import { SessionAgentAssignmentCoordinator } from '../sessionApplication/agentAssignmentCoordinator' import type { + SessionAgentAssignmentPort, SessionAssignmentPolicyPort, + SessionAssignmentWorkdirPort, SessionLifecycleDeletionPort } from '../sessionApplication/ports' +import { + normalizeActiveSkills, + normalizeDisabledAgentTools +} from '@/agent/shared/agentSessionNormalization' import { buildConversationExportContent, generateExportFilename, @@ -246,7 +251,8 @@ export class AgentSessionPresenter { private legacyImportService: LegacyChatImportService private sessionProjection: SessionProjectionCoordinator private sessionAssignmentPolicy: SessionAssignmentPolicyPort - private sessionAssignment: SessionAgentAssignmentCoordinator + private sessionAssignment: SessionAgentAssignmentPort + private sessionAssignmentWorkdir: SessionAssignmentWorkdirPort private sessionDeletion: SessionLifecycleDeletionPort private skillPresenter?: Pick private sessionPermissionPort?: SessionPermissionPort @@ -263,7 +269,8 @@ export class AgentSessionPresenter { sharedData: AgentSharedDataPorts, sessionProjection: SessionProjectionCoordinator, sessionAssignmentPolicy: SessionAssignmentPolicyPort, - sessionAssignment: SessionAgentAssignmentCoordinator, + sessionAssignment: SessionAgentAssignmentPort, + sessionAssignmentWorkdir: SessionAssignmentWorkdirPort, sessionDeletion: SessionLifecycleDeletionPort, skillPresenter?: Pick, runtimePorts?: { @@ -278,6 +285,7 @@ export class AgentSessionPresenter { this.sessionProjection = sessionProjection this.sessionAssignmentPolicy = sessionAssignmentPolicy this.sessionAssignment = sessionAssignment + this.sessionAssignmentWorkdir = sessionAssignmentWorkdir this.sessionDeletion = sessionDeletion this.skillPresenter = skillPresenter this.sessionManager = appSessionService @@ -638,7 +646,7 @@ export class AgentSessionPresenter { }) } - await this.sessionAssignment.prepareDirectAcpSession(record.id) + await this.sessionAssignmentWorkdir.prepareDirectAcpSession(record.id) this.sessionProjection.notify({ sessionIds: [record.id], reason: createdDraftSession ? 'created' : 'updated' @@ -681,8 +689,8 @@ export class AgentSessionPresenter { const hadMessages = await this.sharedData.transcript.hasMessages(sessionId) let providerId = state?.providerId ?? '' if (!providerId && handle.kind === 'acp') providerId = 'acp' - this.sessionAssignment.assertAcpSessionHasWorkdir(providerId, session.projectDir ?? null) - await this.sessionAssignment.syncAcpSessionWorkdir( + this.sessionAssignmentWorkdir.assertAcpSessionHasWorkdir(providerId, session.projectDir ?? null) + await this.sessionAssignmentWorkdir.syncAcpSessionWorkdir( providerId, sessionId, session.agentId, @@ -730,8 +738,8 @@ export class AgentSessionPresenter { const state = await handle.snapshot() let providerId = state?.providerId ?? '' if (!providerId && handle.kind === 'acp') providerId = 'acp' - this.sessionAssignment.assertAcpSessionHasWorkdir(providerId, session.projectDir ?? null) - await this.sessionAssignment.syncAcpSessionWorkdir( + this.sessionAssignmentWorkdir.assertAcpSessionHasWorkdir(providerId, session.projectDir ?? null) + await this.sessionAssignmentWorkdir.syncAcpSessionWorkdir( providerId, sessionId, session.agentId, @@ -772,8 +780,11 @@ export class AgentSessionPresenter { const { handle } = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)) let providerId = (await handle.snapshot())?.providerId ?? '' if (!providerId && handle.kind === 'acp') providerId = 'acp' - this.sessionAssignment.assertAcpSessionHasWorkdir(providerId, currentSession.projectDir ?? null) - await this.sessionAssignment.syncAcpSessionWorkdir( + this.sessionAssignmentWorkdir.assertAcpSessionHasWorkdir( + providerId, + currentSession.projectDir ?? null + ) + await this.sessionAssignmentWorkdir.syncAcpSessionWorkdir( providerId, sessionId, currentSession.agentId, @@ -1784,7 +1795,7 @@ export class AgentSessionPresenter { await handle.settings.setPermissionMode(config.permissionMode) } - await this.sessionAssignment.syncAcpSessionWorkdir( + await this.sessionAssignmentWorkdir.syncAcpSessionWorkdir( config.providerId, sessionId, config.agentId ?? config.modelId, @@ -1806,7 +1817,7 @@ export class AgentSessionPresenter { await this.agentManager .resolveSessionHandle(toAppSessionId(sessionId)) .handle.lifecycle.initialize(config) - await this.sessionAssignment.syncAcpSessionWorkdir( + await this.sessionAssignmentWorkdir.syncAcpSessionWorkdir( config.providerId, sessionId, config.agentId ?? config.modelId, @@ -1821,7 +1832,7 @@ export class AgentSessionPresenter { const handle = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle if (providerId === 'acp' && handle.kind !== 'acp') { try { - await this.sessionAssignment.clearCompatibilityAcpSession(sessionId) + await this.sessionAssignmentWorkdir.clearCompatibilityAcpSession(sessionId) } catch (error) { console.warn( `[AgentSessionPresenter] Failed to clear ACP session after initialization error ${sessionId}:`, @@ -2234,12 +2245,9 @@ export class AgentSessionPresenter { const disabledAgentTools = this.sqlitePresenter.newSessionsTable.getDisabledAgentTools( sessionRow.id ) - const normalized = this.sessionAssignmentPolicy.normalizeDisabledAgentTools( - disabledAgentTools, - { - dropLegacySearchTools: true - } - ) + const normalized = normalizeDisabledAgentTools(disabledAgentTools, { + dropLegacySearchTools: true + }) if (!this.areStringArraysEqual(disabledAgentTools, normalized)) { this.sessionManager.updateDisabledAgentTools(sessionRow.id, normalized) @@ -2289,12 +2297,9 @@ export class AgentSessionPresenter { continue } - const normalized = this.sessionAssignmentPolicy.normalizeDisabledAgentTools( - config.disabledAgentTools, - { - dropLegacySearchTools: true - } - ) + const normalized = normalizeDisabledAgentTools(config.disabledAgentTools, { + dropLegacySearchTools: true + }) if (this.areStringArraysEqual(config.disabledAgentTools, normalized)) { continue } @@ -2375,7 +2380,7 @@ export class AgentSessionPresenter { : [], search: parsed.search === true, think: parsed.think === true, - activeSkills: this.sessionAssignmentPolicy.normalizeActiveSkills(parsed.activeSkills) + activeSkills: normalizeActiveSkills(parsed.activeSkills) } } catch { return null @@ -2667,7 +2672,7 @@ export class AgentSessionPresenter { const files = Array.isArray(content.files) ? content.files.filter((file): file is MessageFile => Boolean(file)) : [] - const activeSkills = this.sessionAssignmentPolicy.normalizeActiveSkills(content.activeSkills) + const activeSkills = normalizeActiveSkills(content.activeSkills) const inlineItems = Array.isArray(content.inlineItems) ? content.inlineItems : [] return { text, @@ -2697,9 +2702,7 @@ export class AgentSessionPresenter { input: SendMessageInput, activeSkills?: string[] ): SendMessageInput { - const normalizedActiveSkills = this.sessionAssignmentPolicy.normalizeActiveSkills( - activeSkills ?? input.activeSkills - ) + const normalizedActiveSkills = normalizeActiveSkills(activeSkills ?? input.activeSkills) return { ...input, ...(normalizedActiveSkills.length > 0 ? { activeSkills: normalizedActiveSkills } : {}) diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index 768f3978a..0bd265a13 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -769,6 +769,10 @@ export class Presenter implements IPresenter { await this.configPresenter.resolveDeepChatAgentConfig(agentId) } ) + const clearNewAgentSessionSkills = this.skillPresenter.clearNewAgentSessionSkills + if (!clearNewAgentSessionSkills) { + throw new Error('Skill presenter must provide session skill cleanup.') + } this.sessionDeletionTransaction = new SessionDeletionTransaction({ sessions: appSessionService, runtime: { @@ -779,13 +783,15 @@ export class Presenter implements IPresenter { permissions: sessionPermissionPort, skills: { clearNewAgentSessionSkills: async (sessionId) => - await this.skillPresenter.clearNewAgentSessionSkills?.(sessionId) + await clearNewAgentSessionSkills.call(this.skillPresenter, sessionId) }, projection: this.sessionProjectionCoordinator }) this.sessionAgentAssignmentCoordinator = new SessionAgentAssignmentCoordinator({ sessions: appSessionService, runtime: { + getSessionAgentKind: (sessionId) => + this.agentManager.resolveSessionBackend(sessionId).descriptor.kind, resolveSession: (sessionId) => this.agentManager.resolveSessionHandle(sessionId), resolveTransferSource: (sessionId) => this.agentManager.resolveTransferSource(sessionId), resolveDeepChatTransferTarget: (agentId) => @@ -810,6 +816,7 @@ export class Presenter implements IPresenter { this.sessionProjectionCoordinator, this.sessionAgentAssignmentPolicy, this.sessionAgentAssignmentCoordinator, + this.sessionAgentAssignmentCoordinator, this.sessionDeletionTransaction, this.skillPresenter, { diff --git a/src/main/presenter/sessionApplication/agentAssignmentCoordinator.ts b/src/main/presenter/sessionApplication/agentAssignmentCoordinator.ts index e68389c32..93e23cd53 100644 --- a/src/main/presenter/sessionApplication/agentAssignmentCoordinator.ts +++ b/src/main/presenter/sessionApplication/agentAssignmentCoordinator.ts @@ -11,6 +11,7 @@ import type { import type { AcpConfigState } from '@shared/presenter' import { resolveAcpAgentAlias } from '@shared/utils/acpAgentAlias' import type { + SessionAgentAssignmentPort, SessionAssignmentAcpControlPort, SessionAssignmentEnvironmentPort, SessionAssignmentPolicyPort, @@ -20,6 +21,7 @@ import type { SessionAssignmentWorkdirPort, SessionLifecycleDeletionPort } from './ports' +import { normalizeDisabledAgentTools } from '@/agent/shared/agentSessionNormalization' export interface SessionAgentAssignmentDependencies { sessions: SessionAssignmentStorePort @@ -31,7 +33,9 @@ export interface SessionAgentAssignmentDependencies { acp: SessionAssignmentAcpControlPort } -export class SessionAgentAssignmentCoordinator implements SessionAssignmentWorkdirPort { +export class SessionAgentAssignmentCoordinator + implements SessionAgentAssignmentPort, SessionAssignmentWorkdirPort +{ constructor(private readonly dependencies: SessionAgentAssignmentDependencies) {} async mergeSubagentTape( @@ -288,8 +292,7 @@ export class SessionAgentAssignmentCoordinator implements SessionAssignmentWorkd throw new Error('Only regular sessions can change subagent state.') } - const resolved = this.dependencies.runtime.resolveSession(toAppSessionId(sessionId)) - if (resolved.descriptor.kind !== 'deepchat') { + if (this.dependencies.runtime.getSessionAgentKind(toAppSessionId(sessionId)) !== 'deepchat') { throw new Error('Only DeepChat sessions can change subagent state.') } @@ -311,7 +314,7 @@ export class SessionAgentAssignmentCoordinator implements SessionAssignmentWorkd providerId: string, modelId: string ): Promise { - this.requireSession(sessionId) + const session = this.requireSession(sessionId) const nextProviderId = providerId?.trim() const nextModelId = modelId?.trim() if (!nextProviderId || !nextModelId) { @@ -321,13 +324,15 @@ export class SessionAgentAssignmentCoordinator implements SessionAssignmentWorkd const { handle } = this.dependencies.runtime.resolveSession(toAppSessionId(sessionId)) if (handle.kind !== 'deepchat') throw new Error('ACP session model is locked.') await handle.deepchat.setModel(nextProviderId, nextModelId) - - 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 after model update: ${sessionId}`) + const state = await handle.snapshot() + const updated: SessionWithState = { + ...session, + status: state?.status ?? 'idle', + providerId: state?.providerId ?? nextProviderId, + modelId: state?.modelId ?? nextModelId } - return materialized + this.dependencies.projection.notify({ sessionIds: [sessionId], reason: 'updated' }) + return updated } async setSessionProjectDir( @@ -375,7 +380,7 @@ export class SessionAgentAssignmentCoordinator implements SessionAssignmentWorkd disabledAgentTools: string[] ): Promise { this.requireSession(sessionId) - const normalized = this.dependencies.policy.normalizeDisabledAgentTools(disabledAgentTools) + const normalized = normalizeDisabledAgentTools(disabledAgentTools) this.dependencies.sessions.updateDisabledAgentTools(sessionId, normalized) const { handle } = this.dependencies.runtime.resolveSession(toAppSessionId(sessionId)) diff --git a/src/main/presenter/sessionApplication/agentAssignmentPolicy.ts b/src/main/presenter/sessionApplication/agentAssignmentPolicy.ts index c05b5b94d..a1c4c28c9 100644 --- a/src/main/presenter/sessionApplication/agentAssignmentPolicy.ts +++ b/src/main/presenter/sessionApplication/agentAssignmentPolicy.ts @@ -14,14 +14,10 @@ import type { SessionAssignmentPolicyPort, SubagentAssignmentInput } from './ports' - -const RETIRED_DEFAULT_AGENT_TOOLS = new Set(['find', 'ls']) -const LEGACY_PERSISTED_DISABLED_AGENT_TOOLS = new Set(['find', 'grep', 'ls']) -const LEGACY_AGENT_TOOL_NAME_MAP: Record = { - yo_browser_cdp_send: 'cdp_send', - yo_browser_window_open: 'load_url', - yo_browser_window_list: 'get_browser_status' -} +import { + normalizeActiveSkills, + normalizeDisabledAgentTools +} from '@/agent/shared/agentSessionNormalization' const normalizePermissionMode = (mode: PermissionMode | null | undefined): PermissionMode => mode === 'default' || mode === 'auto_approve' ? mode : 'full_access' @@ -73,9 +69,7 @@ export class SessionAgentAssignmentPolicy implements SessionAssignmentPolicyPort ), disabledAgentTools: descriptor.kind === 'deepchat' - ? this.normalizeDisabledAgentTools( - input.disabledAgentTools ?? agentConfig?.disabledAgentTools - ) + ? normalizeDisabledAgentTools(input.disabledAgentTools ?? agentConfig?.disabledAgentTools) : [], subagentEnabled: descriptor.kind === 'deepchat' @@ -129,8 +123,8 @@ export class SessionAgentAssignmentPolicy implements SessionAssignmentPolicyPort providerId: input.providerId, modelId: input.modelId, generationSettings: input.generationSettings, - disabledAgentTools: this.normalizeDisabledAgentTools(input.disabledAgentTools), - activeSkills: this.normalizeActiveSkills(input.activeSkills) + disabledAgentTools: normalizeDisabledAgentTools(input.disabledAgentTools), + activeSkills: normalizeActiveSkills(input.activeSkills) } } @@ -172,7 +166,7 @@ export class SessionAgentAssignmentPolicy implements SessionAssignmentPolicyPort null, permissionMode: normalizePermissionMode(agentConfig?.permissionMode), generationSettings: this.mergeDefaultGenerationSettings(agentConfig), - disabledAgentTools: this.normalizeDisabledAgentTools(agentConfig?.disabledAgentTools), + disabledAgentTools: normalizeDisabledAgentTools(agentConfig?.disabledAgentTools), subagentEnabled: agentConfig?.subagentEnabled === true } } @@ -183,38 +177,6 @@ export class SessionAgentAssignmentPolicy implements SessionAssignmentPolicyPort } } - normalizeDisabledAgentTools( - disabledAgentTools?: string[], - options?: { dropLegacySearchTools?: boolean } - ): string[] { - if (!Array.isArray(disabledAgentTools)) return [] - const retiredTools = options?.dropLegacySearchTools - ? LEGACY_PERSISTED_DISABLED_AGENT_TOOLS - : RETIRED_DEFAULT_AGENT_TOOLS - - return Array.from( - new Set( - disabledAgentTools - .filter((item): item is string => typeof item === 'string') - .map((item) => item.trim()) - .map((item) => LEGACY_AGENT_TOOL_NAME_MAP[item] ?? item) - .filter((item) => Boolean(item) && !retiredTools.has(item)) - ) - ).sort((left, right) => left.localeCompare(right)) - } - - normalizeActiveSkills(activeSkills?: string[]): string[] { - if (!Array.isArray(activeSkills)) return [] - return Array.from( - new Set( - activeSkills - .filter((item): item is string => typeof item === 'string') - .map((item) => item.trim()) - .filter(Boolean) - ) - ) - } - private resolveProjectDir( input: CreateAssignmentInput, agentDefaultProjectDir: string | null | undefined diff --git a/src/main/presenter/sessionApplication/ports.ts b/src/main/presenter/sessionApplication/ports.ts index c98131609..8512f9ba1 100644 --- a/src/main/presenter/sessionApplication/ports.ts +++ b/src/main/presenter/sessionApplication/ports.ts @@ -11,6 +11,7 @@ import type { ResolvedTransferSource } from '@/agent/manager/agentManager' import type { + AgentTransferImpact, DeepChatAgentConfig, DeepChatSessionState, PermissionMode, @@ -21,6 +22,7 @@ import type { SessionRecord, SessionWithState } from '@shared/types/agent-interface' +import type { AcpConfigState } from '@shared/presenter' import type { AcpAsLlmProviderSessionControlPort } from '../runtimePorts' import type { DeepChatMessageRow } from '../sqlitePresenter/tables/deepchatMessages' import type { DeepChatMessageSearchResultRow } from '../sqlitePresenter/tables/deepchatMessageSearchResults' @@ -243,11 +245,6 @@ export interface SessionAssignmentPolicyPort { currentProjectDir: string | null ): Promise assertAcpSessionHasWorkdir(providerId: string, projectDir: string | null): void - normalizeDisabledAgentTools( - disabledAgentTools?: string[], - options?: { dropLegacySearchTools?: boolean } - ): string[] - normalizeActiveSkills(activeSkills?: string[]): string[] } export interface SessionAssignmentStorePort { @@ -263,6 +260,7 @@ export interface SessionAssignmentStorePort { } export interface SessionAssignmentRuntimePort { + getSessionAgentKind(sessionId: AppSessionId): 'deepchat' | 'acp' resolveSession(sessionId: AppSessionId): ResolvedAgentSession resolveTransferSource(sessionId: AppSessionId): ResolvedTransferSource resolveDeepChatTransferTarget(agentId: string): ResolvedDeepChatTransferTarget @@ -294,6 +292,54 @@ export interface SessionAssignmentWorkdirPort { clearCompatibilityAcpSession(sessionId: string): Promise } +export interface SessionAgentAssignmentPort { + mergeSubagentTape( + parentSessionId: string, + childSessionId: string, + meta?: Record + ): Promise + discardSubagentTape( + parentSessionId: string, + childSessionId: string, + meta?: Record + ): Promise + getAgentTransferImpact(agentId: string): Promise + moveAgentSessions( + fromAgentId: string, + toAgentId: string + ): Promise<{ movedSessionIds: string[]; deletedSessionIds: string[] }> + deleteAgentSessions(agentId: string): Promise + moveSessionToAgent(sessionId: string, toAgentId: string): Promise + getAcpSessionCommands(sessionId: string): Promise< + Array<{ + name: string + description: string + input?: { hint: string } | null + }> + > + getAcpSessionConfigOptions(sessionId: string): Promise + setAcpSessionConfigOption( + sessionId: string, + configId: string, + value: string | boolean + ): 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 + getSessionDisabledAgentTools(sessionId: string): Promise + updateSessionDisabledAgentTools( + sessionId: string, + disabledAgentTools: string[] + ): Promise + updateSessionGenerationSettings( + sessionId: string, + settings: Partial + ): Promise +} + export interface SessionDeletionStorePort { get(sessionId: string): SessionRecord | null list(filters?: SessionListFilters): SessionRecord[] diff --git a/test/main/presenter/agentSessionPresenter/agentSessionPresenter.test.ts b/test/main/presenter/agentSessionPresenter/agentSessionPresenter.test.ts index 8f0a982e7..49fd26d7e 100644 --- a/test/main/presenter/agentSessionPresenter/agentSessionPresenter.test.ts +++ b/test/main/presenter/agentSessionPresenter/agentSessionPresenter.test.ts @@ -608,6 +608,7 @@ function createDescriptorIndependentDeleteHarness(options: { projection, sessionApplications.policy, sessionApplications.assignment, + sessionApplications.assignment, sessionApplications.deletion, skillPresenter, { sessionPermissionPort } @@ -766,6 +767,7 @@ describe('AgentSessionPresenter', () => { projection, sessionApplications.policy, sessionApplications.assignment, + sessionApplications.assignment, sessionApplications.deletion, skillPresenter, undefined @@ -1026,6 +1028,7 @@ describe('AgentSessionPresenter', () => { projection, sessionApplications.policy, sessionApplications.assignment, + sessionApplications.assignment, sessionApplications.deletion, skillPresenter ) diff --git a/test/main/presenter/agentSessionPresenter/assignmentCoordinatorFixture.ts b/test/main/presenter/agentSessionPresenter/assignmentCoordinatorFixture.ts index 20a8fb6bb..5f1e8a8eb 100644 --- a/test/main/presenter/agentSessionPresenter/assignmentCoordinatorFixture.ts +++ b/test/main/presenter/agentSessionPresenter/assignmentCoordinatorFixture.ts @@ -63,6 +63,8 @@ export const createAssignmentCoordinatorFixture = (input: { const assignment = new SessionAgentAssignmentCoordinator({ sessions: input.appSessionService, runtime: { + getSessionAgentKind: (sessionId) => + input.agentManager.resolveSessionBackend(sessionId).descriptor.kind, resolveSession: (sessionId) => input.agentManager.resolveSessionHandle(sessionId), resolveTransferSource: (sessionId) => input.agentManager.resolveTransferSource(sessionId), resolveDeepChatTransferTarget: (agentId) => diff --git a/test/main/presenter/agentSessionPresenter/integration.test.ts b/test/main/presenter/agentSessionPresenter/integration.test.ts index 7dece8da0..25b7ef110 100644 --- a/test/main/presenter/agentSessionPresenter/integration.test.ts +++ b/test/main/presenter/agentSessionPresenter/integration.test.ts @@ -723,6 +723,7 @@ describe('Integration: createSession end-to-end', () => { projection, sessionApplications.policy, sessionApplications.assignment, + sessionApplications.assignment, sessionApplications.deletion ) }) @@ -910,6 +911,7 @@ describe('Integration: ACP hooks bridge', () => { projection, sessionApplications.policy, sessionApplications.assignment, + sessionApplications.assignment, sessionApplications.deletion ) }) @@ -1025,6 +1027,7 @@ describe('Integration: multi-turn context', () => { projection, sessionApplications.policy, sessionApplications.assignment, + sessionApplications.assignment, sessionApplications.deletion ) }) diff --git a/test/main/presenter/agentSessionPresenter/usageDashboard.test.ts b/test/main/presenter/agentSessionPresenter/usageDashboard.test.ts index 5cd37af27..5f42e9cb1 100644 --- a/test/main/presenter/agentSessionPresenter/usageDashboard.test.ts +++ b/test/main/presenter/agentSessionPresenter/usageDashboard.test.ts @@ -496,6 +496,7 @@ describe('AgentSessionPresenter usage dashboard', () => { projection, sessionApplications.policy, sessionApplications.assignment, + sessionApplications.assignment, sessionApplications.deletion ) diff --git a/test/main/presenter/sessionApplication/agentAssignmentCoordinator.test.ts b/test/main/presenter/sessionApplication/agentAssignmentCoordinator.test.ts index f873e9172..bb1e53cac 100644 --- a/test/main/presenter/sessionApplication/agentAssignmentCoordinator.test.ts +++ b/test/main/presenter/sessionApplication/agentAssignmentCoordinator.test.ts @@ -100,6 +100,9 @@ function createHarness(initialSessions: SessionRecord[] = [createSession()]) { updateDisabledAgentTools: vi.fn() } const runtime = { + getSessionAgentKind: vi.fn((sessionId: string) => + records.get(sessionId)?.agentId.includes('acp') ? 'acp' : 'deepchat' + ), resolveSession: vi.fn((sessionId: string) => { const session = records.get(sessionId) const isAcp = session?.agentId.includes('acp') @@ -156,9 +159,7 @@ function createHarness(initialSessions: SessionRecord[] = [createSession()]) { })), assertAcpSessionHasWorkdir: vi.fn((providerId: string, projectDir: string | null) => { if (providerId === 'acp' && !projectDir) throw new Error('workdir required') - }), - normalizeDisabledAgentTools: vi.fn((tools?: string[]) => tools ?? []), - normalizeActiveSkills: vi.fn((skills?: string[]) => skills ?? []) + }) } const projection = { materialize: vi.fn(async (sessionId: string) => { @@ -323,7 +324,81 @@ describe('SessionAgentAssignmentCoordinator', () => { expect(order).toEqual(['store', 'environment', 'runtime-setting', 'acp-workdir']) }) - it('routes direct and compatibility ACP commands through their narrow controls', async () => { + 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) + + await expect( + harness.coordinator.setSessionModel('s1', ' openrouter ', ' custom-model ') + ).resolves.toMatchObject({ + status: 'idle', + providerId: 'openrouter', + modelId: 'custom-model' + }) + expect(harness.deepchat.setModel).toHaveBeenCalledWith('openrouter', 'custom-model') + expect(harness.projection.materialize).not.toHaveBeenCalled() + expect(harness.projection.notify).toHaveBeenCalledWith({ + sessionIds: ['s1'], + reason: 'updated' + }) + }) + + it('does not publish a model update when the post-set snapshot fails', async () => { + const harness = createHarness() + harness.deepchatHandle.snapshot.mockRejectedValue(new Error('snapshot failed')) + + await expect(harness.coordinator.setSessionModel('s1', 'openai', 'gpt-5')).rejects.toThrow( + 'snapshot failed' + ) + expect(harness.deepchat.setModel).toHaveBeenCalledWith('openai', 'gpt-5') + expect(harness.projection.notify).not.toHaveBeenCalled() + }) + + it('keeps direct ACP models locked before invoking DeepChat model control', async () => { + const harness = createHarness([createSession({ agentId: 'claude-acp' })]) + + await expect(harness.coordinator.setSessionModel('s1', 'openai', 'gpt-5')).rejects.toThrow( + 'ACP session model is locked.' + ) + expect(harness.deepchat.setModel).not.toHaveBeenCalled() + expect(harness.projection.notify).not.toHaveBeenCalled() + }) + + it('owns permission, generation, and disabled-tool settings', async () => { + const harness = createHarness() + harness.sessions.getDisabledAgentTools.mockReturnValue(['read']) + harness.settings.getGenerationSettings.mockResolvedValue({ temperature: 0.7 }) + + await expect(harness.coordinator.getPermissionMode('s1')).resolves.toBe('full_access') + await harness.coordinator.setPermissionMode('s1', 'auto_approve') + await expect(harness.coordinator.getSessionGenerationSettings('s1')).resolves.toEqual({ + temperature: 0.7 + }) + await expect( + harness.coordinator.updateSessionGenerationSettings('s1', { temperature: 0.2 }) + ).resolves.toEqual({ temperature: 0.2 }) + await expect(harness.coordinator.getSessionDisabledAgentTools('s1')).resolves.toEqual(['read']) + await expect( + harness.coordinator.updateSessionDisabledAgentTools('s1', ['find', 'write', 'write']) + ).resolves.toEqual(['write']) + + expect(harness.settings.setPermissionMode).toHaveBeenCalledWith('auto_approve') + expect(harness.settings.updateGenerationSettings).toHaveBeenCalledWith({ temperature: 0.2 }) + expect(harness.sessions.updateDisabledAgentTools).toHaveBeenCalledWith('s1', ['write']) + expect(harness.deepchat.invalidateSystemPromptCache).toHaveBeenCalledOnce() + }) + + it('routes direct and compatibility ACP commands and config through narrow controls', async () => { const harness = createHarness([ createSession({ id: 'direct', agentId: 'claude-acp' }), createSession({ id: 'compat', agentId: 'source' }) @@ -340,8 +415,73 @@ describe('SessionAgentAssignmentCoordinator', () => { await expect(harness.coordinator.getAcpSessionCommands('compat')).resolves.toEqual([ { name: 'compat', description: 'Compat' } ]) + await expect(harness.coordinator.getAcpSessionConfigOptions('direct')).resolves.toEqual({ + options: [] + }) + await expect( + harness.coordinator.setAcpSessionConfigOption('direct', 'mode', 'plan') + ).resolves.toEqual({ options: [] }) + await expect(harness.coordinator.getAcpSessionConfigOptions('compat')).resolves.toEqual({ + options: [] + }) + await expect( + harness.coordinator.setAcpSessionConfigOption('compat', 'mode', 'plan') + ).resolves.toEqual({ options: [] }) expect(harness.acpFacet.getCommands).toHaveBeenCalledOnce() expect(harness.acp.getAcpSessionCommands).toHaveBeenCalledWith('compat') + expect(harness.acpFacet.getConfigOptions).toHaveBeenCalledOnce() + expect(harness.acpFacet.setConfigOption).toHaveBeenCalledWith('mode', 'plan') + expect(harness.acp.getAcpSessionConfigOptions).toHaveBeenCalledWith('compat') + expect(harness.acp.setAcpSessionConfigOption).toHaveBeenCalledWith('compat', 'mode', 'plan') + }) + + it('preserves transfer mutation order before materializing state', async () => { + const harness = createHarness() + const order: string[] = [] + harness.deepchat.setSessionAgentContext.mockImplementation(async () => { + order.push('runtime-context') + }) + harness.sessions.updateAgentId.mockImplementation((sessionId: string, agentId: string) => { + order.push('agent-id') + const session = harness.records.get(sessionId) + if (session) harness.records.set(sessionId, { ...session, agentId }) + }) + harness.sessions.update.mockImplementation( + (sessionId: string, fields: Partial) => { + order.push('session-row') + const session = harness.records.get(sessionId) + if (session) harness.records.set(sessionId, { ...session, ...fields }) + } + ) + harness.sessions.updateDisabledAgentTools.mockImplementation(() => order.push('tools')) + harness.projection.materialize.mockImplementation(async (sessionId: string) => { + order.push('materialize') + return materialize(harness.records.get(sessionId)!) + }) + + await harness.coordinator.moveSessionToAgent('s1', 'target') + expect(order).toEqual(['runtime-context', 'agent-id', 'session-row', 'tools', 'materialize']) + }) + + it('clears compatibility ACP binding only after transfer commit materialization', async () => { + const harness = createHarness() + const order: string[] = [] + harness.deepchatHandle.snapshot.mockResolvedValue({ + status: 'idle', + providerId: 'acp', + modelId: 'claude-acp' + }) + harness.projection.materialize.mockImplementation(async (sessionId: string) => { + order.push('materialize') + return materialize(harness.records.get(sessionId)!) + }) + harness.acp.clearAcpSession.mockImplementation(async () => { + order.push('clear-acp') + }) + + await harness.coordinator.moveSessionToAgent('s1', 'target') + expect(order).toEqual(['materialize', 'clear-acp']) + expect(harness.acp.clearAcpSession).toHaveBeenCalledWith('s1') }) it('validates Tape parentage before resolving the runtime facet', async () => { diff --git a/test/main/presenter/sessionApplication/agentAssignmentPolicy.test.ts b/test/main/presenter/sessionApplication/agentAssignmentPolicy.test.ts index 02fad8d99..c724e49cc 100644 --- a/test/main/presenter/sessionApplication/agentAssignmentPolicy.test.ts +++ b/test/main/presenter/sessionApplication/agentAssignmentPolicy.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import { SessionAgentAssignmentPolicy } from '@/presenter/sessionApplication/agentAssignmentPolicy' +import { + normalizeActiveSkills, + normalizeDisabledAgentTools +} from '@/agent/shared/agentSessionNormalization' function createHarness() { const agents = new Map([ @@ -149,16 +153,11 @@ describe('SessionAgentAssignmentPolicy', () => { }) it('normalizes legacy tool and skill values deterministically', () => { - const { policy } = createHarness() - expect( - policy.normalizeDisabledAgentTools([' yo_browser_cdp_send ', 'grep', 'ls', 'cdp_send'], { + normalizeDisabledAgentTools([' yo_browser_cdp_send ', 'grep', 'ls', 'cdp_send'], { dropLegacySearchTools: true }) ).toEqual(['cdp_send']) - expect(policy.normalizeActiveSkills([' review ', '', 'review', 'test'])).toEqual([ - 'review', - 'test' - ]) + expect(normalizeActiveSkills([' review ', '', 'review', 'test'])).toEqual(['review', 'test']) }) }) From 7789c3b640019c4fbf65d097557b84fb2fffef6f Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 13 Jul 2026 18:56:48 +0800 Subject: [PATCH 09/29] refactor(session): extract turn coordinator --- .../session-application-coordinators/tasks.md | 10 +- .../agent/shared/agentSessionNormalization.ts | 37 ++ .../presenter/agentSessionPresenter/index.ts | 337 ++------------ src/main/presenter/index.ts | 41 ++ .../presenter/sessionApplication/ports.ts | 106 ++++- .../sessionApplication/turnCoordinator.ts | 288 ++++++++++++ .../agentSessionPresenter.test.ts | 6 + .../assignmentCoordinatorFixture.ts | 41 +- .../agentSessionPresenter/integration.test.ts | 6 + .../usageDashboard.test.ts | 2 + .../turnCoordinator.test.ts | 425 ++++++++++++++++++ 11 files changed, 991 insertions(+), 308 deletions(-) create mode 100644 src/main/presenter/sessionApplication/turnCoordinator.ts create mode 100644 test/main/presenter/sessionApplication/turnCoordinator.test.ts diff --git a/docs/architecture/session-application-coordinators/tasks.md b/docs/architecture/session-application-coordinators/tasks.md index 95440f5b4..bcf679d14 100644 --- a/docs/architecture/session-application-coordinators/tasks.md +++ b/docs/architecture/session-application-coordinators/tasks.md @@ -36,11 +36,11 @@ ## 4. SessionTurnCoordinator -- [ ] Extract send, steer, and pending-input operations. -- [ ] Extract retry/delete/edit/clear message operations. -- [ ] Extract cancellation, tool-interaction response, and compaction. -- [ ] Add the narrow initial-turn operation used by Lifecycle. -- [ ] Rewire compatibility presenter forwarding and move owner tests. +- [x] Extract send, steer, and pending-input operations. +- [x] Extract retry/delete/edit/clear message operations. +- [x] Extract cancellation, tool-interaction response, and compaction. +- [x] Add the narrow initial-turn operation used by Lifecycle. +- [x] Rewire compatibility presenter forwarding and move owner tests. ## 5. SessionLifecycleCoordinator diff --git a/src/main/agent/shared/agentSessionNormalization.ts b/src/main/agent/shared/agentSessionNormalization.ts index a0afb9601..b1ea4ab1b 100644 --- a/src/main/agent/shared/agentSessionNormalization.ts +++ b/src/main/agent/shared/agentSessionNormalization.ts @@ -1,3 +1,9 @@ +import type { + CreateSessionInput, + MessageFile, + SendMessageInput +} from '@shared/types/agent-interface' + const RETIRED_DEFAULT_AGENT_TOOLS = new Set(['find', 'ls']) const LEGACY_PERSISTED_DISABLED_AGENT_TOOLS = new Set(['find', 'grep', 'ls']) const LEGACY_AGENT_TOOL_NAME_MAP: Record = { @@ -37,3 +43,34 @@ export const normalizeActiveSkills = (activeSkills?: string[]): string[] => { ) ) } + +export const normalizeSendMessageInput = (content: string | SendMessageInput): SendMessageInput => { + if (typeof content === 'string') { + return { text: content, files: [] } + } + + if (!content || typeof content !== 'object') { + return { text: '', files: [] } + } + + const text = typeof content.text === 'string' ? content.text : '' + const files = Array.isArray(content.files) + ? content.files.filter((file): file is MessageFile => Boolean(file)) + : [] + const activeSkills = normalizeActiveSkills(content.activeSkills) + const inlineItems = Array.isArray(content.inlineItems) ? content.inlineItems : [] + return { + text, + files, + ...(activeSkills.length > 0 ? { activeSkills } : {}), + ...(inlineItems.length > 0 ? { inlineItems } : {}) + } +} + +export const normalizeCreateSessionInput = (input: CreateSessionInput): SendMessageInput => + normalizeSendMessageInput({ + text: typeof input.message === 'string' ? input.message : '', + files: Array.isArray(input.files) ? input.files : [], + activeSkills: input.activeSkills, + inlineItems: Array.isArray(input.inlineItems) ? input.inlineItems : [] + }) diff --git a/src/main/presenter/agentSessionPresenter/index.ts b/src/main/presenter/agentSessionPresenter/index.ts index 2405db897..5e39033f6 100644 --- a/src/main/presenter/agentSessionPresenter/index.ts +++ b/src/main/presenter/agentSessionPresenter/index.ts @@ -70,10 +70,13 @@ import type { SessionAgentAssignmentPort, SessionAssignmentPolicyPort, SessionAssignmentWorkdirPort, - SessionLifecycleDeletionPort + SessionInitialTurnPort, + SessionLifecycleDeletionPort, + SessionTurnPort } from '../sessionApplication/ports' import { normalizeActiveSkills, + normalizeCreateSessionInput, normalizeDisabledAgentTools } from '@/agent/shared/agentSessionNormalization' import { @@ -253,6 +256,8 @@ export class AgentSessionPresenter { private sessionAssignmentPolicy: SessionAssignmentPolicyPort private sessionAssignment: SessionAgentAssignmentPort private sessionAssignmentWorkdir: SessionAssignmentWorkdirPort + private sessionTurn: SessionTurnPort + private sessionInitialTurn: SessionInitialTurnPort private sessionDeletion: SessionLifecycleDeletionPort private skillPresenter?: Pick private sessionPermissionPort?: SessionPermissionPort @@ -271,6 +276,8 @@ export class AgentSessionPresenter { sessionAssignmentPolicy: SessionAssignmentPolicyPort, sessionAssignment: SessionAgentAssignmentPort, sessionAssignmentWorkdir: SessionAssignmentWorkdirPort, + sessionTurn: SessionTurnPort, + sessionInitialTurn: SessionInitialTurnPort, sessionDeletion: SessionLifecycleDeletionPort, skillPresenter?: Pick, runtimePorts?: { @@ -286,6 +293,8 @@ export class AgentSessionPresenter { this.sessionAssignmentPolicy = sessionAssignmentPolicy this.sessionAssignment = sessionAssignment this.sessionAssignmentWorkdir = sessionAssignmentWorkdir + this.sessionTurn = sessionTurn + this.sessionInitialTurn = sessionInitialTurn this.sessionDeletion = sessionDeletion this.skillPresenter = skillPresenter this.sessionManager = appSessionService @@ -321,7 +330,7 @@ export class AgentSessionPresenter { logger.info( `[AgentSessionPresenter] createSession agent=${agentId} webContentsId=${webContentsId}` ) - const normalizedInput = this.normalizeCreateSessionInput(input) + const normalizedInput = normalizeCreateSessionInput(input) logger.info(`[AgentSessionPresenter] resolved provider=${providerId} model=${modelId}`) // Create session record @@ -389,27 +398,14 @@ export class AgentSessionPresenter { modelId: state?.modelId ?? modelId } - // Start the first message (non-blocking) after returning session ID. - const hasInitialTurn = - normalizedInput.text.trim().length > 0 || (normalizedInput.files?.length ?? 0) > 0 - if (hasInitialTurn) { - logger.info(`[AgentSessionPresenter] firing initial send (non-blocking)`) - handle - .send({ - content: this.withInitialMessageActiveSkills(normalizedInput, input.activeSkills), - context: { projectDir }, - queue: { source: 'send', projectDir } - }) - .catch((err) => { - console.error('[AgentSessionPresenter] initial send failed:', err) - }) - this.sessionProjection.scheduleTitleGeneration({ - sessionId, - initialTitle: title, - fallbackProviderId: providerId, - fallbackModelId: modelId - }) - } + this.sessionInitialTurn.startInitialTurn({ + sessionId, + content: normalizedInput, + projectDir, + initialTitle: title, + fallbackProviderId: providerId, + fallbackModelId: modelId + }) return sessionResult } @@ -668,207 +664,47 @@ export class AgentSessionPresenter { content: string | SendMessageInput, options?: { maxProviderRounds?: number } ): Promise { - let session = this.sessionManager.get(sessionId) - if (!session) throw new Error(`Session not found: ${sessionId}`) - const wasDraft = session.isDraft - const normalizedInput = this.normalizeSendMessageInput(content) - - if (session.isDraft) { - const title = normalizedInput.text.trim().slice(0, 50) || 'New Chat' - this.sessionManager.update(sessionId, { isDraft: false, title }) - this.sessionProjection.notify({ - sessionIds: [sessionId], - reason: 'updated' - }) - session = this.sessionManager.get(sessionId) - if (!session) throw new Error(`Session not found: ${sessionId}`) - } - - const { handle } = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)) - const state = await handle.snapshot() - const hadMessages = await this.sharedData.transcript.hasMessages(sessionId) - let providerId = state?.providerId ?? '' - if (!providerId && handle.kind === 'acp') providerId = 'acp' - this.sessionAssignmentWorkdir.assertAcpSessionHasWorkdir(providerId, session.projectDir ?? null) - await this.sessionAssignmentWorkdir.syncAcpSessionWorkdir( - providerId, - sessionId, - session.agentId, - session.projectDir ?? null - ) - const result = await handle.send({ - content: normalizedInput, - context: { - projectDir: session.projectDir ?? null, - maxProviderRounds: options?.maxProviderRounds - }, - queue: { - source: 'send', - projectDir: session.projectDir ?? null - } - }) - if (!hadMessages && !wasDraft) { - this.sessionProjection.scheduleTitleGeneration({ - sessionId, - initialTitle: session.title, - fallbackProviderId: providerId, - fallbackModelId: state?.modelId ?? '' - }) - } - return result + return await this.sessionTurn.sendMessage(sessionId, content, options) } async steerActiveTurn(sessionId: string, content: string | SendMessageInput): Promise { - let session = this.sessionManager.get(sessionId) - if (!session) throw new Error(`Session not found: ${sessionId}`) - const normalizedInput = this.normalizeSendMessageInput(content) - - if (session.isDraft) { - const title = normalizedInput.text.trim().slice(0, 50) || 'New Chat' - this.sessionManager.update(sessionId, { isDraft: false, title }) - this.sessionProjection.notify({ - sessionIds: [sessionId], - reason: 'updated' - }) - session = this.sessionManager.get(sessionId) - if (!session) throw new Error(`Session not found: ${sessionId}`) - } - - const { handle } = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)) - const state = await handle.snapshot() - let providerId = state?.providerId ?? '' - if (!providerId && handle.kind === 'acp') providerId = 'acp' - this.sessionAssignmentWorkdir.assertAcpSessionHasWorkdir(providerId, session.projectDir ?? null) - await this.sessionAssignmentWorkdir.syncAcpSessionWorkdir( - providerId, - sessionId, - session.agentId, - session.projectDir ?? null - ) - - await handle.pending.steerActiveTurn(normalizedInput) + await this.sessionTurn.steerActiveTurn(sessionId, content) } async listPendingInputs(sessionId: string) { - const session = this.sessionManager.get(sessionId) - if (!session) { - return [] - } - return await this.agentManager - .resolveSessionHandle(toAppSessionId(sessionId)) - .handle.pending.list() + return await this.sessionTurn.listPendingInputs(sessionId) } async queuePendingInput(sessionId: string, content: string | SendMessageInput) { - const session = this.sessionManager.get(sessionId) - if (!session) { - throw new Error(`Session not found: ${sessionId}`) - } - - let currentSession = session - const normalizedInput = this.normalizeSendMessageInput(content) - if (currentSession.isDraft) { - const title = normalizedInput.text.trim().slice(0, 50) || 'New Chat' - this.sessionManager.update(sessionId, { isDraft: false, title }) - this.sessionProjection.notify({ - sessionIds: [sessionId], - reason: 'updated' - }) - currentSession = this.sessionManager.get(sessionId) ?? currentSession - } - - const { handle } = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)) - let providerId = (await handle.snapshot())?.providerId ?? '' - if (!providerId && handle.kind === 'acp') providerId = 'acp' - this.sessionAssignmentWorkdir.assertAcpSessionHasWorkdir( - providerId, - currentSession.projectDir ?? null - ) - await this.sessionAssignmentWorkdir.syncAcpSessionWorkdir( - providerId, - sessionId, - currentSession.agentId, - currentSession.projectDir ?? null - ) - return await handle.pending.queue(normalizedInput, { - source: 'queue', - projectDir: currentSession.projectDir ?? null - }) + return await this.sessionTurn.queuePendingInput(sessionId, content) } async updateQueuedInput(sessionId: string, itemId: string, content: string | SendMessageInput) { - const session = this.sessionManager.get(sessionId) - if (!session) { - throw new Error(`Session not found: ${sessionId}`) - } - return await this.agentManager - .resolveSessionHandle(toAppSessionId(sessionId)) - .handle.pending.update(itemId, this.normalizeSendMessageInput(content)) + return await this.sessionTurn.updateQueuedInput(sessionId, itemId, content) } async moveQueuedInput(sessionId: string, itemId: string, toIndex: number) { - const session = this.sessionManager.get(sessionId) - if (!session) { - throw new Error(`Session not found: ${sessionId}`) - } - return await this.agentManager - .resolveSessionHandle(toAppSessionId(sessionId)) - .handle.pending.move(itemId, toIndex) + return await this.sessionTurn.moveQueuedInput(sessionId, itemId, toIndex) } async convertPendingInputToSteer(sessionId: string, itemId: string) { - const session = this.sessionManager.get(sessionId) - if (!session) { - throw new Error(`Session not found: ${sessionId}`) - } - return await this.agentManager - .resolveSessionHandle(toAppSessionId(sessionId)) - .handle.pending.convertToSteer(itemId) + return await this.sessionTurn.convertPendingInputToSteer(sessionId, itemId) } async steerPendingInput(sessionId: string, itemId: string) { - const session = this.sessionManager.get(sessionId) - if (!session) { - throw new Error(`Session not found: ${sessionId}`) - } - return await this.agentManager - .resolveSessionHandle(toAppSessionId(sessionId)) - .handle.pending.steer(itemId) + return await this.sessionTurn.steerPendingInput(sessionId, itemId) } async deletePendingInput(sessionId: string, itemId: string): Promise { - const session = this.sessionManager.get(sessionId) - if (!session) { - throw new Error(`Session not found: ${sessionId}`) - } - await this.agentManager - .resolveSessionHandle(toAppSessionId(sessionId)) - .handle.pending.delete(itemId) + await this.sessionTurn.deletePendingInput(sessionId, itemId) } async retryMessage(sessionId: string, messageId: string): Promise { - const session = this.sessionManager.get(sessionId) - if (!session) { - throw new Error(`Session not found: ${sessionId}`) - } - const handle = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle - const prepared = await this.sharedData.transcriptMutation.prepareRetryMessage( - sessionId, - messageId - ) - await handle.send({ - content: prepared.content, - context: { projectDir: prepared.projectDir, emitRefreshBeforeStream: true } - }) + await this.sessionTurn.retryMessage(sessionId, messageId) } async deleteMessage(sessionId: string, messageId: string): Promise { - const session = this.sessionManager.get(sessionId) - if (!session) { - throw new Error(`Session not found: ${sessionId}`) - } - await this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle.cancel() - await this.sharedData.transcriptMutation.deleteMessage(sessionId, messageId) + await this.sessionTurn.deleteMessage(sessionId, messageId) } async editUserMessage( @@ -876,11 +712,7 @@ export class AgentSessionPresenter { messageId: string, text: string ): Promise { - const session = this.sessionManager.get(sessionId) - if (!session) { - throw new Error(`Session not found: ${sessionId}`) - } - return await this.sharedData.transcriptMutation.editUserMessage(sessionId, messageId, text) + return await this.sessionTurn.editUserMessage(sessionId, messageId, text) } async forkSession( @@ -1164,42 +996,13 @@ export class AgentSessionPresenter { } async getSessionCompactionState(sessionId: string): Promise { - const session = this.sessionManager.get(sessionId) - if (!session) { - throw new Error(`Session not found: ${sessionId}`) - } - - const handle = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle - if (handle.kind !== 'deepchat') { - return { - status: 'idle', - cursorOrderSeq: 1, - summaryUpdatedAt: null - } - } - - return await handle.deepchat.getCompactionState() + return await this.sessionTurn.getSessionCompactionState(sessionId) } async compactSession( sessionId: string ): Promise<{ compacted: boolean; state: SessionCompactionState }> { - const session = this.sessionManager.get(sessionId) - if (!session) { - throw new Error(`Session not found: ${sessionId}`) - } - - const handle = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle - if (handle.kind !== 'deepchat') { - throw new Error(`Agent ${session.agentId} does not support manual compaction.`) - } - - const state = await handle.snapshot() - if (state?.providerId === 'acp') { - throw new Error('Manual compaction is only available for DeepChat agent sessions.') - } - - return await handle.deepchat.compact() + return await this.sessionTurn.compactSession(sessionId) } async getTapeInfo(sessionId: string): Promise { @@ -1542,17 +1345,7 @@ export class AgentSessionPresenter { } async clearSessionMessages(sessionId: string): Promise { - const session = this.sessionManager.get(sessionId) - if (!session) { - throw new Error(`Session not found: ${sessionId}`) - } - - await this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle.cancel() - await this.sharedData.transcriptMutation.clearMessages(sessionId) - this.sessionProjection.notify({ - sessionIds: [sessionId], - reason: 'updated' - }) + await this.sessionTurn.clearSessionMessages(sessionId) } async exportSession( @@ -1615,9 +1408,7 @@ export class AgentSessionPresenter { } async cancelGeneration(sessionId: string): Promise { - const session = this.sessionManager.get(sessionId) - if (!session) return - await this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle.cancel() + await this.sessionTurn.cancelGeneration(sessionId) } clearSessionPermissions(sessionId: string): void { @@ -1630,13 +1421,7 @@ export class AgentSessionPresenter { toolCallId: string, response: ToolInteractionResponse ): Promise { - const session = this.sessionManager.get(sessionId) - if (!session) { - throw new Error(`Session not found: ${sessionId}`) - } - return await this.agentManager - .resolveSessionHandle(toAppSessionId(sessionId)) - .handle.toolInteractions.respond(messageId, toolCallId, response) + return await this.sessionTurn.respondToolInteraction(sessionId, messageId, toolCallId, response) } async getAcpSessionCommands(sessionId: string): Promise< @@ -2659,56 +2444,6 @@ export class AgentSessionPresenter { return 'English' } - private normalizeSendMessageInput(content: string | SendMessageInput): SendMessageInput { - if (typeof content === 'string') { - return { text: content, files: [] } - } - - if (!content || typeof content !== 'object') { - return { text: '', files: [] } - } - - const text = typeof content.text === 'string' ? content.text : '' - const files = Array.isArray(content.files) - ? content.files.filter((file): file is MessageFile => Boolean(file)) - : [] - const activeSkills = normalizeActiveSkills(content.activeSkills) - const inlineItems = Array.isArray(content.inlineItems) ? content.inlineItems : [] - return { - text, - files, - ...(activeSkills.length > 0 ? { activeSkills } : {}), - ...(inlineItems.length > 0 ? { inlineItems } : {}) - } - } - - private normalizeCreateSessionInput(input: CreateSessionInput): SendMessageInput { - const text = typeof input.message === 'string' ? input.message : '' - const files = Array.isArray(input.files) - ? input.files.filter((file): file is MessageFile => Boolean(file)) - : [] - const inlineItems = Array.isArray(input.inlineItems) ? input.inlineItems : [] - return this.withInitialMessageActiveSkills( - { - text, - files, - ...(inlineItems.length > 0 ? { inlineItems } : {}) - }, - input.activeSkills - ) - } - - private withInitialMessageActiveSkills( - input: SendMessageInput, - activeSkills?: string[] - ): SendMessageInput { - const normalizedActiveSkills = normalizeActiveSkills(activeSkills ?? input.activeSkills) - return { - ...input, - ...(normalizedActiveSkills.length > 0 ? { activeSkills: normalizedActiveSkills } : {}) - } - } - private areStringArraysEqual(left: string[], right: string[]): boolean { if (left.length !== right.length) { return false diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index 0bd265a13..c89d5ac2c 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -80,6 +80,7 @@ import { SessionProjectionCoordinator } from './sessionApplication/projectionCoo import { SessionAgentAssignmentPolicy } from './sessionApplication/agentAssignmentPolicy' import { SessionAgentAssignmentCoordinator } from './sessionApplication/agentAssignmentCoordinator' import { SessionDeletionTransaction } from './sessionApplication/lifecycleDeletionTransaction' +import { SessionTurnCoordinator } from './sessionApplication/turnCoordinator' import { AgentRuntimePresenter } from './agentRuntimePresenter' import { AcpAgentRuntime } from '@/agent/acp/instance' import type { @@ -164,6 +165,7 @@ export class Presenter implements IPresenter { sessionProjectionCoordinator: SessionProjectionCoordinator sessionAgentAssignmentPolicy: SessionAgentAssignmentPolicy sessionAgentAssignmentCoordinator: SessionAgentAssignmentCoordinator + sessionTurnCoordinator: SessionTurnCoordinator sessionDeletionTransaction: SessionDeletionTransaction agentManager: AgentManager acpAgentRuntime: AcpAgentRuntime @@ -806,6 +808,43 @@ export class Presenter implements IPresenter { }, acp: this.acpAsLlmProviderSessionControl }) + this.sessionTurnCoordinator = new SessionTurnCoordinator({ + sessions: appSessionService, + runtime: { + resolveSession: (sessionId) => { + const { handle } = this.agentManager.resolveSessionHandle(sessionId) + const turn = { + pending: handle.pending, + toolInteractions: handle.toolInteractions, + send: (input: Parameters[0]) => handle.send(input), + cancel: () => handle.cancel(), + snapshot: () => handle.snapshot() + } + return handle.kind === 'deepchat' + ? { + ...turn, + kind: handle.kind, + compaction: { + getState: () => handle.deepchat.getCompactionState(), + compact: () => handle.deepchat.compact() + } + } + : { ...turn, kind: handle.kind } + } + }, + transcript: { + hasMessages: (sessionId) => agentSharedData.transcript.hasMessages(sessionId), + clearMessages: (sessionId) => agentSharedData.transcriptMutation.clearMessages(sessionId), + prepareRetryMessage: (sessionId, messageId) => + agentSharedData.transcriptMutation.prepareRetryMessage(sessionId, messageId), + deleteMessage: (sessionId, messageId) => + agentSharedData.transcriptMutation.deleteMessage(sessionId, messageId), + editUserMessage: (sessionId, messageId, text) => + agentSharedData.transcriptMutation.editUserMessage(sessionId, messageId, text) + }, + workdir: this.sessionAgentAssignmentCoordinator, + projection: this.sessionProjectionCoordinator + }) this.agentSessionPresenter = new AgentSessionPresenter( this.agentManager, appSessionService, @@ -817,6 +856,8 @@ export class Presenter implements IPresenter { this.sessionAgentAssignmentPolicy, this.sessionAgentAssignmentCoordinator, this.sessionAgentAssignmentCoordinator, + this.sessionTurnCoordinator, + this.sessionTurnCoordinator, this.sessionDeletionTransaction, this.skillPresenter, { diff --git a/src/main/presenter/sessionApplication/ports.ts b/src/main/presenter/sessionApplication/ports.ts index 8512f9ba1..48e11516a 100644 --- a/src/main/presenter/sessionApplication/ports.ts +++ b/src/main/presenter/sessionApplication/ports.ts @@ -2,8 +2,14 @@ import type { AppSessionId } from '@/agent/shared/agentSessionIds' import type { AgentSessionStatePort, AgentTapePort, + AgentTranscriptMutationPort, AgentTranscriptReadPort } from '@/agent/shared/agentSharedData' +import type { AgentSessionSendInput } from '@/agent/shared/agentSessionHandle' +import type { + AgentPendingInputFacet, + AgentToolInteractionFacet +} from '@/agent/manager/sessionHandles' import type { ResolvedAgentSession, ResolvedDeepChatTransferTarget, @@ -12,15 +18,22 @@ import type { } from '@/agent/manager/agentManager' import type { AgentTransferImpact, + ChatMessageRecord, DeepChatAgentConfig, DeepChatSessionState, + MessageStartResult, + PendingSessionInputRecord, PermissionMode, + SendMessageInput, + SessionCompactionState, SessionGenerationSettings, SessionLightweightListResult, SessionListItem, SessionPageCursor, SessionRecord, - SessionWithState + SessionWithState, + ToolInteractionResponse, + ToolInteractionResult } from '@shared/types/agent-interface' import type { AcpConfigState } from '@shared/presenter' import type { AcpAsLlmProviderSessionControlPort } from '../runtimePorts' @@ -167,6 +180,97 @@ export interface SessionProjectionMutationPort { scheduleTitleGeneration(input: TitleGenerationInput): void } +export interface SessionTurnStorePort { + get(sessionId: string): SessionRecord | null + update(sessionId: string, fields: Partial>): void +} + +interface SessionTurnRuntimeBase { + readonly pending: AgentPendingInputFacet + readonly toolInteractions: AgentToolInteractionFacet + send(input: AgentSessionSendInput): Promise + cancel(): Promise + snapshot(): Promise +} + +export type SessionTurnRuntimeSession = + | (SessionTurnRuntimeBase & { + readonly kind: 'deepchat' + readonly compaction: { + getState(): Promise + compact(): Promise<{ compacted: boolean; state: SessionCompactionState }> + } + }) + | (SessionTurnRuntimeBase & { readonly kind: 'acp' }) + +export interface SessionTurnRuntimePort { + resolveSession(sessionId: AppSessionId): SessionTurnRuntimeSession +} + +export type SessionTurnTranscriptPort = Pick & + Pick< + AgentTranscriptMutationPort, + 'clearMessages' | 'prepareRetryMessage' | 'deleteMessage' | 'editUserMessage' + > + +export type SessionTurnProjectionPort = Pick< + SessionProjectionMutationPort, + 'notify' | 'scheduleTitleGeneration' +> + +export interface SessionInitialTurnInput { + sessionId: string + content: SendMessageInput + projectDir: string | null + initialTitle: string + fallbackProviderId: string + fallbackModelId: string +} + +export interface SessionInitialTurnPort { + startInitialTurn(input: SessionInitialTurnInput): void +} + +export interface SessionTurnPort { + sendMessage( + sessionId: string, + content: string | SendMessageInput, + options?: { maxProviderRounds?: number } + ): Promise + steerActiveTurn(sessionId: string, content: string | SendMessageInput): Promise + listPendingInputs(sessionId: string): Promise + queuePendingInput( + sessionId: string, + content: string | SendMessageInput + ): Promise + updateQueuedInput( + sessionId: string, + itemId: string, + content: string | SendMessageInput + ): Promise + moveQueuedInput( + sessionId: string, + itemId: string, + toIndex: number + ): Promise + convertPendingInputToSteer(sessionId: string, itemId: string): Promise + steerPendingInput(sessionId: string, itemId: string): Promise + deletePendingInput(sessionId: string, itemId: string): Promise + retryMessage(sessionId: string, messageId: string): Promise + deleteMessage(sessionId: string, messageId: string): Promise + editUserMessage(sessionId: string, messageId: string, text: string): Promise + getSessionCompactionState(sessionId: string): Promise + compactSession(sessionId: string): Promise<{ compacted: boolean; state: SessionCompactionState }> + clearSessionMessages(sessionId: string): Promise + cancelGeneration(sessionId: string): Promise + respondToolInteraction( + sessionId: string, + messageId: string, + toolCallId: string, + response: ToolInteractionResponse + ): Promise +} + export interface SessionAssignmentCatalogPort { resolveAgent(agentId: string): { id: string; kind: 'deepchat' | 'acp' } } diff --git a/src/main/presenter/sessionApplication/turnCoordinator.ts b/src/main/presenter/sessionApplication/turnCoordinator.ts new file mode 100644 index 000000000..6b37261d3 --- /dev/null +++ b/src/main/presenter/sessionApplication/turnCoordinator.ts @@ -0,0 +1,288 @@ +import { toAppSessionId } from '@/agent/shared/agentSessionIds' +import { normalizeSendMessageInput } from '@/agent/shared/agentSessionNormalization' +import type { + ChatMessageRecord, + MessageStartResult, + PendingSessionInputRecord, + SendMessageInput, + SessionCompactionState, + ToolInteractionResponse, + ToolInteractionResult +} from '@shared/types/agent-interface' +import type { + SessionAssignmentWorkdirPort, + SessionInitialTurnInput, + SessionInitialTurnPort, + SessionTurnPort, + SessionTurnProjectionPort, + SessionTurnRuntimePort, + SessionTurnStorePort, + SessionTurnTranscriptPort +} from './ports' + +export interface SessionTurnCoordinatorDependencies { + sessions: SessionTurnStorePort + runtime: SessionTurnRuntimePort + transcript: SessionTurnTranscriptPort + workdir: SessionAssignmentWorkdirPort + projection: SessionTurnProjectionPort +} + +export class SessionTurnCoordinator implements SessionTurnPort, SessionInitialTurnPort { + constructor(private readonly dependencies: SessionTurnCoordinatorDependencies) {} + + startInitialTurn(input: SessionInitialTurnInput): void { + const content = normalizeSendMessageInput(input.content) + if (!content.text.trim() && (content.files?.length ?? 0) === 0) return + + const runtime = this.dependencies.runtime.resolveSession(toAppSessionId(input.sessionId)) + void runtime + .send({ + content, + context: { projectDir: input.projectDir }, + queue: { source: 'send', projectDir: input.projectDir } + }) + .catch((error) => { + console.error('[SessionTurnCoordinator] initial send failed:', error) + }) + this.dependencies.projection.scheduleTitleGeneration({ + sessionId: input.sessionId, + initialTitle: input.initialTitle, + fallbackProviderId: input.fallbackProviderId, + fallbackModelId: input.fallbackModelId + }) + } + + async sendMessage( + sessionId: string, + content: string | SendMessageInput, + options?: { maxProviderRounds?: number } + ): Promise { + let session = this.requireSession(sessionId) + const wasDraft = session.isDraft + const normalizedInput = normalizeSendMessageInput(content) + + if (session.isDraft) { + this.promoteDraft(sessionId, normalizedInput) + session = this.requireSession(sessionId) + } + + const runtime = this.dependencies.runtime.resolveSession(toAppSessionId(sessionId)) + const state = await runtime.snapshot() + const hadMessages = await this.dependencies.transcript.hasMessages(sessionId) + const providerId = this.resolveProviderId(runtime.kind, state?.providerId) + this.dependencies.workdir.assertAcpSessionHasWorkdir(providerId, session.projectDir ?? null) + await this.dependencies.workdir.syncAcpSessionWorkdir( + providerId, + sessionId, + session.agentId, + session.projectDir ?? null + ) + const result = await runtime.send({ + content: normalizedInput, + context: { + projectDir: session.projectDir ?? null, + maxProviderRounds: options?.maxProviderRounds + }, + queue: { + source: 'send', + projectDir: session.projectDir ?? null + } + }) + if (!hadMessages && !wasDraft) { + this.dependencies.projection.scheduleTitleGeneration({ + sessionId, + initialTitle: session.title, + fallbackProviderId: providerId, + fallbackModelId: state?.modelId ?? '' + }) + } + return result + } + + async steerActiveTurn(sessionId: string, content: string | SendMessageInput): Promise { + let session = this.requireSession(sessionId) + const normalizedInput = normalizeSendMessageInput(content) + + if (session.isDraft) { + this.promoteDraft(sessionId, normalizedInput) + session = this.requireSession(sessionId) + } + + const runtime = this.dependencies.runtime.resolveSession(toAppSessionId(sessionId)) + const state = await runtime.snapshot() + const providerId = this.resolveProviderId(runtime.kind, state?.providerId) + this.dependencies.workdir.assertAcpSessionHasWorkdir(providerId, session.projectDir ?? null) + await this.dependencies.workdir.syncAcpSessionWorkdir( + providerId, + sessionId, + session.agentId, + session.projectDir ?? null + ) + await runtime.pending.steerActiveTurn(normalizedInput) + } + + async listPendingInputs(sessionId: string): Promise { + if (!this.dependencies.sessions.get(sessionId)) return [] + return await this.dependencies.runtime.resolveSession(toAppSessionId(sessionId)).pending.list() + } + + async queuePendingInput( + sessionId: string, + content: string | SendMessageInput + ): Promise { + let session = this.requireSession(sessionId) + const normalizedInput = normalizeSendMessageInput(content) + if (session.isDraft) { + this.promoteDraft(sessionId, normalizedInput) + session = this.dependencies.sessions.get(sessionId) ?? session + } + + const runtime = this.dependencies.runtime.resolveSession(toAppSessionId(sessionId)) + const state = await runtime.snapshot() + const providerId = this.resolveProviderId(runtime.kind, state?.providerId) + this.dependencies.workdir.assertAcpSessionHasWorkdir(providerId, session.projectDir ?? null) + await this.dependencies.workdir.syncAcpSessionWorkdir( + providerId, + sessionId, + session.agentId, + session.projectDir ?? null + ) + return await runtime.pending.queue(normalizedInput, { + source: 'queue', + projectDir: session.projectDir ?? null + }) + } + + async updateQueuedInput( + sessionId: string, + itemId: string, + content: string | SendMessageInput + ): Promise { + this.requireSession(sessionId) + return await this.dependencies.runtime + .resolveSession(toAppSessionId(sessionId)) + .pending.update(itemId, normalizeSendMessageInput(content)) + } + + async moveQueuedInput( + sessionId: string, + itemId: string, + toIndex: number + ): Promise { + this.requireSession(sessionId) + return await this.dependencies.runtime + .resolveSession(toAppSessionId(sessionId)) + .pending.move(itemId, toIndex) + } + + async convertPendingInputToSteer( + sessionId: string, + itemId: string + ): Promise { + this.requireSession(sessionId) + return await this.dependencies.runtime + .resolveSession(toAppSessionId(sessionId)) + .pending.convertToSteer(itemId) + } + + async steerPendingInput(sessionId: string, itemId: string): Promise { + this.requireSession(sessionId) + return await this.dependencies.runtime + .resolveSession(toAppSessionId(sessionId)) + .pending.steer(itemId) + } + + async deletePendingInput(sessionId: string, itemId: string): Promise { + this.requireSession(sessionId) + await this.dependencies.runtime.resolveSession(toAppSessionId(sessionId)).pending.delete(itemId) + } + + async retryMessage(sessionId: string, messageId: string): Promise { + this.requireSession(sessionId) + const runtime = this.dependencies.runtime.resolveSession(toAppSessionId(sessionId)) + const prepared = await this.dependencies.transcript.prepareRetryMessage(sessionId, messageId) + await runtime.send({ + content: prepared.content, + context: { projectDir: prepared.projectDir, emitRefreshBeforeStream: true } + }) + } + + async deleteMessage(sessionId: string, messageId: string): Promise { + this.requireSession(sessionId) + await this.dependencies.runtime.resolveSession(toAppSessionId(sessionId)).cancel() + await this.dependencies.transcript.deleteMessage(sessionId, messageId) + } + + async editUserMessage( + sessionId: string, + messageId: string, + text: string + ): Promise { + this.requireSession(sessionId) + return await this.dependencies.transcript.editUserMessage(sessionId, messageId, text) + } + + async getSessionCompactionState(sessionId: string): Promise { + this.requireSession(sessionId) + const runtime = this.dependencies.runtime.resolveSession(toAppSessionId(sessionId)) + if (runtime.kind === 'acp') { + return { status: 'idle', cursorOrderSeq: 1, summaryUpdatedAt: null } + } + return await runtime.compaction.getState() + } + + async compactSession( + sessionId: string + ): Promise<{ compacted: boolean; state: SessionCompactionState }> { + const session = this.requireSession(sessionId) + const runtime = this.dependencies.runtime.resolveSession(toAppSessionId(sessionId)) + if (runtime.kind === 'acp') { + throw new Error(`Agent ${session.agentId} does not support manual compaction.`) + } + if ((await runtime.snapshot())?.providerId === 'acp') { + throw new Error('Manual compaction is only available for DeepChat agent sessions.') + } + return await runtime.compaction.compact() + } + + async clearSessionMessages(sessionId: string): Promise { + this.requireSession(sessionId) + await this.dependencies.runtime.resolveSession(toAppSessionId(sessionId)).cancel() + await this.dependencies.transcript.clearMessages(sessionId) + this.dependencies.projection.notify({ sessionIds: [sessionId], reason: 'updated' }) + } + + async cancelGeneration(sessionId: string): Promise { + if (!this.dependencies.sessions.get(sessionId)) return + await this.dependencies.runtime.resolveSession(toAppSessionId(sessionId)).cancel() + } + + async respondToolInteraction( + sessionId: string, + messageId: string, + toolCallId: string, + response: ToolInteractionResponse + ): Promise { + this.requireSession(sessionId) + return await this.dependencies.runtime + .resolveSession(toAppSessionId(sessionId)) + .toolInteractions.respond(messageId, toolCallId, response) + } + + private promoteDraft(sessionId: string, input: SendMessageInput): void { + const title = input.text.trim().slice(0, 50) || 'New Chat' + this.dependencies.sessions.update(sessionId, { isDraft: false, title }) + this.dependencies.projection.notify({ sessionIds: [sessionId], reason: 'updated' }) + } + + private requireSession(sessionId: string) { + const session = this.dependencies.sessions.get(sessionId) + if (!session) throw new Error(`Session not found: ${sessionId}`) + return session + } + + private resolveProviderId(kind: 'deepchat' | 'acp', providerId?: string): string { + return providerId || (kind === 'acp' ? 'acp' : '') + } +} diff --git a/test/main/presenter/agentSessionPresenter/agentSessionPresenter.test.ts b/test/main/presenter/agentSessionPresenter/agentSessionPresenter.test.ts index 49fd26d7e..57358657d 100644 --- a/test/main/presenter/agentSessionPresenter/agentSessionPresenter.test.ts +++ b/test/main/presenter/agentSessionPresenter/agentSessionPresenter.test.ts @@ -609,6 +609,8 @@ function createDescriptorIndependentDeleteHarness(options: { sessionApplications.policy, sessionApplications.assignment, sessionApplications.assignment, + sessionApplications.turn, + sessionApplications.turn, sessionApplications.deletion, skillPresenter, { sessionPermissionPort } @@ -768,6 +770,8 @@ describe('AgentSessionPresenter', () => { sessionApplications.policy, sessionApplications.assignment, sessionApplications.assignment, + sessionApplications.turn, + sessionApplications.turn, sessionApplications.deletion, skillPresenter, undefined @@ -1029,6 +1033,8 @@ describe('AgentSessionPresenter', () => { sessionApplications.policy, sessionApplications.assignment, sessionApplications.assignment, + sessionApplications.turn, + sessionApplications.turn, sessionApplications.deletion, skillPresenter ) diff --git a/test/main/presenter/agentSessionPresenter/assignmentCoordinatorFixture.ts b/test/main/presenter/agentSessionPresenter/assignmentCoordinatorFixture.ts index 5f1e8a8eb..1347fdf2d 100644 --- a/test/main/presenter/agentSessionPresenter/assignmentCoordinatorFixture.ts +++ b/test/main/presenter/agentSessionPresenter/assignmentCoordinatorFixture.ts @@ -11,6 +11,7 @@ import { SessionAgentAssignmentPolicy } from '@/presenter/sessionApplication/age import { SessionAgentAssignmentCoordinator } from '@/presenter/sessionApplication/agentAssignmentCoordinator' import { SessionDeletionTransaction } from '@/presenter/sessionApplication/lifecycleDeletionTransaction' import type { SessionProjectionCoordinator } from '@/presenter/sessionApplication/projectionCoordinator' +import { SessionTurnCoordinator } from '@/presenter/sessionApplication/turnCoordinator' export const createAssignmentCoordinatorFixture = (input: { agentManager: AgentManager @@ -25,6 +26,7 @@ export const createAssignmentCoordinatorFixture = (input: { }): { policy: SessionAgentAssignmentPolicy assignment: SessionAgentAssignmentCoordinator + turn: SessionTurnCoordinator deletion: SessionDeletionTransaction } => { const policy = new SessionAgentAssignmentPolicy( @@ -79,6 +81,43 @@ export const createAssignmentCoordinatorFixture = (input: { }, acp: input.acp }) + const turn = new SessionTurnCoordinator({ + sessions: input.appSessionService, + runtime: { + resolveSession: (sessionId) => { + const { handle } = input.agentManager.resolveSessionHandle(sessionId) + const base = { + pending: handle.pending, + toolInteractions: handle.toolInteractions, + send: (sendInput: Parameters[0]) => handle.send(sendInput), + cancel: () => handle.cancel(), + snapshot: () => handle.snapshot() + } + return handle.kind === 'deepchat' + ? { + ...base, + kind: handle.kind, + compaction: { + getState: () => handle.deepchat.getCompactionState(), + compact: () => handle.deepchat.compact() + } + } + : { ...base, kind: handle.kind } + } + }, + transcript: { + hasMessages: (sessionId) => input.sharedData.transcript.hasMessages(sessionId), + clearMessages: (sessionId) => input.sharedData.transcriptMutation.clearMessages(sessionId), + prepareRetryMessage: (sessionId, messageId) => + input.sharedData.transcriptMutation.prepareRetryMessage(sessionId, messageId), + deleteMessage: (sessionId, messageId) => + input.sharedData.transcriptMutation.deleteMessage(sessionId, messageId), + editUserMessage: (sessionId, messageId, text) => + input.sharedData.transcriptMutation.editUserMessage(sessionId, messageId, text) + }, + workdir: assignment, + projection: input.projection + }) - return { policy, assignment, deletion } + return { policy, assignment, turn, deletion } } diff --git a/test/main/presenter/agentSessionPresenter/integration.test.ts b/test/main/presenter/agentSessionPresenter/integration.test.ts index 25b7ef110..348ef8a57 100644 --- a/test/main/presenter/agentSessionPresenter/integration.test.ts +++ b/test/main/presenter/agentSessionPresenter/integration.test.ts @@ -724,6 +724,8 @@ describe('Integration: createSession end-to-end', () => { sessionApplications.policy, sessionApplications.assignment, sessionApplications.assignment, + sessionApplications.turn, + sessionApplications.turn, sessionApplications.deletion ) }) @@ -912,6 +914,8 @@ describe('Integration: ACP hooks bridge', () => { sessionApplications.policy, sessionApplications.assignment, sessionApplications.assignment, + sessionApplications.turn, + sessionApplications.turn, sessionApplications.deletion ) }) @@ -1028,6 +1032,8 @@ describe('Integration: multi-turn context', () => { sessionApplications.policy, sessionApplications.assignment, sessionApplications.assignment, + sessionApplications.turn, + sessionApplications.turn, sessionApplications.deletion ) }) diff --git a/test/main/presenter/agentSessionPresenter/usageDashboard.test.ts b/test/main/presenter/agentSessionPresenter/usageDashboard.test.ts index 5f42e9cb1..0355d545c 100644 --- a/test/main/presenter/agentSessionPresenter/usageDashboard.test.ts +++ b/test/main/presenter/agentSessionPresenter/usageDashboard.test.ts @@ -497,6 +497,8 @@ describe('AgentSessionPresenter usage dashboard', () => { sessionApplications.policy, sessionApplications.assignment, sessionApplications.assignment, + sessionApplications.turn, + sessionApplications.turn, sessionApplications.deletion ) diff --git a/test/main/presenter/sessionApplication/turnCoordinator.test.ts b/test/main/presenter/sessionApplication/turnCoordinator.test.ts new file mode 100644 index 000000000..f19ebde6c --- /dev/null +++ b/test/main/presenter/sessionApplication/turnCoordinator.test.ts @@ -0,0 +1,425 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + ChatMessageRecord, + PendingSessionInputRecord, + SessionRecord +} from '@shared/types/agent-interface' +import { + SessionTurnCoordinator, + type SessionTurnCoordinatorDependencies +} from '@/presenter/sessionApplication/turnCoordinator' + +const createSession = (overrides: Partial = {}): SessionRecord => ({ + id: 's1', + agentId: 'deepchat', + title: 'Session', + projectDir: '/repo', + isPinned: false, + isDraft: false, + sessionKind: 'regular', + parentSessionId: null, + subagentEnabled: false, + subagentMeta: null, + createdAt: 100, + updatedAt: 200, + ...overrides +}) + +const createPending = (overrides: Partial = {}) => ({ + id: 'pending-1', + sessionId: 's1', + mode: 'queue' as const, + state: 'pending' as const, + payload: { text: 'Pending', files: [] }, + queueOrder: 1, + claimedAt: null, + consumedAt: null, + createdAt: 100, + updatedAt: 100, + ...overrides +}) + +const createMessage = (overrides: Partial = {}): ChatMessageRecord => ({ + id: 'message-1', + sessionId: 's1', + orderSeq: 1, + role: 'user', + content: JSON.stringify({ text: 'Edited', files: [] }), + status: 'sent', + isContextEdge: 0, + metadata: '{}', + createdAt: 100, + updatedAt: 100, + ...overrides +}) + +function createHarness( + options: { + kind?: 'deepchat' | 'acp' + providerId?: string + sessions?: SessionRecord[] + hasMessages?: boolean + } = {} +) { + const records = new Map( + (options.sessions ?? [createSession()]).map((session) => [session.id, session]) + ) + const pendingRecord = createPending() + const pending = { + steerActiveTurn: vi.fn().mockResolvedValue(undefined), + list: vi.fn().mockResolvedValue([pendingRecord]), + queue: vi.fn().mockResolvedValue(pendingRecord), + update: vi.fn().mockResolvedValue(pendingRecord), + move: vi.fn().mockResolvedValue([pendingRecord]), + convertToSteer: vi.fn().mockResolvedValue({ ...pendingRecord, mode: 'steer' }), + steer: vi.fn().mockResolvedValue({ ...pendingRecord, mode: 'steer', state: 'claimed' }), + delete: vi.fn().mockResolvedValue(undefined) + } + const toolInteractions = { + respond: vi.fn().mockResolvedValue({ resumed: true }) + } + const send = vi.fn().mockResolvedValue({ requestId: 'request-1', messageId: 'message-1' }) + const cancel = vi.fn().mockResolvedValue(undefined) + const snapshot = vi.fn().mockResolvedValue({ + status: 'idle', + providerId: options.providerId ?? (options.kind === 'acp' ? 'acp' : 'openai'), + modelId: 'model-1', + permissionMode: 'full_access' + }) + const compaction = { + getState: vi.fn().mockResolvedValue({ + status: 'idle', + cursorOrderSeq: 3, + summaryUpdatedAt: null + }), + compact: vi.fn().mockResolvedValue({ + compacted: true, + state: { status: 'idle', cursorOrderSeq: 4, summaryUpdatedAt: 200 } + }) + } + const runtimeSession = + options.kind === 'acp' + ? ({ kind: 'acp', pending, toolInteractions, send, cancel, snapshot } as const) + : ({ + kind: 'deepchat', + pending, + toolInteractions, + send, + cancel, + snapshot, + compaction + } as const) + const resolveSession = vi.fn(() => runtimeSession) + const sessions = { + get: vi.fn((sessionId: string) => records.get(sessionId) ?? null), + update: vi.fn((sessionId: string, fields: Partial) => { + const session = records.get(sessionId) + if (session) records.set(sessionId, { ...session, ...fields }) + }) + } + const transcript = { + hasMessages: vi.fn().mockResolvedValue(options.hasMessages ?? false), + clearMessages: vi.fn().mockResolvedValue(undefined), + prepareRetryMessage: vi.fn().mockResolvedValue({ + content: { text: 'Retry', files: [] }, + projectDir: '/retry' + }), + deleteMessage: vi.fn().mockResolvedValue(undefined), + editUserMessage: vi.fn().mockResolvedValue(createMessage()) + } + const workdir = { + assertAcpSessionHasWorkdir: vi.fn(), + syncAcpSessionWorkdir: vi.fn().mockResolvedValue(undefined), + prepareDirectAcpSession: vi.fn().mockResolvedValue(undefined), + clearCompatibilityAcpSession: vi.fn().mockResolvedValue(undefined) + } + const projection = { + notify: vi.fn(), + scheduleTitleGeneration: vi.fn() + } + const dependencies: SessionTurnCoordinatorDependencies = { + sessions, + runtime: { resolveSession }, + transcript, + workdir, + projection + } + + return { + coordinator: new SessionTurnCoordinator(dependencies), + records, + sessions, + resolveSession, + pending, + toolInteractions, + send, + cancel, + snapshot, + compaction, + transcript, + workdir, + projection + } +} + +describe('SessionTurnCoordinator', () => { + it('forwards send, steer, and queue metadata through workdir preparation', async () => { + const harness = createHarness({ hasMessages: false }) + + await expect( + harness.coordinator.sendMessage('s1', 'Send', { maxProviderRounds: 4 }) + ).resolves.toEqual({ requestId: 'request-1', messageId: 'message-1' }) + await harness.coordinator.steerActiveTurn('s1', 'Steer') + await harness.coordinator.queuePendingInput('s1', 'Queue') + + expect(harness.send).toHaveBeenCalledWith({ + content: { text: 'Send', files: [] }, + context: { projectDir: '/repo', maxProviderRounds: 4 }, + queue: { source: 'send', projectDir: '/repo' } + }) + expect(harness.pending.steerActiveTurn).toHaveBeenCalledWith({ text: 'Steer', files: [] }) + expect(harness.pending.queue).toHaveBeenCalledWith( + { text: 'Queue', files: [] }, + { source: 'queue', projectDir: '/repo' } + ) + expect(harness.workdir.assertAcpSessionHasWorkdir).toHaveBeenCalledTimes(3) + expect(harness.workdir.syncAcpSessionWorkdir).toHaveBeenCalledTimes(3) + expect(harness.projection.scheduleTitleGeneration).toHaveBeenCalledWith({ + sessionId: 's1', + initialTitle: 'Session', + fallbackProviderId: 'openai', + fallbackModelId: 'model-1' + }) + }) + + it.each([ + ['send', (coordinator: SessionTurnCoordinator) => coordinator.sendMessage('draft', 'Prompt')], + [ + 'steer', + (coordinator: SessionTurnCoordinator) => coordinator.steerActiveTurn('draft', 'Prompt') + ], + [ + 'queue', + (coordinator: SessionTurnCoordinator) => coordinator.queuePendingInput('draft', 'Prompt') + ] + ])('does not roll back draft promotion when %s fails later', async (_name, invoke) => { + const harness = createHarness({ sessions: [createSession({ id: 'draft', isDraft: true })] }) + harness.resolveSession.mockImplementation(() => { + throw new Error('runtime failed') + }) + + await expect(invoke(harness.coordinator)).rejects.toThrow('runtime failed') + expect(harness.records.get('draft')).toMatchObject({ isDraft: false, title: 'Prompt' }) + expect(harness.projection.notify).toHaveBeenCalledWith({ + sessionIds: ['draft'], + reason: 'updated' + }) + }) + + it('owns pending mutations and preserves missing-session behavior', async () => { + const harness = createHarness() + + await expect(harness.coordinator.listPendingInputs('s1')).resolves.toHaveLength(1) + await harness.coordinator.updateQueuedInput('s1', 'pending-1', 'Updated') + await harness.coordinator.moveQueuedInput('s1', 'pending-1', 2) + await harness.coordinator.convertPendingInputToSteer('s1', 'pending-1') + await harness.coordinator.steerPendingInput('s1', 'pending-1') + await harness.coordinator.deletePendingInput('s1', 'pending-1') + + expect(harness.pending.update).toHaveBeenCalledWith('pending-1', { + text: 'Updated', + files: [] + }) + expect(harness.pending.move).toHaveBeenCalledWith('pending-1', 2) + expect(harness.pending.convertToSteer).toHaveBeenCalledWith('pending-1') + expect(harness.pending.steer).toHaveBeenCalledWith('pending-1') + expect(harness.pending.delete).toHaveBeenCalledWith('pending-1') + + harness.resolveSession.mockClear() + await expect(harness.coordinator.listPendingInputs('missing')).resolves.toEqual([]) + await expect(harness.coordinator.cancelGeneration('missing')).resolves.toBeUndefined() + const missingMutations = [ + () => harness.coordinator.sendMessage('missing', 'Send'), + () => harness.coordinator.steerActiveTurn('missing', 'Steer'), + () => harness.coordinator.queuePendingInput('missing', 'Queue'), + () => harness.coordinator.updateQueuedInput('missing', 'pending-1', 'Updated'), + () => harness.coordinator.moveQueuedInput('missing', 'pending-1', 1), + () => harness.coordinator.convertPendingInputToSteer('missing', 'pending-1'), + () => harness.coordinator.steerPendingInput('missing', 'pending-1'), + () => harness.coordinator.deletePendingInput('missing', 'pending-1'), + () => harness.coordinator.retryMessage('missing', 'message-1'), + () => harness.coordinator.deleteMessage('missing', 'message-1'), + () => harness.coordinator.editUserMessage('missing', 'message-1', 'Edited'), + () => harness.coordinator.getSessionCompactionState('missing'), + () => harness.coordinator.compactSession('missing'), + () => harness.coordinator.clearSessionMessages('missing'), + () => + harness.coordinator.respondToolInteraction('missing', 'message-1', 'tool-1', { + kind: 'permission', + granted: true + }) + ] + for (const mutate of missingMutations) { + await expect(mutate()).rejects.toThrow('Session not found: missing') + } + expect(harness.resolveSession).not.toHaveBeenCalled() + }) + + it('preserves retry metadata and message mutation cancellation ordering', async () => { + const harness = createHarness() + + await harness.coordinator.retryMessage('s1', 'message-1') + expect(harness.send).toHaveBeenCalledWith({ + content: { text: 'Retry', files: [] }, + context: { projectDir: '/retry', emitRefreshBeforeStream: true } + }) + expect(harness.cancel).not.toHaveBeenCalled() + + await harness.coordinator.deleteMessage('s1', 'message-1') + expect(harness.cancel.mock.invocationCallOrder[0]).toBeLessThan( + harness.transcript.deleteMessage.mock.invocationCallOrder[0] + ) + + harness.cancel.mockClear() + await harness.coordinator.editUserMessage('s1', 'message-1', 'Edited') + expect(harness.transcript.editUserMessage).toHaveBeenCalledWith('s1', 'message-1', 'Edited') + expect(harness.cancel).not.toHaveBeenCalled() + + await harness.coordinator.clearSessionMessages('s1') + expect(harness.cancel.mock.invocationCallOrder[0]).toBeLessThan( + harness.transcript.clearMessages.mock.invocationCallOrder[0] + ) + expect(harness.projection.notify).toHaveBeenCalledWith({ + sessionIds: ['s1'], + reason: 'updated' + }) + }) + + it('stops delete and clear mutations when cancellation fails', async () => { + const harness = createHarness() + harness.cancel.mockRejectedValue(new Error('cancel failed')) + + await expect(harness.coordinator.deleteMessage('s1', 'message-1')).rejects.toThrow( + 'cancel failed' + ) + await expect(harness.coordinator.clearSessionMessages('s1')).rejects.toThrow('cancel failed') + + expect(harness.transcript.deleteMessage).not.toHaveBeenCalled() + expect(harness.transcript.clearMessages).not.toHaveBeenCalled() + }) + + it('uses direct ACP tool interaction while rejecting ACP manual compaction', async () => { + const harness = createHarness({ + kind: 'acp', + sessions: [createSession({ agentId: 'acp-coder' })] + }) + const response = { kind: 'permission' as const, granted: true } + + await expect( + harness.coordinator.respondToolInteraction('s1', 'message-1', 'tool-1', response) + ).resolves.toEqual({ resumed: true }) + expect(harness.toolInteractions.respond).toHaveBeenCalledWith('message-1', 'tool-1', response) + await expect(harness.coordinator.getSessionCompactionState('s1')).resolves.toEqual({ + status: 'idle', + cursorOrderSeq: 1, + summaryUpdatedAt: null + }) + await expect(harness.coordinator.compactSession('s1')).rejects.toThrow( + 'Agent acp-coder does not support manual compaction.' + ) + }) + + it('keeps compatibility ACP sessions out of DeepChat manual compaction', async () => { + const harness = createHarness({ providerId: 'acp' }) + + await expect(harness.coordinator.compactSession('s1')).rejects.toThrow( + 'Manual compaction is only available for DeepChat agent sessions.' + ) + expect(harness.compaction.compact).not.toHaveBeenCalled() + }) + + it('delegates DeepChat compaction state and mutation', async () => { + const harness = createHarness() + + await expect(harness.coordinator.getSessionCompactionState('s1')).resolves.toMatchObject({ + cursorOrderSeq: 3 + }) + await expect(harness.coordinator.compactSession('s1')).resolves.toMatchObject({ + compacted: true + }) + }) + + it('starts the lifecycle initial turn without awaiting it and schedules title generation', () => { + const harness = createHarness() + let resolveSend: (() => void) | undefined + harness.send.mockReturnValue( + new Promise((resolve) => { + resolveSend = () => resolve({ requestId: null, messageId: null }) + }) + ) + + expect( + harness.coordinator.startInitialTurn({ + sessionId: 's1', + content: { text: 'Initial', files: [], activeSkills: [' review ', 'review'] }, + projectDir: '/repo', + initialTitle: 'Initial', + fallbackProviderId: 'openai', + fallbackModelId: 'model-1' + }) + ).toBeUndefined() + + expect(harness.send).toHaveBeenCalledWith({ + content: { text: 'Initial', files: [], activeSkills: ['review'] }, + context: { projectDir: '/repo' }, + queue: { source: 'send', projectDir: '/repo' } + }) + expect(harness.projection.scheduleTitleGeneration).toHaveBeenCalledWith({ + sessionId: 's1', + initialTitle: 'Initial', + fallbackProviderId: 'openai', + fallbackModelId: 'model-1' + }) + resolveSend?.() + }) + + it('does not resolve runtime for an empty lifecycle initial turn', () => { + const harness = createHarness() + + harness.coordinator.startInitialTurn({ + sessionId: 's1', + content: { text: ' ', files: [] }, + projectDir: '/repo', + initialTitle: 'New Chat', + fallbackProviderId: 'openai', + fallbackModelId: 'model-1' + }) + + expect(harness.resolveSession).not.toHaveBeenCalled() + expect(harness.projection.scheduleTitleGeneration).not.toHaveBeenCalled() + }) + + it('contains lifecycle initial-turn rejection after creation returns', async () => { + const harness = createHarness() + const error = new Error('send failed') + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + harness.send.mockRejectedValue(error) + + harness.coordinator.startInitialTurn({ + sessionId: 's1', + content: { text: 'Initial', files: [] }, + projectDir: '/repo', + initialTitle: 'Initial', + fallbackProviderId: 'openai', + fallbackModelId: 'model-1' + }) + await Promise.resolve() + + expect(consoleError).toHaveBeenCalledWith( + '[SessionTurnCoordinator] initial send failed:', + error + ) + expect(harness.projection.scheduleTitleGeneration).toHaveBeenCalledOnce() + consoleError.mockRestore() + }) +}) From e59719dcbb8055baaa3e6208d8696a6170ab60e3 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 13 Jul 2026 19:04:38 +0800 Subject: [PATCH 10/29] fix(session): contain initial turn failures --- .../sessionApplication/turnCoordinator.ts | 24 ++++--- .../turnCoordinator.test.ts | 72 ++++++++++++++++--- 2 files changed, 78 insertions(+), 18 deletions(-) diff --git a/src/main/presenter/sessionApplication/turnCoordinator.ts b/src/main/presenter/sessionApplication/turnCoordinator.ts index 6b37261d3..27ccbecd2 100644 --- a/src/main/presenter/sessionApplication/turnCoordinator.ts +++ b/src/main/presenter/sessionApplication/turnCoordinator.ts @@ -35,16 +35,20 @@ export class SessionTurnCoordinator implements SessionTurnPort, SessionInitialTu const content = normalizeSendMessageInput(input.content) if (!content.text.trim() && (content.files?.length ?? 0) === 0) return - const runtime = this.dependencies.runtime.resolveSession(toAppSessionId(input.sessionId)) - void runtime - .send({ - content, - context: { projectDir: input.projectDir }, - queue: { source: 'send', projectDir: input.projectDir } - }) - .catch((error) => { - console.error('[SessionTurnCoordinator] initial send failed:', error) - }) + try { + const runtime = this.dependencies.runtime.resolveSession(toAppSessionId(input.sessionId)) + void runtime + .send({ + content, + context: { projectDir: input.projectDir }, + queue: { source: 'send', projectDir: input.projectDir } + }) + .catch((error) => { + console.error('[SessionTurnCoordinator] initial send failed:', error) + }) + } catch (error) { + console.error('[SessionTurnCoordinator] initial send failed:', error) + } this.dependencies.projection.scheduleTitleGeneration({ sessionId: input.sessionId, initialTitle: input.initialTitle, diff --git a/test/main/presenter/sessionApplication/turnCoordinator.test.ts b/test/main/presenter/sessionApplication/turnCoordinator.test.ts index f19ebde6c..76df4ba75 100644 --- a/test/main/presenter/sessionApplication/turnCoordinator.test.ts +++ b/test/main/presenter/sessionApplication/turnCoordinator.test.ts @@ -405,14 +405,16 @@ describe('SessionTurnCoordinator', () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) harness.send.mockRejectedValue(error) - harness.coordinator.startInitialTurn({ - sessionId: 's1', - content: { text: 'Initial', files: [] }, - projectDir: '/repo', - initialTitle: 'Initial', - fallbackProviderId: 'openai', - fallbackModelId: 'model-1' - }) + expect(() => + harness.coordinator.startInitialTurn({ + sessionId: 's1', + content: { text: 'Initial', files: [] }, + projectDir: '/repo', + initialTitle: 'Initial', + fallbackProviderId: 'openai', + fallbackModelId: 'model-1' + }) + ).not.toThrow() await Promise.resolve() expect(consoleError).toHaveBeenCalledWith( @@ -422,4 +424,58 @@ describe('SessionTurnCoordinator', () => { expect(harness.projection.scheduleTitleGeneration).toHaveBeenCalledOnce() consoleError.mockRestore() }) + + it('contains synchronous runtime resolution failure after creation returns', () => { + const harness = createHarness() + const error = new Error('resolve failed') + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + harness.resolveSession.mockImplementation(() => { + throw error + }) + + expect(() => + harness.coordinator.startInitialTurn({ + sessionId: 's1', + content: { text: 'Initial', files: [] }, + projectDir: '/repo', + initialTitle: 'Initial', + fallbackProviderId: 'openai', + fallbackModelId: 'model-1' + }) + ).not.toThrow() + + expect(consoleError).toHaveBeenCalledWith( + '[SessionTurnCoordinator] initial send failed:', + error + ) + expect(harness.projection.scheduleTitleGeneration).toHaveBeenCalledOnce() + consoleError.mockRestore() + }) + + it('contains synchronous send invocation failure after creation returns', () => { + const harness = createHarness() + const error = new Error('send invocation failed') + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + harness.send.mockImplementation(() => { + throw error + }) + + expect(() => + harness.coordinator.startInitialTurn({ + sessionId: 's1', + content: { text: 'Initial', files: [] }, + projectDir: '/repo', + initialTitle: 'Initial', + fallbackProviderId: 'openai', + fallbackModelId: 'model-1' + }) + ).not.toThrow() + + expect(consoleError).toHaveBeenCalledWith( + '[SessionTurnCoordinator] initial send failed:', + error + ) + expect(harness.projection.scheduleTitleGeneration).toHaveBeenCalledOnce() + consoleError.mockRestore() + }) }) From 9dc7f231674684146e976686e04cf1bd01c057ae Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 13 Jul 2026 19:19:17 +0800 Subject: [PATCH 11/29] refactor(session): extract lifecycle --- .../session-application-coordinators/tasks.md | 8 +- .../presenter/agentSessionPresenter/index.ts | 568 +----------------- src/main/presenter/index.ts | 44 +- .../lifecycleCoordinator.ts | 500 +++++++++++++++ .../presenter/sessionApplication/ports.ts | 94 +++ .../agentSessionPresenter.test.ts | 29 +- .../assignmentCoordinatorFixture.ts | 42 +- .../agentSessionPresenter/integration.test.ts | 21 +- .../usageDashboard.test.ts | 7 +- .../lifecycleCoordinator.test.ts | 540 +++++++++++++++++ 10 files changed, 1245 insertions(+), 608 deletions(-) create mode 100644 src/main/presenter/sessionApplication/lifecycleCoordinator.ts create mode 100644 test/main/presenter/sessionApplication/lifecycleCoordinator.test.ts diff --git a/docs/architecture/session-application-coordinators/tasks.md b/docs/architecture/session-application-coordinators/tasks.md index bcf679d14..3987d8b89 100644 --- a/docs/architecture/session-application-coordinators/tasks.md +++ b/docs/architecture/session-application-coordinators/tasks.md @@ -44,10 +44,10 @@ ## 5. SessionLifecycleCoordinator -- [ ] Extract create, detached, subagent, ACP draft, fork, and recursive delete transactions. -- [ ] Extract runtime initialization, workdir sync, and failed-create cleanup. -- [ ] Connect real Assignment policy, Turn initial-message, and Projection mutation owners. -- [ ] Rewire compatibility presenter forwarding and move owner tests. +- [x] Extract create, detached, subagent, ACP draft, fork, and recursive delete transactions. +- [x] Extract runtime initialization, workdir sync, and failed-create cleanup. +- [x] Connect real Assignment policy, Turn initial-message, and Projection mutation owners. +- [x] Rewire compatibility presenter forwarding and move owner tests. ## 6. SessionService and ChatService diff --git a/src/main/presenter/agentSessionPresenter/index.ts b/src/main/presenter/agentSessionPresenter/index.ts index 5e39033f6..a95f4077b 100644 --- a/src/main/presenter/agentSessionPresenter/index.ts +++ b/src/main/presenter/agentSessionPresenter/index.ts @@ -30,7 +30,6 @@ import type { PermissionMode, SessionCompactionState, SessionGenerationSettings, - DeepChatSubagentMeta, ToolInteractionResponse, ToolInteractionResult, UsageDashboardData, @@ -52,7 +51,6 @@ import type { HistorySearchSessionHit, HistorySearchMessageHit, ILlmProviderPresenter, - ISkillPresenter, CONVERSATION } from '@shared/presenter' import type { SQLitePresenter } from '../sqlitePresenter' @@ -68,15 +66,12 @@ import { LegacyChatImportService } from './legacyImportService' import { SessionProjectionCoordinator } from '../sessionApplication/projectionCoordinator' import type { SessionAgentAssignmentPort, - SessionAssignmentPolicyPort, - SessionAssignmentWorkdirPort, - SessionInitialTurnPort, - SessionLifecycleDeletionPort, + SessionLifecyclePort, + SessionLifecycleSubagentInput, SessionTurnPort } from '../sessionApplication/ports' import { normalizeActiveSkills, - normalizeCreateSessionInput, normalizeDisabledAgentTools } from '@/agent/shared/agentSessionNormalization' import { @@ -115,7 +110,6 @@ type SearchableMessageRow = { updatedAt: number } -const SUBAGENT_SESSION_INIT_MAX_ATTEMPTS = 2 const SQLITE_MAINLINE_NORMALIZATION_KEY = 'sqlite-mainline-normalization-v1' const DISABLED_SEARCH_TOOL_CLEANUP_KEY = 'agent-disabled-search-tool-cleanup-v1' @@ -253,13 +247,9 @@ export class AgentSessionPresenter { private sharedData: AgentSharedDataPorts private legacyImportService: LegacyChatImportService private sessionProjection: SessionProjectionCoordinator - private sessionAssignmentPolicy: SessionAssignmentPolicyPort + private sessionLifecycle: SessionLifecyclePort private sessionAssignment: SessionAgentAssignmentPort - private sessionAssignmentWorkdir: SessionAssignmentWorkdirPort private sessionTurn: SessionTurnPort - private sessionInitialTurn: SessionInitialTurnPort - private sessionDeletion: SessionLifecycleDeletionPort - private skillPresenter?: Pick private sessionPermissionPort?: SessionPermissionPort private usageStatsBackfillPromise: Promise | null = null private mainlineNormalizationPromise: Promise | null = null @@ -273,13 +263,9 @@ export class AgentSessionPresenter { sqlitePresenter: SQLitePresenter, sharedData: AgentSharedDataPorts, sessionProjection: SessionProjectionCoordinator, - sessionAssignmentPolicy: SessionAssignmentPolicyPort, + sessionLifecycle: SessionLifecyclePort, sessionAssignment: SessionAgentAssignmentPort, - sessionAssignmentWorkdir: SessionAssignmentWorkdirPort, sessionTurn: SessionTurnPort, - sessionInitialTurn: SessionInitialTurnPort, - sessionDeletion: SessionLifecycleDeletionPort, - skillPresenter?: Pick, runtimePorts?: { sessionPermissionPort?: SessionPermissionPort } @@ -290,13 +276,9 @@ export class AgentSessionPresenter { this.configPresenter = configPresenter this.sharedData = sharedData this.sessionProjection = sessionProjection - this.sessionAssignmentPolicy = sessionAssignmentPolicy + this.sessionLifecycle = sessionLifecycle this.sessionAssignment = sessionAssignment - this.sessionAssignmentWorkdir = sessionAssignmentWorkdir this.sessionTurn = sessionTurn - this.sessionInitialTurn = sessionInitialTurn - this.sessionDeletion = sessionDeletion - this.skillPresenter = skillPresenter this.sessionManager = appSessionService this.legacyImportService = new LegacyChatImportService(sqlitePresenter) this.sessionPermissionPort = runtimePorts?.sessionPermissionPort @@ -305,289 +287,15 @@ export class AgentSessionPresenter { // ---- IPC-facing methods ---- async createSession(input: CreateSessionInput, webContentsId: number): Promise { - const requestedAgentId = input.agentId || 'deepchat' - const assignment = await this.sessionAssignmentPolicy.resolveCreateAssignment({ - agentId: requestedAgentId, - providerId: input.providerId, - modelId: input.modelId, - projectDir: input.projectDir, - permissionMode: input.permissionMode, - generationSettings: input.generationSettings, - disabledAgentTools: input.disabledAgentTools, - subagentEnabled: input.subagentEnabled, - preserveExplicitNullProjectDir: true - }) - const { - agentId, - providerId, - modelId, - projectDir, - permissionMode, - generationSettings, - disabledAgentTools, - subagentEnabled - } = assignment - logger.info( - `[AgentSessionPresenter] createSession agent=${agentId} webContentsId=${webContentsId}` - ) - const normalizedInput = normalizeCreateSessionInput(input) - logger.info(`[AgentSessionPresenter] resolved provider=${providerId} model=${modelId}`) - - // Create session record - const title = normalizedInput.text.slice(0, 50) || 'New Chat' - const sessionId = this.sessionManager.create(agentId, title, projectDir, { - isDraft: false, - disabledAgentTools, - subagentEnabled - }) - logger.info(`[AgentSessionPresenter] session created id=${sessionId} title="${title}"`) - - // Initialize agent-side session - const initConfig: { - agentId?: string - providerId: string - modelId: string - projectDir: string | null - permissionMode: PermissionMode - generationSettings?: Partial - } = { - agentId, - providerId, - modelId, - projectDir, - permissionMode - } - if (generationSettings) { - initConfig.generationSettings = generationSettings - } - try { - await this.initializeSessionRuntime(sessionId, initConfig) - } catch (error) { - await this.cleanupFailedSessionInitialization(sessionId, providerId) - throw error - } - logger.info(`[AgentSessionPresenter] agent.initSession done`) - - // Bind to the window and publish the created session projection. - this.sessionProjection.bindWindow(webContentsId, sessionId) - this.sessionProjection.notify({ - sessionIds: [sessionId], - reason: 'created', - activeSessionId: sessionId, - webContentsId - }) - - // Return enriched session first - const { handle } = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)) - const state = await handle.snapshot() - const sessionResult: SessionWithState = { - id: sessionId, - agentId, - title, - projectDir, - isPinned: false, - isDraft: false, - sessionKind: 'regular', - parentSessionId: null, - subagentEnabled, - subagentMeta: null, - createdAt: Date.now(), - updatedAt: Date.now(), - status: state?.status ?? 'idle', - providerId: state?.providerId ?? providerId, - modelId: state?.modelId ?? modelId - } - - this.sessionInitialTurn.startInitialTurn({ - sessionId, - content: normalizedInput, - projectDir, - initialTitle: title, - fallbackProviderId: providerId, - fallbackModelId: modelId - }) - - return sessionResult + return await this.sessionLifecycle.createSession(input, webContentsId) } async createDetachedSession(input: CreateDetachedSessionInput): Promise { - const requestedAgentId = input.agentId?.trim() || 'deepchat' - const title = input.title?.trim() || 'New Chat' - const { - agentId, - providerId, - modelId, - projectDir, - permissionMode, - generationSettings, - disabledAgentTools, - subagentEnabled - } = await this.sessionAssignmentPolicy.resolveCreateAssignment({ - agentId: requestedAgentId, - providerId: input.providerId, - modelId: input.modelId, - projectDir: input.projectDir, - permissionMode: input.permissionMode, - generationSettings: input.generationSettings, - disabledAgentTools: input.disabledAgentTools, - subagentEnabled: input.subagentEnabled, - preserveExplicitNullProjectDir: false - }) - - const sessionId = this.sessionManager.create(agentId, title, projectDir, { - isDraft: false, - disabledAgentTools, - subagentEnabled, - metadata: input.metadata ?? null - }) - - try { - await this.initializeSessionRuntime(sessionId, { - agentId, - providerId, - modelId, - projectDir, - permissionMode, - generationSettings - }) - } catch (error) { - await this.cleanupFailedSessionInitialization(sessionId, providerId) - throw error - } - - if (input.activeSkills && input.activeSkills.length > 0 && this.skillPresenter) { - await this.skillPresenter.setActiveSkills(sessionId, input.activeSkills) - } - - this.sessionProjection.notify({ - sessionIds: [sessionId], - reason: 'created' - }) - - const state = await this.agentManager - .resolveSessionHandle(toAppSessionId(sessionId)) - .handle.snapshot() - return { - id: sessionId, - agentId, - title, - projectDir, - isPinned: false, - isDraft: false, - sessionKind: 'regular', - parentSessionId: null, - subagentEnabled, - subagentMeta: null, - createdAt: Date.now(), - updatedAt: Date.now(), - ...(input.metadata ? { metadata: input.metadata } : {}), - status: state?.status ?? 'idle', - providerId: state?.providerId ?? providerId, - modelId: state?.modelId ?? modelId - } + return await this.sessionLifecycle.createDetachedSession(input) } - async createSubagentSession(input: { - parentSessionId: string - agentId: string - slotId: string - displayName: string - targetAgentId?: string | null - projectDir?: string | null - providerId: string - modelId: string - permissionMode: PermissionMode - generationSettings?: Partial - disabledAgentTools?: string[] - activeSkills?: string[] - }): Promise { - const parentSessionId = input.parentSessionId?.trim() - if (!parentSessionId) { - throw new Error('Subagent session requires a parentSessionId.') - } - - const slotId = input.slotId?.trim() - if (!slotId) { - throw new Error('Subagent session requires a slotId.') - } - - const displayName = input.displayName?.trim() || 'Subagent' - const agentId = input.agentId?.trim() - if (!agentId) { - throw new Error('Subagent session requires an agentId.') - } - - const projectDir = input.projectDir?.trim() || null - const runtimeConfig = await this.sessionAssignmentPolicy.resolveSubagentAssignment({ - agentId, - targetAgentId: input.targetAgentId, - projectDir, - providerId: input.providerId, - modelId: input.modelId, - generationSettings: input.generationSettings, - disabledAgentTools: input.disabledAgentTools, - activeSkills: input.activeSkills - }) - const subagentMeta: DeepChatSubagentMeta = { - slotId, - displayName, - targetAgentId: runtimeConfig.targetAgentId || null - } - let lastError: unknown = null - - for (let attempt = 1; attempt <= SUBAGENT_SESSION_INIT_MAX_ATTEMPTS; attempt += 1) { - const sessionId = this.sessionManager.create(runtimeConfig.agentId, displayName, projectDir, { - isDraft: false, - disabledAgentTools: runtimeConfig.disabledAgentTools, - subagentEnabled: false, - sessionKind: 'subagent', - parentSessionId, - subagentMeta - }) - - try { - await this.initializeSessionRuntime(sessionId, { - agentId: runtimeConfig.agentId, - providerId: runtimeConfig.providerId, - modelId: runtimeConfig.modelId, - projectDir, - permissionMode: input.permissionMode, - generationSettings: runtimeConfig.generationSettings - }) - - if (runtimeConfig.activeSkills.length > 0 && this.skillPresenter) { - await this.skillPresenter.setActiveSkills(sessionId, runtimeConfig.activeSkills) - } - - const record = this.sessionManager.get(sessionId) - if (!record) { - throw new Error(`Subagent session not found after creation: ${sessionId}`) - } - - const session = await this.sessionProjection.materializeRequired(sessionId) - this.sessionProjection.notify({ - sessionIds: [session.id], - reason: 'created' - }) - return session - } catch (error) { - lastError = error - await this.cleanupFailedSessionInitialization(sessionId, runtimeConfig.providerId) - - if (attempt >= SUBAGENT_SESSION_INIT_MAX_ATTEMPTS) { - throw error - } - - console.warn( - `[AgentSessionPresenter] Retrying subagent session initialization (${attempt}/${SUBAGENT_SESSION_INIT_MAX_ATTEMPTS - 1} retry used) for agent=${runtimeConfig.agentId} slot=${slotId}:`, - error - ) - } - } - - throw lastError instanceof Error - ? lastError - : new Error(`Failed to create subagent session for slot ${slotId}.`) + async createSubagentSession(input: SessionLifecycleSubagentInput): Promise { + return await this.sessionLifecycle.createSubagentSession(input) } async ensureAcpDraftSession(input: { @@ -595,68 +303,7 @@ export class AgentSessionPresenter { projectDir: string permissionMode?: PermissionMode }): Promise { - const agentId = input.agentId?.trim() - if (!agentId) { - throw new Error('ACP draft session requires an agentId.') - } - - const projectDir = input.projectDir?.trim() - if (!projectDir) { - throw new Error('ACP draft session requires a non-empty projectDir.') - } - - const { agentId: canonicalAgentId, permissionMode } = - this.sessionAssignmentPolicy.resolveAcpDraftAssignment(agentId, input.permissionMode) - - let record = await this.findReusableDraftSession(canonicalAgentId, projectDir) - let createdDraftSession = false - if (!record) { - const sessionId = this.sessionManager.create(canonicalAgentId, 'New Chat', projectDir, { - isDraft: true, - subagentEnabled: false - }) - try { - await this.ensureSessionRuntimeInitialized(sessionId, { - agentId: canonicalAgentId, - providerId: 'acp', - modelId: canonicalAgentId, - projectDir, - permissionMode - }) - } catch (error) { - await this.cleanupFailedSessionInitialization(sessionId, 'acp') - throw error - } - record = this.sessionManager.get(sessionId) - if (!record) { - throw new Error(`Failed to read created ACP draft session: ${sessionId}`) - } - createdDraftSession = true - } else { - await this.ensureSessionRuntimeInitialized(record.id, { - agentId: canonicalAgentId, - providerId: 'acp', - modelId: canonicalAgentId, - projectDir, - permissionMode - }) - } - - await this.sessionAssignmentWorkdir.prepareDirectAcpSession(record.id) - this.sessionProjection.notify({ - sessionIds: [record.id], - reason: createdDraftSession ? 'created' : 'updated' - }) - - const state = await this.agentManager - .resolveSessionHandle(toAppSessionId(record.id)) - .handle.snapshot() - return { - ...record, - status: state?.status ?? 'idle', - providerId: state?.providerId ?? 'acp', - modelId: state?.modelId ?? canonicalAgentId - } + return await this.sessionLifecycle.ensureAcpDraftSession(input) } async sendMessage( @@ -720,75 +367,7 @@ export class AgentSessionPresenter { targetMessageId: string, newTitle?: string ): Promise { - const sourceSession = this.sessionManager.get(sourceSessionId) - if (!sourceSession) { - throw new Error(`Session not found: ${sourceSessionId}`) - } - - const sourceHandle = this.agentManager.resolveSessionHandle( - toAppSessionId(sourceSessionId) - ).handle - const sourceState = await sourceHandle.snapshot() - if (!sourceState) { - throw new Error(`Session state not found: ${sourceSessionId}`) - } - - const generationSettings = await sourceHandle.settings.getGenerationSettings() - - const title = this.buildForkTitle(sourceSession.title, newTitle) - const targetSessionId = this.sessionManager.create( - sourceSession.agentId, - title, - sourceSession.projectDir ?? null, - { isDraft: false } - ) - - try { - await this.initializeSessionRuntime(targetSessionId, { - agentId: sourceSession.agentId, - providerId: sourceState.providerId, - modelId: sourceState.modelId, - projectDir: sourceSession.projectDir ?? null, - permissionMode: sourceState.permissionMode, - generationSettings: generationSettings ?? undefined - }) - await this.sharedData.transcriptMutation.forkSessionFromMessage( - sourceSessionId, - targetSessionId, - targetMessageId - ) - } catch (error) { - try { - await this.agentManager.resolveSessionHandle(toAppSessionId(targetSessionId)).handle.close() - } catch (cleanupError) { - console.warn( - `[AgentSessionPresenter] Failed to cleanup forked session runtime ${targetSessionId}:`, - cleanupError - ) - } - this.sessionManager.delete(targetSessionId) - throw error - } - - this.sessionProjection.notify({ - sessionIds: [targetSessionId], - reason: 'created' - }) - - const record = this.sessionManager.get(targetSessionId) - if (!record) { - throw new Error(`Forked session not found: ${targetSessionId}`) - } - - const targetState = await this.agentManager - .resolveSessionHandle(toAppSessionId(targetSessionId)) - .handle.snapshot() - return { - ...record, - status: targetState?.status ?? 'idle', - providerId: targetState?.providerId ?? sourceState.providerId, - modelId: targetState?.modelId ?? sourceState.modelId - } + return await this.sessionLifecycle.forkSession(sourceSessionId, targetMessageId, newTitle) } async getSessionList(filters?: { @@ -1381,11 +960,7 @@ export class AgentSessionPresenter { } async deleteSession(sessionId: string): Promise { - const deletedSessionIds = await this.sessionDeletion.deleteSessionTree(sessionId) - this.sessionProjection.notify({ - sessionIds: deletedSessionIds, - reason: 'deleted' - }) + await this.sessionLifecycle.deleteSession(sessionId) } async getAgentTransferImpact(agentId: string): Promise { @@ -1531,113 +1106,6 @@ export class AgentSessionPresenter { } } - private async findReusableDraftSession( - agentId: string, - projectDir: string - ): Promise { - const candidates = this.sessionManager.list({ agentId, projectDir }) - for (const session of candidates) { - if (!session.isDraft) continue - const hasMessages = await this.hasSessionMessages(session.id) - if (!hasMessages) { - return session - } - } - return null - } - - private async hasSessionMessages(sessionId: string): Promise { - try { - return await this.sharedData.transcript.hasMessages(sessionId) - } catch (error) { - console.warn( - `[AgentSessionPresenter] Failed to inspect messages for session=${sessionId}:`, - error - ) - return true - } - } - - private async ensureSessionRuntimeInitialized( - sessionId: string, - config: { - agentId?: string - providerId: string - modelId: string - projectDir: string - permissionMode: PermissionMode - } - ): Promise { - const handle = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle - if (!(await handle.lifecycle.isInitialized())) { - await this.initializeSessionRuntime(sessionId, config) - return - } - const state = await handle.snapshot() - if (!state) throw new Error(`Session ${sessionId} not found`) - - if (state.permissionMode && state.permissionMode !== config.permissionMode) { - await handle.settings.setPermissionMode(config.permissionMode) - } - - await this.sessionAssignmentWorkdir.syncAcpSessionWorkdir( - config.providerId, - sessionId, - config.agentId ?? config.modelId, - config.projectDir - ) - } - - private async initializeSessionRuntime( - sessionId: string, - config: { - agentId?: string - providerId: string - modelId: string - projectDir?: string | null - permissionMode: PermissionMode - generationSettings?: Partial - } - ): Promise { - await this.agentManager - .resolveSessionHandle(toAppSessionId(sessionId)) - .handle.lifecycle.initialize(config) - await this.sessionAssignmentWorkdir.syncAcpSessionWorkdir( - config.providerId, - sessionId, - config.agentId ?? config.modelId, - config.projectDir ?? null - ) - } - - private async cleanupFailedSessionInitialization( - sessionId: string, - providerId?: string - ): Promise { - const handle = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle - if (providerId === 'acp' && handle.kind !== 'acp') { - try { - await this.sessionAssignmentWorkdir.clearCompatibilityAcpSession(sessionId) - } catch (error) { - console.warn( - `[AgentSessionPresenter] Failed to clear ACP session after initialization error ${sessionId}:`, - error - ) - } - } - - try { - await handle.close() - } catch (cleanupError) { - console.warn( - `[AgentSessionPresenter] Failed to cleanup session runtime after initialization error ${sessionId}:`, - cleanupError - ) - } - - this.sessionManager.delete(sessionId) - } - private async buildExportConversation( session: SessionRecord, providerId: string, @@ -2372,18 +1840,6 @@ export class AgentSessionPresenter { await new Promise((resolve) => setTimeout(resolve, 0)) } - private buildForkTitle(sourceTitle: string, customTitle?: string): string { - const normalizedCustom = customTitle?.trim() - if (normalizedCustom) { - return normalizedCustom - } - const base = sourceTitle?.trim() || 'New Chat' - if (base.length >= 60) { - return base.slice(0, 60).trim() - } - return `${base} - Fork` - } - private resolveTranslateLanguage(locale?: string): string { const normalized = locale?.trim().toLowerCase() || '' if (!normalized) { diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index c89d5ac2c..d338a9c61 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -81,6 +81,7 @@ import { SessionAgentAssignmentPolicy } from './sessionApplication/agentAssignme import { SessionAgentAssignmentCoordinator } from './sessionApplication/agentAssignmentCoordinator' import { SessionDeletionTransaction } from './sessionApplication/lifecycleDeletionTransaction' import { SessionTurnCoordinator } from './sessionApplication/turnCoordinator' +import { SessionLifecycleCoordinator } from './sessionApplication/lifecycleCoordinator' import { AgentRuntimePresenter } from './agentRuntimePresenter' import { AcpAgentRuntime } from '@/agent/acp/instance' import type { @@ -166,6 +167,7 @@ export class Presenter implements IPresenter { sessionAgentAssignmentPolicy: SessionAgentAssignmentPolicy sessionAgentAssignmentCoordinator: SessionAgentAssignmentCoordinator sessionTurnCoordinator: SessionTurnCoordinator + sessionLifecycleCoordinator: SessionLifecycleCoordinator sessionDeletionTransaction: SessionDeletionTransaction agentManager: AgentManager acpAgentRuntime: AcpAgentRuntime @@ -845,6 +847,42 @@ export class Presenter implements IPresenter { workdir: this.sessionAgentAssignmentCoordinator, projection: this.sessionProjectionCoordinator }) + this.sessionLifecycleCoordinator = new SessionLifecycleCoordinator({ + sessions: appSessionService, + runtime: { + resolveSession: (sessionId) => { + const { handle } = this.agentManager.resolveSessionHandle(sessionId) + return { + kind: handle.kind, + initialize: (config) => handle.lifecycle.initialize(config), + isInitialized: () => handle.lifecycle.isInitialized(), + snapshot: () => handle.snapshot(), + getGenerationSettings: () => handle.settings.getGenerationSettings(), + setPermissionMode: (mode) => handle.settings.setPermissionMode(mode), + close: () => handle.close() + } + } + }, + transcript: { + hasMessages: (sessionId) => agentSharedData.transcript.hasMessages(sessionId), + forkSessionFromMessage: (sourceSessionId, targetSessionId, targetMessageId) => + agentSharedData.transcriptMutation.forkSessionFromMessage( + sourceSessionId, + targetSessionId, + targetMessageId + ) + }, + skills: { + setActiveSkills: async (sessionId, activeSkills) => { + await this.skillPresenter.setActiveSkills(sessionId, activeSkills) + } + }, + assignmentPolicy: this.sessionAgentAssignmentPolicy, + workdir: this.sessionAgentAssignmentCoordinator, + initialTurn: this.sessionTurnCoordinator, + projection: this.sessionProjectionCoordinator, + deletion: this.sessionDeletionTransaction + }) this.agentSessionPresenter = new AgentSessionPresenter( this.agentManager, appSessionService, @@ -853,13 +891,9 @@ export class Presenter implements IPresenter { this.sqlitePresenter as unknown as import('./sqlitePresenter').SQLitePresenter, agentSharedData, this.sessionProjectionCoordinator, - this.sessionAgentAssignmentPolicy, + this.sessionLifecycleCoordinator, this.sessionAgentAssignmentCoordinator, - this.sessionAgentAssignmentCoordinator, - this.sessionTurnCoordinator, this.sessionTurnCoordinator, - this.sessionDeletionTransaction, - this.skillPresenter, { sessionPermissionPort } diff --git a/src/main/presenter/sessionApplication/lifecycleCoordinator.ts b/src/main/presenter/sessionApplication/lifecycleCoordinator.ts new file mode 100644 index 000000000..16cafa65f --- /dev/null +++ b/src/main/presenter/sessionApplication/lifecycleCoordinator.ts @@ -0,0 +1,500 @@ +import logger from '@shared/logger' +import { toAppSessionId } from '@/agent/shared/agentSessionIds' +import { normalizeCreateSessionInput } from '@/agent/shared/agentSessionNormalization' +import type { + CreateDetachedSessionInput, + CreateSessionInput, + DeepChatSubagentMeta, + PermissionMode, + SessionRecord, + SessionWithState +} from '@shared/types/agent-interface' +import type { + SessionAssignmentPolicyPort, + SessionAssignmentWorkdirPort, + SessionInitialTurnPort, + SessionLifecycleDeletionPort, + SessionLifecyclePort, + SessionLifecycleProjectionPort, + SessionLifecycleRuntimeConfig, + SessionLifecycleRuntimePort, + SessionLifecycleSkillPort, + SessionLifecycleStorePort, + SessionLifecycleSubagentInput, + SessionLifecycleTranscriptPort +} from './ports' + +const SUBAGENT_SESSION_INIT_MAX_ATTEMPTS = 2 + +export interface SessionLifecycleCoordinatorDependencies { + sessions: SessionLifecycleStorePort + runtime: SessionLifecycleRuntimePort + transcript: SessionLifecycleTranscriptPort + skills: SessionLifecycleSkillPort + assignmentPolicy: SessionAssignmentPolicyPort + workdir: SessionAssignmentWorkdirPort + initialTurn: SessionInitialTurnPort + projection: SessionLifecycleProjectionPort + deletion: SessionLifecycleDeletionPort +} + +export class SessionLifecycleCoordinator implements SessionLifecyclePort { + constructor(private readonly dependencies: SessionLifecycleCoordinatorDependencies) {} + + async createSession(input: CreateSessionInput, webContentsId: number): Promise { + const assignment = await this.dependencies.assignmentPolicy.resolveCreateAssignment({ + agentId: input.agentId || 'deepchat', + providerId: input.providerId, + modelId: input.modelId, + projectDir: input.projectDir, + permissionMode: input.permissionMode, + generationSettings: input.generationSettings, + disabledAgentTools: input.disabledAgentTools, + subagentEnabled: input.subagentEnabled, + preserveExplicitNullProjectDir: true + }) + const { + agentId, + providerId, + modelId, + projectDir, + permissionMode, + generationSettings, + disabledAgentTools, + subagentEnabled + } = assignment + logger.info( + `[SessionLifecycleCoordinator] createSession agent=${agentId} webContentsId=${webContentsId}` + ) + const normalizedInput = normalizeCreateSessionInput(input) + logger.info(`[SessionLifecycleCoordinator] resolved provider=${providerId} model=${modelId}`) + + const title = normalizedInput.text.slice(0, 50) || 'New Chat' + const sessionId = this.dependencies.sessions.create(agentId, title, projectDir, { + isDraft: false, + disabledAgentTools, + subagentEnabled + }) + logger.info(`[SessionLifecycleCoordinator] session created id=${sessionId} title="${title}"`) + + try { + await this.initializeSessionRuntime(sessionId, { + agentId, + providerId, + modelId, + projectDir, + permissionMode, + ...(generationSettings ? { generationSettings } : {}) + }) + } catch (error) { + await this.cleanupFailedSessionInitialization(sessionId, providerId) + throw error + } + logger.info('[SessionLifecycleCoordinator] agent.initSession done') + + this.dependencies.projection.bindWindow(webContentsId, sessionId) + this.dependencies.projection.notify({ + sessionIds: [sessionId], + reason: 'created', + activeSessionId: sessionId, + webContentsId + }) + + const state = await this.dependencies.runtime.resolveSession(sessionId).snapshot() + const result: SessionWithState = { + id: sessionId, + agentId, + title, + projectDir, + isPinned: false, + isDraft: false, + sessionKind: 'regular', + parentSessionId: null, + subagentEnabled, + subagentMeta: null, + createdAt: Date.now(), + updatedAt: Date.now(), + status: state?.status ?? 'idle', + providerId: state?.providerId ?? providerId, + modelId: state?.modelId ?? modelId + } + + this.dependencies.initialTurn.startInitialTurn({ + sessionId, + content: normalizedInput, + projectDir, + initialTitle: title, + fallbackProviderId: providerId, + fallbackModelId: modelId + }) + return result + } + + async createDetachedSession(input: CreateDetachedSessionInput): Promise { + const title = input.title?.trim() || 'New Chat' + const { + agentId, + providerId, + modelId, + projectDir, + permissionMode, + generationSettings, + disabledAgentTools, + subagentEnabled + } = await this.dependencies.assignmentPolicy.resolveCreateAssignment({ + agentId: input.agentId?.trim() || 'deepchat', + providerId: input.providerId, + modelId: input.modelId, + projectDir: input.projectDir, + 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 { + await this.initializeSessionRuntime(sessionId, { + agentId, + providerId, + modelId, + projectDir, + permissionMode, + generationSettings + }) + } catch (error) { + await this.cleanupFailedSessionInitialization(sessionId, providerId) + throw error + } + + if (input.activeSkills && input.activeSkills.length > 0) { + await this.dependencies.skills.setActiveSkills(sessionId, input.activeSkills) + } + this.dependencies.projection.notify({ sessionIds: [sessionId], reason: 'created' }) + + const state = await this.dependencies.runtime.resolveSession(sessionId).snapshot() + return { + id: sessionId, + agentId, + title, + projectDir, + isPinned: false, + isDraft: false, + sessionKind: 'regular', + parentSessionId: null, + subagentEnabled, + subagentMeta: null, + createdAt: Date.now(), + updatedAt: Date.now(), + ...(input.metadata ? { metadata: input.metadata } : {}), + status: state?.status ?? 'idle', + providerId: state?.providerId ?? providerId, + modelId: state?.modelId ?? modelId + } + } + + async createSubagentSession(input: SessionLifecycleSubagentInput): Promise { + const parentSessionId = input.parentSessionId?.trim() + if (!parentSessionId) throw new Error('Subagent session requires a parentSessionId.') + + const slotId = input.slotId?.trim() + if (!slotId) throw new Error('Subagent session requires a slotId.') + + const displayName = input.displayName?.trim() || 'Subagent' + const agentId = input.agentId?.trim() + if (!agentId) throw new Error('Subagent session requires an agentId.') + + const projectDir = input.projectDir?.trim() || null + const runtimeConfig = await this.dependencies.assignmentPolicy.resolveSubagentAssignment({ + agentId, + targetAgentId: input.targetAgentId, + projectDir, + providerId: input.providerId, + modelId: input.modelId, + generationSettings: input.generationSettings, + disabledAgentTools: input.disabledAgentTools, + activeSkills: input.activeSkills + }) + const subagentMeta: DeepChatSubagentMeta = { + slotId, + displayName, + targetAgentId: runtimeConfig.targetAgentId || null + } + let lastError: unknown = null + + for (let attempt = 1; attempt <= SUBAGENT_SESSION_INIT_MAX_ATTEMPTS; attempt += 1) { + const sessionId = this.dependencies.sessions.create( + runtimeConfig.agentId, + displayName, + projectDir, + { + isDraft: false, + disabledAgentTools: runtimeConfig.disabledAgentTools, + subagentEnabled: false, + sessionKind: 'subagent', + parentSessionId, + subagentMeta + } + ) + + try { + await this.initializeSessionRuntime(sessionId, { + agentId: runtimeConfig.agentId, + providerId: runtimeConfig.providerId, + modelId: runtimeConfig.modelId, + projectDir, + permissionMode: input.permissionMode, + generationSettings: runtimeConfig.generationSettings + }) + if (runtimeConfig.activeSkills.length > 0) { + await this.dependencies.skills.setActiveSkills(sessionId, runtimeConfig.activeSkills) + } + if (!this.dependencies.sessions.get(sessionId)) { + throw new Error(`Subagent session not found after creation: ${sessionId}`) + } + + const session = await this.dependencies.projection.materializeRequired(sessionId) + this.dependencies.projection.notify({ sessionIds: [session.id], reason: 'created' }) + return session + } catch (error) { + lastError = error + await this.cleanupFailedSessionInitialization(sessionId, runtimeConfig.providerId) + if (attempt >= SUBAGENT_SESSION_INIT_MAX_ATTEMPTS) throw error + + console.warn( + `[SessionLifecycleCoordinator] Retrying subagent session initialization (${attempt}/${SUBAGENT_SESSION_INIT_MAX_ATTEMPTS - 1} retry used) for agent=${runtimeConfig.agentId} slot=${slotId}:`, + error + ) + } + } + + throw lastError instanceof Error + ? lastError + : new Error(`Failed to create subagent session for slot ${slotId}.`) + } + + async ensureAcpDraftSession(input: { + agentId: string + projectDir: string + permissionMode?: PermissionMode + }): Promise { + const agentId = input.agentId?.trim() + if (!agentId) throw new Error('ACP draft session requires an agentId.') + + const projectDir = input.projectDir?.trim() + if (!projectDir) throw new Error('ACP draft session requires a non-empty projectDir.') + + const { agentId: canonicalAgentId, permissionMode } = + this.dependencies.assignmentPolicy.resolveAcpDraftAssignment(agentId, input.permissionMode) + let record = await this.findReusableDraftSession(canonicalAgentId, projectDir) + let createdDraftSession = false + if (!record) { + const sessionId = this.dependencies.sessions.create( + canonicalAgentId, + 'New Chat', + projectDir, + { isDraft: true, subagentEnabled: false } + ) + try { + await this.ensureSessionRuntimeInitialized(sessionId, { + agentId: canonicalAgentId, + providerId: 'acp', + modelId: canonicalAgentId, + projectDir, + permissionMode + }) + } catch (error) { + await this.cleanupFailedSessionInitialization(sessionId, 'acp') + throw error + } + record = this.dependencies.sessions.get(sessionId) + if (!record) throw new Error(`Failed to read created ACP draft session: ${sessionId}`) + createdDraftSession = true + } else { + await this.ensureSessionRuntimeInitialized(record.id, { + agentId: canonicalAgentId, + providerId: 'acp', + modelId: canonicalAgentId, + projectDir, + permissionMode + }) + } + + await this.dependencies.workdir.prepareDirectAcpSession(record.id) + this.dependencies.projection.notify({ + sessionIds: [record.id], + reason: createdDraftSession ? 'created' : 'updated' + }) + const state = await this.dependencies.runtime + .resolveSession(toAppSessionId(record.id)) + .snapshot() + return { + ...record, + status: state?.status ?? 'idle', + providerId: state?.providerId ?? 'acp', + modelId: state?.modelId ?? canonicalAgentId + } + } + + async forkSession( + sourceSessionId: string, + targetMessageId: string, + newTitle?: string + ): Promise { + const sourceSession = this.dependencies.sessions.get(sourceSessionId) + if (!sourceSession) throw new Error(`Session not found: ${sourceSessionId}`) + + const sourceRuntime = this.dependencies.runtime.resolveSession(toAppSessionId(sourceSessionId)) + const sourceState = await sourceRuntime.snapshot() + if (!sourceState) throw new Error(`Session state not found: ${sourceSessionId}`) + const generationSettings = await sourceRuntime.getGenerationSettings() + const title = this.buildForkTitle(sourceSession.title, newTitle) + const targetSessionId = this.dependencies.sessions.create( + sourceSession.agentId, + title, + sourceSession.projectDir ?? null, + { isDraft: false } + ) + + try { + await this.initializeSessionRuntime(targetSessionId, { + agentId: sourceSession.agentId, + providerId: sourceState.providerId, + modelId: sourceState.modelId, + projectDir: sourceSession.projectDir ?? null, + permissionMode: sourceState.permissionMode, + generationSettings: generationSettings ?? undefined + }) + await this.dependencies.transcript.forkSessionFromMessage( + sourceSessionId, + targetSessionId, + targetMessageId + ) + } catch (error) { + try { + await this.dependencies.runtime.resolveSession(targetSessionId).close() + } catch (cleanupError) { + console.warn( + `[SessionLifecycleCoordinator] Failed to cleanup forked session runtime ${targetSessionId}:`, + cleanupError + ) + } + this.dependencies.sessions.delete(targetSessionId) + throw error + } + + this.dependencies.projection.notify({ sessionIds: [targetSessionId], reason: 'created' }) + const record = this.dependencies.sessions.get(targetSessionId) + if (!record) throw new Error(`Forked session not found: ${targetSessionId}`) + const targetState = await this.dependencies.runtime.resolveSession(targetSessionId).snapshot() + return { + ...record, + status: targetState?.status ?? 'idle', + providerId: targetState?.providerId ?? sourceState.providerId, + modelId: targetState?.modelId ?? sourceState.modelId + } + } + + async deleteSession(sessionId: string): Promise { + const deletedSessionIds = await this.dependencies.deletion.deleteSessionTree(sessionId) + this.dependencies.projection.notify({ sessionIds: deletedSessionIds, reason: 'deleted' }) + } + + private async findReusableDraftSession( + agentId: string, + projectDir: string + ): Promise { + for (const session of this.dependencies.sessions.list({ agentId, projectDir })) { + if (!session.isDraft) continue + if (!(await this.hasSessionMessages(session.id))) return session + } + return null + } + + private async hasSessionMessages(sessionId: string): Promise { + try { + return await this.dependencies.transcript.hasMessages(sessionId) + } catch (error) { + console.warn( + `[SessionLifecycleCoordinator] Failed to inspect messages for session=${sessionId}:`, + error + ) + return true + } + } + + private async ensureSessionRuntimeInitialized( + sessionId: string, + config: SessionLifecycleRuntimeConfig & { projectDir: string } + ): Promise { + const runtime = this.dependencies.runtime.resolveSession(toAppSessionId(sessionId)) + if (!(await runtime.isInitialized())) { + await this.initializeSessionRuntime(sessionId, config) + return + } + const state = await runtime.snapshot() + if (!state) throw new Error(`Session ${sessionId} not found`) + if (state.permissionMode && state.permissionMode !== config.permissionMode) { + await runtime.setPermissionMode(config.permissionMode) + } + await this.dependencies.workdir.syncAcpSessionWorkdir( + config.providerId, + sessionId, + config.agentId ?? config.modelId, + config.projectDir + ) + } + + private async initializeSessionRuntime( + sessionId: string, + config: SessionLifecycleRuntimeConfig + ): Promise { + await this.dependencies.runtime.resolveSession(toAppSessionId(sessionId)).initialize(config) + await this.dependencies.workdir.syncAcpSessionWorkdir( + config.providerId, + sessionId, + config.agentId ?? config.modelId, + config.projectDir ?? null + ) + } + + private async cleanupFailedSessionInitialization( + sessionId: string, + providerId?: string + ): Promise { + const runtime = this.dependencies.runtime.resolveSession(toAppSessionId(sessionId)) + if (providerId === 'acp' && runtime.kind !== 'acp') { + try { + await this.dependencies.workdir.clearCompatibilityAcpSession(sessionId) + } catch (error) { + console.warn( + `[SessionLifecycleCoordinator] Failed to clear ACP session after initialization error ${sessionId}:`, + error + ) + } + } + + try { + await runtime.close() + } catch (cleanupError) { + console.warn( + `[SessionLifecycleCoordinator] Failed to cleanup session runtime after initialization error ${sessionId}:`, + cleanupError + ) + } + this.dependencies.sessions.delete(sessionId) + } + + private buildForkTitle(sourceTitle: string, customTitle?: string): string { + const normalizedCustom = customTitle?.trim() + if (normalizedCustom) return normalizedCustom + const base = sourceTitle?.trim() || 'New Chat' + return base.length >= 60 ? base.slice(0, 60).trim() : `${base} - Fork` + } +} diff --git a/src/main/presenter/sessionApplication/ports.ts b/src/main/presenter/sessionApplication/ports.ts index 48e11516a..090e48d84 100644 --- a/src/main/presenter/sessionApplication/ports.ts +++ b/src/main/presenter/sessionApplication/ports.ts @@ -19,7 +19,10 @@ import type { import type { AgentTransferImpact, ChatMessageRecord, + CreateDetachedSessionInput, + CreateSessionInput, DeepChatAgentConfig, + DeepChatSubagentMeta, DeepChatSessionState, MessageStartResult, PendingSessionInputRecord, @@ -27,11 +30,13 @@ import type { SendMessageInput, SessionCompactionState, SessionGenerationSettings, + SessionKind, SessionLightweightListResult, SessionListItem, SessionPageCursor, SessionRecord, SessionWithState, + SessionMetadata, ToolInteractionResponse, ToolInteractionResult } from '@shared/types/agent-interface' @@ -396,6 +401,95 @@ export interface SessionAssignmentWorkdirPort { clearCompatibilityAcpSession(sessionId: string): Promise } +export interface SessionLifecycleStorePort { + create( + agentId: string, + title: string, + projectDir: string | null, + options?: { + isDraft?: boolean + disabledAgentTools?: string[] + subagentEnabled?: boolean + sessionKind?: SessionKind + parentSessionId?: string | null + subagentMeta?: DeepChatSubagentMeta | null + metadata?: SessionMetadata | null + } + ): AppSessionId + get(sessionId: string): SessionRecord | null + list(filters?: SessionListFilters): SessionRecord[] + delete(sessionId: string): void +} + +export interface SessionLifecycleRuntimeConfig { + agentId?: string + providerId: string + modelId: string + projectDir?: string | null + permissionMode: PermissionMode + generationSettings?: Partial +} + +export interface SessionLifecycleRuntimeSession { + readonly kind: 'deepchat' | 'acp' + initialize(config: SessionLifecycleRuntimeConfig): Promise + isInitialized(): Promise + snapshot(): Promise + getGenerationSettings(): Promise + setPermissionMode(mode: PermissionMode): Promise + close(): Promise +} + +export interface SessionLifecycleRuntimePort { + resolveSession(sessionId: AppSessionId): SessionLifecycleRuntimeSession +} + +export type SessionLifecycleTranscriptPort = Pick & + Pick + +export interface SessionLifecycleSkillPort { + setActiveSkills(sessionId: string, activeSkills: string[]): Promise +} + +export type SessionLifecycleProjectionPort = Pick< + SessionProjectionMutationPort, + 'bindWindow' | 'notify' +> & { + materializeRequired(sessionId: string): Promise +} + +export interface SessionLifecycleSubagentInput { + parentSessionId: string + agentId: string + slotId: string + displayName: string + targetAgentId?: string | null + projectDir?: string | null + providerId: string + modelId: string + permissionMode: PermissionMode + generationSettings?: Partial + disabledAgentTools?: string[] + activeSkills?: string[] +} + +export interface SessionLifecyclePort { + createSession(input: CreateSessionInput, webContentsId: number): Promise + createDetachedSession(input: CreateDetachedSessionInput): Promise + createSubagentSession(input: SessionLifecycleSubagentInput): Promise + ensureAcpDraftSession(input: { + agentId: string + projectDir: string + permissionMode?: PermissionMode + }): Promise + forkSession( + sourceSessionId: string, + targetMessageId: string, + newTitle?: string + ): Promise + deleteSession(sessionId: string): Promise +} + export interface SessionAgentAssignmentPort { mergeSubagentTape( parentSessionId: string, diff --git a/test/main/presenter/agentSessionPresenter/agentSessionPresenter.test.ts b/test/main/presenter/agentSessionPresenter/agentSessionPresenter.test.ts index 57358657d..92eabbd7d 100644 --- a/test/main/presenter/agentSessionPresenter/agentSessionPresenter.test.ts +++ b/test/main/presenter/agentSessionPresenter/agentSessionPresenter.test.ts @@ -606,13 +606,9 @@ function createDescriptorIndependentDeleteHarness(options: { sqliteWithAgents, sharedData, projection, - sessionApplications.policy, + sessionApplications.lifecycle, sessionApplications.assignment, - sessionApplications.assignment, - sessionApplications.turn, sessionApplications.turn, - sessionApplications.deletion, - skillPresenter, { sessionPermissionPort } ) @@ -767,14 +763,9 @@ describe('AgentSessionPresenter', () => { sqlitePresenter, sharedData, projection, - sessionApplications.policy, + sessionApplications.lifecycle, sessionApplications.assignment, - sessionApplications.assignment, - sessionApplications.turn, - sessionApplications.turn, - sessionApplications.deletion, - skillPresenter, - undefined + sessionApplications.turn ) }) @@ -1030,13 +1021,9 @@ describe('AgentSessionPresenter', () => { sqliteWithAgents, integratedSharedData, projection, - sessionApplications.policy, - sessionApplications.assignment, + sessionApplications.lifecycle, sessionApplications.assignment, - sessionApplications.turn, - sessionApplications.turn, - sessionApplications.deletion, - skillPresenter + sessionApplications.turn ) await expect(integratedPresenter.sendMessage('deepchat-session', 'Hello')).resolves.toEqual({ @@ -2050,11 +2037,11 @@ describe('AgentSessionPresenter', () => { expect(publishDeepchatEvent).not.toHaveBeenCalled() expect(sessionUiPort.refreshSessionUi).not.toHaveBeenCalled() expect(warnSpy).toHaveBeenCalledWith( - '[AgentSessionPresenter] Failed to clear ACP session after initialization error mock-session-id:', + '[SessionLifecycleCoordinator] Failed to clear ACP session after initialization error mock-session-id:', clearError ) expect(warnSpy).toHaveBeenCalledWith( - '[AgentSessionPresenter] Failed to cleanup session runtime after initialization error mock-session-id:', + '[SessionLifecycleCoordinator] Failed to cleanup session runtime after initialization error mock-session-id:', closeError ) warnSpy.mockRestore() @@ -3023,7 +3010,7 @@ describe('AgentSessionPresenter', () => { expect(rows.has('mock-session-id')).toBe(false) expect(publishDeepchatEvent).not.toHaveBeenCalled() expect(warnSpy).toHaveBeenCalledWith( - '[AgentSessionPresenter] Failed to cleanup forked session runtime mock-session-id:', + '[SessionLifecycleCoordinator] Failed to cleanup forked session runtime mock-session-id:', closeError ) warnSpy.mockRestore() diff --git a/test/main/presenter/agentSessionPresenter/assignmentCoordinatorFixture.ts b/test/main/presenter/agentSessionPresenter/assignmentCoordinatorFixture.ts index 1347fdf2d..4c0b6e33d 100644 --- a/test/main/presenter/agentSessionPresenter/assignmentCoordinatorFixture.ts +++ b/test/main/presenter/agentSessionPresenter/assignmentCoordinatorFixture.ts @@ -12,6 +12,7 @@ import { SessionAgentAssignmentCoordinator } from '@/presenter/sessionApplicatio import { SessionDeletionTransaction } from '@/presenter/sessionApplication/lifecycleDeletionTransaction' import type { SessionProjectionCoordinator } from '@/presenter/sessionApplication/projectionCoordinator' import { SessionTurnCoordinator } from '@/presenter/sessionApplication/turnCoordinator' +import { SessionLifecycleCoordinator } from '@/presenter/sessionApplication/lifecycleCoordinator' export const createAssignmentCoordinatorFixture = (input: { agentManager: AgentManager @@ -21,12 +22,13 @@ export const createAssignmentCoordinatorFixture = (input: { sharedData: AgentSharedDataPorts projection: SessionProjectionCoordinator acp: AcpAsLlmProviderSessionControlPort - skillPresenter?: Pick + skillPresenter?: Pick sessionPermissionPort?: Pick }): { policy: SessionAgentAssignmentPolicy assignment: SessionAgentAssignmentCoordinator turn: SessionTurnCoordinator + lifecycle: SessionLifecycleCoordinator deletion: SessionDeletionTransaction } => { const policy = new SessionAgentAssignmentPolicy( @@ -118,6 +120,42 @@ export const createAssignmentCoordinatorFixture = (input: { workdir: assignment, projection: input.projection }) + const lifecycle = new SessionLifecycleCoordinator({ + sessions: input.appSessionService, + runtime: { + resolveSession: (sessionId) => { + const { handle } = input.agentManager.resolveSessionHandle(sessionId) + return { + kind: handle.kind, + initialize: (config) => handle.lifecycle.initialize(config), + isInitialized: () => handle.lifecycle.isInitialized(), + snapshot: () => handle.snapshot(), + getGenerationSettings: () => handle.settings.getGenerationSettings(), + setPermissionMode: (mode) => handle.settings.setPermissionMode(mode), + close: () => handle.close() + } + } + }, + transcript: { + hasMessages: (sessionId) => input.sharedData.transcript.hasMessages(sessionId), + forkSessionFromMessage: (sourceSessionId, targetSessionId, targetMessageId) => + input.sharedData.transcriptMutation.forkSessionFromMessage( + sourceSessionId, + targetSessionId, + targetMessageId + ) + }, + skills: { + setActiveSkills: async (sessionId, activeSkills) => { + await input.skillPresenter?.setActiveSkills?.(sessionId, activeSkills) + } + }, + assignmentPolicy: policy, + workdir: assignment, + initialTurn: turn, + projection: input.projection, + deletion + }) - return { policy, assignment, turn, deletion } + return { policy, assignment, turn, lifecycle, deletion } } diff --git a/test/main/presenter/agentSessionPresenter/integration.test.ts b/test/main/presenter/agentSessionPresenter/integration.test.ts index 348ef8a57..73cfdd42c 100644 --- a/test/main/presenter/agentSessionPresenter/integration.test.ts +++ b/test/main/presenter/agentSessionPresenter/integration.test.ts @@ -721,12 +721,9 @@ describe('Integration: createSession end-to-end', () => { sqlitePresenter, sharedData, projection, - sessionApplications.policy, + sessionApplications.lifecycle, sessionApplications.assignment, - sessionApplications.assignment, - sessionApplications.turn, - sessionApplications.turn, - sessionApplications.deletion + sessionApplications.turn ) }) @@ -911,12 +908,9 @@ describe('Integration: ACP hooks bridge', () => { sqlitePresenter, sharedData, projection, - sessionApplications.policy, - sessionApplications.assignment, + sessionApplications.lifecycle, sessionApplications.assignment, - sessionApplications.turn, - sessionApplications.turn, - sessionApplications.deletion + sessionApplications.turn ) }) @@ -1029,12 +1023,9 @@ describe('Integration: multi-turn context', () => { sqlitePresenter, sharedData, projection, - sessionApplications.policy, - sessionApplications.assignment, + sessionApplications.lifecycle, sessionApplications.assignment, - sessionApplications.turn, - sessionApplications.turn, - sessionApplications.deletion + sessionApplications.turn ) }) diff --git a/test/main/presenter/agentSessionPresenter/usageDashboard.test.ts b/test/main/presenter/agentSessionPresenter/usageDashboard.test.ts index 0355d545c..242a77513 100644 --- a/test/main/presenter/agentSessionPresenter/usageDashboard.test.ts +++ b/test/main/presenter/agentSessionPresenter/usageDashboard.test.ts @@ -494,12 +494,9 @@ describe('AgentSessionPresenter usage dashboard', () => { sqlitePresenter, sharedData, projection, - sessionApplications.policy, + sessionApplications.lifecycle, sessionApplications.assignment, - sessionApplications.assignment, - sessionApplications.turn, - sessionApplications.turn, - sessionApplications.deletion + sessionApplications.turn ) return { diff --git a/test/main/presenter/sessionApplication/lifecycleCoordinator.test.ts b/test/main/presenter/sessionApplication/lifecycleCoordinator.test.ts new file mode 100644 index 000000000..bfe02ea68 --- /dev/null +++ b/test/main/presenter/sessionApplication/lifecycleCoordinator.test.ts @@ -0,0 +1,540 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + DeepChatSessionState, + SessionGenerationSettings, + SessionRecord, + SessionWithState +} from '@shared/types/agent-interface' +import { + SessionLifecycleCoordinator, + type SessionLifecycleCoordinatorDependencies +} from '@/presenter/sessionApplication/lifecycleCoordinator' + +const createRecord = (overrides: Partial = {}): SessionRecord => ({ + id: 'existing', + agentId: 'deepchat', + title: 'Session', + projectDir: '/repo', + isPinned: false, + isDraft: false, + sessionKind: 'regular', + parentSessionId: null, + subagentEnabled: false, + subagentMeta: null, + createdAt: 100, + updatedAt: 200, + ...overrides +}) + +function createHarness(initialSessions: SessionRecord[] = []) { + const records = new Map(initialSessions.map((session) => [session.id, session])) + const runtimeSessions = new Map< + string, + { + kind: 'deepchat' | 'acp' + initialize: ReturnType + isInitialized: ReturnType + snapshot: ReturnType + getGenerationSettings: ReturnType + setPermissionMode: ReturnType + close: ReturnType + } + >() + const order: string[] = [] + let sequence = 0 + + const getRuntime = (sessionId: string) => { + const existing = runtimeSessions.get(sessionId) + if (existing) return existing + + let initialized = false + let state: DeepChatSessionState | null = null + const kind = records.get(sessionId)?.agentId.startsWith('acp') ? 'acp' : 'deepchat' + const runtime = { + kind, + initialize: vi.fn( + async (config: { + providerId: string + modelId: string + permissionMode: DeepChatSessionState['permissionMode'] + }) => { + order.push(`initialize:${sessionId}`) + initialized = true + state = { + status: 'idle', + providerId: config.providerId, + modelId: config.modelId, + permissionMode: config.permissionMode + } + } + ), + isInitialized: vi.fn(async () => initialized), + snapshot: vi.fn(async () => state), + getGenerationSettings: vi.fn(async (): Promise => null), + setPermissionMode: vi.fn(async (permissionMode: DeepChatSessionState['permissionMode']) => { + if (state) state = { ...state, permissionMode } + }), + close: vi.fn(async () => { + order.push(`close:${sessionId}`) + }) + } + runtimeSessions.set(sessionId, runtime) + return runtime + } + + const sessions = { + create: vi.fn( + ( + agentId: string, + title: string, + projectDir: string | null, + options: { + isDraft?: boolean + disabledAgentTools?: string[] + subagentEnabled?: boolean + sessionKind?: SessionRecord['sessionKind'] + parentSessionId?: string | null + subagentMeta?: SessionRecord['subagentMeta'] + metadata?: SessionRecord['metadata'] + } = {} + ) => { + const id = `session-${++sequence}` + order.push(`create:${id}`) + records.set( + id, + createRecord({ + id, + agentId, + title, + projectDir, + isDraft: options.isDraft ?? false, + sessionKind: options.sessionKind ?? 'regular', + parentSessionId: options.parentSessionId ?? null, + subagentEnabled: options.subagentEnabled ?? false, + subagentMeta: options.subagentMeta ?? null, + metadata: options.metadata ?? null + }) + ) + return id + } + ), + get: vi.fn((sessionId: string) => records.get(sessionId) ?? null), + list: vi.fn((filters?: { agentId?: string; projectDir?: string }) => + [...records.values()].filter((session) => { + if (filters?.agentId && session.agentId !== filters.agentId) return false + if (filters?.projectDir && session.projectDir !== filters.projectDir) return false + return true + }) + ), + delete: vi.fn((sessionId: string) => { + order.push(`delete:${sessionId}`) + records.delete(sessionId) + }) + } + const runtime = { resolveSession: vi.fn((sessionId: string) => getRuntime(sessionId)) } + const transcript = { + hasMessages: vi.fn().mockResolvedValue(false), + forkSessionFromMessage: vi.fn().mockResolvedValue(undefined) + } + const skills = { setActiveSkills: vi.fn().mockResolvedValue(undefined) } + const assignmentPolicy = { + resolveCreateAssignment: vi.fn( + async (input: { + agentId: string + providerId?: string + modelId?: string + projectDir?: string | null + permissionMode?: DeepChatSessionState['permissionMode'] + generationSettings?: Partial + disabledAgentTools?: string[] + subagentEnabled?: boolean + }) => ({ + agentId: input.agentId, + agentType: input.providerId === 'acp' ? ('acp' as const) : ('deepchat' as const), + providerId: input.providerId ?? 'openai', + modelId: input.modelId ?? 'model-1', + projectDir: input.projectDir === undefined ? '/default' : input.projectDir, + permissionMode: input.permissionMode ?? ('full_access' as const), + generationSettings: input.generationSettings, + disabledAgentTools: input.disabledAgentTools ?? [], + subagentEnabled: input.subagentEnabled ?? false + }) + ), + resolveAcpDraftAssignment: vi.fn( + (agentId: string, permissionMode?: DeepChatSessionState['permissionMode']) => ({ + agentId: agentId.trim(), + permissionMode: permissionMode ?? ('full_access' as const) + }) + ), + resolveSubagentAssignment: vi.fn( + async (input: { + agentId: string + targetAgentId?: string | null + providerId: string + modelId: string + generationSettings?: Partial + disabledAgentTools?: string[] + activeSkills?: string[] + }) => ({ + agentId: input.agentId, + targetAgentId: input.targetAgentId ?? null, + providerId: input.providerId, + modelId: input.modelId, + generationSettings: input.generationSettings, + disabledAgentTools: input.disabledAgentTools ?? [], + activeSkills: input.activeSkills ?? [] + }) + ) + } + const workdir = { + assertAcpSessionHasWorkdir: vi.fn(), + syncAcpSessionWorkdir: vi.fn(async (_providerId: string, sessionId: string) => { + order.push(`sync:${sessionId}`) + }), + prepareDirectAcpSession: vi.fn().mockResolvedValue(undefined), + clearCompatibilityAcpSession: vi.fn().mockResolvedValue(undefined) + } + const initialTurn = { + startInitialTurn: vi.fn(() => { + order.push('initial-turn') + }) + } + const projection = { + bindWindow: vi.fn((_webContentsId: number, sessionId: string) => { + order.push(`bind:${sessionId}`) + }), + notify: vi.fn((input: { reason?: string; sessionIds?: string[] }) => { + order.push(`notify:${input.reason}:${input.sessionIds?.join(',')}`) + }), + materializeRequired: vi.fn(async (sessionId: string): Promise => { + const record = records.get(sessionId) + if (!record) throw new Error(`Session not found: ${sessionId}`) + const state = (await getRuntime(sessionId).snapshot()) as DeepChatSessionState | null + return { + ...record, + status: state?.status ?? 'idle', + providerId: state?.providerId ?? 'openai', + modelId: state?.modelId ?? 'model-1' + } + }) + } + const deletion = { deleteSessionTree: vi.fn().mockResolvedValue([]) } + const dependencies = { + sessions, + runtime, + transcript, + skills, + assignmentPolicy, + workdir, + initialTurn, + projection, + deletion + } as unknown as SessionLifecycleCoordinatorDependencies + + return { + coordinator: new SessionLifecycleCoordinator(dependencies), + records, + order, + getRuntime, + sessions, + runtime, + transcript, + skills, + assignmentPolicy, + workdir, + initialTurn, + projection, + deletion + } +} + +describe('SessionLifecycleCoordinator', () => { + it('initializes before publication and starts the initial turn without awaiting it', async () => { + const harness = createHarness() + const pendingInitialTurn = new Promise(() => undefined) + harness.initialTurn.startInitialTurn.mockImplementation(() => { + harness.order.push('initial-turn') + return pendingInitialTurn + }) + + await expect( + harness.coordinator.createSession( + { + agentId: 'deepchat', + message: 'Hello', + projectDir: null, + activeSkills: ['review'] + }, + 42 + ) + ).resolves.toMatchObject({ + id: 'session-1', + title: 'Hello', + projectDir: null, + providerId: 'openai', + modelId: 'model-1' + }) + + expect(harness.assignmentPolicy.resolveCreateAssignment).toHaveBeenCalledWith( + expect.objectContaining({ projectDir: null, preserveExplicitNullProjectDir: true }) + ) + expect(harness.order).toEqual([ + 'create:session-1', + 'initialize:session-1', + 'sync:session-1', + 'bind:session-1', + 'notify:created:session-1', + 'initial-turn' + ]) + expect(harness.projection.bindWindow).toHaveBeenCalledWith(42, 'session-1') + expect(harness.initialTurn.startInitialTurn).toHaveBeenCalledWith({ + sessionId: 'session-1', + content: { text: 'Hello', files: [], activeSkills: ['review'] }, + projectDir: null, + initialTitle: 'Hello', + fallbackProviderId: 'openai', + fallbackModelId: 'model-1' + }) + }) + + it('preserves the initialization error after rollback cleanup failures', async () => { + const harness = createHarness() + const initializationError = new Error('workdir failed') + const clearError = new Error('compatibility cleanup failed') + const closeError = new Error('runtime close failed') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + harness.assignmentPolicy.resolveCreateAssignment.mockResolvedValueOnce({ + agentId: 'deepchat', + agentType: 'acp', + providerId: 'acp', + modelId: 'acp-coder', + projectDir: '/repo', + permissionMode: 'full_access', + disabledAgentTools: [], + subagentEnabled: false + }) + harness.workdir.syncAcpSessionWorkdir.mockRejectedValueOnce(initializationError) + harness.workdir.clearCompatibilityAcpSession.mockRejectedValueOnce(clearError) + harness.getRuntime('session-1').close.mockRejectedValueOnce(closeError) + + await expect( + harness.coordinator.createSession({ agentId: 'deepchat', message: 'Hello ACP' }, 1) + ).rejects.toBe(initializationError) + + expect(harness.workdir.clearCompatibilityAcpSession).toHaveBeenCalledWith('session-1') + expect(harness.getRuntime('session-1').close).toHaveBeenCalledOnce() + expect(harness.sessions.delete).toHaveBeenCalledWith('session-1') + expect(harness.records.has('session-1')).toBe(false) + expect(harness.projection.bindWindow).not.toHaveBeenCalled() + expect(harness.projection.notify).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledTimes(2) + warn.mockRestore() + }) + + it('creates detached sessions without binding a window or starting a turn', async () => { + const harness = createHarness() + const metadata = { + source: 'cron_job' as const, + cronJobId: 'cron-1', + cronJobRunId: 'run-1', + scheduledAt: 100 + } + + await expect( + harness.coordinator.createDetachedSession({ + title: ' Cron run ', + projectDir: '/cron', + activeSkills: ['report'], + metadata + }) + ).resolves.toMatchObject({ id: 'session-1', title: 'Cron run', metadata }) + + expect(harness.assignmentPolicy.resolveCreateAssignment).toHaveBeenCalledWith( + expect.objectContaining({ projectDir: '/cron', preserveExplicitNullProjectDir: false }) + ) + expect(harness.sessions.create).toHaveBeenCalledWith( + 'deepchat', + 'Cron run', + '/cron', + expect.objectContaining({ metadata }) + ) + expect(harness.skills.setActiveSkills).toHaveBeenCalledWith('session-1', ['report']) + expect(harness.projection.notify).toHaveBeenCalledWith({ + sessionIds: ['session-1'], + reason: 'created' + }) + expect(harness.projection.bindWindow).not.toHaveBeenCalled() + expect(harness.initialTurn.startInitialTurn).not.toHaveBeenCalled() + }) + + it('retries subagent initialization once with a fresh row and publishes only success', async () => { + const harness = createHarness() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + harness.assignmentPolicy.resolveSubagentAssignment.mockResolvedValueOnce({ + agentId: 'acp-reviewer', + targetAgentId: 'acp-reviewer', + providerId: 'acp', + modelId: 'acp-reviewer', + generationSettings: { systemPrompt: '' }, + disabledAgentTools: [], + activeSkills: ['review'] + }) + harness.workdir.syncAcpSessionWorkdir + .mockRejectedValueOnce(new Error('warmup failed')) + .mockResolvedValueOnce(undefined) + + await expect( + harness.coordinator.createSubagentSession({ + parentSessionId: 'parent', + agentId: 'acp-reviewer', + slotId: 'reviewer', + displayName: 'Reviewer', + targetAgentId: 'acp-reviewer', + projectDir: '/repo', + providerId: 'openai', + modelId: 'model-1', + permissionMode: 'full_access' + }) + ).resolves.toMatchObject({ id: 'session-2', sessionKind: 'subagent' }) + + expect(harness.sessions.create).toHaveBeenCalledTimes(2) + expect(harness.getRuntime('session-1').close).toHaveBeenCalledOnce() + expect(harness.sessions.delete).toHaveBeenCalledWith('session-1') + expect(harness.records.has('session-1')).toBe(false) + expect(harness.records.has('session-2')).toBe(true) + expect(harness.skills.setActiveSkills).toHaveBeenCalledExactlyOnceWith('session-2', ['review']) + expect(harness.projection.materializeRequired).toHaveBeenCalledExactlyOnceWith('session-2') + expect(harness.projection.notify).toHaveBeenCalledExactlyOnceWith({ + sessionIds: ['session-2'], + reason: 'created' + }) + expect(warn).toHaveBeenCalledOnce() + warn.mockRestore() + }) + + it('reuses an empty ACP draft and synchronizes changed permission state', async () => { + const draft = createRecord({ + id: 'draft-1', + agentId: 'acp-coder', + title: 'New Chat', + projectDir: '/repo', + isDraft: true + }) + const harness = createHarness([draft]) + const runtime = harness.getRuntime('draft-1') + runtime.isInitialized.mockResolvedValue(true) + runtime.snapshot.mockResolvedValue({ + status: 'idle', + providerId: 'acp', + modelId: 'acp-coder', + permissionMode: 'default' + }) + + await expect( + harness.coordinator.ensureAcpDraftSession({ + agentId: 'acp-coder', + projectDir: '/repo', + permissionMode: 'full_access' + }) + ).resolves.toMatchObject({ id: 'draft-1', isDraft: true, providerId: 'acp' }) + + expect(harness.sessions.create).not.toHaveBeenCalled() + expect(runtime.setPermissionMode).toHaveBeenCalledWith('full_access') + expect(harness.workdir.syncAcpSessionWorkdir).toHaveBeenCalledWith( + 'acp', + 'draft-1', + 'acp-coder', + '/repo' + ) + expect(harness.workdir.prepareDirectAcpSession).toHaveBeenCalledWith('draft-1') + expect(harness.projection.notify).toHaveBeenCalledWith({ + sessionIds: ['draft-1'], + reason: 'updated' + }) + }) + + it('does not reuse a draft when transcript inspection fails', async () => { + const harness = createHarness([ + createRecord({ + id: 'draft-1', + agentId: 'acp-coder', + projectDir: '/repo', + isDraft: true + }) + ]) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + harness.transcript.hasMessages.mockRejectedValueOnce(new Error('transcript failed')) + + await expect( + harness.coordinator.ensureAcpDraftSession({ + agentId: 'acp-coder', + projectDir: '/repo' + }) + ).resolves.toMatchObject({ id: 'session-1', isDraft: true }) + + expect(harness.sessions.create).toHaveBeenCalledOnce() + expect(harness.workdir.prepareDirectAcpSession).toHaveBeenCalledWith('session-1') + expect(harness.projection.notify).toHaveBeenCalledWith({ + sessionIds: ['session-1'], + reason: 'created' + }) + expect(warn).toHaveBeenCalledOnce() + warn.mockRestore() + }) + + it('deletes a failed fork row and preserves the transcript error when close fails', async () => { + const harness = createHarness([createRecord({ id: 'source', title: 'Source' })]) + const sourceRuntime = harness.getRuntime('source') + sourceRuntime.snapshot.mockResolvedValue({ + status: 'idle', + providerId: 'openai', + modelId: 'model-1', + permissionMode: 'default' + }) + sourceRuntime.getGenerationSettings.mockResolvedValue({ + systemPrompt: 'Keep this', + temperature: 0.2, + contextLength: 32000, + maxTokens: 2048, + timeout: 60 + }) + const forkError = new Error('fork transcript failed') + const closeError = new Error('fork runtime close failed') + harness.transcript.forkSessionFromMessage.mockRejectedValueOnce(forkError) + harness.getRuntime('session-1').close.mockRejectedValueOnce(closeError) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await expect(harness.coordinator.forkSession('source', 'message-1')).rejects.toBe(forkError) + + expect(harness.transcript.forkSessionFromMessage).toHaveBeenCalledWith( + 'source', + 'session-1', + 'message-1' + ) + expect(harness.getRuntime('session-1').close).toHaveBeenCalledOnce() + expect(harness.sessions.delete).toHaveBeenCalledWith('session-1') + expect(harness.getRuntime('session-1').close.mock.invocationCallOrder[0]).toBeLessThan( + harness.sessions.delete.mock.invocationCallOrder[0] + ) + expect(harness.records.has('session-1')).toBe(false) + expect(harness.projection.notify).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledWith( + '[SessionLifecycleCoordinator] Failed to cleanup forked session runtime session-1:', + closeError + ) + warn.mockRestore() + }) + + it('delegates tree deletion and publishes the transaction result in order', async () => { + const harness = createHarness() + harness.deletion.deleteSessionTree.mockResolvedValueOnce(['child', 'parent']) + + await harness.coordinator.deleteSession('parent') + + expect(harness.deletion.deleteSessionTree).toHaveBeenCalledExactlyOnceWith('parent') + expect(harness.projection.notify).toHaveBeenCalledExactlyOnceWith({ + sessionIds: ['child', 'parent'], + reason: 'deleted' + }) + }) +}) From 0ef0b03e13b8658a49f9c614856574a41758ebeb Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 13 Jul 2026 19:26:07 +0800 Subject: [PATCH 12/29] fix(session): contain lifecycle cleanup --- .../lifecycleCoordinator.ts | 25 +++++------ .../lifecycleCoordinator.test.ts | 43 ++++++++++++++++++- 2 files changed, 54 insertions(+), 14 deletions(-) diff --git a/src/main/presenter/sessionApplication/lifecycleCoordinator.ts b/src/main/presenter/sessionApplication/lifecycleCoordinator.ts index 16cafa65f..5b1d037e7 100644 --- a/src/main/presenter/sessionApplication/lifecycleCoordinator.ts +++ b/src/main/presenter/sessionApplication/lifecycleCoordinator.ts @@ -468,27 +468,28 @@ export class SessionLifecycleCoordinator implements SessionLifecyclePort { sessionId: string, providerId?: string ): Promise { - const runtime = this.dependencies.runtime.resolveSession(toAppSessionId(sessionId)) - if (providerId === 'acp' && runtime.kind !== 'acp') { - try { - await this.dependencies.workdir.clearCompatibilityAcpSession(sessionId) - } catch (error) { - console.warn( - `[SessionLifecycleCoordinator] Failed to clear ACP session after initialization error ${sessionId}:`, - error - ) + try { + const runtime = this.dependencies.runtime.resolveSession(toAppSessionId(sessionId)) + if (providerId === 'acp' && runtime.kind !== 'acp') { + try { + await this.dependencies.workdir.clearCompatibilityAcpSession(sessionId) + } catch (error) { + console.warn( + `[SessionLifecycleCoordinator] Failed to clear ACP session after initialization error ${sessionId}:`, + error + ) + } } - } - try { await runtime.close() } catch (cleanupError) { console.warn( `[SessionLifecycleCoordinator] Failed to cleanup session runtime after initialization error ${sessionId}:`, cleanupError ) + } finally { + this.dependencies.sessions.delete(sessionId) } - this.dependencies.sessions.delete(sessionId) } private buildForkTitle(sourceTitle: string, customTitle?: string): string { diff --git a/test/main/presenter/sessionApplication/lifecycleCoordinator.test.ts b/test/main/presenter/sessionApplication/lifecycleCoordinator.test.ts index bfe02ea68..9191cef9f 100644 --- a/test/main/presenter/sessionApplication/lifecycleCoordinator.test.ts +++ b/test/main/presenter/sessionApplication/lifecycleCoordinator.test.ts @@ -331,6 +331,33 @@ describe('SessionLifecycleCoordinator', () => { warn.mockRestore() }) + it('deletes the failed create row when cleanup cannot resolve its runtime', async () => { + const harness = createHarness() + const initializationError = new Error('workdir failed') + const cleanupError = new Error('cleanup resolve failed') + const failedRuntime = harness.getRuntime('session-1') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + harness.workdir.syncAcpSessionWorkdir.mockRejectedValueOnce(initializationError) + harness.runtime.resolveSession + .mockImplementationOnce(() => failedRuntime) + .mockImplementationOnce(() => { + throw cleanupError + }) + + await expect( + harness.coordinator.createSession({ agentId: 'deepchat', message: 'Hello' }, 1) + ).rejects.toBe(initializationError) + + expect(failedRuntime.close).not.toHaveBeenCalled() + expect(harness.sessions.delete).toHaveBeenCalledExactlyOnceWith('session-1') + expect(harness.records.has('session-1')).toBe(false) + expect(warn).toHaveBeenCalledWith( + '[SessionLifecycleCoordinator] Failed to cleanup session runtime after initialization error session-1:', + cleanupError + ) + warn.mockRestore() + }) + it('creates detached sessions without binding a window or starting a turn', async () => { const harness = createHarness() const metadata = { @@ -369,6 +396,8 @@ describe('SessionLifecycleCoordinator', () => { it('retries subagent initialization once with a fresh row and publishes only success', async () => { const harness = createHarness() + const cleanupError = new Error('cleanup resolve failed') + const failedRuntime = harness.getRuntime('session-1') const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) harness.assignmentPolicy.resolveSubagentAssignment.mockResolvedValueOnce({ agentId: 'acp-reviewer', @@ -382,6 +411,12 @@ describe('SessionLifecycleCoordinator', () => { harness.workdir.syncAcpSessionWorkdir .mockRejectedValueOnce(new Error('warmup failed')) .mockResolvedValueOnce(undefined) + harness.runtime.resolveSession + .mockImplementationOnce(() => failedRuntime) + .mockImplementationOnce(() => { + throw cleanupError + }) + .mockImplementation((sessionId: string) => harness.getRuntime(sessionId)) await expect( harness.coordinator.createSubagentSession({ @@ -398,7 +433,7 @@ describe('SessionLifecycleCoordinator', () => { ).resolves.toMatchObject({ id: 'session-2', sessionKind: 'subagent' }) expect(harness.sessions.create).toHaveBeenCalledTimes(2) - expect(harness.getRuntime('session-1').close).toHaveBeenCalledOnce() + expect(failedRuntime.close).not.toHaveBeenCalled() expect(harness.sessions.delete).toHaveBeenCalledWith('session-1') expect(harness.records.has('session-1')).toBe(false) expect(harness.records.has('session-2')).toBe(true) @@ -408,7 +443,11 @@ describe('SessionLifecycleCoordinator', () => { sessionIds: ['session-2'], reason: 'created' }) - expect(warn).toHaveBeenCalledOnce() + expect(warn).toHaveBeenCalledWith( + '[SessionLifecycleCoordinator] Failed to cleanup session runtime after initialization error session-1:', + cleanupError + ) + expect(warn).toHaveBeenCalledTimes(2) warn.mockRestore() }) From 0825aca80779d63d19c5e411b0c92109ca6dad95 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 13 Jul 2026 19:38:04 +0800 Subject: [PATCH 13/29] refactor(routes): inject session ports --- src/main/presenter/index.ts | 6 + src/main/routes/chat/chatService.ts | 61 ++- src/main/routes/hotPathPorts.ts | 129 +---- src/main/routes/index.ts | 32 +- src/main/routes/sessions/sessionService.ts | 44 +- test/main/routes/chatService.test.ts | 599 +++++++-------------- test/main/routes/dispatcher.test.ts | 109 +++- test/main/routes/sessionService.test.ts | 108 ++-- 8 files changed, 430 insertions(+), 658 deletions(-) diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index d338a9c61..c4c9a1e2b 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -169,6 +169,7 @@ export class Presenter implements IPresenter { sessionTurnCoordinator: SessionTurnCoordinator sessionLifecycleCoordinator: SessionLifecycleCoordinator sessionDeletionTransaction: SessionDeletionTransaction + sessionPermissionPort: SessionPermissionPort agentManager: AgentManager acpAgentRuntime: AcpAgentRuntime memoryPresenter: MemoryPresenter @@ -586,6 +587,7 @@ export class Presenter implements IPresenter { } } } + this.sessionPermissionPort = sessionPermissionPort // Initialize agent memory layer (opt-in per agent; vectors stored separately from knowledge base) const memoryDbDir = path.join(dbDir, 'AgentMemory') const memoryVectorDbPath = (agentId: string) => path.join(memoryDbDir, `${agentId}.duckdb`) @@ -1310,6 +1312,10 @@ const buildMainKernelRouteRuntime = () => llmProviderPresenter: presenter.llmproviderPresenter, acpProviderAdminPort: presenter.acpProviderAdminPort, agentSessionPresenter: presenter.agentSessionPresenter, + sessionLifecyclePort: presenter.sessionLifecycleCoordinator, + sessionProjectionPort: presenter.sessionProjectionCoordinator, + sessionTurnPort: presenter.sessionTurnCoordinator, + sessionPermissionPort: presenter.sessionPermissionPort, skillPresenter: presenter.skillPresenter, skillSyncPresenter: presenter.skillSyncPresenter, exporter: presenter.exporter, diff --git a/src/main/routes/chat/chatService.ts b/src/main/routes/chat/chatService.ts index e585af985..ba13c57e2 100644 --- a/src/main/routes/chat/chatService.ts +++ b/src/main/routes/chat/chatService.ts @@ -1,11 +1,12 @@ -import type { SendMessageInput } from '@shared/types/agent-interface' import type { - MessageRepository, - ProviderCatalogPort, - ProviderExecutionPort, - SessionPermissionPort, - SessionRepository -} from '../hotPathPorts' + ChatMessageRecord, + MessageStartResult, + SendMessageInput, + SessionWithState, + ToolInteractionResponse, + ToolInteractionResult +} from '@shared/types/agent-interface' +import type { ProviderCatalogPort, SessionPermissionPort } from '@/presenter/runtimePorts' import type { Scheduler } from '../scheduler' const CHAT_LOOKUP_TIMEOUT_MS = 5_000 @@ -13,16 +14,32 @@ const CHAT_SEND_TIMEOUT_MS = 30 * 60 * 1_000 const CHAT_STOP_TIMEOUT_MS = 5_000 const CHAT_INTERACTION_TIMEOUT_MS = CHAT_SEND_TIMEOUT_MS +export interface ChatServiceTurnPort { + sendMessage(sessionId: string, content: string | SendMessageInput): Promise + steerActiveTurn(sessionId: string, content: string | SendMessageInput): Promise + cancelGeneration(sessionId: string): Promise + respondToolInteraction( + sessionId: string, + messageId: string, + toolCallId: string, + response: ToolInteractionResponse + ): Promise +} + +export interface ChatServiceProjectionPort { + getSession(sessionId: string): Promise + getMessage(messageId: string): Promise +} + export class ChatService { private readonly activeControllers = new Map() constructor( private readonly deps: { - sessionRepository: SessionRepository - messageRepository: MessageRepository - providerExecutionPort: ProviderExecutionPort - providerCatalogPort: ProviderCatalogPort - sessionPermissionPort: SessionPermissionPort + turn: ChatServiceTurnPort + projection: ChatServiceProjectionPort + providerCatalogPort: Pick + sessionPermissionPort: Pick scheduler: Scheduler } ) {} @@ -44,7 +61,7 @@ export class ChatService { try { const session = await this.deps.scheduler.timeout({ - task: this.deps.sessionRepository.get(sessionId), + task: this.deps.projection.getSession(sessionId), ms: CHAT_LOOKUP_TIMEOUT_MS, reason: `chat.sendMessage:${sessionId}:session` }) @@ -64,7 +81,7 @@ export class ChatService { } const result = await this.deps.scheduler.timeout({ - task: this.deps.providerExecutionPort.sendMessage(sessionId, content), + task: this.deps.turn.sendMessage(sessionId, content), ms: CHAT_SEND_TIMEOUT_MS, reason: `chat.sendMessage:${sessionId}`, signal: controller.signal @@ -79,7 +96,7 @@ export class ChatService { if (error instanceof Error && error.name === 'TimeoutError') { const cleanupResults = await Promise.allSettled([ Promise.resolve(this.deps.sessionPermissionPort.clearSessionPermissions(sessionId)), - this.deps.providerExecutionPort.cancelGeneration(sessionId) + this.deps.turn.cancelGeneration(sessionId) ]) const clearPermissionsResult = cleanupResults[0] if (clearPermissionsResult?.status === 'rejected') { @@ -109,7 +126,7 @@ export class ChatService { content: string | SendMessageInput ): Promise<{ accepted: true }> { const session = await this.deps.scheduler.timeout({ - task: this.deps.sessionRepository.get(sessionId), + task: this.deps.projection.getSession(sessionId), ms: CHAT_LOOKUP_TIMEOUT_MS, reason: `chat.steerActiveTurn:${sessionId}:session` }) @@ -119,7 +136,7 @@ export class ChatService { } await this.deps.scheduler.timeout({ - task: this.deps.providerExecutionPort.steerActiveTurn(sessionId, content), + task: this.deps.turn.steerActiveTurn(sessionId, content), ms: CHAT_SEND_TIMEOUT_MS, reason: `chat.steerActiveTurn:${sessionId}` }) @@ -135,7 +152,7 @@ export class ChatService { if (!targetSessionId && input.requestId) { const message = await this.deps.scheduler.timeout({ - task: this.deps.messageRepository.get(input.requestId), + task: this.deps.projection.getMessage(input.requestId), ms: CHAT_LOOKUP_TIMEOUT_MS, reason: `chat.stopStream:${input.requestId}:message` }) @@ -157,9 +174,7 @@ export class ChatService { Promise.resolve().then(() => this.deps.sessionPermissionPort.clearSessionPermissions(targetSessionId) ), - Promise.resolve().then(() => - this.deps.providerExecutionPort.cancelGeneration(targetSessionId) - ) + Promise.resolve().then(() => this.deps.turn.cancelGeneration(targetSessionId)) ]).then((results) => { const clearPermissionsResult = results[0] if (clearPermissionsResult?.status === 'rejected') { @@ -188,7 +203,7 @@ export class ChatService { sessionId: string messageId: string toolCallId: string - response: Parameters[3] + response: ToolInteractionResponse }): Promise<{ accepted: true resumed?: boolean @@ -196,7 +211,7 @@ export class ChatService { handledInline?: boolean }> { const result = await this.deps.scheduler.timeout({ - task: this.deps.providerExecutionPort.respondToolInteraction( + task: this.deps.turn.respondToolInteraction( input.sessionId, input.messageId, input.toolCallId, diff --git a/src/main/routes/hotPathPorts.ts b/src/main/routes/hotPathPorts.ts index 78006c713..f79077e3d 100644 --- a/src/main/routes/hotPathPorts.ts +++ b/src/main/routes/hotPathPorts.ts @@ -1,64 +1,7 @@ -import type { - IAgentSessionPresenter, - IConfigPresenter, - ILlmProviderPresenter -} from '@shared/presenter' -import type { DeepchatEventName, DeepchatEventPayload } from '@shared/contracts/events' -import type { - ChatMessagePageResult, - ChatMessageRecord, - CreateSessionInput, - MessagePageCursor, - MessageStartResult, - SendMessageInput, - SessionWithState, - ToolInteractionResponse, - ToolInteractionResult -} from '@shared/types/agent-interface' -import type { - ProviderCatalogPort as PresenterProviderCatalogPort, - SessionPermissionPort as PresenterSessionPermissionPort -} from '../presenter/runtimePorts' -import { publishDeepchatEvent } from './publishDeepchatEvent' - -export type SessionListFilters = { - agentId?: string - projectDir?: string - includeSubagents?: boolean - parentSessionId?: string -} - -export interface SessionRepository { - create(input: CreateSessionInput, webContentsId: number): Promise - get(sessionId: string): Promise - list(filters?: SessionListFilters): Promise - activate(webContentsId: number, sessionId: string): Promise - deactivate(webContentsId: number): Promise - getActive(webContentsId: number): Promise -} - -export interface MessageRepository { - listBySession(sessionId: string): Promise - listPageBySession( - sessionId: string, - options?: { - limit?: number - cursor?: MessagePageCursor | null - } - ): Promise - get(messageId: string): Promise -} +import type { IConfigPresenter, ILlmProviderPresenter } from '@shared/presenter' +import type { ProviderCatalogPort as PresenterProviderCatalogPort } from '../presenter/runtimePorts' export interface ProviderExecutionPort { - sendMessage(sessionId: string, content: string | SendMessageInput): Promise - steerActiveTurn(sessionId: string, content: string | SendMessageInput): Promise - cancelGeneration(sessionId: string): Promise - respondToolInteraction( - sessionId: string, - messageId: string, - toolCallId: string, - response: ToolInteractionResponse - ): Promise testConnection( providerId: string, modelId?: string @@ -73,74 +16,15 @@ export type ProviderCatalogPort = Pick< 'getAgentType' | 'getProviderModels' | 'getCustomModels' > -export type SessionPermissionPort = Pick - -export interface WindowEventPort { - publish(name: T, payload: DeepchatEventPayload): void -} - export function createPresenterHotPathPorts(deps: { - agentSessionPresenter: Pick< - IAgentSessionPresenter, - | 'createSession' - | 'getSession' - | 'getSessionList' - | 'activateSession' - | 'deactivateSession' - | 'getActiveSession' - | 'getMessages' - | 'listMessagesPage' - | 'getMessage' - | 'sendMessage' - | 'steerActiveTurn' - | 'cancelGeneration' - | 'respondToolInteraction' - > & { - clearSessionPermissions: (sessionId: string) => void | Promise - } configPresenter: Pick llmProviderPresenter: Pick }): { - sessionRepository: SessionRepository - messageRepository: MessageRepository providerExecutionPort: ProviderExecutionPort providerCatalogPort: ProviderCatalogPort - sessionPermissionPort: SessionPermissionPort - windowEventPort: WindowEventPort } { return { - sessionRepository: { - create: async (input, webContentsId) => - await deps.agentSessionPresenter.createSession(input, webContentsId), - get: async (sessionId) => await deps.agentSessionPresenter.getSession(sessionId), - list: async (filters) => await deps.agentSessionPresenter.getSessionList(filters), - activate: async (webContentsId, sessionId) => - await deps.agentSessionPresenter.activateSession(webContentsId, sessionId), - deactivate: async (webContentsId) => - await deps.agentSessionPresenter.deactivateSession(webContentsId), - getActive: async (webContentsId) => - await deps.agentSessionPresenter.getActiveSession(webContentsId) - }, - messageRepository: { - listBySession: async (sessionId) => await deps.agentSessionPresenter.getMessages(sessionId), - listPageBySession: async (sessionId, options) => - await deps.agentSessionPresenter.listMessagesPage(sessionId, options), - get: async (messageId) => await deps.agentSessionPresenter.getMessage(messageId) - }, providerExecutionPort: { - sendMessage: async (sessionId, content) => - await deps.agentSessionPresenter.sendMessage(sessionId, content), - steerActiveTurn: async (sessionId, content) => - await deps.agentSessionPresenter.steerActiveTurn(sessionId, content), - cancelGeneration: async (sessionId) => - await deps.agentSessionPresenter.cancelGeneration(sessionId), - respondToolInteraction: async (sessionId, messageId, toolCallId, response) => - await deps.agentSessionPresenter.respondToolInteraction( - sessionId, - messageId, - toolCallId, - response - ), testConnection: async (providerId, modelId) => await deps.llmProviderPresenter.check(providerId, modelId) }, @@ -148,15 +32,6 @@ export function createPresenterHotPathPorts(deps: { getProviderModels: (providerId) => deps.configPresenter.getProviderModels(providerId) ?? [], getCustomModels: (providerId) => deps.configPresenter.getCustomModels(providerId) ?? [], getAgentType: async (agentId) => await deps.configPresenter.getAgentType(agentId) - }, - sessionPermissionPort: { - clearSessionPermissions: (sessionId) => - deps.agentSessionPresenter.clearSessionPermissions(sessionId) - }, - windowEventPort: { - publish: (name, payload) => { - publishDeepchatEvent(name, payload) - } } } } diff --git a/src/main/routes/index.ts b/src/main/routes/index.ts index 4898b1f88..bf653f592 100644 --- a/src/main/routes/index.ts +++ b/src/main/routes/index.ts @@ -403,7 +403,11 @@ import { } from '@shared/contracts/routes/memory.routes' import type { ChatMessageRecord } from '@shared/types/agent-interface' import { buildEffectiveTapeView } from '../presenter/agentRuntimePresenter/tapeEffectiveView' -import { ChatService } from './chat/chatService' +import { + ChatService, + type ChatServiceProjectionPort, + type ChatServiceTurnPort +} from './chat/chatService' import { dispatchConfigRoute } from './config/configRouteHandler' import { createPresenterHotPathPorts } from './hotPathPorts' import { dispatchModelRoute } from './models/modelRouteHandler' @@ -420,7 +424,11 @@ import { ProviderImportService } from './providers/providerImportService' import { ProviderService } from './providers/providerService' import { createSettingsRouteAdapter } from './settings/settingsAdapter' import { createSettingsRouteHandler } from './settings/settingsHandler' -import { SessionService } from './sessions/sessionService' +import { + SessionService, + type SessionServiceLifecyclePort, + type SessionServiceProjectionPort +} from './sessions/sessionService' import type { StartupWorkloadCoordinator } from '@/presenter/startupWorkloadCoordinator' import type { PluginPresenter } from '@/presenter/pluginPresenter' import type { DatabaseSecurityPresenter } from '@/presenter/databaseSecurityPresenter' @@ -432,7 +440,7 @@ import type { AgentMemoryAuditRow } from '@/presenter/memoryPresenter/domain/aud import type { DeepChatTapeEntryRow } from '@/presenter/sqlitePresenter/tables/deepchatTapeEntries' import type { SQLitePresenter } from '@/presenter/sqlitePresenter' import type { CronJobsService } from '@/presenter/cronJobs' -import type { AcpProviderAdminPort } from '@/presenter/runtimePorts' +import type { AcpProviderAdminPort, SessionPermissionPort } from '@/presenter/runtimePorts' import { killTerminal, writeToTerminal } from '@/agent/acp/launch/acpInitHelper' const MEMORY_PERSONA_STATES = ['draft', 'active', 'superseded', 'rejected'] as const @@ -722,6 +730,10 @@ export function createMainKernelRouteRuntime(deps: { llmProviderPresenter: ILlmProviderPresenter acpProviderAdminPort: AcpProviderAdminPort agentSessionPresenter: IAgentSessionPresenter + sessionLifecyclePort: SessionServiceLifecyclePort + sessionProjectionPort: SessionServiceProjectionPort & ChatServiceProjectionPort + sessionTurnPort: ChatServiceTurnPort + sessionPermissionPort: Pick skillPresenter: ISkillPresenter skillSyncPresenter: ISkillSyncPresenter exporter: IConversationExporter @@ -750,24 +762,20 @@ export function createMainKernelRouteRuntime(deps: { }): MainKernelRouteRuntime { const scheduler = createNodeScheduler() const hotPathPorts = createPresenterHotPathPorts({ - agentSessionPresenter: deps.agentSessionPresenter as IAgentSessionPresenter & { - clearSessionPermissions: (sessionId: string) => void | Promise - }, configPresenter: deps.configPresenter, llmProviderPresenter: deps.llmProviderPresenter }) const sessionService = new SessionService({ - sessionRepository: hotPathPorts.sessionRepository, - messageRepository: hotPathPorts.messageRepository, + lifecycle: deps.sessionLifecyclePort, + projection: deps.sessionProjectionPort, scheduler }) const chatService = new ChatService({ - sessionRepository: hotPathPorts.sessionRepository, - messageRepository: hotPathPorts.messageRepository, - providerExecutionPort: hotPathPorts.providerExecutionPort, + turn: deps.sessionTurnPort, + projection: deps.sessionProjectionPort, providerCatalogPort: hotPathPorts.providerCatalogPort, - sessionPermissionPort: hotPathPorts.sessionPermissionPort, + sessionPermissionPort: deps.sessionPermissionPort, scheduler }) diff --git a/src/main/routes/sessions/sessionService.ts b/src/main/routes/sessions/sessionService.ts index 434779ff8..29341ba9f 100644 --- a/src/main/routes/sessions/sessionService.ts +++ b/src/main/routes/sessions/sessionService.ts @@ -4,7 +4,6 @@ import type { MessagePageCursor, SessionWithState } from '@shared/types/agent-interface' -import type { MessageRepository, SessionListFilters, SessionRepository } from '../hotPathPorts' import type { Scheduler } from '../scheduler' const SESSION_OPERATION_TIMEOUT_MS = 5_000 @@ -15,11 +14,34 @@ export type SessionRouteContext = { windowId: number | null } +export type SessionListFilters = { + agentId?: string + projectDir?: string + includeSubagents?: boolean + parentSessionId?: string +} + +export interface SessionServiceLifecyclePort { + createSession(input: CreateSessionInput, webContentsId: number): Promise +} + +export interface SessionServiceProjectionPort { + getSession(sessionId: string): Promise + listSessions(filters?: SessionListFilters): Promise + listMessagesPage( + sessionId: string, + options?: { limit?: number; cursor?: MessagePageCursor | null } + ): Promise + activate(webContentsId: number, sessionId: string): Promise + deactivate(webContentsId: number): Promise + getActive(webContentsId: number): Promise +} + export class SessionService { constructor( private readonly deps: { - sessionRepository: SessionRepository - messageRepository: MessageRepository + lifecycle: SessionServiceLifecyclePort + projection: SessionServiceProjectionPort scheduler: Scheduler } ) {} @@ -29,7 +51,7 @@ export class SessionService { context: SessionRouteContext ): Promise { return await this.deps.scheduler.timeout({ - task: this.deps.sessionRepository.create(input, context.webContentsId), + task: this.deps.lifecycle.createSession(input, context.webContentsId), ms: SESSION_OPERATION_TIMEOUT_MS, reason: 'sessions.create' }) @@ -47,7 +69,7 @@ export class SessionService { const session = await this.deps.scheduler.retry({ task: async () => await this.deps.scheduler.timeout({ - task: this.deps.sessionRepository.get(sessionId), + task: this.deps.projection.getSession(sessionId), ms: SESSION_OPERATION_TIMEOUT_MS, reason: `sessions.restore:${sessionId}:session` }), @@ -67,7 +89,7 @@ export class SessionService { } const page = await this.deps.scheduler.timeout({ - task: this.deps.messageRepository.listPageBySession(sessionId, { + task: this.deps.projection.listMessagesPage(sessionId, { limit: effectiveLimit }), ms: SESSION_OPERATION_TIMEOUT_MS, @@ -88,7 +110,7 @@ export class SessionService { } ): Promise { return await this.deps.scheduler.timeout({ - task: this.deps.messageRepository.listPageBySession(sessionId, options), + task: this.deps.projection.listMessagesPage(sessionId, options), ms: SESSION_OPERATION_TIMEOUT_MS, reason: `sessions.listMessagesPage:${sessionId}` }) @@ -96,7 +118,7 @@ export class SessionService { async listSessions(filters?: SessionListFilters) { return await this.deps.scheduler.timeout({ - task: this.deps.sessionRepository.list(filters), + task: this.deps.projection.listSessions(filters), ms: SESSION_OPERATION_TIMEOUT_MS, reason: 'sessions.list' }) @@ -104,7 +126,7 @@ export class SessionService { async activateSession(context: SessionRouteContext, sessionId: string): Promise { await this.deps.scheduler.timeout({ - task: this.deps.sessionRepository.activate(context.webContentsId, sessionId), + task: this.deps.projection.activate(context.webContentsId, sessionId), ms: SESSION_OPERATION_TIMEOUT_MS, reason: `sessions.activate:${sessionId}` }) @@ -112,7 +134,7 @@ export class SessionService { async deactivateSession(context: SessionRouteContext): Promise { await this.deps.scheduler.timeout({ - task: this.deps.sessionRepository.deactivate(context.webContentsId), + task: this.deps.projection.deactivate(context.webContentsId), ms: SESSION_OPERATION_TIMEOUT_MS, reason: 'sessions.deactivate' }) @@ -120,7 +142,7 @@ export class SessionService { async getActiveSession(context: SessionRouteContext): Promise { return await this.deps.scheduler.timeout({ - task: this.deps.sessionRepository.getActive(context.webContentsId), + task: this.deps.projection.getActive(context.webContentsId), ms: SESSION_OPERATION_TIMEOUT_MS, reason: 'sessions.getActive' }) diff --git a/test/main/routes/chatService.test.ts b/test/main/routes/chatService.test.ts index e662a48ca..cc598a4e9 100644 --- a/test/main/routes/chatService.test.ts +++ b/test/main/routes/chatService.test.ts @@ -1,77 +1,108 @@ +import type { ChatMessageRecord, SessionWithState } from '@shared/types/agent-interface' import { ChatService } from '@/routes/chat/chatService' -describe('ChatService', () => { - const createScheduler = () => ({ - sleep: vi.fn(), - timeout: vi.fn(async ({ task }: { task: Promise }) => await task), - retry: vi.fn() +const createSession = (): SessionWithState => ({ + id: 'session-1', + agentId: 'deepchat', + title: 'Session', + projectDir: null, + isPinned: false, + isDraft: false, + sessionKind: 'regular', + parentSessionId: null, + subagentEnabled: false, + subagentMeta: null, + createdAt: 1, + updatedAt: 1, + status: 'idle', + providerId: 'openai', + modelId: 'model-1' +}) + +const createMessage = (): ChatMessageRecord => ({ + id: 'message-1', + sessionId: 'session-1', + orderSeq: 1, + role: 'user', + content: '{"text":"Hello"}', + status: 'sent', + isContextEdge: 0, + metadata: '{}', + createdAt: 1, + updatedAt: 1 +}) + +const createScheduler = () => ({ + sleep: vi.fn(), + timeout: vi.fn(async ({ task }: { task: Promise }) => await task), + retry: vi.fn() +}) + +function createHarness() { + const scheduler = createScheduler() + const projection = { + getSession: vi.fn().mockResolvedValue(createSession()), + getMessage: vi.fn().mockResolvedValue(null) + } + const turn = { + sendMessage: vi.fn().mockResolvedValue({ requestId: null, messageId: null }), + steerActiveTurn: vi.fn().mockResolvedValue(undefined), + cancelGeneration: vi.fn().mockResolvedValue(undefined), + respondToolInteraction: vi.fn().mockResolvedValue({ resumed: true }) + } + const providerCatalogPort = { + getAgentType: vi.fn().mockResolvedValue('deepchat' as const) + } + const sessionPermissionPort = { + clearSessionPermissions: vi.fn() + } + const service = new ChatService({ + projection, + turn, + providerCatalogPort, + sessionPermissionPort, + scheduler }) - it('sends messages through the scheduler after resolving the session owner', async () => { - const scheduler = createScheduler() - const sessionRepository = { - get: vi.fn().mockResolvedValue({ - id: 'session-1', - agentId: 'deepchat' - }) - } - const messageRepository = { - listBySession: vi.fn(), - get: vi.fn() - } - const providerExecutionPort = { - sendMessage: vi.fn().mockResolvedValue({ - requestId: null, - messageId: null - }), - steerActiveTurn: vi.fn().mockResolvedValue(undefined), - cancelGeneration: vi.fn().mockResolvedValue(undefined), - respondToolInteraction: vi.fn().mockResolvedValue({ - resumed: true - }) - } - const providerCatalogPort = { - getAgentType: vi.fn().mockResolvedValue('deepchat') - } - const sessionPermissionPort = { - clearSessionPermissions: vi.fn() - } + return { + service, + scheduler, + projection, + turn, + providerCatalogPort, + sessionPermissionPort + } +} - const service = new ChatService({ - sessionRepository: sessionRepository as any, - messageRepository: messageRepository as any, - providerExecutionPort, - providerCatalogPort, - sessionPermissionPort, - scheduler - }) +describe('ChatService', () => { + it('sends messages through the scheduler after resolving the session owner', async () => { + const harness = createHarness() - await expect(service.sendMessage('session-1', 'hello')).resolves.toEqual({ + await expect(harness.service.sendMessage('session-1', 'hello')).resolves.toEqual({ accepted: true, requestId: null, messageId: null }) - expect(sessionRepository.get).toHaveBeenCalledWith('session-1') - expect(providerCatalogPort.getAgentType).toHaveBeenCalledWith('deepchat') - expect(providerExecutionPort.sendMessage).toHaveBeenCalledWith('session-1', 'hello') - expect(messageRepository.listBySession).not.toHaveBeenCalled() - expect(scheduler.timeout).toHaveBeenCalledTimes(3) - expect(scheduler.timeout).toHaveBeenNthCalledWith( + expect(harness.projection.getSession).toHaveBeenCalledWith('session-1') + expect(harness.providerCatalogPort.getAgentType).toHaveBeenCalledWith('deepchat') + expect(harness.turn.sendMessage).toHaveBeenCalledWith('session-1', 'hello') + expect(harness.scheduler.timeout).toHaveBeenCalledTimes(3) + expect(harness.scheduler.timeout).toHaveBeenNthCalledWith( 1, expect.objectContaining({ ms: 5_000, reason: 'chat.sendMessage:session-1:session' }) ) - expect(scheduler.timeout).toHaveBeenNthCalledWith( + expect(harness.scheduler.timeout).toHaveBeenNthCalledWith( 2, expect.objectContaining({ ms: 5_000, reason: 'chat.sendMessage:session-1:agentType' }) ) - expect(scheduler.timeout).toHaveBeenNthCalledWith( + expect(harness.scheduler.timeout).toHaveBeenNthCalledWith( 3, expect.objectContaining({ ms: 30 * 60 * 1_000, @@ -82,146 +113,68 @@ describe('ChatService', () => { }) it('releases the send lock after missing session and agent type preflight failures', async () => { - const scheduler = createScheduler() - const sessionRepository = { - get: vi - .fn() - .mockResolvedValueOnce(null) - .mockResolvedValue({ id: 'session-1', agentId: 'deepchat' }) - } - const providerExecutionPort = { - sendMessage: vi.fn().mockResolvedValue({ - requestId: 'request-1', - messageId: 'message-1' - }), - steerActiveTurn: vi.fn(), - cancelGeneration: vi.fn(), - respondToolInteraction: vi.fn() - } - const providerCatalogPort = { - getAgentType: vi.fn().mockResolvedValueOnce(null).mockResolvedValue('deepchat') - } - const service = new ChatService({ - sessionRepository: sessionRepository as any, - messageRepository: { listBySession: vi.fn(), get: vi.fn() } as any, - providerExecutionPort, - providerCatalogPort, - sessionPermissionPort: { clearSessionPermissions: vi.fn() }, - scheduler + const harness = createHarness() + harness.projection.getSession.mockResolvedValueOnce(null).mockResolvedValue(createSession()) + harness.providerCatalogPort.getAgentType + .mockResolvedValueOnce(null) + .mockResolvedValue('deepchat') + harness.turn.sendMessage.mockResolvedValueOnce({ + requestId: 'request-1', + messageId: 'message-1' }) - await expect(service.sendMessage('session-1', 'missing session')).rejects.toThrow( + await expect(harness.service.sendMessage('session-1', 'missing session')).rejects.toThrow( 'Session not found: session-1' ) - expect(providerCatalogPort.getAgentType).not.toHaveBeenCalled() - expect(providerExecutionPort.sendMessage).not.toHaveBeenCalled() + expect(harness.providerCatalogPort.getAgentType).not.toHaveBeenCalled() + expect(harness.turn.sendMessage).not.toHaveBeenCalled() - await expect(service.sendMessage('session-1', 'missing agent type')).rejects.toThrow( + await expect(harness.service.sendMessage('session-1', 'missing agent type')).rejects.toThrow( 'Agent type not found: deepchat' ) - expect(providerExecutionPort.sendMessage).not.toHaveBeenCalled() + expect(harness.turn.sendMessage).not.toHaveBeenCalled() - await expect(service.sendMessage('session-1', 'retry')).resolves.toEqual({ + await expect(harness.service.sendMessage('session-1', 'retry')).resolves.toEqual({ accepted: true, requestId: 'request-1', messageId: 'message-1' }) - expect(providerExecutionPort.sendMessage).toHaveBeenCalledOnce() - expect(providerExecutionPort.sendMessage).toHaveBeenCalledWith('session-1', 'retry') + expect(harness.turn.sendMessage).toHaveBeenCalledExactlyOnceWith('session-1', 'retry') }) it('steers the active turn without claiming the normal send lock', async () => { - const scheduler = createScheduler() - const sessionRepository = { - get: vi.fn().mockResolvedValue({ - id: 'session-1', - agentId: 'deepchat' - }) - } - const providerExecutionPort = { - sendMessage: vi.fn(), - steerActiveTurn: vi.fn().mockResolvedValue(undefined), - cancelGeneration: vi.fn(), - respondToolInteraction: vi.fn() - } - - const service = new ChatService({ - sessionRepository: sessionRepository as any, - messageRepository: { - listBySession: vi.fn(), - get: vi.fn() - } as any, - providerExecutionPort, - providerCatalogPort: { - getAgentType: vi.fn() - } as any, - sessionPermissionPort: { - clearSessionPermissions: vi.fn() - }, - scheduler - }) + const harness = createHarness() - await expect(service.steerActiveTurn('session-1', 'refine this')).resolves.toEqual({ + await expect(harness.service.steerActiveTurn('session-1', 'refine this')).resolves.toEqual({ accepted: true }) - expect(sessionRepository.get).toHaveBeenCalledWith('session-1') - expect(providerExecutionPort.steerActiveTurn).toHaveBeenCalledWith('session-1', 'refine this') - expect(scheduler.timeout).toHaveBeenCalledWith( - expect.objectContaining({ - reason: 'chat.steerActiveTurn:session-1' - }) + expect(harness.projection.getSession).toHaveBeenCalledWith('session-1') + expect(harness.turn.steerActiveTurn).toHaveBeenCalledWith('session-1', 'refine this') + expect(harness.scheduler.timeout).toHaveBeenCalledWith( + expect.objectContaining({ reason: 'chat.steerActiveTurn:session-1' }) ) }) - it('resolves stopStream by request id and clears permissions before cancelling', async () => { - const scheduler = createScheduler() - const sessionRepository = { - get: vi.fn() - } - const messageRepository = { - listBySession: vi.fn(), - get: vi.fn().mockResolvedValue({ - id: 'message-1', - sessionId: 'session-1' - }) - } - const providerExecutionPort = { - sendMessage: vi.fn(), - steerActiveTurn: vi.fn(), - cancelGeneration: vi.fn().mockResolvedValue(undefined), - respondToolInteraction: vi.fn() - } - const providerCatalogPort = { - getAgentType: vi.fn() - } - const sessionPermissionPort = { - clearSessionPermissions: vi.fn() - } + it('resolves stopStream by request id and cleans up the session', async () => { + const harness = createHarness() + harness.projection.getMessage.mockResolvedValueOnce(createMessage()) - const service = new ChatService({ - sessionRepository: sessionRepository as any, - messageRepository: messageRepository as any, - providerExecutionPort, - providerCatalogPort, - sessionPermissionPort, - scheduler - }) - - await expect(service.stopStream({ requestId: 'message-1' })).resolves.toEqual({ + await expect(harness.service.stopStream({ requestId: 'message-1' })).resolves.toEqual({ stopped: true }) - expect(messageRepository.get).toHaveBeenCalledWith('message-1') - expect(sessionPermissionPort.clearSessionPermissions).toHaveBeenCalledWith('session-1') - expect(providerExecutionPort.cancelGeneration).toHaveBeenCalledWith('session-1') - expect(scheduler.timeout).toHaveBeenNthCalledWith( + + expect(harness.projection.getMessage).toHaveBeenCalledWith('message-1') + expect(harness.sessionPermissionPort.clearSessionPermissions).toHaveBeenCalledWith('session-1') + expect(harness.turn.cancelGeneration).toHaveBeenCalledWith('session-1') + expect(harness.scheduler.timeout).toHaveBeenNthCalledWith( 1, expect.objectContaining({ ms: 5_000, reason: 'chat.stopStream:message-1:message' }) ) - expect(scheduler.timeout).toHaveBeenNthCalledWith( + expect(harness.scheduler.timeout).toHaveBeenNthCalledWith( 2, expect.objectContaining({ ms: 5_000, @@ -231,123 +184,48 @@ describe('ChatService', () => { }) it('returns stopped false when a request id cannot be mapped to a session', async () => { - const scheduler = createScheduler() - const messageRepository = { - listBySession: vi.fn(), - get: vi.fn().mockResolvedValue(null) - } - const providerExecutionPort = { - sendMessage: vi.fn(), - steerActiveTurn: vi.fn(), - cancelGeneration: vi.fn(), - respondToolInteraction: vi.fn() - } - const sessionPermissionPort = { - clearSessionPermissions: vi.fn() - } - const service = new ChatService({ - sessionRepository: { get: vi.fn() } as any, - messageRepository: messageRepository as any, - providerExecutionPort, - providerCatalogPort: { getAgentType: vi.fn() } as any, - sessionPermissionPort, - scheduler - }) + const harness = createHarness() - await expect(service.stopStream({ requestId: 'missing-message' })).resolves.toEqual({ + await expect(harness.service.stopStream({ requestId: 'missing-message' })).resolves.toEqual({ stopped: false }) - expect(scheduler.timeout).toHaveBeenCalledOnce() - expect(scheduler.timeout).toHaveBeenCalledWith( - expect.objectContaining({ - ms: 5_000, - reason: 'chat.stopStream:missing-message:message' - }) - ) - expect(sessionPermissionPort.clearSessionPermissions).not.toHaveBeenCalled() - expect(providerExecutionPort.cancelGeneration).not.toHaveBeenCalled() - }) - it('attempts both stopStream cleanups even if clearing permissions fails', async () => { - const scheduler = createScheduler() - const sessionRepository = { - get: vi.fn() - } - const messageRepository = { - listBySession: vi.fn(), - get: vi.fn().mockResolvedValue({ - id: 'message-1', - sessionId: 'session-1' - }) - } - const providerExecutionPort = { - sendMessage: vi.fn(), - steerActiveTurn: vi.fn(), - cancelGeneration: vi.fn().mockResolvedValue(undefined), - respondToolInteraction: vi.fn() - } - const providerCatalogPort = { - getAgentType: vi.fn() - } - const sessionPermissionPort = { - clearSessionPermissions: vi.fn().mockRejectedValue(new Error('permission cleanup failed')) - } + expect(harness.scheduler.timeout).toHaveBeenCalledOnce() + expect(harness.sessionPermissionPort.clearSessionPermissions).not.toHaveBeenCalled() + expect(harness.turn.cancelGeneration).not.toHaveBeenCalled() + }) - const service = new ChatService({ - sessionRepository: sessionRepository as any, - messageRepository: messageRepository as any, - providerExecutionPort, - providerCatalogPort, - sessionPermissionPort, - scheduler - }) + it('attempts both stopStream cleanups when permission cleanup fails', async () => { + const harness = createHarness() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + harness.projection.getMessage.mockResolvedValueOnce(createMessage()) + harness.sessionPermissionPort.clearSessionPermissions.mockRejectedValueOnce( + new Error('permission cleanup failed') + ) - await expect(service.stopStream({ requestId: 'message-1' })).resolves.toEqual({ + await expect(harness.service.stopStream({ requestId: 'message-1' })).resolves.toEqual({ stopped: true }) - expect(sessionPermissionPort.clearSessionPermissions).toHaveBeenCalledWith('session-1') - expect(providerExecutionPort.cancelGeneration).toHaveBeenCalledWith('session-1') - }) - it('responds to tool interactions through the provider execution port', async () => { - const scheduler = createScheduler() - const providerExecutionPort = { - sendMessage: vi.fn(), - steerActiveTurn: vi.fn(), - cancelGeneration: vi.fn(), - respondToolInteraction: vi.fn().mockResolvedValue({ - resumed: true, - waitingForUserMessage: false - }) - } + expect(harness.sessionPermissionPort.clearSessionPermissions).toHaveBeenCalledWith('session-1') + expect(harness.turn.cancelGeneration).toHaveBeenCalledWith('session-1') + expect(warn).toHaveBeenCalledOnce() + warn.mockRestore() + }) - const service = new ChatService({ - sessionRepository: { - get: vi.fn() - } as any, - messageRepository: { - listBySession: vi.fn(), - get: vi.fn() - } as any, - providerExecutionPort, - providerCatalogPort: { - getAgentType: vi.fn() - } as any, - sessionPermissionPort: { - clearSessionPermissions: vi.fn() - }, - scheduler + it('responds to tool interactions through the turn port', async () => { + const harness = createHarness() + harness.turn.respondToolInteraction.mockResolvedValueOnce({ + resumed: true, + waitingForUserMessage: false }) await expect( - service.respondToolInteraction({ + harness.service.respondToolInteraction({ sessionId: 'session-1', messageId: 'message-1', toolCallId: 'tool-1', - response: { - kind: 'permission', - granted: true - } + response: { kind: 'permission', granted: true } }) ).resolves.toEqual({ accepted: true, @@ -355,16 +233,13 @@ describe('ChatService', () => { waitingForUserMessage: false }) - expect(providerExecutionPort.respondToolInteraction).toHaveBeenCalledWith( + expect(harness.turn.respondToolInteraction).toHaveBeenCalledWith( 'session-1', 'message-1', 'tool-1', - { - kind: 'permission', - granted: true - } + { kind: 'permission', granted: true } ) - expect(scheduler.timeout).toHaveBeenCalledWith( + expect(harness.scheduler.timeout).toHaveBeenCalledWith( expect.objectContaining({ ms: 30 * 60 * 1_000, reason: 'chat.respondToolInteraction:session-1:tool-1' @@ -372,82 +247,39 @@ describe('ChatService', () => { ) }) - it('attempts both timeout cleanups even if clearing permissions fails', async () => { - const scheduler = createScheduler() + it('attempts both timeout cleanups when permission cleanup fails', async () => { + const harness = createHarness() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const timeoutError = new Error('timed out') timeoutError.name = 'TimeoutError' - const sessionRepository = { - get: vi.fn().mockResolvedValue({ - id: 'session-1', - agentId: 'deepchat' - }) - } - const messageRepository = { - listBySession: vi.fn().mockResolvedValue([]), - get: vi.fn() - } - const providerExecutionPort = { - sendMessage: vi.fn().mockRejectedValue(timeoutError), - steerActiveTurn: vi.fn(), - cancelGeneration: vi.fn().mockResolvedValue(undefined), - respondToolInteraction: vi.fn() - } - const providerCatalogPort = { - getAgentType: vi.fn().mockResolvedValue('deepchat') - } - const sessionPermissionPort = { - clearSessionPermissions: vi.fn().mockRejectedValue(new Error('permission cleanup failed')) - } - - const service = new ChatService({ - sessionRepository: sessionRepository as any, - messageRepository: messageRepository as any, - providerExecutionPort, - providerCatalogPort, - sessionPermissionPort, - scheduler - }) + harness.turn.sendMessage.mockRejectedValueOnce(timeoutError) + harness.sessionPermissionPort.clearSessionPermissions.mockRejectedValueOnce( + new Error('permission cleanup failed') + ) - await expect(service.sendMessage('session-1', 'hello')).rejects.toBe(timeoutError) + await expect(harness.service.sendMessage('session-1', 'hello')).rejects.toBe(timeoutError) - expect(sessionPermissionPort.clearSessionPermissions).toHaveBeenCalledWith('session-1') - expect(providerExecutionPort.cancelGeneration).toHaveBeenCalledWith('session-1') + expect(harness.sessionPermissionPort.clearSessionPermissions).toHaveBeenCalledWith('session-1') + expect(harness.turn.cancelGeneration).toHaveBeenCalledWith('session-1') + expect(warn).toHaveBeenCalledOnce() + warn.mockRestore() }) - it('releases the send lock after non-timeout failures without timeout cleanup', async () => { - const scheduler = createScheduler() + it('releases the send lock after non-timeout failures without cleanup', async () => { + const harness = createHarness() const sendError = new Error('provider failed') - const providerExecutionPort = { - sendMessage: vi - .fn() - .mockRejectedValueOnce(sendError) - .mockResolvedValueOnce({ requestId: 'request-2', messageId: 'message-2' }), - steerActiveTurn: vi.fn(), - cancelGeneration: vi.fn(), - respondToolInteraction: vi.fn() - } - const sessionPermissionPort = { - clearSessionPermissions: vi.fn() - } - const service = new ChatService({ - sessionRepository: { - get: vi.fn().mockResolvedValue({ id: 'session-1', agentId: 'deepchat' }) - } as any, - messageRepository: { listBySession: vi.fn(), get: vi.fn() } as any, - providerExecutionPort, - providerCatalogPort: { getAgentType: vi.fn().mockResolvedValue('deepchat') } as any, - sessionPermissionPort, - scheduler - }) + harness.turn.sendMessage + .mockRejectedValueOnce(sendError) + .mockResolvedValueOnce({ requestId: 'request-2', messageId: 'message-2' }) - await expect(service.sendMessage('session-1', 'first')).rejects.toBe(sendError) - await expect(service.sendMessage('session-1', 'second')).resolves.toEqual({ + await expect(harness.service.sendMessage('session-1', 'first')).rejects.toBe(sendError) + await expect(harness.service.sendMessage('session-1', 'second')).resolves.toEqual({ accepted: true, requestId: 'request-2', messageId: 'message-2' }) - expect(sessionPermissionPort.clearSessionPermissions).not.toHaveBeenCalled() - expect(providerExecutionPort.cancelGeneration).not.toHaveBeenCalled() + expect(harness.sessionPermissionPort.clearSessionPermissions).not.toHaveBeenCalled() + expect(harness.turn.cancelGeneration).not.toHaveBeenCalled() }) it('aborts a pending send when stopStream races during preflight', async () => { @@ -468,9 +300,7 @@ describe('ChatService', () => { signal?: AbortSignal reason: string }) => { - if (signal?.aborted) { - throw createAbortError(reason) - } + if (signal?.aborted) throw createAbortError(reason) return await new Promise((resolve, reject) => { const onAbort = () => { @@ -494,36 +324,31 @@ describe('ChatService', () => { ), retry: vi.fn() } - let resolveSession!: (value: { id: string; agentId: string }) => void - const sessionRepository = { - get: vi.fn().mockImplementation( + let resolveSession!: (value: SessionWithState) => void + const projection = { + getSession: vi.fn().mockImplementation( async () => - await new Promise<{ id: string; agentId: string }>((resolve) => { + await new Promise((resolve) => { resolveSession = resolve }) - ) - } - const messageRepository = { - listBySession: vi.fn().mockResolvedValue([]), - get: vi.fn() + ), + getMessage: vi.fn().mockResolvedValue(null) } - const providerExecutionPort = { - sendMessage: vi.fn().mockResolvedValue(undefined), + const turn = { + sendMessage: vi.fn().mockResolvedValue({ requestId: null, messageId: null }), steerActiveTurn: vi.fn().mockResolvedValue(undefined), cancelGeneration: vi.fn().mockResolvedValue(undefined), - respondToolInteraction: vi.fn() + respondToolInteraction: vi.fn().mockResolvedValue({}) } const providerCatalogPort = { - getAgentType: vi.fn().mockResolvedValue('deepchat') + getAgentType: vi.fn().mockResolvedValue('deepchat' as const) } const sessionPermissionPort = { clearSessionPermissions: vi.fn().mockResolvedValue(undefined) } - const service = new ChatService({ - sessionRepository: sessionRepository as any, - messageRepository: messageRepository as any, - providerExecutionPort, + projection, + turn, providerCatalogPort, sessionPermissionPort, scheduler @@ -536,74 +361,30 @@ describe('ChatService', () => { stopped: true }) - resolveSession({ - id: 'session-1', - agentId: 'deepchat' - }) + resolveSession(createSession()) - await expect(pendingSend).rejects.toMatchObject({ - name: 'AbortError' - }) + await expect(pendingSend).rejects.toMatchObject({ name: 'AbortError' }) expect(sessionPermissionPort.clearSessionPermissions).toHaveBeenCalledWith('session-1') - expect(providerExecutionPort.cancelGeneration).toHaveBeenCalledWith('session-1') + expect(turn.cancelGeneration).toHaveBeenCalledWith('session-1') }) - it('rejects a new send while another stream is still active for the session', async () => { - const scheduler = createScheduler() - const sessionRepository = { - get: vi.fn().mockResolvedValue({ - id: 'session-1', - agentId: 'deepchat' - }) - } - const messageRepository = { - listBySession: vi.fn(), - get: vi.fn() - } + it('rejects a new send while another stream is active for the session', async () => { + const harness = createHarness() let resolveFirstSend!: (value: { requestId: string; messageId: string }) => void - const providerExecutionPort = { - sendMessage: vi - .fn() - .mockImplementationOnce( - async () => - await new Promise<{ requestId: string; messageId: string }>((resolve) => { - resolveFirstSend = resolve - }) - ) - .mockResolvedValue({ - requestId: 'assistant-1', - messageId: 'assistant-1' - }), - steerActiveTurn: vi.fn().mockResolvedValue(undefined), - cancelGeneration: vi.fn().mockResolvedValue(undefined), - respondToolInteraction: vi.fn() - } - const providerCatalogPort = { - getAgentType: vi.fn().mockResolvedValue('deepchat') - } - const sessionPermissionPort = { - clearSessionPermissions: vi.fn() - } - - const service = new ChatService({ - sessionRepository: sessionRepository as any, - messageRepository: messageRepository as any, - providerExecutionPort, - providerCatalogPort, - sessionPermissionPort, - scheduler - }) + harness.turn.sendMessage.mockImplementationOnce( + async () => + await new Promise<{ requestId: string; messageId: string }>((resolve) => { + resolveFirstSend = resolve + }) + ) - const firstSend = service.sendMessage('session-1', 'hello') + const firstSend = harness.service.sendMessage('session-1', 'hello') - await expect(service.sendMessage('session-1', 'again')).rejects.toThrow( + await expect(harness.service.sendMessage('session-1', 'again')).rejects.toThrow( 'A stream is already active for session session-1' ) - resolveFirstSend({ - requestId: 'assistant-1', - messageId: 'assistant-1' - }) + resolveFirstSend({ requestId: 'assistant-1', messageId: 'assistant-1' }) await expect(firstSend).resolves.toEqual({ accepted: true, requestId: 'assistant-1', diff --git a/test/main/routes/dispatcher.test.ts b/test/main/routes/dispatcher.test.ts index 226cae7f1..c1770c2b3 100644 --- a/test/main/routes/dispatcher.test.ts +++ b/test/main/routes/dispatcher.test.ts @@ -597,6 +597,76 @@ function createRuntime() { clearSessionPermissions: vi.fn() } as unknown as IAgentSessionPresenter + const sessionSnapshot = { + id: 'session-1', + agentId: 'deepchat', + title: 'Restored', + projectDir: '/workspace', + isPinned: false, + isDraft: false, + sessionKind: 'regular' as const, + parentSessionId: null, + subagentEnabled: false, + subagentMeta: null, + createdAt: 1, + updatedAt: 2, + status: 'idle' as const, + providerId: 'openai', + modelId: 'gpt-5.4' + } + const sessionLifecyclePort = { + createSession: vi.fn().mockResolvedValue({ ...sessionSnapshot, title: 'New Chat' }) + } + const sessionProjectionPort = { + getSession: vi.fn().mockResolvedValue(sessionSnapshot), + listSessions: vi.fn().mockResolvedValue([]), + listMessagesPage: vi.fn().mockResolvedValue({ + messages: [ + { + id: 'message-1', + sessionId: 'session-1', + orderSeq: 1, + role: 'user' as const, + content: '{"text":"hello"}', + status: 'sent' as const, + isContextEdge: 0, + metadata: '{}', + createdAt: 1, + updatedAt: 1 + } + ], + nextCursor: null, + hasMore: false + }), + activate: vi.fn().mockResolvedValue(undefined), + deactivate: vi.fn().mockResolvedValue(undefined), + getActive: vi.fn().mockResolvedValue(null), + getMessage: vi.fn().mockResolvedValue({ + id: 'message-1', + sessionId: 'session-1', + orderSeq: 1, + role: 'user' as const, + content: '{"text":"hello"}', + status: 'sent' as const, + isContextEdge: 0, + metadata: '{}', + createdAt: 1, + updatedAt: 1 + }) + } + const sessionTurnPort = { + sendMessage: vi.fn().mockResolvedValue({ + requestId: 'message-2', + messageId: 'message-2' + }), + steerActiveTurn: vi.fn().mockResolvedValue(undefined), + cancelGeneration: vi.fn().mockResolvedValue(undefined), + respondToolInteraction: vi.fn().mockResolvedValue({ resumed: true }) + } + const sessionPermissionPort = { + clearSessionPermissions: vi.fn() + } + let rateLimitConfig = { enabled: false, qpsLimit: 1 @@ -1273,6 +1343,10 @@ function createRuntime() { llmProviderPresenter, acpProviderAdminPort, agentSessionPresenter, + sessionLifecyclePort, + sessionProjectionPort, + sessionTurnPort, + sessionPermissionPort, skillPresenter, skillSyncPresenter, exporter, @@ -1295,6 +1369,10 @@ function createRuntime() { llmProviderPresenter, acpProviderAdminPort, agentSessionPresenter, + sessionLifecyclePort, + sessionProjectionPort, + sessionTurnPort, + sessionPermissionPort, skillPresenter, skillSyncPresenter, exporter, @@ -3696,7 +3774,8 @@ describe('dispatchDeepchatRoute', () => { }) it('dispatches session and chat routes with renderer context', async () => { - const { runtime, agentSessionPresenter } = createRuntime() + const { runtime, agentSessionPresenter, sessionLifecyclePort, sessionTurnPort } = + createRuntime() const createResult = await dispatchDeepchatRoute( runtime, @@ -3711,7 +3790,7 @@ describe('dispatchDeepchatRoute', () => { } ) - expect(agentSessionPresenter.createSession).toHaveBeenCalledWith( + expect(sessionLifecyclePort.createSession).toHaveBeenCalledWith( { agentId: 'deepchat', message: 'hello world' @@ -3737,7 +3816,7 @@ describe('dispatchDeepchatRoute', () => { } ) - expect(agentSessionPresenter.sendMessage).toHaveBeenCalledWith('session-1', 'follow up') + expect(sessionTurnPort.sendMessage).toHaveBeenCalledWith('session-1', 'follow up') await dispatchDeepchatRoute( runtime, @@ -3752,7 +3831,7 @@ describe('dispatchDeepchatRoute', () => { } ) - expect(agentSessionPresenter.steerActiveTurn).toHaveBeenCalledWith( + expect(sessionTurnPort.steerActiveTurn).toHaveBeenCalledWith( 'session-1', 'refine the active answer' ) @@ -3878,7 +3957,7 @@ describe('dispatchDeepchatRoute', () => { configPresenter, llmProviderPresenter, acpProviderAdminPort, - agentSessionPresenter + sessionTurnPort } = createRuntime() const modelsResult = await dispatchDeepchatRoute( @@ -4042,7 +4121,7 @@ describe('dispatchDeepchatRoute', () => { 'codex-acp', '/repo' ) - expect(agentSessionPresenter.respondToolInteraction).toHaveBeenCalledWith( + expect(sessionTurnPort.respondToolInteraction).toHaveBeenCalledWith( 'session-1', 'message-1', 'tool-1', @@ -4135,8 +4214,8 @@ describe('dispatchDeepchatRoute', () => { }) it('activates, deactivates, and reads the active session through typed routes', async () => { - const { runtime, agentSessionPresenter } = createRuntime() - ;(agentSessionPresenter.getActiveSession as ReturnType).mockResolvedValueOnce({ + const { runtime, sessionProjectionPort } = createRuntime() + sessionProjectionPort.getActive.mockResolvedValueOnce({ id: 'session-1', agentId: 'deepchat', title: 'Restored', @@ -4186,9 +4265,9 @@ describe('dispatchDeepchatRoute', () => { } ) - expect(agentSessionPresenter.activateSession).toHaveBeenCalledWith(88, 'session-1') - expect(agentSessionPresenter.deactivateSession).toHaveBeenCalledWith(88) - expect(agentSessionPresenter.getActiveSession).toHaveBeenCalledWith(88) + expect(sessionProjectionPort.activate).toHaveBeenCalledWith(88, 'session-1') + expect(sessionProjectionPort.deactivate).toHaveBeenCalledWith(88) + expect(sessionProjectionPort.getActive).toHaveBeenCalledWith(88) expect(activateResult).toEqual({ activated: true }) expect(deactivateResult).toEqual({ deactivated: true }) expect(activeResult).toEqual({ @@ -4199,7 +4278,8 @@ describe('dispatchDeepchatRoute', () => { }) it('resolves stopStream by requestId when sessionId is omitted', async () => { - const { runtime, agentSessionPresenter } = createRuntime() + const { runtime, sessionProjectionPort, sessionTurnPort, sessionPermissionPort } = + createRuntime() const result = await dispatchDeepchatRoute( runtime, @@ -4213,8 +4293,9 @@ describe('dispatchDeepchatRoute', () => { } ) - expect(agentSessionPresenter.getMessage).toHaveBeenCalledWith('message-1') - expect(agentSessionPresenter.cancelGeneration).toHaveBeenCalledWith('session-1') + expect(sessionProjectionPort.getMessage).toHaveBeenCalledWith('message-1') + expect(sessionPermissionPort.clearSessionPermissions).toHaveBeenCalledWith('session-1') + expect(sessionTurnPort.cancelGeneration).toHaveBeenCalledWith('session-1') expect(result).toEqual({ stopped: true }) }) diff --git a/test/main/routes/sessionService.test.ts b/test/main/routes/sessionService.test.ts index f971bc5b7..b063d6a01 100644 --- a/test/main/routes/sessionService.test.ts +++ b/test/main/routes/sessionService.test.ts @@ -7,31 +7,27 @@ describe('SessionService', () => { retry: vi.fn(async ({ task }: { task: () => Promise }) => await task()) }) - it('restores session snapshots through the scheduler and repositories', async () => { + it('restores session snapshots through the scheduler and projection port', async () => { const scheduler = createScheduler() - const sessionRepository = { - create: vi.fn(), - get: vi.fn().mockResolvedValue({ + const lifecycle = { createSession: vi.fn() } + const projection = { + getSession: vi.fn().mockResolvedValue({ id: 'session-1' }), - list: vi.fn(), - activate: vi.fn(), - deactivate: vi.fn(), - getActive: vi.fn() - } - const messageRepository = { - listBySession: vi.fn(), - listPageBySession: vi.fn().mockResolvedValue({ + listSessions: vi.fn(), + listMessagesPage: vi.fn().mockResolvedValue({ messages: [{ id: 'message-1', sessionId: 'session-1' }], nextCursor: null, hasMore: false }), - get: vi.fn() + activate: vi.fn(), + deactivate: vi.fn(), + getActive: vi.fn() } const service = new SessionService({ - sessionRepository, - messageRepository, + lifecycle, + projection, scheduler }) @@ -60,8 +56,8 @@ describe('SessionService', () => { reason: 'sessions.restore:session-1:messages' }) ) - expect(sessionRepository.get).toHaveBeenCalledWith('session-1') - expect(messageRepository.listPageBySession).toHaveBeenCalledWith('session-1', { + expect(projection.getSession).toHaveBeenCalledWith('session-1') + expect(projection.listMessagesPage).toHaveBeenCalledWith('session-1', { limit: 100 }) expect(result).toEqual({ @@ -74,51 +70,43 @@ describe('SessionService', () => { it('uses an explicit restore message limit when provided', async () => { const scheduler = createScheduler() - const sessionRepository = { - create: vi.fn(), - get: vi.fn().mockResolvedValue({ id: 'session-1' }), - list: vi.fn(), - activate: vi.fn(), - deactivate: vi.fn(), - getActive: vi.fn() - } - const messageRepository = { - listBySession: vi.fn(), - listPageBySession: vi.fn().mockResolvedValue({ + const lifecycle = { createSession: vi.fn() } + const projection = { + getSession: vi.fn().mockResolvedValue({ id: 'session-1' }), + listSessions: vi.fn(), + listMessagesPage: vi.fn().mockResolvedValue({ messages: [], nextCursor: null, hasMore: false }), - get: vi.fn() + activate: vi.fn(), + deactivate: vi.fn(), + getActive: vi.fn() } - const service = new SessionService({ sessionRepository, messageRepository, scheduler }) + const service = new SessionService({ lifecycle, projection, scheduler }) await service.restoreSession('session-1', 25) - expect(messageRepository.listPageBySession).toHaveBeenCalledWith('session-1', { + expect(projection.listMessagesPage).toHaveBeenCalledWith('session-1', { limit: 25 }) }) it('returns an empty restore payload when the session no longer exists', async () => { const scheduler = createScheduler() - const sessionRepository = { - create: vi.fn(), - get: vi.fn().mockResolvedValue(null), - list: vi.fn(), + const lifecycle = { createSession: vi.fn() } + const projection = { + getSession: vi.fn().mockResolvedValue(null), + listSessions: vi.fn(), + listMessagesPage: vi.fn(), activate: vi.fn(), deactivate: vi.fn(), getActive: vi.fn() } - const messageRepository = { - listBySession: vi.fn(), - listPageBySession: vi.fn(), - get: vi.fn() - } const service = new SessionService({ - sessionRepository, - messageRepository, + lifecycle, + projection, scheduler }) @@ -128,30 +116,26 @@ describe('SessionService', () => { nextCursor: null, hasMore: false }) - expect(messageRepository.listPageBySession).not.toHaveBeenCalled() + expect(projection.listMessagesPage).not.toHaveBeenCalled() }) it('routes session lifecycle operations through five-second scheduler boundaries', async () => { const scheduler = createScheduler() const session = { id: 'session-1' } - const sessionRepository = { - create: vi.fn().mockResolvedValue(session), - get: vi.fn(), - list: vi.fn().mockResolvedValue([session]), - activate: vi.fn().mockResolvedValue(undefined), - deactivate: vi.fn().mockResolvedValue(undefined), - getActive: vi.fn().mockResolvedValue(session) - } - const messageRepository = { - listBySession: vi.fn(), - listPageBySession: vi.fn().mockResolvedValue({ + const lifecycle = { createSession: vi.fn().mockResolvedValue(session) } + const projection = { + getSession: vi.fn(), + listSessions: vi.fn().mockResolvedValue([session]), + listMessagesPage: vi.fn().mockResolvedValue({ messages: [], nextCursor: null, hasMore: false }), - get: vi.fn() + activate: vi.fn().mockResolvedValue(undefined), + deactivate: vi.fn().mockResolvedValue(undefined), + getActive: vi.fn().mockResolvedValue(session) } - const service = new SessionService({ sessionRepository, messageRepository, scheduler }) + const service = new SessionService({ lifecycle, projection, scheduler }) const context = { webContentsId: 42, windowId: 7 } const input = { agentId: 'deepchat', message: 'hello' } const filters = { agentId: 'deepchat' } @@ -168,12 +152,12 @@ describe('SessionService', () => { await expect(service.deactivateSession(context)).resolves.toBeUndefined() await expect(service.getActiveSession(context)).resolves.toEqual(session) - expect(sessionRepository.create).toHaveBeenCalledWith(input, 42) - expect(sessionRepository.list).toHaveBeenCalledWith(filters) - expect(messageRepository.listPageBySession).toHaveBeenCalledWith('session-1', pageOptions) - expect(sessionRepository.activate).toHaveBeenCalledWith(42, 'session-1') - expect(sessionRepository.deactivate).toHaveBeenCalledWith(42) - expect(sessionRepository.getActive).toHaveBeenCalledWith(42) + expect(lifecycle.createSession).toHaveBeenCalledWith(input, 42) + expect(projection.listSessions).toHaveBeenCalledWith(filters) + expect(projection.listMessagesPage).toHaveBeenCalledWith('session-1', pageOptions) + expect(projection.activate).toHaveBeenCalledWith(42, 'session-1') + expect(projection.deactivate).toHaveBeenCalledWith(42) + expect(projection.getActive).toHaveBeenCalledWith(42) expect(scheduler.timeout.mock.calls.map(([options]) => [options.ms, options.reason])).toEqual([ [5_000, 'sessions.create'], [5_000, 'sessions.list'], From 8d5142e15326d63cd9584050a16499f027ab5c3a Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 13 Jul 2026 19:51:57 +0800 Subject: [PATCH 14/29] refactor(session): wire remote and cron --- src/main/presenter/cronJobs/index.ts | 6 + .../presenter/cronJobs/runSessionStarter.ts | 113 +++ src/main/presenter/index.ts | 14 +- .../hooks/after-start/cronJobsStartHook.ts | 11 +- .../presenter/remoteControlPresenter/index.ts | 5 +- .../remoteControlPresenter/interface.ts | 43 +- .../services/remoteConversationRunner.ts | 46 +- src/main/routes/index.ts | 84 -- .../cronJobRunSessionStarter.test.ts | 156 ++++ .../after-start/cronJobsStartHook.test.ts | 18 +- .../remoteControlPresenter.test.ts | 75 +- .../remoteConversationRunner.test.ts | 721 ++++++++++-------- test/main/routes/dispatcher.test.ts | 159 +--- 13 files changed, 807 insertions(+), 644 deletions(-) create mode 100644 src/main/presenter/cronJobs/runSessionStarter.ts create mode 100644 test/main/presenter/cronJobRunSessionStarter.test.ts diff --git a/src/main/presenter/cronJobs/index.ts b/src/main/presenter/cronJobs/index.ts index 90a2b52ca..1e7d090e1 100644 --- a/src/main/presenter/cronJobs/index.ts +++ b/src/main/presenter/cronJobs/index.ts @@ -486,4 +486,10 @@ export class CronJobsService { } export { CronJobsRepository } +export { createCronJobRunSessionStarter } from './runSessionStarter' +export type { + CronJobAgentCatalogPort, + CronJobSessionLifecyclePort, + CronJobSessionTurnPort +} from './runSessionStarter' export type { CronJobRunSessionStarter } diff --git a/src/main/presenter/cronJobs/runSessionStarter.ts b/src/main/presenter/cronJobs/runSessionStarter.ts new file mode 100644 index 000000000..054f5d47a --- /dev/null +++ b/src/main/presenter/cronJobs/runSessionStarter.ts @@ -0,0 +1,113 @@ +import type { + AgentType, + CreateDetachedSessionInput, + MessageStartResult +} from '@shared/types/agent-interface' +import type { CronJobRunSessionStarter } from './runExecutor' + +export interface CronJobSessionLifecyclePort { + createDetachedSession(input: CreateDetachedSessionInput): Promise<{ id: string }> +} + +export interface CronJobSessionTurnPort { + sendMessage( + sessionId: string, + content: string, + options?: { maxProviderRounds?: number } + ): Promise + cancelGeneration(sessionId: string): Promise +} + +export interface CronJobAgentCatalogPort { + getAgentType(agentId: string): Promise +} + +export const createCronJobRunSessionStarter = (deps: { + lifecycle: CronJobSessionLifecyclePort + turn: CronJobSessionTurnPort + agentCatalog: CronJobAgentCatalogPort +}): CronJobRunSessionStarter => ({ + async createSessionForRun({ job, run }) { + if (!job.agentId) { + throw new Error('Cron job requires an enabled agent.') + } + console.info('[CronJobs] Resolving agent for session:', { + jobId: job.id, + runId: run.id, + agentId: job.agentId + }) + const agentType = await deps.agentCatalog.getAgentType(job.agentId) + const snapshotConfig = job.agentSnapshot?.config as + | { + defaultModelPreset?: { providerId?: string; modelId?: string } | null + permissionMode?: 'default' | 'auto_approve' | 'full_access' + disabledAgentTools?: string[] + subagentEnabled?: boolean + systemPrompt?: string + } + | null + | undefined + const modelPreset = + agentType === 'deepchat' && job.modelPolicy === 'pin_current' + ? snapshotConfig?.defaultModelPreset + : null + const systemPrompt = job.taskSystemInstruction?.trim() || snapshotConfig?.systemPrompt + + const session = await deps.lifecycle.createDetachedSession({ + agentId: job.agentId, + title: job.name, + ...(agentType === 'acp' ? { providerId: 'acp', modelId: job.agentId } : {}), + ...(modelPreset?.providerId ? { providerId: modelPreset.providerId } : {}), + ...(modelPreset?.modelId ? { modelId: modelPreset.modelId } : {}), + ...(job.permissionPolicy === 'snapshot' && snapshotConfig?.permissionMode + ? { permissionMode: snapshotConfig.permissionMode } + : {}), + ...(job.toolPolicy === 'snapshot' && snapshotConfig?.disabledAgentTools + ? { disabledAgentTools: snapshotConfig.disabledAgentTools } + : {}), + ...(snapshotConfig?.subagentEnabled !== undefined + ? { subagentEnabled: snapshotConfig.subagentEnabled } + : {}), + ...(systemPrompt ? { generationSettings: { systemPrompt } } : {}), + metadata: { + source: 'cron_job', + cronJobId: job.id, + cronJobRunId: run.id, + scheduledAt: run.scheduledAt + } + }) + console.info('[CronJobs] Detached session created:', { + jobId: job.id, + runId: run.id, + sessionId: session.id, + agentType + }) + return { sessionId: session.id } + }, + + async startSessionRun({ job, sessionId }) { + if (!job.taskPrompt.trim()) { + throw new Error('Cron job task prompt is empty.') + } + console.info('[CronJobs] Sending task prompt to session:', { + jobId: job.id, + sessionId, + promptLength: job.taskPrompt.length + }) + const result = await deps.turn.sendMessage(sessionId, job.taskPrompt, { + maxProviderRounds: job.runtime.maxTurns + }) + console.info('[CronJobs] Task prompt accepted by session:', { + jobId: job.id, + sessionId, + outputMessageId: result.messageId ?? null + }) + return { + outputMessageId: result.messageId ?? null + } + }, + + async cancelSessionRun({ sessionId }) { + await deps.turn.cancelGeneration(sessionId) + } +}) diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index c4c9a1e2b..048efaa9a 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -66,7 +66,7 @@ import type { SkillSessionStatePort } from './skillPresenter' import { SkillSyncPresenter } from './skillSyncPresenter' import { HooksNotificationsService } from './hooksNotifications' import { NewSessionHooksBridge } from './hooksNotifications/newSessionBridge' -import { CronJobsService } from './cronJobs' +import { CronJobsService, createCronJobRunSessionStarter } from './cronJobs' import { AgentManager } from '@/agent/manager/agentManager' import { createDeepChatAgentBackend } from '@/agent/manager/deepChatAgentBackend' import { createDirectAcpAgentBackend } from '@/agent/manager/directAcpAgentBackend' @@ -885,6 +885,13 @@ export class Presenter implements IPresenter { projection: this.sessionProjectionCoordinator, deletion: this.sessionDeletionTransaction }) + this.cronJobs.setRunSessionStarter( + createCronJobRunSessionStarter({ + lifecycle: this.sessionLifecycleCoordinator, + turn: this.sessionTurnCoordinator, + agentCatalog: this.configPresenter + }) + ) this.agentSessionPresenter = new AgentSessionPresenter( this.agentManager, appSessionService, @@ -907,7 +914,10 @@ export class Presenter implements IPresenter { ) this.#remoteControlPresenter = new RemoteControlPresenter({ configPresenter: this.configPresenter, - agentSessionPresenter: this.agentSessionPresenter, + lifecycle: this.sessionLifecycleCoordinator, + turn: this.sessionTurnCoordinator, + assignment: this.sessionAgentAssignmentCoordinator, + projection: this.sessionProjectionCoordinator, filePresenter: this.filePresenter, agentManager: this.agentManager, windowPresenter: this.windowPresenter, diff --git a/src/main/presenter/lifecyclePresenter/hooks/after-start/cronJobsStartHook.ts b/src/main/presenter/lifecyclePresenter/hooks/after-start/cronJobsStartHook.ts index ae3bf09ba..9610ec9dc 100644 --- a/src/main/presenter/lifecyclePresenter/hooks/after-start/cronJobsStartHook.ts +++ b/src/main/presenter/lifecyclePresenter/hooks/after-start/cronJobsStartHook.ts @@ -1,6 +1,6 @@ import logger from '@shared/logger' import { LifecycleHook, LifecycleContext } from '@shared/presenter' -import { presenter, getMainKernelRouteRuntime } from '@/presenter' +import { presenter } from '@/presenter' import { LifecyclePhase } from '@shared/lifecycle' export const cronJobsStartHook: LifecycleHook = { @@ -13,15 +13,6 @@ export const cronJobsStartHook: LifecycleHook = { throw new Error('cronJobsStartHook: Presenter not initialized') } - try { - getMainKernelRouteRuntime() - } catch (error) { - console.warn( - '[cronJobsStartHook] Failed to prime route runtime; cron jobs cannot create sessions until routes initialize:', - error - ) - } - presenter.cronJobs.start() logger.info('cronJobsStartHook: Scheduler reconciled') } diff --git a/src/main/presenter/remoteControlPresenter/index.ts b/src/main/presenter/remoteControlPresenter/index.ts index 7e19b68ba..819efc5dc 100644 --- a/src/main/presenter/remoteControlPresenter/index.ts +++ b/src/main/presenter/remoteControlPresenter/index.ts @@ -2675,7 +2675,10 @@ export class RemoteControlPresenter { return new RemoteConversationRunner( { configPresenter: this.deps.configPresenter, - agentSessionPresenter: this.deps.agentSessionPresenter, + lifecycle: this.deps.lifecycle, + turn: this.deps.turn, + assignment: this.deps.assignment, + projection: this.deps.projection, filePresenter: this.deps.filePresenter, agentManager: this.deps.agentManager, windowPresenter: this.deps.windowPresenter, diff --git a/src/main/presenter/remoteControlPresenter/interface.ts b/src/main/presenter/remoteControlPresenter/interface.ts index ce5b57332..7054d2a06 100644 --- a/src/main/presenter/remoteControlPresenter/interface.ts +++ b/src/main/presenter/remoteControlPresenter/interface.ts @@ -2,7 +2,6 @@ import type { DiscordRemoteSettings, FeishuRemoteSettings, IConfigPresenter, - IAgentSessionPresenter, IFilePresenter, IRemoteControlPresenter, QQBotRemoteSettings, @@ -11,12 +10,52 @@ import type { TelegramRemoteSettings, WeixinIlinkRemoteSettings } from '@shared/presenter' +import type { + ChatMessageRecord, + CreateDetachedSessionInput, + MessageStartResult, + SendMessageInput, + SessionWithState, + ToolInteractionResponse, + ToolInteractionResult +} from '@shared/types/agent-interface' +import type { SearchResult } from '@shared/types/core/search' import type { AgentManagerGenerationPort } from '@/agent/manager/agentManager' import type { CronJobRemoteDeliveryPort } from '../cronJobs/deliveryRouter' +export interface RemoteSessionLifecyclePort { + createDetachedSession(input: CreateDetachedSessionInput): Promise +} + +export interface RemoteSessionTurnPort { + sendMessage(sessionId: string, content: string | SendMessageInput): Promise + respondToolInteraction( + sessionId: string, + messageId: string, + toolCallId: string, + response: ToolInteractionResponse + ): Promise +} + +export interface RemoteSessionAssignmentPort { + setSessionModel(sessionId: string, providerId: string, modelId: string): Promise +} + +export interface RemoteSessionProjectionPort { + getSession(sessionId: string): Promise + listSessions(filters?: { agentId?: string }): Promise + getMessages(sessionId: string): Promise + getMessage(messageId: string): Promise + getSearchResults(messageId: string, searchId?: string): Promise + activate(webContentsId: number, sessionId: string): Promise +} + export interface RemoteControlPresenterDeps { configPresenter: IConfigPresenter - agentSessionPresenter: IAgentSessionPresenter + lifecycle: RemoteSessionLifecyclePort + turn: RemoteSessionTurnPort + assignment: RemoteSessionAssignmentPort + projection: RemoteSessionProjectionPort filePresenter?: IFilePresenter agentManager: AgentManagerGenerationPort windowPresenter: IWindowPresenter diff --git a/src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts b/src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts index c418e5c2f..c8120552c 100644 --- a/src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts +++ b/src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts @@ -13,7 +13,6 @@ import type { import type { SearchResult } from '@shared/types/core/search' import type { IConfigPresenter, - IAgentSessionPresenter, IFilePresenter, ITabPresenter, IWindowPresenter @@ -48,6 +47,12 @@ import { } from './remoteBlockRenderer' import { RemoteBindingStore } from './remoteBindingStore' import { collectPendingInteraction } from './remoteInteraction' +import type { + RemoteSessionAssignmentPort, + RemoteSessionLifecyclePort, + RemoteSessionProjectionPort, + RemoteSessionTurnPort +} from '../interface' const sleep = async (ms: number): Promise => { await new Promise((resolve) => setTimeout(resolve, ms)) @@ -425,7 +430,10 @@ export type RemoteOpenSessionResult = type RemoteConversationRunnerDeps = { configPresenter: IConfigPresenter - agentSessionPresenter: IAgentSessionPresenter + lifecycle: RemoteSessionLifecyclePort + turn: RemoteSessionTurnPort + assignment: RemoteSessionAssignmentPort + projection: RemoteSessionProjectionPort filePresenter?: IFilePresenter agentManager: AgentManagerGenerationPort windowPresenter: IWindowPresenter @@ -459,7 +467,7 @@ export class RemoteConversationRunner { throw new Error('ACP remote agent requires a channel default directory.') } - const session = await this.deps.agentSessionPresenter.createDetachedSession({ + const session = await this.deps.lifecycle.createDetachedSession({ title: title?.trim() || 'New Chat', agentId, ...(projectDir ? { projectDir } : {}), @@ -485,7 +493,7 @@ export class RemoteConversationRunner { return null } - const session = await this.deps.agentSessionPresenter.getSession(binding.sessionId) + const session = await this.deps.projection.getSession(binding.sessionId) if (!session) { this.bindingStore.clearBinding(endpointKey) return null @@ -517,7 +525,7 @@ export class RemoteConversationRunner { async listSessions(endpointKey: string): Promise { const agentId = await this.resolveSessionListAgentId(endpointKey) - const sessions = await this.deps.agentSessionPresenter.getSessionList({ + const sessions = await this.deps.projection.listSessions({ agentId }) const sorted = [...sessions] @@ -545,7 +553,7 @@ export class RemoteConversationRunner { throw new Error('Session index is out of range.') } - const session = await this.deps.agentSessionPresenter.getSession(sessionId) + const session = await this.deps.projection.getSession(sessionId) if (!session) { throw new Error('Selected session no longer exists.') } @@ -587,7 +595,7 @@ export class RemoteConversationRunner { throw new Error('No bound session. Send a message, /new, or /use first.') } - return await this.deps.agentSessionPresenter.setSessionModel(session.id, providerId, modelId) + return await this.deps.assignment.setSessionModel(session.id, providerId, modelId) } async listAvailableAgents(): Promise { @@ -661,7 +669,7 @@ export class RemoteConversationRunner { const session = await this.ensureBoundSession(endpointKey, bindingMeta, { requireCurrentDefaultAgent: true }) - const beforeMessages = await this.deps.agentSessionPresenter.getMessages(session.id) + const beforeMessages = await this.deps.projection.getMessages(session.id) const lastOrderSeq = beforeMessages.at(-1)?.orderSeq ?? 0 const previousActiveEventId = this.deps.agentManager.getActiveGeneration(toAppSessionId(session.id))?.eventId ?? null @@ -675,7 +683,7 @@ export class RemoteConversationRunner { const text = input.text.trim() || (files.length > 0 ? 'Please use the attached files.' : '') const messageInput: string | SendMessageInput = files.length > 0 ? { text, files } : text - await this.deps.agentSessionPresenter.sendMessage(session.id, messageInput) + await this.deps.turn.sendMessage(session.id, messageInput) const seededMessage = await this.waitForAssistantMessage(session.id, lastOrderSeq, 800, { ignoreMessageId: previousActiveEventId @@ -723,7 +731,7 @@ export class RemoteConversationRunner { throw new Error('No pending interaction was found.') } - const result = await this.deps.agentSessionPresenter.respondToolInteraction( + const result = await this.deps.turn.respondToolInteraction( session.id, interaction.messageId, interaction.toolCallId, @@ -789,7 +797,7 @@ export class RemoteConversationRunner { } } - await this.deps.agentSessionPresenter.activateSession(window.webContents.id, session.id) + await this.deps.projection.activate(window.webContents.id, session.id) this.deps.windowPresenter.show(window.id, true) return { status: 'ok', @@ -1092,7 +1100,7 @@ export class RemoteConversationRunner { ignoreMessageId: string | null } ): Promise { - const session = await this.deps.agentSessionPresenter.getSession(sessionId) + const session = await this.deps.projection.getSession(sessionId) if (!session) { this.bindingStore.clearBinding(endpointKey) return { @@ -1204,12 +1212,8 @@ export class RemoteConversationRunner { } private async loadSearchResults(messageId: string, searchId?: string): Promise { - if (typeof this.deps.agentSessionPresenter.getSearchResults !== 'function') { - return [] - } - try { - return await this.deps.agentSessionPresenter.getSearchResults(messageId, searchId) + return await this.deps.projection.getSearchResults(messageId, searchId) } catch (error) { console.warn('[RemoteConversationRunner] Failed to load search results:', { messageId, @@ -1313,7 +1317,7 @@ export class RemoteConversationRunner { while (Date.now() < deadline) { const activeGeneration = this.deps.agentManager.getActiveGeneration(toAppSessionId(sessionId)) if (activeGeneration?.eventId && activeGeneration.eventId !== options?.ignoreMessageId) { - const message = await this.deps.agentSessionPresenter.getMessage(activeGeneration.eventId) + const message = await this.deps.projection.getMessage(activeGeneration.eventId) if (message?.role === 'assistant') { return message } @@ -1349,7 +1353,7 @@ export class RemoteConversationRunner { continue } - const message = await this.deps.agentSessionPresenter.getMessage(messageId) + const message = await this.deps.projection.getMessage(messageId) if (message?.role === 'assistant') { return message } @@ -1367,7 +1371,7 @@ export class RemoteConversationRunner { afterOrderSeq: number, ignoreMessageId?: string | null ): Promise { - const messages = await this.deps.agentSessionPresenter.getMessages(sessionId) + const messages = await this.deps.projection.getMessages(sessionId) const assistants = messages.filter( (message) => message.role === 'assistant' && @@ -1400,7 +1404,7 @@ export class RemoteConversationRunner { private async getCurrentPendingInteractionDetails( sessionId: string ): Promise { - const messages = await this.deps.agentSessionPresenter.getMessages(sessionId) + const messages = await this.deps.projection.getMessages(sessionId) const assistants = [...messages] .filter((message) => message.role === 'assistant') .sort((left, right) => right.orderSeq - left.orderSeq) diff --git a/src/main/routes/index.ts b/src/main/routes/index.ts index bf653f592..cf149d286 100644 --- a/src/main/routes/index.ts +++ b/src/main/routes/index.ts @@ -779,90 +779,6 @@ export function createMainKernelRouteRuntime(deps: { scheduler }) - deps.cronJobs.setRunSessionStarter({ - async createSessionForRun({ job, run }) { - if (!job.agentId) { - throw new Error('Cron job requires an enabled agent.') - } - console.info('[CronJobs] Resolving agent for session:', { - jobId: job.id, - runId: run.id, - agentId: job.agentId - }) - const agentType = await deps.configPresenter.getAgentType(job.agentId) - const snapshotConfig = job.agentSnapshot?.config as - | { - defaultModelPreset?: { providerId?: string; modelId?: string } | null - permissionMode?: 'default' | 'auto_approve' | 'full_access' - disabledAgentTools?: string[] - subagentEnabled?: boolean - systemPrompt?: string - } - | null - | undefined - const modelPreset = - agentType === 'deepchat' && job.modelPolicy === 'pin_current' - ? snapshotConfig?.defaultModelPreset - : null - const systemPrompt = job.taskSystemInstruction?.trim() || snapshotConfig?.systemPrompt - - const session = await deps.agentSessionPresenter.createDetachedSession({ - agentId: job.agentId, - title: job.name, - ...(agentType === 'acp' ? { providerId: 'acp', modelId: job.agentId } : {}), - ...(modelPreset?.providerId ? { providerId: modelPreset.providerId } : {}), - ...(modelPreset?.modelId ? { modelId: modelPreset.modelId } : {}), - ...(job.permissionPolicy === 'snapshot' && snapshotConfig?.permissionMode - ? { permissionMode: snapshotConfig.permissionMode } - : {}), - ...(job.toolPolicy === 'snapshot' && snapshotConfig?.disabledAgentTools - ? { disabledAgentTools: snapshotConfig.disabledAgentTools } - : {}), - ...(snapshotConfig?.subagentEnabled !== undefined - ? { subagentEnabled: snapshotConfig.subagentEnabled } - : {}), - ...(systemPrompt ? { generationSettings: { systemPrompt } } : {}), - metadata: { - source: 'cron_job', - cronJobId: job.id, - cronJobRunId: run.id, - scheduledAt: run.scheduledAt - } - }) - console.info('[CronJobs] Detached session created:', { - jobId: job.id, - runId: run.id, - sessionId: session.id, - agentType - }) - return { sessionId: session.id } - }, - async startSessionRun({ job, sessionId }) { - if (!job.taskPrompt.trim()) { - throw new Error('Cron job task prompt is empty.') - } - console.info('[CronJobs] Sending task prompt to session:', { - jobId: job.id, - sessionId, - promptLength: job.taskPrompt.length - }) - const result = await deps.agentSessionPresenter.sendMessage(sessionId, job.taskPrompt, { - maxProviderRounds: job.runtime.maxTurns - }) - console.info('[CronJobs] Task prompt accepted by session:', { - jobId: job.id, - sessionId, - outputMessageId: result.messageId ?? null - }) - return { - outputMessageId: result.messageId ?? null - } - }, - async cancelSessionRun({ sessionId }) { - await deps.agentSessionPresenter.cancelGeneration(sessionId) - } - }) - return { configPresenter: deps.configPresenter, llmProviderPresenter: deps.llmProviderPresenter, diff --git a/test/main/presenter/cronJobRunSessionStarter.test.ts b/test/main/presenter/cronJobRunSessionStarter.test.ts new file mode 100644 index 000000000..93d4b9df8 --- /dev/null +++ b/test/main/presenter/cronJobRunSessionStarter.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, vi } from 'vitest' +import type { CronJob, CronJobRun } from '@shared/cronJobs' +import { + createCronJobRunSessionStarter, + type CronJobAgentCatalogPort, + type CronJobSessionLifecyclePort, + type CronJobSessionTurnPort +} from '@/presenter/cronJobs' + +const createPorts = () => { + const lifecycle: CronJobSessionLifecyclePort = { + createDetachedSession: vi.fn(async () => ({ id: 'session-1' })) + } + const turn: CronJobSessionTurnPort = { + sendMessage: vi.fn(async () => ({ requestId: 'request-1', messageId: 'message-1' })), + cancelGeneration: vi.fn(async () => undefined) + } + const agentCatalog: CronJobAgentCatalogPort = { + getAgentType: vi.fn(async () => 'deepchat') + } + + return { lifecycle, turn, agentCatalog } +} + +const createRun = (): CronJobRun => + ({ + id: 'run-1', + scheduledAt: 123 + }) as CronJobRun + +describe('createCronJobRunSessionStarter', () => { + it('creates detached sessions with cron source metadata', async () => { + const ports = createPorts() + const starter = createCronJobRunSessionStarter(ports) + const job = { + id: 'cron-1', + name: 'Morning job', + agentId: 'deepchat', + agentSnapshot: null, + modelPolicy: 'follow_agent', + permissionPolicy: 'follow_agent', + toolPolicy: 'follow_agent', + taskSystemInstruction: null + } as CronJob + + await expect(starter.createSessionForRun({ job, run: createRun() })).resolves.toEqual({ + sessionId: 'session-1' + }) + + expect(ports.lifecycle.createDetachedSession).toHaveBeenCalledWith({ + agentId: 'deepchat', + title: 'Morning job', + metadata: { + source: 'cron_job', + cronJobId: 'cron-1', + cronJobRunId: 'run-1', + scheduledAt: 123 + } + }) + }) + + it('applies pinned snapshots and task system prompt precedence', async () => { + const ports = createPorts() + const starter = createCronJobRunSessionStarter(ports) + const job = { + id: 'cron-1', + name: 'Morning job', + agentId: 'deepchat', + agentSnapshot: { + version: 1, + capturedAt: 100, + agent: { id: 'deepchat', name: 'DeepChat', type: 'deepchat' }, + config: { + defaultModelPreset: { providerId: 'anthropic', modelId: 'claude-sonnet' }, + permissionMode: 'full_access', + disabledAgentTools: ['write_file'], + subagentEnabled: false, + systemPrompt: 'Snapshot system prompt' + } + }, + modelPolicy: 'pin_current', + permissionPolicy: 'snapshot', + toolPolicy: 'snapshot', + taskSystemInstruction: ' Task-specific system prompt ' + } as CronJob + + await starter.createSessionForRun({ job, run: createRun() }) + + expect(ports.lifecycle.createDetachedSession).toHaveBeenCalledWith({ + agentId: 'deepchat', + title: 'Morning job', + providerId: 'anthropic', + modelId: 'claude-sonnet', + permissionMode: 'full_access', + disabledAgentTools: ['write_file'], + subagentEnabled: false, + generationSettings: { systemPrompt: 'Task-specific system prompt' }, + metadata: { + source: 'cron_job', + cronJobId: 'cron-1', + cronJobRunId: 'run-1', + scheduledAt: 123 + } + }) + }) + + it('routes ACP runs through lifecycle and turn ports', async () => { + const ports = createPorts() + vi.mocked(ports.agentCatalog.getAgentType).mockResolvedValue('acp') + vi.mocked(ports.lifecycle.createDetachedSession).mockResolvedValue({ id: 'acp-session-1' }) + vi.mocked(ports.turn.sendMessage).mockResolvedValue({ + requestId: 'request-2', + messageId: 'message-2' + }) + const starter = createCronJobRunSessionStarter(ports) + const job = { + id: 'cron-acp', + name: 'ACP job', + agentId: 'manual-acp', + agentSnapshot: null, + modelPolicy: 'follow_agent', + permissionPolicy: 'follow_agent', + toolPolicy: 'follow_agent', + taskSystemInstruction: null, + taskPrompt: 'Review the workspace', + runtime: { maxTurns: 7 } + } as CronJob + const run = { id: 'run-acp', scheduledAt: 123 } as CronJobRun + + await expect(starter.createSessionForRun({ job, run })).resolves.toEqual({ + sessionId: 'acp-session-1' + }) + await expect( + starter.startSessionRun({ job, run, sessionId: 'acp-session-1' }) + ).resolves.toEqual({ outputMessageId: 'message-2' }) + + expect(ports.lifecycle.createDetachedSession).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: 'manual-acp', + providerId: 'acp', + modelId: 'manual-acp' + }) + ) + expect(ports.turn.sendMessage).toHaveBeenCalledWith('acp-session-1', 'Review the workspace', { + maxProviderRounds: 7 + }) + + await starter.cancelSessionRun?.({ + job, + run, + sessionId: 'acp-session-1', + reason: 'Cron job exceeded max duration.' + }) + expect(ports.turn.cancelGeneration).toHaveBeenCalledWith('acp-session-1') + }) +}) diff --git a/test/main/presenter/lifecyclePresenter/hooks/after-start/cronJobsStartHook.test.ts b/test/main/presenter/lifecyclePresenter/hooks/after-start/cronJobsStartHook.test.ts index f7bd73a20..a9219fcdc 100644 --- a/test/main/presenter/lifecyclePresenter/hooks/after-start/cronJobsStartHook.test.ts +++ b/test/main/presenter/lifecyclePresenter/hooks/after-start/cronJobsStartHook.test.ts @@ -1,8 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const presenterMocks = vi.hoisted(() => ({ - start: vi.fn(), - getRuntime: vi.fn() + start: vi.fn() })) vi.mock('@/presenter', () => ({ @@ -10,8 +9,7 @@ vi.mock('@/presenter', () => ({ cronJobs: { start: presenterMocks.start } - }, - getMainKernelRouteRuntime: presenterMocks.getRuntime + } })) const { cronJobsStartHook } = @@ -22,17 +20,9 @@ describe('cronJobsStartHook', () => { vi.clearAllMocks() }) - it('primes route runtime before starting cron jobs', async () => { - const calls: string[] = [] - presenterMocks.getRuntime.mockImplementation(() => { - calls.push('runtime') - }) - presenterMocks.start.mockImplementation(() => { - calls.push('start') - }) - + it('starts cron jobs without priming the route runtime', async () => { await cronJobsStartHook.execute({} as never) - expect(calls).toEqual(['runtime', 'start']) + expect(presenterMocks.start).toHaveBeenCalledOnce() }) }) diff --git a/test/main/presenter/remoteControlPresenter/remoteControlPresenter.test.ts b/test/main/presenter/remoteControlPresenter/remoteControlPresenter.test.ts index 2cf76eca1..d288f03a0 100644 --- a/test/main/presenter/remoteControlPresenter/remoteControlPresenter.test.ts +++ b/test/main/presenter/remoteControlPresenter/remoteControlPresenter.test.ts @@ -59,8 +59,39 @@ vi.mock('@/presenter/remoteControlPresenter/telegram/telegramClient', () => ({ })) import { RemoteControlPresenter } from '@/presenter/remoteControlPresenter' +import type { + RemoteSessionAssignmentPort, + RemoteSessionLifecyclePort, + RemoteSessionProjectionPort, + RemoteSessionTurnPort +} from '@/presenter/remoteControlPresenter/interface' import { WeixinIlinkClient } from '@/presenter/remoteControlPresenter/weixinIlink/weixinIlinkClient' +const createRemoteSessionPorts = () => ({ + lifecycle: { + createDetachedSession: vi.fn(async () => { + throw new Error('unused') + }) + } satisfies RemoteSessionLifecyclePort, + turn: { + sendMessage: vi.fn(async () => ({ requestId: null, messageId: null })), + respondToolInteraction: vi.fn(async () => ({})) + } satisfies RemoteSessionTurnPort, + assignment: { + setSessionModel: vi.fn(async () => { + throw new Error('unused') + }) + } satisfies RemoteSessionAssignmentPort, + projection: { + getSession: vi.fn(async () => null), + listSessions: vi.fn(async () => []), + getMessages: vi.fn(async () => []), + getMessage: vi.fn(async () => null), + getSearchResults: vi.fn(async () => []), + activate: vi.fn(async () => undefined) + } satisfies RemoteSessionProjectionPort +}) + const getFreeLoopbackPort = async (): Promise => { const server = net.createServer() await new Promise((resolve, reject) => { @@ -137,7 +168,7 @@ describe('RemoteControlPresenter', () => { const presenter = new RemoteControlPresenter({ configPresenter: configPresenter as any, - agentSessionPresenter: {} as any, + ...createRemoteSessionPorts(), agentManager: {} as any, windowPresenter: { getFocusedWindow: vi.fn(() => undefined), @@ -178,7 +209,7 @@ describe('RemoteControlPresenter', () => { const presenter = new RemoteControlPresenter({ configPresenter: configPresenter as any, - agentSessionPresenter: {} as any, + ...createRemoteSessionPorts(), agentManager: {} as any, windowPresenter: { getFocusedWindow: vi.fn(() => undefined), @@ -206,7 +237,7 @@ describe('RemoteControlPresenter', () => { const presenter = new RemoteControlPresenter({ configPresenter: configPresenter as any, - agentSessionPresenter: {} as any, + ...createRemoteSessionPorts(), agentManager: {} as any, windowPresenter: { getFocusedWindow: vi.fn(() => undefined), @@ -278,7 +309,7 @@ describe('RemoteControlPresenter', () => { const presenter = new RemoteControlPresenter({ configPresenter: configPresenter as any, - agentSessionPresenter: {} as any, + ...createRemoteSessionPorts(), agentManager: {} as any, windowPresenter: { getFocusedWindow: vi.fn(() => undefined), @@ -362,7 +393,7 @@ describe('RemoteControlPresenter', () => { const presenter = new RemoteControlPresenter({ configPresenter: configPresenter as any, - agentSessionPresenter: {} as any, + ...createRemoteSessionPorts(), agentManager: {} as any, windowPresenter: { getFocusedWindow: vi.fn(() => undefined), @@ -426,7 +457,7 @@ describe('RemoteControlPresenter', () => { const presenter = new RemoteControlPresenter({ configPresenter: configPresenter as any, - agentSessionPresenter: {} as any, + ...createRemoteSessionPorts(), agentManager: {} as any, windowPresenter: { getFocusedWindow: vi.fn(() => undefined), @@ -527,7 +558,7 @@ describe('RemoteControlPresenter', () => { const presenter = new RemoteControlPresenter({ configPresenter: configPresenter as any, - agentSessionPresenter: {} as any, + ...createRemoteSessionPorts(), agentManager: {} as any, windowPresenter: { getFocusedWindow: vi.fn(() => undefined), @@ -614,7 +645,7 @@ describe('RemoteControlPresenter', () => { const presenter = new RemoteControlPresenter({ configPresenter: configPresenter as any, - agentSessionPresenter: {} as any, + ...createRemoteSessionPorts(), agentManager: {} as any, windowPresenter: { getFocusedWindow: vi.fn(() => undefined), @@ -685,7 +716,7 @@ describe('RemoteControlPresenter', () => { const presenter = new RemoteControlPresenter({ configPresenter: configPresenter as any, - agentSessionPresenter: {} as any, + ...createRemoteSessionPorts(), agentManager: {} as any, windowPresenter: { getFocusedWindow: vi.fn(() => undefined), @@ -742,7 +773,7 @@ describe('RemoteControlPresenter', () => { const presenter = new RemoteControlPresenter({ configPresenter: configPresenter as any, - agentSessionPresenter: {} as any, + ...createRemoteSessionPorts(), agentManager: {} as any, windowPresenter: { getFocusedWindow: vi.fn(() => undefined), @@ -826,7 +857,7 @@ describe('RemoteControlPresenter', () => { const presenter = new RemoteControlPresenter({ configPresenter: configPresenter as any, - agentSessionPresenter: {} as any, + ...createRemoteSessionPorts(), agentManager: {} as any, windowPresenter: {} as any, tabPresenter: {} as any @@ -883,7 +914,7 @@ describe('RemoteControlPresenter', () => { ...configPresenter, listAgents } as any, - agentSessionPresenter: {} as any, + ...createRemoteSessionPorts(), agentManager: {} as any, windowPresenter: {} as any, tabPresenter: {} as any @@ -912,7 +943,7 @@ describe('RemoteControlPresenter', () => { const presenter = new RemoteControlPresenter({ configPresenter: configPresenter as any, - agentSessionPresenter: {} as any, + ...createRemoteSessionPorts(), agentManager: {} as any, windowPresenter: {} as any, tabPresenter: {} as any @@ -944,7 +975,7 @@ describe('RemoteControlPresenter', () => { listAgents, getAgentType } as any, - agentSessionPresenter: {} as any, + ...createRemoteSessionPorts(), agentManager: {} as any, windowPresenter: {} as any, tabPresenter: {} as any @@ -976,7 +1007,7 @@ describe('RemoteControlPresenter', () => { listAgents, getAgentType } as any, - agentSessionPresenter: {} as any, + ...createRemoteSessionPorts(), agentManager: {} as any, windowPresenter: {} as any, tabPresenter: {} as any @@ -1003,7 +1034,7 @@ describe('RemoteControlPresenter', () => { ...configPresenter, listAgents } as any, - agentSessionPresenter: {} as any, + ...createRemoteSessionPorts(), agentManager: {} as any, windowPresenter: {} as any, tabPresenter: {} as any @@ -1023,7 +1054,7 @@ describe('RemoteControlPresenter', () => { const presenter = new RemoteControlPresenter({ configPresenter: configPresenter as any, - agentSessionPresenter: {} as any, + ...createRemoteSessionPorts(), agentManager: {} as any, windowPresenter: {} as any, tabPresenter: {} as any @@ -1054,7 +1085,7 @@ describe('RemoteControlPresenter', () => { const presenter = new RemoteControlPresenter({ configPresenter: configPresenter as any, - agentSessionPresenter: {} as any, + ...createRemoteSessionPorts(), agentManager: {} as any, windowPresenter: {} as any, tabPresenter: {} as any @@ -1113,7 +1144,7 @@ describe('RemoteControlPresenter', () => { const presenter = new RemoteControlPresenter({ configPresenter: configPresenter as any, - agentSessionPresenter: {} as any, + ...createRemoteSessionPorts(), agentManager: {} as any, windowPresenter: {} as any, tabPresenter: {} as any @@ -1150,7 +1181,7 @@ describe('RemoteControlPresenter', () => { const presenter = new RemoteControlPresenter({ configPresenter: configPresenter as any, - agentSessionPresenter: {} as any, + ...createRemoteSessionPorts(), agentManager: {} as any, windowPresenter: {} as any, tabPresenter: {} as any @@ -1186,7 +1217,7 @@ describe('RemoteControlPresenter', () => { const presenter = new RemoteControlPresenter({ configPresenter: configPresenter as any, - agentSessionPresenter: {} as any, + ...createRemoteSessionPorts(), agentManager: {} as any, windowPresenter: { getFocusedWindow: vi.fn(() => undefined), @@ -1259,7 +1290,7 @@ describe('RemoteControlPresenter', () => { const presenter = new RemoteControlPresenter({ configPresenter: configPresenter as any, - agentSessionPresenter: {} as any, + ...createRemoteSessionPorts(), agentManager: {} as any, windowPresenter: { getFocusedWindow: vi.fn(() => undefined), diff --git a/test/main/presenter/remoteControlPresenter/remoteConversationRunner.test.ts b/test/main/presenter/remoteControlPresenter/remoteConversationRunner.test.ts index 0d3f03630..d2304de5b 100644 --- a/test/main/presenter/remoteControlPresenter/remoteConversationRunner.test.ts +++ b/test/main/presenter/remoteControlPresenter/remoteConversationRunner.test.ts @@ -8,14 +8,23 @@ import { AgentManager } from '@/agent/manager/agentManager' import { createDirectAcpAgentBackend } from '@/agent/manager/directAcpAgentBackend' import type { AcpAgentDescriptor } from '@/agent/shared/agentDescriptors' import { RemoteConversationRunner } from '@/presenter/remoteControlPresenter/services/remoteConversationRunner' - -const createSession = (overrides: Record = {}) => ({ +import type { + RemoteSessionAssignmentPort, + RemoteSessionLifecyclePort, + RemoteSessionProjectionPort, + RemoteSessionTurnPort +} from '@/presenter/remoteControlPresenter/interface' +import type { SessionWithState } from '@shared/types/agent-interface' + +const createSession = (overrides: Partial = {}): SessionWithState => ({ id: 'session-1', agentId: 'deepchat', title: 'Remote Session', projectDir: null, isPinned: false, isDraft: false, + sessionKind: 'regular', + subagentEnabled: false, createdAt: 1, updatedAt: 1, status: 'idle', @@ -24,6 +33,45 @@ const createSession = (overrides: Record = {}) => ({ ...overrides }) +type RemoteSessionPorts = { + lifecycle: RemoteSessionLifecyclePort + turn: RemoteSessionTurnPort + assignment: RemoteSessionAssignmentPort + projection: RemoteSessionProjectionPort +} + +const createRemoteSessionPorts = ( + overrides: { + lifecycle?: Partial + turn?: Partial + assignment?: Partial + projection?: Partial + } = {} +): RemoteSessionPorts => ({ + lifecycle: { + createDetachedSession: vi.fn(async () => createSession()), + ...overrides.lifecycle + }, + turn: { + sendMessage: vi.fn(async () => ({ requestId: null, messageId: null })), + respondToolInteraction: vi.fn(async () => ({})), + ...overrides.turn + }, + assignment: { + setSessionModel: vi.fn(async () => createSession()), + ...overrides.assignment + }, + projection: { + getSession: vi.fn(async () => null), + listSessions: vi.fn(async () => []), + getMessages: vi.fn(async () => []), + getMessage: vi.fn(async () => null), + getSearchResults: vi.fn(async () => []), + activate: vi.fn(async () => undefined), + ...overrides.projection + } +}) + const createConfigPresenter = (overrides: Record = {}) => ({ getAgentType: vi.fn(async (agentId: string) => (agentId === 'acp-agent' ? 'acp' : 'deepchat')), getDefaultProjectPath: vi.fn(() => null), @@ -82,16 +130,18 @@ describe('RemoteConversationRunner', () => { const runner = new RemoteConversationRunner( { configPresenter: createConfigPresenter() as any, - agentSessionPresenter: { - getSession: vi.fn().mockResolvedValue( - createSession({ - agentId: descriptor.id, - providerId: 'acp', - modelId: descriptor.id, - status: 'generating' - }) - ) - } as any, + ...createRemoteSessionPorts({ + projection: { + getSession: vi.fn().mockResolvedValue( + createSession({ + agentId: descriptor.id, + providerId: 'acp', + modelId: descriptor.id, + status: 'generating' + }) + ) + } + }), agentManager: manager, windowPresenter: {} as any, tabPresenter: {} as any, @@ -113,11 +163,13 @@ describe('RemoteConversationRunner', () => { const runner = new RemoteConversationRunner( { configPresenter: createConfigPresenter() as any, - agentSessionPresenter: { - createDetachedSession: vi - .fn() - .mockResolvedValue(createSession({ agentId: 'deepchat-alt' })) - } as any, + ...createRemoteSessionPorts({ + lifecycle: { + createDetachedSession: vi + .fn() + .mockResolvedValue(createSession({ agentId: 'deepchat-alt' })) + } + }), agentManager: {} as any, windowPresenter: {} as any, tabPresenter: {} as any, @@ -133,29 +185,31 @@ describe('RemoteConversationRunner', () => { }) it('creates a new bound session after the default agent changes', async () => { - const agentSessionPresenter = { - createDetachedSession: vi.fn().mockResolvedValue( - createSession({ - id: 'session-new', - agentId: 'deepchat-new' - }) - ), - getSession: vi.fn().mockResolvedValue( - createSession({ - id: 'session-legacy', - agentId: 'deepchat-legacy' + const sessionPorts = createRemoteSessionPorts({ + lifecycle: { + createDetachedSession: vi.fn().mockResolvedValue( + createSession({ + id: 'session-new', + agentId: 'deepchat-new' + }) + ) + }, + projection: { + getSession: vi.fn().mockResolvedValue( + createSession({ + id: 'session-legacy', + agentId: 'deepchat-legacy' + }) + ), + getMessage: vi.fn().mockResolvedValue({ + id: 'msg-1', + role: 'assistant', + content: 'hello from legacy', + status: 'success', + orderSeq: 2 }) - ), - getMessages: vi.fn().mockResolvedValue([]), - sendMessage: vi.fn().mockResolvedValue(undefined), - getMessage: vi.fn().mockResolvedValue({ - id: 'msg-1', - role: 'assistant', - content: 'hello from legacy', - status: 'success', - orderSeq: 2 - }) - } + } + }) const bindingStore = { getBinding: vi.fn().mockReturnValue({ sessionId: 'session-legacy', @@ -175,7 +229,7 @@ describe('RemoteConversationRunner', () => { const runner = new RemoteConversationRunner( { configPresenter: createConfigPresenter() as any, - agentSessionPresenter: agentSessionPresenter as any, + ...sessionPorts, agentManager: agentManager as any, windowPresenter: {} as any, tabPresenter: {} as any, @@ -187,8 +241,8 @@ describe('RemoteConversationRunner', () => { const execution = await runner.sendText('telegram:100:0', 'hello') expect(execution.sessionId).toBe('session-new') - expect(agentSessionPresenter.sendMessage).toHaveBeenCalledWith('session-new', 'hello') - expect(agentSessionPresenter.createDetachedSession).toHaveBeenCalledWith({ + expect(sessionPorts.turn.sendMessage).toHaveBeenCalledWith('session-new', 'hello') + expect(sessionPorts.lifecycle.createDetachedSession).toHaveBeenCalledWith({ title: 'New Chat', agentId: 'deepchat-new' }) @@ -207,12 +261,11 @@ describe('RemoteConversationRunner', () => { id: 'session-bound', projectDir: workspace }) - const agentSessionPresenter = { - getSession: vi.fn().mockResolvedValue(session), - getMessages: vi.fn().mockResolvedValue([]), - sendMessage: vi.fn().mockResolvedValue(undefined), - getMessage: vi.fn().mockResolvedValue(null) - } + const sessionPorts = createRemoteSessionPorts({ + projection: { + getSession: vi.fn().mockResolvedValue(session) + } + }) const filePresenter = { prepareFile: vi.fn(async (filePath: string, mimeType: string) => ({ ...preparedFile, @@ -223,7 +276,7 @@ describe('RemoteConversationRunner', () => { const runner = new RemoteConversationRunner( { configPresenter: createConfigPresenter() as any, - agentSessionPresenter: agentSessionPresenter as any, + ...sessionPorts, filePresenter: filePresenter as any, agentManager: { getActiveGeneration: vi.fn().mockReturnValue(null) @@ -262,7 +315,7 @@ describe('RemoteConversationRunner', () => { expect(preparedPath).toContain('telegram-message-1') expect(path.basename(preparedPath)).toBe('note-1.txt') await expect(fs.readFile(preparedPath, 'utf8')).resolves.toBe('hello file') - expect(agentSessionPresenter.sendMessage).toHaveBeenCalledWith('session-bound', { + expect(sessionPorts.turn.sendMessage).toHaveBeenCalledWith('session-bound', { text: 'read this', files: [ expect.objectContaining({ @@ -284,12 +337,11 @@ describe('RemoteConversationRunner', () => { id: 'session-bound', projectDir: workspace }) - const agentSessionPresenter = { - getSession: vi.fn().mockResolvedValue(session), - getMessages: vi.fn().mockResolvedValue([]), - sendMessage: vi.fn().mockResolvedValue(undefined), - getMessage: vi.fn().mockResolvedValue(null) - } + const sessionPorts = createRemoteSessionPorts({ + projection: { + getSession: vi.fn().mockResolvedValue(session) + } + }) const filePresenter = { prepareFile: vi.fn(async (filePath: string, mimeType: string) => ({ name: path.basename(filePath), @@ -305,7 +357,7 @@ describe('RemoteConversationRunner', () => { const runner = new RemoteConversationRunner( { configPresenter: createConfigPresenter() as any, - agentSessionPresenter: agentSessionPresenter as any, + ...sessionPorts, filePresenter: filePresenter as any, agentManager: { getActiveGeneration: vi.fn().mockReturnValue(null) @@ -353,7 +405,7 @@ describe('RemoteConversationRunner', () => { await expect(fs.readFile(firstPath, 'utf8')).resolves.toBe('first file') await expect(fs.readFile(secondPath, 'utf8')).resolves.toBe('second file') - const sentInput = agentSessionPresenter.sendMessage.mock.calls[0][1] as { + const sentInput = vi.mocked(sessionPorts.turn.sendMessage).mock.calls[0][1] as { files: Array<{ name: string path: string @@ -385,12 +437,11 @@ describe('RemoteConversationRunner', () => { id: 'session-bound', projectDir: workspace }) - const agentSessionPresenter = { - getSession: vi.fn().mockResolvedValue(session), - getMessages: vi.fn().mockResolvedValue([]), - sendMessage: vi.fn().mockResolvedValue(undefined), - getMessage: vi.fn().mockResolvedValue(null) - } + const sessionPorts = createRemoteSessionPorts({ + projection: { + getSession: vi.fn().mockResolvedValue(session) + } + }) const filePresenter = { prepareFile: vi.fn(async (filePath: string, mimeType: string) => ({ ...preparedFile, @@ -401,7 +452,7 @@ describe('RemoteConversationRunner', () => { const runner = new RemoteConversationRunner( { configPresenter: createConfigPresenter() as any, - agentSessionPresenter: agentSessionPresenter as any, + ...sessionPorts, filePresenter: filePresenter as any, agentManager: { getActiveGeneration: vi.fn().mockReturnValue(null) @@ -451,7 +502,7 @@ describe('RemoteConversationRunner', () => { expect(preparedPath).toContain('telegram-message-2') expect(path.basename(preparedPath)).toBe('note-1.txt') await expect(fs.readFile(preparedPath, 'utf8')).resolves.toBe('hello file') - expect(agentSessionPresenter.sendMessage).toHaveBeenCalledWith('session-bound', { + expect(sessionPorts.turn.sendMessage).toHaveBeenCalledWith('session-bound', { text: 'read this', files: [ expect.objectContaining({ @@ -478,16 +529,15 @@ describe('RemoteConversationRunner', () => { id: 'session-bound', projectDir: workspace }) - const agentSessionPresenter = { - getSession: vi.fn().mockResolvedValue(session), - getMessages: vi.fn().mockResolvedValue([]), - sendMessage: vi.fn().mockResolvedValue(undefined), - getMessage: vi.fn().mockResolvedValue(null) - } + const sessionPorts = createRemoteSessionPorts({ + projection: { + getSession: vi.fn().mockResolvedValue(session) + } + }) const runner = new RemoteConversationRunner( { configPresenter: createConfigPresenter() as any, - agentSessionPresenter: agentSessionPresenter as any, + ...sessionPorts, agentManager: { getActiveGeneration: vi.fn().mockReturnValue(null) } as any, @@ -526,7 +576,7 @@ describe('RemoteConversationRunner', () => { ] }) ).rejects.toThrow('All attachments failed validation/download.') - expect(agentSessionPresenter.sendMessage).not.toHaveBeenCalled() + expect(sessionPorts.turn.sendMessage).not.toHaveBeenCalled() expect(warnSpy).toHaveBeenCalledTimes(2) } finally { warnSpy.mockRestore() @@ -558,12 +608,11 @@ describe('RemoteConversationRunner', () => { id: 'session-bound', projectDir: workspace }) - const agentSessionPresenter = { - getSession: vi.fn().mockResolvedValue(session), - getMessages: vi.fn().mockResolvedValue([]), - sendMessage: vi.fn().mockResolvedValue(undefined), - getMessage: vi.fn().mockResolvedValue(null) - } + const sessionPorts = createRemoteSessionPorts({ + projection: { + getSession: vi.fn().mockResolvedValue(session) + } + }) const filePresenter = { prepareFile: vi.fn(async (filePath: string, mimeType: string) => ({ ...preparedFile, @@ -574,7 +623,7 @@ describe('RemoteConversationRunner', () => { const runner = new RemoteConversationRunner( { configPresenter: createConfigPresenter() as any, - agentSessionPresenter: agentSessionPresenter as any, + ...sessionPorts, filePresenter: filePresenter as any, agentManager: { getActiveGeneration: vi.fn().mockReturnValue(null) @@ -621,7 +670,7 @@ describe('RemoteConversationRunner', () => { } ) await expect(fs.readFile(preparedPath)).resolves.toEqual(plainContent) - expect(agentSessionPresenter.sendMessage).toHaveBeenCalledWith('session-bound', { + expect(sessionPorts.turn.sendMessage).toHaveBeenCalledWith('session-bound', { text: 'read this image', files: [ expect.objectContaining({ @@ -745,17 +794,17 @@ describe('RemoteConversationRunner', () => { id: 'session-bound', projectDir: workspace }) - const agentSessionPresenter = { - getSession: vi.fn().mockResolvedValue(session), - getMessages: vi.fn().mockResolvedValueOnce([]).mockResolvedValue([assistantMessage]), - sendMessage: vi.fn().mockResolvedValue(undefined), - getMessage: vi.fn().mockResolvedValue(assistantMessage), - getSearchResults: vi.fn().mockResolvedValue([]) - } + const sessionPorts = createRemoteSessionPorts({ + projection: { + getSession: vi.fn().mockResolvedValue(session), + getMessages: vi.fn().mockResolvedValueOnce([]).mockResolvedValue([assistantMessage]), + getMessage: vi.fn().mockResolvedValue(assistantMessage) + } + }) const runner = new RemoteConversationRunner( { configPresenter: createConfigPresenter() as any, - agentSessionPresenter: agentSessionPresenter as any, + ...sessionPorts, agentManager: { getActiveGeneration: vi.fn().mockReturnValue(null) } as any, @@ -840,17 +889,17 @@ describe('RemoteConversationRunner', () => { id: 'session-bound', projectDir: null }) - const agentSessionPresenter = { - getSession: vi.fn().mockResolvedValue(session), - getMessages: vi.fn().mockResolvedValueOnce([]).mockResolvedValue([assistantMessage]), - sendMessage: vi.fn().mockResolvedValue(undefined), - getMessage: vi.fn().mockResolvedValue(assistantMessage), - getSearchResults: vi.fn().mockResolvedValue([]) - } + const sessionPorts = createRemoteSessionPorts({ + projection: { + getSession: vi.fn().mockResolvedValue(session), + getMessages: vi.fn().mockResolvedValueOnce([]).mockResolvedValue([assistantMessage]), + getMessage: vi.fn().mockResolvedValue(assistantMessage) + } + }) const runner = new RemoteConversationRunner( { configPresenter: createConfigPresenter() as any, - agentSessionPresenter: agentSessionPresenter as any, + ...sessionPorts, agentManager: { getActiveGeneration: vi.fn().mockReturnValue(null) } as any, @@ -887,26 +936,28 @@ describe('RemoteConversationRunner', () => { }) it('lists recent sessions for the currently bound agent before falling back to default agent', async () => { - const agentSessionPresenter = { - getSession: vi.fn().mockResolvedValue( - createSession({ - id: 'session-bound', - agentId: 'deepchat-bound' - }) - ), - getSessionList: vi.fn().mockResolvedValue([ - createSession({ - id: 'session-a', - agentId: 'deepchat-bound', - updatedAt: 5 - }), - createSession({ - id: 'session-b', - agentId: 'deepchat-bound', - updatedAt: 10 - }) - ]) - } + const sessionPorts = createRemoteSessionPorts({ + projection: { + getSession: vi.fn().mockResolvedValue( + createSession({ + id: 'session-bound', + agentId: 'deepchat-bound' + }) + ), + listSessions: vi.fn().mockResolvedValue([ + createSession({ + id: 'session-a', + agentId: 'deepchat-bound', + updatedAt: 5 + }), + createSession({ + id: 'session-b', + agentId: 'deepchat-bound', + updatedAt: 10 + }) + ]) + } + }) const bindingStore = { getBinding: vi.fn().mockReturnValue({ sessionId: 'session-bound', @@ -917,7 +968,7 @@ describe('RemoteConversationRunner', () => { const runner = new RemoteConversationRunner( { configPresenter: createConfigPresenter() as any, - agentSessionPresenter: agentSessionPresenter as any, + ...sessionPorts, agentManager: {} as any, windowPresenter: {} as any, tabPresenter: {} as any, @@ -928,7 +979,7 @@ describe('RemoteConversationRunner', () => { const sessions = await runner.listSessions('telegram:100:0') - expect(agentSessionPresenter.getSessionList).toHaveBeenCalledWith({ + expect(sessionPorts.projection.listSessions).toHaveBeenCalledWith({ agentId: 'deepchat-bound' }) expect(sessions.map((session) => session.id)).toEqual(['session-b', 'session-a']) @@ -939,26 +990,30 @@ describe('RemoteConversationRunner', () => { }) it('delegates remote model switching to the bound session', async () => { - const agentSessionPresenter = { - getSession: vi.fn().mockResolvedValue( - createSession({ - id: 'session-bound', - agentId: 'deepchat-bound' - }) - ), - setSessionModel: vi.fn().mockResolvedValue( - createSession({ - id: 'session-bound', - agentId: 'deepchat-bound', - providerId: 'anthropic', - modelId: 'claude-3-5-sonnet' - }) - ) - } + const sessionPorts = createRemoteSessionPorts({ + projection: { + getSession: vi.fn().mockResolvedValue( + createSession({ + id: 'session-bound', + agentId: 'deepchat-bound' + }) + ) + }, + assignment: { + setSessionModel: vi.fn().mockResolvedValue( + createSession({ + id: 'session-bound', + agentId: 'deepchat-bound', + providerId: 'anthropic', + modelId: 'claude-3-5-sonnet' + }) + ) + } + }) const runner = new RemoteConversationRunner( { configPresenter: createConfigPresenter() as any, - agentSessionPresenter: agentSessionPresenter as any, + ...sessionPorts, agentManager: {} as any, windowPresenter: {} as any, tabPresenter: {} as any, @@ -974,7 +1029,7 @@ describe('RemoteConversationRunner', () => { const updated = await runner.setSessionModel('telegram:100:0', 'anthropic', 'claude-3-5-sonnet') - expect(agentSessionPresenter.setSessionModel).toHaveBeenCalledWith( + expect(sessionPorts.assignment.setSessionModel).toHaveBeenCalledWith( 'session-bound', 'anthropic', 'claude-3-5-sonnet' @@ -987,9 +1042,7 @@ describe('RemoteConversationRunner', () => { const runner = new RemoteConversationRunner( { configPresenter: createConfigPresenter() as any, - agentSessionPresenter: { - getSession: vi.fn() - } as any, + ...createRemoteSessionPorts(), agentManager: {} as any, windowPresenter: { getAllWindows: vi.fn(), @@ -1013,16 +1066,18 @@ describe('RemoteConversationRunner', () => { }) it('returns windowNotFound when /open cannot resolve a desktop chat window', async () => { - const activateSession = vi.fn() + const activate = vi.fn() const createAppWindow = vi.fn().mockResolvedValue(null) const show = vi.fn() const runner = new RemoteConversationRunner( { configPresenter: createConfigPresenter() as any, - agentSessionPresenter: { - getSession: vi.fn().mockResolvedValue(createSession()), - activateSession - } as any, + ...createRemoteSessionPorts({ + projection: { + getSession: vi.fn().mockResolvedValue(createSession()), + activate + } + }), agentManager: {} as any, windowPresenter: { getAllWindows: vi.fn().mockReturnValue([]), @@ -1047,7 +1102,7 @@ describe('RemoteConversationRunner', () => { await expect(runner.open('telegram:100:0')).resolves.toEqual({ status: 'windowNotFound' }) - expect(activateSession).not.toHaveBeenCalled() + expect(activate).not.toHaveBeenCalled() expect(show).not.toHaveBeenCalled() expect(createAppWindow).toHaveBeenCalledWith({ initialRoute: 'chat' @@ -1056,7 +1111,7 @@ describe('RemoteConversationRunner', () => { it('returns ok and activates the bound session when /open resolves a chat window', async () => { const session = createSession() - const activateSession = vi.fn().mockResolvedValue(undefined) + const activate = vi.fn().mockResolvedValue(undefined) const show = vi.fn() const chatWindow = { id: 7, @@ -1068,10 +1123,12 @@ describe('RemoteConversationRunner', () => { const runner = new RemoteConversationRunner( { configPresenter: createConfigPresenter() as any, - agentSessionPresenter: { - getSession: vi.fn().mockResolvedValue(session), - activateSession - } as any, + ...createRemoteSessionPorts({ + projection: { + getSession: vi.fn().mockResolvedValue(session), + activate + } + }), agentManager: {} as any, windowPresenter: { getAllWindows: vi.fn().mockReturnValue([chatWindow]), @@ -1097,7 +1154,7 @@ describe('RemoteConversationRunner', () => { status: 'ok', session }) - expect(activateSession).toHaveBeenCalledWith(70, 'session-1') + expect(activate).toHaveBeenCalledWith(70, 'session-1') expect(show).toHaveBeenCalledWith(7, true) }) @@ -1110,10 +1167,11 @@ describe('RemoteConversationRunner', () => { const runner = new RemoteConversationRunner( { configPresenter: createConfigPresenter() as any, - agentSessionPresenter: { - getSession: vi.fn().mockResolvedValue(session), - getMessages: vi.fn().mockResolvedValue([]) - } as any, + ...createRemoteSessionPorts({ + projection: { + getSession: vi.fn().mockResolvedValue(session) + } + }), agentManager: { getActiveGeneration: vi.fn().mockReturnValue({ eventId: 'manager-event', @@ -1149,9 +1207,7 @@ describe('RemoteConversationRunner', () => { const runner = new RemoteConversationRunner( { configPresenter: createConfigPresenter() as any, - agentSessionPresenter: { - getSession: vi.fn().mockResolvedValue(null) - } as any, + ...createRemoteSessionPorts(), agentManager: { getActiveGeneration: vi.fn().mockReturnValue(null) } as any, @@ -1200,23 +1256,23 @@ describe('RemoteConversationRunner', () => { orderSeq: 2 } - const agentSessionPresenter = { - getSession: vi.fn().mockResolvedValue(session), - getMessages: vi - .fn() - .mockResolvedValueOnce([ - { - id: 'user-1', - role: 'user', - content: 'hello', - status: 'success', - orderSeq: 1 - } - ]) - .mockResolvedValue([oldAssistantMessage]), - sendMessage: vi.fn().mockResolvedValue(undefined), - getMessage: vi.fn().mockResolvedValue(null) - } + const sessionPorts = createRemoteSessionPorts({ + projection: { + getSession: vi.fn().mockResolvedValue(session), + getMessages: vi + .fn() + .mockResolvedValueOnce([ + { + id: 'user-1', + role: 'user', + content: 'hello', + status: 'success', + orderSeq: 1 + } + ]) + .mockResolvedValue([oldAssistantMessage]) + } + }) const bindingStore = { getBinding: vi.fn().mockReturnValue({ sessionId: 'session-legacy', @@ -1239,7 +1295,7 @@ describe('RemoteConversationRunner', () => { const runner = new RemoteConversationRunner( { configPresenter: createConfigPresenter() as any, - agentSessionPresenter: agentSessionPresenter as any, + ...sessionPorts, agentManager: agentManager as any, windowPresenter: {} as any, tabPresenter: {} as any, @@ -1278,51 +1334,52 @@ describe('RemoteConversationRunner', () => { const runner = new RemoteConversationRunner( { configPresenter: createConfigPresenter() as any, - agentSessionPresenter: { - getSession: vi.fn().mockResolvedValue(createSession()), - sendMessage: vi.fn().mockResolvedValue(undefined), - getMessages: vi.fn().mockResolvedValue([ - { - id: 'assistant-1', - role: 'assistant', - orderSeq: 2, - content: JSON.stringify([ - { - type: 'content', - content: 'Need approval before continuing.', - status: 'success', - timestamp: 1 - }, - { - type: 'action', - action_type: 'tool_call_permission', - content: 'Permission requested', - status: 'pending', - timestamp: 2, - tool_call: { - id: 'tool-1', - name: 'shell_command', - params: '{"command":"git push"}' + ...createRemoteSessionPorts({ + projection: { + getSession: vi.fn().mockResolvedValue(createSession()), + getMessages: vi.fn().mockResolvedValue([ + { + id: 'assistant-1', + role: 'assistant', + orderSeq: 2, + content: JSON.stringify([ + { + type: 'content', + content: 'Need approval before continuing.', + status: 'success', + timestamp: 1 }, - extra: { - needsUserAction: true, - permissionType: 'command', - permissionRequest: JSON.stringify({ + { + type: 'action', + action_type: 'tool_call_permission', + content: 'Permission requested', + status: 'pending', + timestamp: 2, + tool_call: { + id: 'tool-1', + name: 'shell_command', + params: '{"command":"git push"}' + }, + extra: { + needsUserAction: true, permissionType: 'command', - description: 'Run git push', - command: 'git push', - commandInfo: { + permissionRequest: JSON.stringify({ + permissionType: 'command', + description: 'Run git push', command: 'git push', - riskLevel: 'high', - suggestion: 'Confirm before pushing.' - } - }) + commandInfo: { + command: 'git push', + riskLevel: 'high', + suggestion: 'Confirm before pushing.' + } + }) + } } - } - ]) - } - ]) - } as any, + ]) + } + ]) + } + }), agentManager: { getActiveGeneration: vi.fn().mockReturnValue(null) } as any, @@ -1373,10 +1430,26 @@ describe('RemoteConversationRunner', () => { const runner = new RemoteConversationRunner( { configPresenter: createConfigPresenter() as any, - agentSessionPresenter: { - getSession: vi.fn().mockResolvedValue(createSession()), - getMessages: vi.fn().mockResolvedValue([ - { + ...createRemoteSessionPorts({ + projection: { + getSession: vi.fn().mockResolvedValue(createSession()), + getMessages: vi.fn().mockResolvedValue([ + { + id: 'assistant-1', + role: 'assistant', + orderSeq: 2, + status: 'pending', + content: JSON.stringify([ + { + type: 'reasoning_content', + content: 'Thinking now', + status: 'pending', + timestamp: 1 + } + ]) + } + ]), + getMessage: vi.fn().mockResolvedValue({ id: 'assistant-1', role: 'assistant', orderSeq: 2, @@ -1389,23 +1462,9 @@ describe('RemoteConversationRunner', () => { timestamp: 1 } ]) - } - ]), - getMessage: vi.fn().mockResolvedValue({ - id: 'assistant-1', - role: 'assistant', - orderSeq: 2, - status: 'pending', - content: JSON.stringify([ - { - type: 'reasoning_content', - content: 'Thinking now', - status: 'pending', - timestamp: 1 - } - ]) - }) - } as any, + }) + } + }), agentManager: { getActiveGeneration: vi.fn().mockReturnValue({ eventId: 'assistant-1', @@ -1444,26 +1503,28 @@ describe('RemoteConversationRunner', () => { const runner = new RemoteConversationRunner( { configPresenter: createConfigPresenter() as any, - agentSessionPresenter: { - getSession: vi.fn().mockResolvedValue(createSession()), - getMessage: vi.fn().mockResolvedValue({ - id: 'assistant-search', - role: 'assistant', - orderSeq: 2, - status: 'success', - content: JSON.stringify([ - { - id: 'search-1', - type: 'search', - content: '', - status: 'success', - timestamp: 1, - extra: { searchId: 'stored-search', label: 'web_search' } - } - ]) - }), - getSearchResults - } as any, + ...createRemoteSessionPorts({ + projection: { + getSession: vi.fn().mockResolvedValue(createSession()), + getMessage: vi.fn().mockResolvedValue({ + id: 'assistant-search', + role: 'assistant', + orderSeq: 2, + status: 'success', + content: JSON.stringify([ + { + id: 'search-1', + type: 'search', + content: '', + status: 'success', + timestamp: 1, + extra: { searchId: 'stored-search', label: 'web_search' } + } + ]) + }), + getSearchResults + } + }), agentManager: { getActiveGeneration: vi.fn().mockReturnValue(null) } as any, @@ -1513,62 +1574,66 @@ describe('RemoteConversationRunner', () => { } ]) }) - const agentSessionPresenter = { - getSession: vi.fn().mockResolvedValue(createSession()), - getMessages: vi - .fn() - .mockResolvedValueOnce([ - { - id: 'assistant-2', - role: 'assistant', - orderSeq: 5, - content: JSON.stringify([ - { - type: 'action', - action_type: 'tool_call_permission', - content: 'Permission requested', - status: 'pending', - timestamp: 1, - tool_call: { - id: 'tool-2', - name: 'shell_command', - params: '{"command":"git push"}' - }, - extra: { - needsUserAction: true, - permissionType: 'command', - permissionRequest: JSON.stringify({ + const sessionPorts = createRemoteSessionPorts({ + projection: { + getSession: vi.fn().mockResolvedValue(createSession()), + getMessages: vi + .fn() + .mockResolvedValueOnce([ + { + id: 'assistant-2', + role: 'assistant', + orderSeq: 5, + content: JSON.stringify([ + { + type: 'action', + action_type: 'tool_call_permission', + content: 'Permission requested', + status: 'pending', + timestamp: 1, + tool_call: { + id: 'tool-2', + name: 'shell_command', + params: '{"command":"git push"}' + }, + extra: { + needsUserAction: true, permissionType: 'command', - description: 'Run git push', - command: 'git push' - }) + permissionRequest: JSON.stringify({ + permissionType: 'command', + description: 'Run git push', + command: 'git push' + }) + } } - } - ]) - } - ]) - .mockResolvedValue([ - { - id: 'assistant-2', - role: 'assistant', - orderSeq: 5, - status: 'success', - content: JSON.stringify([ - { - type: 'content', - content: 'Push completed.', - status: 'success', - timestamp: 2 - } - ]) - } - ]), - respondToolInteraction: vi.fn().mockResolvedValue({ - resumed: true, - waitingForUserMessage: false - }), - getMessage - } + ]) + } + ]) + .mockResolvedValue([ + { + id: 'assistant-2', + role: 'assistant', + orderSeq: 5, + status: 'success', + content: JSON.stringify([ + { + type: 'content', + content: 'Push completed.', + status: 'success', + timestamp: 2 + } + ]) + } + ]), + getMessage + }, + turn: { + respondToolInteraction: vi.fn().mockResolvedValue({ + resumed: true, + waitingForUserMessage: false + }) + } + }) const bindingStore = { getBinding: vi.fn().mockReturnValue({ sessionId: 'session-1', @@ -1580,7 +1645,7 @@ describe('RemoteConversationRunner', () => { const runner = new RemoteConversationRunner( { configPresenter: createConfigPresenter() as any, - agentSessionPresenter: agentSessionPresenter as any, + ...sessionPorts, agentManager: { getActiveGeneration: vi.fn().mockReturnValue(null) } as any, @@ -1596,7 +1661,7 @@ describe('RemoteConversationRunner', () => { granted: true }) - expect(agentSessionPresenter.respondToolInteraction).toHaveBeenCalledWith( + expect(sessionPorts.turn.respondToolInteraction).toHaveBeenCalledWith( 'session-1', 'assistant-2', 'tool-2', @@ -1649,9 +1714,7 @@ describe('RemoteConversationRunner', () => { configPresenter: createConfigPresenter({ getDefaultProjectPath: vi.fn(() => '/workspaces/remote') }) as any, - agentSessionPresenter: { - createDetachedSession - } as any, + ...createRemoteSessionPorts({ lifecycle: { createDetachedSession } }), agentManager: {} as any, windowPresenter: {} as any, tabPresenter: {} as any, @@ -1680,7 +1743,7 @@ describe('RemoteConversationRunner', () => { configPresenter: createConfigPresenter({ getDefaultProjectPath: vi.fn(() => '/workspaces/global') }) as any, - agentSessionPresenter: {} as any, + ...createRemoteSessionPorts(), agentManager: {} as any, windowPresenter: {} as any, tabPresenter: {} as any, @@ -1700,7 +1763,7 @@ describe('RemoteConversationRunner', () => { configPresenter: createConfigPresenter({ getDefaultProjectPath: vi.fn(() => '/workspaces/global') }) as any, - agentSessionPresenter: {} as any, + ...createRemoteSessionPorts(), agentManager: {} as any, windowPresenter: {} as any, tabPresenter: {} as any, @@ -1718,9 +1781,7 @@ describe('RemoteConversationRunner', () => { const runner = new RemoteConversationRunner( { configPresenter: createConfigPresenter() as any, - agentSessionPresenter: { - createDetachedSession: vi.fn() - } as any, + ...createRemoteSessionPorts(), agentManager: {} as any, windowPresenter: {} as any, tabPresenter: {} as any, @@ -1747,7 +1808,7 @@ describe('RemoteConversationRunner', () => { const runner = new RemoteConversationRunner( { configPresenter: configPresenter as any, - agentSessionPresenter: {} as any, + ...createRemoteSessionPorts(), agentManager: {} as any, windowPresenter: {} as any, tabPresenter: {} as any, @@ -1778,9 +1839,7 @@ describe('RemoteConversationRunner', () => { const runner = new RemoteConversationRunner( { configPresenter: configPresenter as any, - agentSessionPresenter: { - createDetachedSession - } as any, + ...createRemoteSessionPorts({ lifecycle: { createDetachedSession } }), agentManager: {} as any, windowPresenter: {} as any, tabPresenter: {} as any, @@ -1810,7 +1869,7 @@ describe('RemoteConversationRunner', () => { const runner = new RemoteConversationRunner( { configPresenter: configPresenter as any, - agentSessionPresenter: {} as any, + ...createRemoteSessionPorts(), agentManager: {} as any, windowPresenter: {} as any, tabPresenter: {} as any, @@ -1836,9 +1895,7 @@ describe('RemoteConversationRunner', () => { const runner = new RemoteConversationRunner( { configPresenter: configPresenter as any, - agentSessionPresenter: { - createDetachedSession: vi.fn() - } as any, + ...createRemoteSessionPorts(), agentManager: {} as any, windowPresenter: {} as any, tabPresenter: {} as any, diff --git a/test/main/routes/dispatcher.test.ts b/test/main/routes/dispatcher.test.ts index c1770c2b3..1c036450b 100644 --- a/test/main/routes/dispatcher.test.ts +++ b/test/main/routes/dispatcher.test.ts @@ -20,7 +20,6 @@ import type { ISkillSyncPresenter } from '@shared/presenter' import type { CronJob, CronJobRun } from '@shared/cronJobs' -import type { CronJobRunSessionStarter } from '@/presenter/cronJobs' import type { ProviderInstallPreview } from '@shared/providerDeeplink' import { createEmptyArchiveCandidateLifecyclePreview, @@ -1578,162 +1577,10 @@ describe('dispatchDeepchatRoute', () => { }) }) - it('wires Cron Job run sessions with source metadata', async () => { - const { cronJobs, agentSessionPresenter } = createRuntime() - const starter = vi.mocked(cronJobs.setRunSessionStarter).mock.calls[0]?.[0] as - | CronJobRunSessionStarter - | undefined + it('does not wire the Cron session starter from route runtime construction', () => { + const { cronJobs } = createRuntime() - expect(starter).toBeDefined() - await starter!.createSessionForRun({ - job: { - id: 'cron-1', - name: 'Morning job', - agentId: 'deepchat', - agentSnapshot: null, - modelPolicy: 'follow_agent', - permissionPolicy: 'follow_agent', - toolPolicy: 'follow_agent', - taskSystemInstruction: null - } as CronJob, - run: { - id: 'run-1', - scheduledAt: 123 - } as CronJobRun - }) - - expect(agentSessionPresenter.createDetachedSession).toHaveBeenCalledWith({ - agentId: 'deepchat', - title: 'Morning job', - metadata: { - source: 'cron_job', - cronJobId: 'cron-1', - cronJobRunId: 'run-1', - scheduledAt: 123 - } - }) - }) - - it('wires pinned Cron Job snapshots into detached sessions', async () => { - const { cronJobs, agentSessionPresenter } = createRuntime() - const starter = vi.mocked(cronJobs.setRunSessionStarter).mock.calls[0]?.[0] as - | CronJobRunSessionStarter - | undefined - - expect(starter).toBeDefined() - await starter!.createSessionForRun({ - job: { - id: 'cron-1', - name: 'Morning job', - agentId: 'deepchat', - agentSnapshot: { - version: 1, - capturedAt: 100, - agent: { id: 'deepchat', name: 'DeepChat', type: 'deepchat' }, - config: { - defaultModelPreset: { providerId: 'anthropic', modelId: 'claude-sonnet' }, - permissionMode: 'full_access', - disabledAgentTools: ['write_file'], - subagentEnabled: false, - systemPrompt: 'Snapshot system prompt' - } - }, - modelPolicy: 'pin_current', - permissionPolicy: 'snapshot', - toolPolicy: 'snapshot', - taskSystemInstruction: ' Task-specific system prompt ' - } as CronJob, - run: { - id: 'run-1', - scheduledAt: 123 - } as CronJobRun - }) - - expect(agentSessionPresenter.createDetachedSession).toHaveBeenCalledWith({ - agentId: 'deepchat', - title: 'Morning job', - providerId: 'anthropic', - modelId: 'claude-sonnet', - permissionMode: 'full_access', - disabledAgentTools: ['write_file'], - subagentEnabled: false, - generationSettings: { systemPrompt: 'Task-specific system prompt' }, - metadata: { - source: 'cron_job', - cronJobId: 'cron-1', - cronJobRunId: 'run-1', - scheduledAt: 123 - } - }) - }) - - it('routes ACP Cron Job prompts through the agent-session direct-routing facade', async () => { - const { cronJobs, configPresenter, agentSessionPresenter } = createRuntime() - vi.mocked(configPresenter.getAgentType).mockResolvedValue('acp') - vi.mocked(agentSessionPresenter.sendMessage).mockResolvedValueOnce({ - requestId: 'request-2', - messageId: 'message-2' - }) - vi.mocked(agentSessionPresenter.createDetachedSession).mockResolvedValue({ - id: 'acp-session-1', - agentId: 'manual-acp', - title: 'ACP job', - projectDir: '/workspace', - isPinned: false, - isDraft: false, - sessionKind: 'regular', - parentSessionId: null, - subagentEnabled: false, - subagentMeta: null, - createdAt: 1, - updatedAt: 2, - status: 'idle', - providerId: 'acp', - modelId: 'manual-acp' - }) - const starter = vi.mocked(cronJobs.setRunSessionStarter).mock.calls[0]?.[0] as - | CronJobRunSessionStarter - | undefined - const job = { - id: 'cron-acp', - name: 'ACP job', - agentId: 'manual-acp', - agentSnapshot: null, - modelPolicy: 'follow_agent', - permissionPolicy: 'follow_agent', - toolPolicy: 'follow_agent', - taskSystemInstruction: null, - taskPrompt: 'Review the workspace', - runtime: { maxTurns: 7 } - } as CronJob - const run = { id: 'run-acp', scheduledAt: 123 } as CronJobRun - - await expect(starter!.createSessionForRun({ job, run })).resolves.toEqual({ - sessionId: 'acp-session-1' - }) - await expect( - starter!.startSessionRun({ job, run, sessionId: 'acp-session-1' }) - ).resolves.toEqual({ outputMessageId: 'message-2' }) - - expect(agentSessionPresenter.createDetachedSession).toHaveBeenCalledWith( - expect.objectContaining({ - agentId: 'manual-acp', - providerId: 'acp', - modelId: 'manual-acp' - }) - ) - expect(agentSessionPresenter.sendMessage).toHaveBeenCalledWith( - 'acp-session-1', - 'Review the workspace', - { maxProviderRounds: 7 } - ) - await starter!.cancelSessionRun?.({ - job, - run, - sessionId: 'acp-session-1', - reason: 'Cron job exceeded max duration.' - }) - expect(agentSessionPresenter.cancelGeneration).toHaveBeenCalledWith('acp-session-1') + expect(cronJobs.setRunSessionStarter).not.toHaveBeenCalled() }) it('reconciles Cron Jobs after agent mutation routes', async () => { From 1cceca6ee480b50774b92d547ef1a73fc912e471 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 13 Jul 2026 20:08:37 +0800 Subject: [PATCH 15/29] test(architecture): guard session boundaries --- .../session-application-coordinators/tasks.md | 20 +- scripts/architecture-guard.mjs | 223 ++++++++++++++++++ test/main/scripts/architectureGuard.test.ts | 186 +++++++++++++++ 3 files changed, 419 insertions(+), 10 deletions(-) diff --git a/docs/architecture/session-application-coordinators/tasks.md b/docs/architecture/session-application-coordinators/tasks.md index 3987d8b89..ea5ccd660 100644 --- a/docs/architecture/session-application-coordinators/tasks.md +++ b/docs/architecture/session-application-coordinators/tasks.md @@ -58,19 +58,19 @@ ## 7. Remote and Cron -- [ ] Inject separate Lifecycle, Turn, Assignment, and Projection ports into Remote. -- [ ] Keep Remote active-generation lookup/cancel on `AgentManagerGenerationPort`. -- [ ] Replace untyped Remote presenter fixtures with typed port stubs. -- [ ] Build the Cron starter from Lifecycle/Turn in the composition root. -- [ ] Remove route-runtime starter side effects and startup route-runtime priming. -- [ ] Preserve Cron metadata, max-turn, output, status, timeout, and delivery semantics. +- [x] Inject separate Lifecycle, Turn, Assignment, and Projection ports into Remote. +- [x] Keep Remote active-generation lookup/cancel on `AgentManagerGenerationPort`. +- [x] Replace untyped Remote presenter fixtures with typed port stubs. +- [x] Build the Cron starter from Lifecycle/Turn in the composition root. +- [x] Remove route-runtime starter side effects and startup route-runtime priming. +- [x] Preserve Cron metadata, max-turn, output, status, timeout, and delivery semantics. ## 8. Façade and Enforcement -- [ ] Remove migrated implementation state/helpers/imports from `AgentSessionPresenter`. -- [ ] Keep stage-2 compatibility signatures and forwarding; do not retire the façade. -- [ ] Exhaust production/test searches for presenter dependencies in migrated consumers. -- [ ] Add architecture guards for consumer imports, duplicate construction, foreign-owner imports, +- [x] Remove migrated implementation state/helpers/imports from `AgentSessionPresenter`. +- [x] Keep stage-2 compatibility signatures and forwarding; do not retire the façade. +- [x] Exhaust production/test searches for presenter dependencies in migrated consumers. +- [x] Add architecture guards for consumer imports, duplicate construction, foreign-owner imports, and combined façade regression. - [ ] Update current architecture, session management, flows, and code navigation. - [ ] Review the dependency diff and regenerate maintained baselines only when intentional. diff --git a/scripts/architecture-guard.mjs b/scripts/architecture-guard.mjs index 196879eb4..b829d1d82 100644 --- a/scripts/architecture-guard.mjs +++ b/scripts/architecture-guard.mjs @@ -72,6 +72,48 @@ const AGENT_RUNTIME_PRESENTER_ROOT = path.join( const MEMORY_PRESENTER_ROOT = path.join(ROOT, 'src/main/presenter/memoryPresenter') const SQLITE_PRESENTER_ROOT = path.join(ROOT, 'src/main/presenter/sqlitePresenter') const PRESENTER_ROOT_ENTRY = path.join(ROOT, 'src/main/presenter/index.ts') +const SESSION_APPLICATION_ROOT = path.join(ROOT, 'src/main/presenter/sessionApplication') +const SESSION_APPLICATION_OWNER_PATHS = new Set( + [ + 'projectionCoordinator.ts', + 'agentAssignmentPolicy.ts', + 'agentAssignmentCoordinator.ts', + 'turnCoordinator.ts', + 'lifecycleCoordinator.ts', + 'lifecycleDeletionTransaction.ts' + ].map((fileName) => path.resolve(SESSION_APPLICATION_ROOT, fileName)) +) +const SESSION_APPLICATION_OWNER_NAMES = new Set([ + 'SessionProjectionCoordinator', + 'SessionAgentAssignmentPolicy', + 'SessionAgentAssignmentCoordinator', + 'SessionTurnCoordinator', + 'SessionLifecycleCoordinator', + 'SessionDeletionTransaction' +]) +const SESSION_MIGRATED_CONSUMER_PATHS = new Set( + [ + 'src/main/routes/sessions/sessionService.ts', + 'src/main/routes/chat/chatService.ts', + 'src/main/routes/hotPathPorts.ts', + 'src/main/presenter/remoteControlPresenter/interface.ts', + 'src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts', + 'src/main/presenter/cronJobs/runSessionStarter.ts', + 'src/main/presenter/lifecyclePresenter/hooks/after-start/cronJobsStartHook.ts' + ].map((fileName) => path.resolve(ROOT, fileName)) +) +const SESSION_COORDINATOR_WHOLE_DEPENDENCY_NAMES = new Set([ + 'Presenter', + 'IAgentSessionPresenter', + 'AgentSharedDataPorts', + 'SQLitePresenter' +]) +const SESSION_COMBINED_FACADE_NAMES = new Set([ + 'SessionApplicationServices', + 'SessionApplicationCoordinator' +]) +const SESSION_PHASE_ONE_FOREIGN_IMPORT_PATTERN = + /(?:^|[/_.-])(?:history|export(?:er)?|usage(?:[-_.]?stats?)?|rtk|legacy[-_.]?import(?:er|s)?|import(?:er|s)?|migrations?|translation|catalog)(?:$|[/_.-])/ const PHASE_ORDER = new Map([ ['P0', 0], ['P1', 1], @@ -206,6 +248,20 @@ function isUnder(targetPath, parentPath) { ) } +function isSessionMigratedConsumerPath(filePath) { + return ( + SESSION_MIGRATED_CONSUMER_PATHS.has(path.resolve(filePath)) || + path.basename(filePath).startsWith('__architecture_guard_session_consumer_') + ) +} + +function isSessionApplicationOwnerPath(filePath) { + return ( + SESSION_APPLICATION_OWNER_PATHS.has(path.resolve(filePath)) || + path.basename(filePath).startsWith('__architecture_guard_session_coordinator_') + ) +} + function isRendererQuarantineFile(filePath) { return RENDERER_QUARANTINE_ROOTS.some((quarantineRoot) => isUnder(filePath, quarantineRoot)) } @@ -272,6 +328,90 @@ function sourceFileForAst(source, filePath, scriptKind = ts.ScriptKind.TS) { ) } +function importRecordsFromSourceFile(sourceFile) { + const records = [] + + for (const statement of sourceFile.statements) { + if ( + !ts.isImportDeclaration(statement) || + !statement.importClause || + !ts.isStringLiteralLike(statement.moduleSpecifier) + ) { + continue + } + + const specifier = statement.moduleSpecifier.text + if (statement.importClause.name) { + records.push({ + specifier, + importedName: 'default', + localName: statement.importClause.name.text + }) + } + + const bindings = statement.importClause.namedBindings + if (bindings && ts.isNamespaceImport(bindings)) { + records.push({ specifier, importedName: '*', localName: bindings.name.text }) + continue + } + + if (bindings && ts.isNamedImports(bindings)) { + for (const element of bindings.elements) { + records.push({ + specifier, + importedName: element.propertyName?.text ?? element.name.text, + localName: element.name.text + }) + } + } + } + + return records +} + +function findIdentifierNames(sourceFile, names) { + const found = new Set() + const visit = (node) => { + if (ts.isIdentifier(node) && names.has(node.text)) found.add(node.text) + const member = accessMemberName(node) + if (member && names.has(member)) found.add(member) + ts.forEachChild(node, visit) + } + visit(sourceFile) + return found +} + +function findSessionApplicationOwnerConstructions(sourceFile, importRecords) { + const importedAliases = new Map() + for (const record of importRecords) { + if (SESSION_APPLICATION_OWNER_NAMES.has(record.importedName)) { + importedAliases.set(record.localName, record.importedName) + } + } + + const owners = new Set() + const visit = (node) => { + if (ts.isNewExpression(node)) { + const expression = unwrapExpression(node.expression) + if (ts.isIdentifier(expression)) { + const owner = importedAliases.get(expression.text) ?? expression.text + if (SESSION_APPLICATION_OWNER_NAMES.has(owner)) owners.add(owner) + } else { + const owner = accessMemberName(expression) + if (owner && SESSION_APPLICATION_OWNER_NAMES.has(owner)) owners.add(owner) + } + } + ts.forEachChild(node, visit) + } + visit(sourceFile) + return owners +} + +function isSessionPhaseOneForeignImport(value) { + const normalized = value.replaceAll(/([a-z\d])([A-Z])/g, '$1-$2').toLowerCase() + return SESSION_PHASE_ONE_FOREIGN_IMPORT_PATTERN.test(normalized) +} + function findNamedClassDeclarations(sourceFile, className) { const declarations = [] const visit = (node) => { @@ -995,6 +1135,89 @@ export async function runArchitectureGuard({ virtualFiles = new Map(), memoryCom for (const filePath of [...fileSet].sort()) { const source = await readSource(filePath) const specifiers = extractModuleSpecifiers(source) + const isMainSource = isUnder(filePath, MAIN_SOURCE_ROOT) + + if (isMainSource) { + const sourceFile = sourceFileForAst(source, filePath) + const importRecords = importRecordsFromSourceFile(sourceFile) + + if (isSessionMigratedConsumerPath(filePath)) { + const presenterDependencies = findIdentifierNames( + sourceFile, + new Set(['IAgentSessionPresenter', 'agentSessionPresenter']) + ) + if (presenterDependencies.has('IAgentSessionPresenter')) { + violations.push( + `[session-consumer-presenter-type] ${relativePath(filePath)} must not use IAgentSessionPresenter` + ) + } + if (presenterDependencies.has('agentSessionPresenter')) { + violations.push( + `[session-consumer-presenter-facade] ${relativePath(filePath)} must not use agentSessionPresenter` + ) + } + } + + if (path.resolve(filePath) !== path.resolve(PRESENTER_ROOT_ENTRY)) { + for (const owner of findSessionApplicationOwnerConstructions(sourceFile, importRecords)) { + violations.push( + `[session-application-duplicate-construction] ${relativePath(filePath)} constructs ${owner}; construct session application owners only in src/main/presenter/index.ts` + ) + } + } + + for (const facade of findIdentifierNames(sourceFile, SESSION_COMBINED_FACADE_NAMES)) { + violations.push( + `[session-application-combined-facade] ${relativePath(filePath)} contains ${facade}` + ) + } + + if (isSessionApplicationOwnerPath(filePath)) { + for (const specifier of specifiers) { + if (isSessionPhaseOneForeignImport(specifier)) { + violations.push( + `[session-coordinator-phase1-import] ${relativePath(filePath)} -> ${specifier}` + ) + } + } + + const wholeDependencies = findIdentifierNames( + sourceFile, + SESSION_COORDINATOR_WHOLE_DEPENDENCY_NAMES + ) + + for (const specifier of new Set(importRecords.map((record) => record.specifier))) { + const aggregateRecords = importRecords.filter( + (record) => + record.specifier === specifier && + (record.importedName === 'default' || + record.importedName === '*' || + record.importedName === 'presenter') + ) + if (aggregateRecords.length === 0) continue + + const resolved = await resolveImport( + specifier, + filePath, + MAIN_SOURCE_ROOT, + normalizedVirtualFiles + ) + if (!resolved) continue + if (path.resolve(resolved) === path.resolve(PRESENTER_ROOT_ENTRY)) { + wholeDependencies.add('Presenter') + } + if (isUnder(resolved, SQLITE_PRESENTER_ROOT)) { + wholeDependencies.add('SQLitePresenter') + } + } + + for (const dependency of wholeDependencies) { + violations.push( + `[session-coordinator-whole-dependency] ${relativePath(filePath)} imports ${dependency}` + ) + } + } + } if (isUnder(filePath, path.join(ROOT, 'src')) || isUnder(filePath, REGULAR_MAIN_TEST_ROOT)) { for (const [symbol, pattern] of RETIRED_AGENT_RUNTIME_PATTERNS) { diff --git a/test/main/scripts/architectureGuard.test.ts b/test/main/scripts/architectureGuard.test.ts index fd6e9bbac..8689159bf 100644 --- a/test/main/scripts/architectureGuard.test.ts +++ b/test/main/scripts/architectureGuard.test.ts @@ -107,6 +107,137 @@ const CAUSAL_OBSERVATION_ARROW_FIXTURE = path.join( ROOT, 'src/main/presenter/agentRuntimePresenter/__architecture_guard_causal_observation_arrow_fixture__.ts' ) +const SESSION_APPLICATION_ROOT = path.join(ROOT, 'src/main/presenter/sessionApplication') + +const SESSION_CONSUMER_FIXTURES = [ + { + filePath: path.join( + ROOT, + 'src/main/routes/__architecture_guard_session_consumer_presenter_type_fixture__.ts' + ), + rule: '[session-consumer-presenter-type]', + expected: 'IAgentSessionPresenter', + source: ` + import type { IAgentSessionPresenter as SessionPresenter } from '@shared/presenter' + export type Fixture = SessionPresenter + ` + }, + { + filePath: path.join( + ROOT, + 'src/main/routes/__architecture_guard_session_consumer_presenter_facade_fixture__.ts' + ), + rule: '[session-consumer-presenter-facade]', + expected: 'agentSessionPresenter', + source: ` + declare const presenter: Record + export const fixture = presenter['agentSessionPresenter'] + ` + } +] + +const SESSION_DUPLICATE_CONSTRUCTION_FIXTURES = [ + 'SessionProjectionCoordinator', + 'SessionAgentAssignmentPolicy', + 'SessionAgentAssignmentCoordinator', + 'SessionTurnCoordinator', + 'SessionLifecycleCoordinator', + 'SessionDeletionTransaction' +].map((owner) => ({ + filePath: path.join( + SESSION_APPLICATION_ROOT, + `__architecture_guard_duplicate_${owner}_fixture__.ts` + ), + rule: '[session-application-duplicate-construction]', + expected: owner, + source: + owner === 'SessionProjectionCoordinator' + ? ` + import { SessionProjectionCoordinator as ProjectionOwner } from './projectionCoordinator' + export const fixture = new ProjectionOwner() + ` + : `export const fixture = new ${owner}()` +})) + +const SESSION_FOREIGN_OWNER_FIXTURES = [ + ['history', './history'], + ['export', './exporter'], + ['usage', './usageStats'], + ['rtk', './rtkRuntimeService'], + ['import', './legacyImportService'], + ['migration', './migrations'], + ['translation', './translation'], + ['catalog', './catalog'] +].map(([owner, specifier]) => ({ + filePath: path.join( + SESSION_APPLICATION_ROOT, + `__architecture_guard_session_coordinator_phase1_${owner}_fixture__.ts` + ), + rule: '[session-coordinator-phase1-import]', + expected: specifier, + source: `import '${specifier}'\nexport const fixture = true` +})) + +const SESSION_WHOLE_DEPENDENCY_FIXTURES = [ + { + dependency: 'Presenter', + specifier: '../index', + localName: 'AppPresenter' + }, + { + dependency: 'IAgentSessionPresenter', + specifier: '@shared/presenter', + localName: 'SessionPresenter' + }, + { + dependency: 'AgentSharedDataPorts', + specifier: '@/agent/shared/agentSharedData', + localName: 'SharedDataPorts' + }, + { + dependency: 'SQLitePresenter', + specifier: '../sqlitePresenter', + localName: 'DatabasePresenter' + } +].map(({ dependency, specifier, localName }) => ({ + filePath: path.join( + SESSION_APPLICATION_ROOT, + `__architecture_guard_session_coordinator_whole_${dependency}_fixture__.ts` + ), + rule: '[session-coordinator-whole-dependency]', + expected: dependency, + source: ` + import type { ${dependency} as ${localName} } from '${specifier}' + export type Fixture = ${localName} + ` +})) + +const SESSION_COMBINED_FACADE_FIXTURES = [ + { + facade: 'SessionApplicationServices', + declaration: 'interface' + }, + { + facade: 'SessionApplicationCoordinator', + declaration: 'class' + } +].map(({ facade, declaration }) => ({ + filePath: path.join( + ROOT, + `src/main/presenter/__architecture_guard_combined_${facade}_fixture__.ts` + ), + rule: '[session-application-combined-facade]', + expected: facade, + source: `export ${declaration} ${facade} {}` +})) + +const SESSION_ARCHITECTURE_FIXTURES = [ + ...SESSION_CONSUMER_FIXTURES, + ...SESSION_DUPLICATE_CONSTRUCTION_FIXTURES, + ...SESSION_FOREIGN_OWNER_FIXTURES, + ...SESSION_WHOLE_DEPENDENCY_FIXTURES, + ...SESSION_COMBINED_FACADE_FIXTURES +] const retiredAgentRuntimeSymbols = [ ['IAgent', 'Implementation'].join(''), @@ -126,6 +257,7 @@ const kindAliasProperty = ['agent', 'Type'].join('') const typeProperty = ['ty', 'pe'].join('') const virtualFiles = new Map([ + ...SESSION_ARCHITECTURE_FIXTURES.map(({ filePath, source }) => [filePath, source] as const), [ SETTINGS_FIXTURE, ` @@ -434,6 +566,10 @@ function forFile(violations: string[], filePath: string): string[] { return violations.filter((violation) => violation.includes(relative)) } +function sessionViolationsForFile(violations: string[], filePath: string): string[] { + return forFile(violations, filePath).filter((violation) => violation.startsWith('[session-')) +} + const VALID_MEMORY_COORDINATOR_FIXTURE = ` interface MemoryInjectionAccessTurnEntry {} export class MemoryRuntimeCoordinator { @@ -487,6 +623,56 @@ describe('architecture guard', () => { expect(result.stdout).toContain('Architecture guard passed.') }) + it.each(SESSION_CONSUMER_FIXTURES)( + 'rejects migrated consumer dependency on $expected', + ({ filePath, rule, expected }) => { + const fixtureViolations = sessionViolationsForFile(violations, filePath) + expect(fixtureViolations).toHaveLength(1) + expect(fixtureViolations[0]).toContain(rule) + expect(fixtureViolations[0]).toContain(expected) + } + ) + + it.each(SESSION_DUPLICATE_CONSTRUCTION_FIXTURES)( + 'rejects duplicate construction of $expected', + ({ filePath, rule, expected }) => { + const fixtureViolations = sessionViolationsForFile(violations, filePath) + expect(fixtureViolations).toHaveLength(1) + expect(fixtureViolations[0]).toContain(rule) + expect(fixtureViolations[0]).toContain(expected) + } + ) + + it.each(SESSION_FOREIGN_OWNER_FIXTURES)( + 'keeps the $expected Phase-1 owner outside session coordinators', + ({ filePath, rule, expected }) => { + const fixtureViolations = sessionViolationsForFile(violations, filePath) + expect(fixtureViolations).toHaveLength(1) + expect(fixtureViolations[0]).toContain(rule) + expect(fixtureViolations[0]).toContain(expected) + } + ) + + it.each(SESSION_WHOLE_DEPENDENCY_FIXTURES)( + 'keeps the whole $expected dependency outside session coordinators', + ({ filePath, rule, expected }) => { + const fixtureViolations = sessionViolationsForFile(violations, filePath) + expect(fixtureViolations).toHaveLength(1) + expect(fixtureViolations[0]).toContain(rule) + expect(fixtureViolations[0]).toContain(expected) + } + ) + + it.each(SESSION_COMBINED_FACADE_FIXTURES)( + 'rejects the combined $expected facade', + ({ filePath, rule, expected }) => { + const fixtureViolations = sessionViolationsForFile(violations, filePath) + expect(fixtureViolations).toHaveLength(1) + expect(fixtureViolations[0]).toContain(rule) + expect(fixtureViolations[0]).toContain(expected) + } + ) + it('keeps renderer legacy boundaries enforced without writing source fixtures', () => { const fixtureViolations = forFile(violations, SETTINGS_FIXTURE).join('\n') expect(fixtureViolations).toContain('[renderer-business-direct-use-presenter-import]') From 862018a20d4058c6f01e6dfc54e71e6e285f6414 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 13 Jul 2026 20:22:45 +0800 Subject: [PATCH 16/29] fix(architecture): close session guard gaps --- scripts/architecture-guard.mjs | 136 +++++++++++++++++--- test/main/scripts/architectureGuard.test.ts | 133 ++++++++++++++----- 2 files changed, 217 insertions(+), 52 deletions(-) diff --git a/scripts/architecture-guard.mjs b/scripts/architecture-guard.mjs index b829d1d82..d49496ef5 100644 --- a/scripts/architecture-guard.mjs +++ b/scripts/architecture-guard.mjs @@ -96,6 +96,7 @@ const SESSION_MIGRATED_CONSUMER_PATHS = new Set( 'src/main/routes/sessions/sessionService.ts', 'src/main/routes/chat/chatService.ts', 'src/main/routes/hotPathPorts.ts', + 'src/main/presenter/remoteControlPresenter/index.ts', 'src/main/presenter/remoteControlPresenter/interface.ts', 'src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts', 'src/main/presenter/cronJobs/runSessionStarter.ts', @@ -112,6 +113,18 @@ const SESSION_COMBINED_FACADE_NAMES = new Set([ 'SessionApplicationServices', 'SessionApplicationCoordinator' ]) +const SESSION_FACADE_CAPABILITY_CATEGORIES = new Map([ + ['SessionLifecyclePort', 'lifecycle'], + ['SessionLifecycleCoordinator', 'lifecycle'], + ['SessionTurnPort', 'turn'], + ['SessionTurnCoordinator', 'turn'], + ['SessionAgentAssignmentPort', 'assignment'], + ['SessionAgentAssignmentCoordinator', 'assignment'], + ['SessionProjectionCoordinator', 'projection'], + ['SessionProjectionReadPort', 'projection'], + ['SessionProjectionMutationPort', 'projection'], + ['SessionWindowProjectionPort', 'projection'] +]) const SESSION_PHASE_ONE_FOREIGN_IMPORT_PATTERN = /(?:^|[/_.-])(?:history|export(?:er)?|usage(?:[-_.]?stats?)?|rtk|legacy[-_.]?import(?:er|s)?|import(?:er|s)?|migrations?|translation|catalog)(?:$|[/_.-])/ const PHASE_ORDER = new Map([ @@ -249,10 +262,7 @@ function isUnder(targetPath, parentPath) { } function isSessionMigratedConsumerPath(filePath) { - return ( - SESSION_MIGRATED_CONSUMER_PATHS.has(path.resolve(filePath)) || - path.basename(filePath).startsWith('__architecture_guard_session_consumer_') - ) + return SESSION_MIGRATED_CONSUMER_PATHS.has(path.resolve(filePath)) } function isSessionApplicationOwnerPath(filePath) { @@ -381,30 +391,107 @@ function findIdentifierNames(sourceFile, names) { return found } +function resolveSessionApplicationOwner(expression, aliases) { + const unwrapped = unwrapExpression(expression) + if (ts.isIdentifier(unwrapped)) { + const owner = aliases.get(unwrapped.text) ?? unwrapped.text + return SESSION_APPLICATION_OWNER_NAMES.has(owner) ? owner : null + } + + const owner = accessMemberName(unwrapped) + return owner && SESSION_APPLICATION_OWNER_NAMES.has(owner) ? owner : null +} + function findSessionApplicationOwnerConstructions(sourceFile, importRecords) { - const importedAliases = new Map() + const aliases = new Map() for (const record of importRecords) { if (SESSION_APPLICATION_OWNER_NAMES.has(record.importedName)) { - importedAliases.set(record.localName, record.importedName) + aliases.set(record.localName, record.importedName) + } + } + + const constDeclarations = [] + const collectAliases = (node) => { + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.initializer && + ts.isVariableDeclarationList(node.parent) && + (node.parent.flags & ts.NodeFlags.Const) !== 0 + ) { + constDeclarations.push(node) } + ts.forEachChild(node, collectAliases) } + collectAliases(sourceFile) - const owners = new Set() + let changed = true + while (changed) { + changed = false + for (const declaration of constDeclarations) { + const owner = resolveSessionApplicationOwner(declaration.initializer, aliases) + if (owner && aliases.get(declaration.name.text) !== owner) { + aliases.set(declaration.name.text, owner) + changed = true + } + } + } + + const constructions = new Map() const visit = (node) => { if (ts.isNewExpression(node)) { - const expression = unwrapExpression(node.expression) - if (ts.isIdentifier(expression)) { - const owner = importedAliases.get(expression.text) ?? expression.text - if (SESSION_APPLICATION_OWNER_NAMES.has(owner)) owners.add(owner) - } else { - const owner = accessMemberName(expression) - if (owner && SESSION_APPLICATION_OWNER_NAMES.has(owner)) owners.add(owner) + const owner = resolveSessionApplicationOwner(node.expression, aliases) + if (owner) { + constructions.set(owner, (constructions.get(owner) ?? 0) + 1) } } ts.forEachChild(node, visit) } visit(sourceFile) - return owners + return constructions +} + +function findCombinedSessionFacadeDeclarations(sourceFile, importRecords) { + const aliases = new Map( + importRecords + .filter((record) => SESSION_FACADE_CAPABILITY_CATEGORIES.has(record.importedName)) + .map((record) => [record.localName, record.importedName]) + ) + const facades = [] + + const capabilityCategories = (nodes) => { + const categories = new Set() + const visit = (node) => { + if (ts.isIdentifier(node)) { + const name = aliases.get(node.text) ?? node.text + const category = SESSION_FACADE_CAPABILITY_CATEGORIES.get(name) + if (category) categories.add(category) + } + ts.forEachChild(node, visit) + } + for (const node of nodes) visit(node) + return categories + } + + const visit = (node) => { + let name = null + let structure = [] + if (ts.isInterfaceDeclaration(node)) { + name = node.name.text + structure = [...(node.heritageClauses ?? []), ...node.members] + } else if (ts.isTypeAliasDeclaration(node)) { + name = node.name.text + structure = [node.type] + } else if (ts.isClassDeclaration(node) && node.name) { + name = node.name.text + structure = [...(node.heritageClauses ?? [])] + } + + if (name && capabilityCategories(structure).size === 4) facades.push(name) + ts.forEachChild(node, visit) + } + visit(sourceFile) + return facades } function isSessionPhaseOneForeignImport(value) { @@ -1158,15 +1245,24 @@ export async function runArchitectureGuard({ virtualFiles = new Map(), memoryCom } } - if (path.resolve(filePath) !== path.resolve(PRESENTER_ROOT_ENTRY)) { - for (const owner of findSessionApplicationOwnerConstructions(sourceFile, importRecords)) { + const allowedOwnerConstructions = + path.resolve(filePath) === path.resolve(PRESENTER_ROOT_ENTRY) ? 1 : 0 + for (const [owner, count] of findSessionApplicationOwnerConstructions( + sourceFile, + importRecords + )) { + if (count > allowedOwnerConstructions) { violations.push( - `[session-application-duplicate-construction] ${relativePath(filePath)} constructs ${owner}; construct session application owners only in src/main/presenter/index.ts` + `[session-application-duplicate-construction] ${relativePath(filePath)} constructs ${owner} ${count} times; expected <= ${allowedOwnerConstructions}` ) } } - for (const facade of findIdentifierNames(sourceFile, SESSION_COMBINED_FACADE_NAMES)) { + const combinedFacades = findIdentifierNames(sourceFile, SESSION_COMBINED_FACADE_NAMES) + for (const facade of findCombinedSessionFacadeDeclarations(sourceFile, importRecords)) { + combinedFacades.add(facade) + } + for (const facade of combinedFacades) { violations.push( `[session-application-combined-facade] ${relativePath(filePath)} contains ${facade}` ) @@ -1180,7 +1276,9 @@ export async function runArchitectureGuard({ virtualFiles = new Map(), memoryCom ) } } + } + if (isUnder(filePath, SESSION_APPLICATION_ROOT)) { const wholeDependencies = findIdentifierNames( sourceFile, SESSION_COORDINATOR_WHOLE_DEPENDENCY_NAMES diff --git a/test/main/scripts/architectureGuard.test.ts b/test/main/scripts/architectureGuard.test.ts index 8689159bf..8d915a5ea 100644 --- a/test/main/scripts/architectureGuard.test.ts +++ b/test/main/scripts/architectureGuard.test.ts @@ -107,26 +107,26 @@ const CAUSAL_OBSERVATION_ARROW_FIXTURE = path.join( ROOT, 'src/main/presenter/agentRuntimePresenter/__architecture_guard_causal_observation_arrow_fixture__.ts' ) +const PRESENTER_ROOT_ENTRY = path.join(ROOT, 'src/main/presenter/index.ts') const SESSION_APPLICATION_ROOT = path.join(ROOT, 'src/main/presenter/sessionApplication') +const SESSION_APPLICATION_PORTS_PATH = path.join(SESSION_APPLICATION_ROOT, 'ports.ts') +const REMOTE_CONTROL_PRESENTER_PATH = path.join( + ROOT, + 'src/main/presenter/remoteControlPresenter/index.ts' +) -const SESSION_CONSUMER_FIXTURES = [ +const SESSION_PRODUCTION_CONSUMER_FIXTURES = [ { - filePath: path.join( - ROOT, - 'src/main/routes/__architecture_guard_session_consumer_presenter_type_fixture__.ts' - ), + filePath: REMOTE_CONTROL_PRESENTER_PATH, rule: '[session-consumer-presenter-type]', expected: 'IAgentSessionPresenter', source: ` - import type { IAgentSessionPresenter as SessionPresenter } from '@shared/presenter' - export type Fixture = SessionPresenter + import type { IAgentSessionPresenter as SessionFacade } from '@shared/presenter' + export type Fixture = SessionFacade ` }, { - filePath: path.join( - ROOT, - 'src/main/routes/__architecture_guard_session_consumer_presenter_facade_fixture__.ts' - ), + filePath: REMOTE_CONTROL_PRESENTER_PATH, rule: '[session-consumer-presenter-facade]', expected: 'agentSessionPresenter', source: ` @@ -136,7 +136,7 @@ const SESSION_CONSUMER_FIXTURES = [ } ] -const SESSION_DUPLICATE_CONSTRUCTION_FIXTURES = [ +const SESSION_OUTSIDE_ROOT_CONSTRUCTION_FIXTURES = [ 'SessionProjectionCoordinator', 'SessionAgentAssignmentPolicy', 'SessionAgentAssignmentCoordinator', @@ -154,11 +154,38 @@ const SESSION_DUPLICATE_CONSTRUCTION_FIXTURES = [ owner === 'SessionProjectionCoordinator' ? ` import { SessionProjectionCoordinator as ProjectionOwner } from './projectionCoordinator' - export const fixture = new ProjectionOwner() + const ProjectionAlias = ProjectionOwner + const ChainedProjectionAlias = ProjectionAlias + export const fixture = new ChainedProjectionAlias() ` : `export const fixture = new ${owner}()` })) +const SESSION_ROOT_DUPLICATE_CONSTRUCTION_FIXTURE = { + filePath: PRESENTER_ROOT_ENTRY, + rule: '[session-application-duplicate-construction]', + expected: 'SessionTurnCoordinator', + source: ` + import { SessionTurnCoordinator as ImportedTurnOwner } from './sessionApplication/turnCoordinator' + const TurnOwner = ImportedTurnOwner + const ChainedTurnOwner = TurnOwner + export const owners = [ + new SessionProjectionCoordinator(), + new SessionAgentAssignmentPolicy(), + new SessionAgentAssignmentCoordinator(), + new ChainedTurnOwner(), + new SessionLifecycleCoordinator(), + new SessionDeletionTransaction() + ] + export const duplicate = new ChainedTurnOwner() + ` +} + +const SESSION_DUPLICATE_CONSTRUCTION_FIXTURES = [ + ...SESSION_OUTSIDE_ROOT_CONSTRUCTION_FIXTURES, + SESSION_ROOT_DUPLICATE_CONSTRUCTION_FIXTURE +] + const SESSION_FOREIGN_OWNER_FIXTURES = [ ['history', './history'], ['export', './exporter'], @@ -187,7 +214,8 @@ const SESSION_WHOLE_DEPENDENCY_FIXTURES = [ { dependency: 'IAgentSessionPresenter', specifier: '@shared/presenter', - localName: 'SessionPresenter' + localName: 'SessionFacade', + filePath: SESSION_APPLICATION_PORTS_PATH }, { dependency: 'AgentSharedDataPorts', @@ -199,16 +227,18 @@ const SESSION_WHOLE_DEPENDENCY_FIXTURES = [ specifier: '../sqlitePresenter', localName: 'DatabasePresenter' } -].map(({ dependency, specifier, localName }) => ({ - filePath: path.join( - SESSION_APPLICATION_ROOT, - `__architecture_guard_session_coordinator_whole_${dependency}_fixture__.ts` - ), +].map(({ dependency, specifier, localName, filePath }) => ({ + filePath: + filePath ?? + path.join( + SESSION_APPLICATION_ROOT, + `__architecture_guard_session_coordinator_whole_${dependency}_fixture__.ts` + ), rule: '[session-coordinator-whole-dependency]', expected: dependency, source: ` import type { ${dependency} as ${localName} } from '${specifier}' - export type Fixture = ${localName} + export type SessionCapabilityPort = ${localName} ` })) @@ -221,22 +251,51 @@ const SESSION_COMBINED_FACADE_FIXTURES = [ facade: 'SessionApplicationCoordinator', declaration: 'class' } -].map(({ facade, declaration }) => ({ +] + .map(({ facade, declaration }) => ({ + filePath: path.join( + ROOT, + `src/main/presenter/__architecture_guard_combined_${facade}_fixture__.ts` + ), + rule: '[session-application-combined-facade]', + expected: facade, + source: `export ${declaration} ${facade} {}` + })) + .concat({ + filePath: path.join( + ROOT, + 'src/main/presenter/__architecture_guard_combined_structural_fixture__.ts' + ), + rule: '[session-application-combined-facade]', + expected: 'SessionCapabilityHub', + source: ` + import type { + SessionLifecyclePort as Lifecycle, + SessionTurnPort as Turn, + SessionAgentAssignmentPort as Assignment, + SessionProjectionReadPort as Projection + } from './sessionApplication/ports' + export interface SessionCapabilityHub extends Lifecycle, Turn, Assignment, Projection {} + ` + }) + +const SESSION_NARROW_PORT_FIXTURE = { filePath: path.join( ROOT, - `src/main/presenter/__architecture_guard_combined_${facade}_fixture__.ts` + 'src/main/presenter/__architecture_guard_narrow_session_port_fixture__.ts' ), - rule: '[session-application-combined-facade]', - expected: facade, - source: `export ${declaration} ${facade} {}` -})) + source: ` + import type { SessionProjectionReadPort as Projection } from './sessionApplication/ports' + export interface SessionReadCapabilityPort extends Projection {} + ` +} const SESSION_ARCHITECTURE_FIXTURES = [ - ...SESSION_CONSUMER_FIXTURES, ...SESSION_DUPLICATE_CONSTRUCTION_FIXTURES, ...SESSION_FOREIGN_OWNER_FIXTURES, ...SESSION_WHOLE_DEPENDENCY_FIXTURES, - ...SESSION_COMBINED_FACADE_FIXTURES + ...SESSION_COMBINED_FACADE_FIXTURES, + SESSION_NARROW_PORT_FIXTURE ] const retiredAgentRuntimeSymbols = [ @@ -623,14 +682,18 @@ describe('architecture guard', () => { expect(result.stdout).toContain('Architecture guard passed.') }) - it.each(SESSION_CONSUMER_FIXTURES)( - 'rejects migrated consumer dependency on $expected', - ({ filePath, rule, expected }) => { - const fixtureViolations = sessionViolationsForFile(violations, filePath) + it.each(SESSION_PRODUCTION_CONSUMER_FIXTURES)( + 'guards the production Remote presenter path from $expected', + async ({ filePath, source, rule, expected }) => { + const fixtureViolations = sessionViolationsForFile( + await runArchitectureGuard({ virtualFiles: new Map([[filePath, source]]) }), + filePath + ) expect(fixtureViolations).toHaveLength(1) expect(fixtureViolations[0]).toContain(rule) expect(fixtureViolations[0]).toContain(expected) - } + }, + 10_000 ) it.each(SESSION_DUPLICATE_CONSTRUCTION_FIXTURES)( @@ -673,6 +736,10 @@ describe('architecture guard', () => { } ) + it('allows a single-capability session port', () => { + expect(sessionViolationsForFile(violations, SESSION_NARROW_PORT_FIXTURE.filePath)).toEqual([]) + }) + it('keeps renderer legacy boundaries enforced without writing source fixtures', () => { const fixtureViolations = forFile(violations, SETTINGS_FIXTURE).join('\n') expect(fixtureViolations).toContain('[renderer-business-direct-use-presenter-import]') From 17f3d2da1268f490518703987283d980db090ca0 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 13 Jul 2026 20:24:38 +0800 Subject: [PATCH 17/29] docs(session): document coordinators --- docs/ARCHITECTURE.md | 20 ++++---- docs/FLOWS.md | 39 +++++++++------- docs/README.md | 4 +- .../agent-system-layered-runtime/README.md | 30 +++++++----- docs/architecture/agent-system.md | 35 ++++++++------ .../session-application-coordinators/tasks.md | 12 ++--- docs/architecture/session-management.md | 46 +++++++++++-------- docs/guides/code-navigation.md | 21 +++++---- docs/guides/getting-started.md | 19 +++++--- 9 files changed, 132 insertions(+), 94 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 20d94e39d..36e11bbde 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -11,9 +11,10 @@ flowchart LR Client --> Bridge["window.deepchat / preload bridge"] Bridge --> Contracts["shared/contracts routes + events"] Contracts --> Routes["src/main/routes dispatcher"] - Routes --> Ports["route services / presenter-backed ports"] - Ports --> SessionFacade["AgentSessionPresenter
route/application façade"] - SessionFacade --> Manager["AgentManager
descriptor.kind router"] + Routes --> Services["SessionService / ChatService"] + Services --> Coordinators["Lifecycle / Turn / Assignment / Projection"] + Compat["AgentSessionPresenter
compatibility façade"] --> Coordinators + Coordinators --> Manager["AgentManager
descriptor.kind router"] Manager --> DeepBackend["typed DeepChat backend"] Manager --> AcpBackend["direct ACP backend"] DeepBackend --> DeepRuntime["DeepChatAgentRuntime"] @@ -34,8 +35,10 @@ flowchart LR - `kind=acp` 使用 direct ACP backend 和外部 ACP protocol loop,不进入 `DeepChatLoopEngine`。 - `kind=deepchat + providerId=acp` 仍是受支持的兼容组合:session 走 DeepChat backend/loop,provider 选择才进入 `AcpProvider` adapter。 -- `AgentSessionPresenter` 仍是 renderer route/application façade,保留 session CRUD、title、transfer、 - import/export/search/dashboard,以及 shared projection 编排。 +- 四个 `sessionApplication` coordinator 拥有 core session lifecycle、turn、assignment 和 projection; + Session/Chat routes、Remote 和 Cron 通过 consumer-owned narrow ports 使用同一组实例。 +- `AgentSessionPresenter` 是 compatibility façade;core session methods 只 forwarding。当前 `dev` 上尚未 + 拆出的 import/export/search/dashboard 等 foreign capabilities 仍由其兼容承载。 - `AgentRuntimePresenter` 仍初始化 `DeepChatAgentRuntime`,并保留 DeepChat state/delegate、message、Tape、 prompt/tool/provider adapter wiring;它不再实现 unified agent interface,也不负责 ACP runtime 构造。 @@ -53,13 +56,14 @@ flowchart LR | DeepChat loop | `src/main/agent/deepchat/loop/` | `LoopRun`、provider/tool round state machine、fixed awaited commits与窄 ports | | DeepChat Memory adapter | `src/main/agent/deepchat/memory/` | sole runtime coordinator、prompt contributor、background ingestion observer | | ACP runtime | `src/main/agent/acp/` | catalog、launch、client/process/session/protocol、direct instance/runtime | -| `AgentSessionPresenter` | `src/main/presenter/agentSessionPresenter/` | route/application façade与 shared session/projection operations | +| session application | `src/main/presenter/sessionApplication/` | Lifecycle、Turn、AgentAssignment、Projection coordinators 与窄 dependency ports | +| `AgentSessionPresenter` | `src/main/presenter/agentSessionPresenter/` | compatibility forwarding 与尚未迁移的 foreign route capabilities | | `AgentRuntimePresenter` | `src/main/presenter/agentRuntimePresenter/` | retained DeepChat state/delegate façade及现有 message/Tape/provider/tool adapters | | `ToolPresenter` | `src/main/presenter/toolPresenter/` | MCP/local tool 聚合、collision policy、权限预检查、调用路由 | | `MemoryPresenter` | `src/main/presenter/memoryPresenter/` | Memory rows、retrieval、write、vector、maintenance kernel | | `LLMProviderPresenter` | `src/main/presenter/llmProviderPresenter/` | provider/model runtime和 DeepChat ACP-provider compatibility adapter | -| `RemoteControlPresenter` | `src/main/presenter/remoteControlPresenter/` | remote channel control,generation control 走 manager port | -| `CronJobsService` | `src/main/presenter/cronJobs/` | detached session run、cron 调度和 Remote 投递 | +| `RemoteControlPresenter` | `src/main/presenter/remoteControlPresenter/` | remote channel control;session 操作走四个 narrow ports,generation control 走 manager port | +| `CronJobsService` | `src/main/presenter/cronJobs/` | detached session run、composition-owned starter、cron 调度和 Remote 投递 | ## Agent runtime 分层 diff --git a/docs/FLOWS.md b/docs/FLOWS.md index 2c784dc05..e698a5c94 100644 --- a/docs/FLOWS.md +++ b/docs/FLOWS.md @@ -10,18 +10,18 @@ sequenceDiagram participant R as Renderer participant C as SessionClient/ChatClient participant Route as src/main/routes - participant F as AgentSessionPresenter + participant App as Lifecycle / Turn / Assignment / Projection participant S as AppSessionService participant M as AgentManager participant B as Typed Backend R->>C: create/send/restore C->>Route: window.deepchat.invoke(route) - Route->>F: createSession/restore/send/listMessagesPage - F->>S: create/bind/read app-session shell - F->>M: resolve executable descriptor/session handle + Route->>App: narrow lifecycle/turn/projection port + App->>S: create/bind/read app-session shell + App->>M: resolve executable descriptor/session handle M->>B: switch descriptor.kind and open handle - F->>B: initialize/send/snapshot + App->>B: initialize/send/snapshot B-->>R: existing message projection + chat.stream.* events ``` @@ -35,11 +35,12 @@ sequenceDiagram - `src/main/agent/manager/agentManager.ts` - `src/main/agent/manager/deepChatAgentBackend.ts` - `src/main/agent/manager/directAcpAgentBackend.ts` +- `src/main/presenter/sessionApplication/` - `src/main/presenter/agentSessionPresenter/index.ts` -`AgentSessionPresenter` 仍是 route/application façade,但 agent kind resolution 和 executable backend -selection 只发生在 `AgentManager`。`new_sessions.session_kind` 仍表示 `regular | subagent`,不决定 -DeepChat/ACP backend。 +`SessionService` / `ChatService` 直接使用 consumer-owned coordinator ports。`AgentSessionPresenter` 只为 +兼容调用转发 core session methods;agent kind resolution 和 executable backend selection 只发生在 +`AgentManager`。`new_sessions.session_kind` 仍表示 `regular | subagent`,不决定 DeepChat/ACP backend。 ## 2. DeepChat 消息处理主循环 @@ -124,13 +125,13 @@ sequenceDiagram participant R as Renderer messageStore participant S as SessionClient participant Route as SessionService - participant N as AgentSessionPresenter + participant P as SessionProjectionCoordinator participant DB as DeepChatMessageStore R->>S: restore(sessionId, limit=100) S->>Route: sessions.restore - Route->>N: restoreSession - N->>DB: listPageBySession + Route->>P: getSession + listMessagesPage + P->>DB: listPageBySession DB-->>R: latest page + nextCursor R->>S: listMessagesPage(cursor) S->>Route: sessions.listMessagesPage @@ -148,7 +149,7 @@ sequenceDiagram ```mermaid flowchart TD - Route["AgentSessionPresenter"] --> Manager["AgentManager"] + Route["Session application coordinator
or compatibility façade"] --> Manager["AgentManager"] Manager --> Kind{"descriptor.kind"} Kind -->|acp| Direct["DirectAcpSessionBackend"] Direct --> AcpRuntime["AcpAgentRuntime"] @@ -220,20 +221,23 @@ sequenceDiagram participant Client as CronJobsClient participant Service as CronJobsService participant Utility as Scheduler utility - participant Agent as AgentSessionPresenter + participant Starter as Cron session starter + participant App as Lifecycle / Turn + participant Runtime as Agent runtime updates participant Remote as RemoteControlPresenter UI->>Client: list/upsert/toggle/runNow Client->>Service: cronJobs.* route Service->>Utility: reconcile enabled jobs Utility->>Service: RUN_DUE - Service->>Agent: create detached session and send task prompt - Agent-->>Service: run status and output updates + Service->>Starter: start run + Starter->>App: create detached session + send task prompt + Runtime-->>Service: DeepChatInternalSessionUpdate status/output/completion Service->>Remote: optional notification-only delivery ``` Triggers 使用 cron 表达式。每次触发创建独立 detached session;Remote 投递只发送通知,不进入普通 -Remote 会话上下文。 +Remote 会话上下文。starter 在 composition root 接线,不依赖 route runtime 初始化。 ## 9. Remote Control @@ -246,7 +250,8 @@ flowchart LR WeChat["WeChat iLink"] --> Remote Remote --> Auth["channel auth / binding store"] Remote --> Runner["remote conversation runner"] - Runner --> Agent["AgentSessionPresenter"] + Runner --> Ports["Lifecycle / Turn / Assignment / Projection ports"] + Runner --> Generation["AgentManager generation port"] ``` 统一远程控制支持绑定、默认 agent、默认 workdir、`/sessions`、`/model`、状态输出、媒体/Markdown diff --git a/docs/README.md b/docs/README.md index 4b5aa9b44..4a7b8156c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,8 +12,8 @@ Renderer -> window.deepchat -> shared/contracts/routes + shared/contracts/events -> src/main/routes dispatcher - -> route services / presenter-backed ports - -> agentSessionPresenter / agentRuntimePresenter / toolPresenter / llmProviderPresenter + -> route services / consumer-owned narrow ports + -> sessionApplication coordinators / typed runtimes / retained resource presenters ``` `useLegacyPresenter()`、`presenter:call`、`remoteControlPresenter:call` 和 diff --git a/docs/architecture/agent-system-layered-runtime/README.md b/docs/architecture/agent-system-layered-runtime/README.md index 1ad107665..736f42297 100644 --- a/docs/architecture/agent-system-layered-runtime/README.md +++ b/docs/architecture/agent-system-layered-runtime/README.md @@ -39,8 +39,9 @@ DeepChat loop + `AcpProvider` compatibility。 Memory runtime orchestration 已收敛到唯一 `MemoryRuntimeCoordinator`,通过 awaited `MemoryPromptContributor` 与 background `MemoryIngestionObserver` 接入;Tape tool facts 已迁到 stable per-fact `TapeRecorder` path,causal observation 只读联结现有 Tape/message/trace。`AgentSessionPresenter` -保留 route/application/shared projection façade,`AgentRuntimePresenter` 保留 DeepChat state/delegate 与 -adapter wiring;两者不再构成 generic agent runtime。current docs、architecture guards 与 baseline generator +保留 compatibility façade;core lifecycle、turn、assignment 与 projection 已由四个 composition-owned +session application coordinators 承担。`AgentRuntimePresenter` 保留 DeepChat state/delegate 与 adapter +wiring;两者不再构成 generic agent runtime。current docs、architecture guards 与 baseline generator 已在 `ASLR-091` 收敛;`ASLR-092` 已完成 canonical baseline write、全量 main/renderer/Memory/native/build/E2E gates 与最终契约 diff。 @@ -167,15 +168,18 @@ accept input / claim pending item ## AFTER:已实现的当前架构 ```text -typed routes / remote / cron - │ - ▼ - AgentManager (control plane) - ├─ AgentCatalog - │ ├─ DeepChatAgentRepository ─┐ - │ └─ AcpAgentRepository ├─ shared agents table + typed codecs - ├─ AppSessionService ──────────┴─ new_sessions / transcript projection - └─ explicit switch(agent.kind) +typed routes -> SessionService / ChatService -- narrow ports ─┐ +remote -> RemoteConversationRunner -- four narrow ports ─────┤ +cron -> Cron session starter -- Lifecycle / Turn ports ──────┤ +AgentSessionPresenter compatibility façade -- forwarding ─────┘ + ▼ +Lifecycle / Turn / AgentAssignment / Projection +├─ AppSessionService ─ new_sessions / window binding / shared CRUD +└─ AgentManager (control plane) + ├─ AgentCatalog + │ ├─ DeepChatAgentRepository ─┐ + │ └─ AcpAgentRepository ├─ shared agents table + typed codecs + └─ explicit switch(agent.kind) │ ├─ kind=deepchat │ ▼ @@ -264,7 +268,8 @@ src/main/agent/ └── pending/ # durable pending input coordination src/main/presenter/ -├── agentSessionPresenter/ # retained route/application/shared-projection façade +├── sessionApplication/ # lifecycle/turn/assignment/projection coordinators +├── agentSessionPresenter/ # retained compatibility façade └── agentRuntimePresenter/ # retained DeepChat state/delegate + message/Tape/resource adapters ``` @@ -401,6 +406,7 @@ renderer event 缺口; | --- | --- | | agent identity、kind、display summary | `AgentCatalog` | | app session title/project/pin/draft/window binding | shared `AppSessionService` / `new_sessions` | +| lifecycle transaction / turn commands / assignment policy / renderer projection | four `sessionApplication` coordinators over narrow owner ports | | DeepChat effective config、status、pending inputs、ordered interactions | `DeepChatAgentInstance` | | pre-stream cancellation before active generation registration | `DeepChatAgentInstance` preparation state | | active run、abort signal、per-attempt requestSeq、outer providerRoundCount、round messages、overflow retry flags | per-turn `LoopRun` | diff --git a/docs/architecture/agent-system.md b/docs/architecture/agent-system.md index 8690a7ccb..35fb9994d 100644 --- a/docs/architecture/agent-system.md +++ b/docs/architecture/agent-system.md @@ -31,9 +31,11 @@ kind=acp -> direct ACP backend -> external ACP protocol ```mermaid flowchart TD - UI["Renderer / typed routes"] --> Facade["AgentSessionPresenter
route/application façade"] - Facade --> Sessions["AppSessionService"] - Facade --> Manager["AgentManager"] + UI["Renderer / typed routes"] --> Services["SessionService / ChatService"] + Services --> App["Session application coordinators"] + Compat["AgentSessionPresenter
compatibility façade"] --> App + App --> Sessions["AppSessionService"] + App --> Manager["AgentManager"] Manager --> Catalog["strict executable catalog"] Manager --> DeepBackend["DeepChatAgentBackend"] Manager --> AcpBackend["DirectAcpSessionBackend"] @@ -52,13 +54,17 @@ flowchart TD - `AgentManager` 是薄 control plane,只做 catalog/app-session lookup、alias normalization、kind switch 和 required facet selection。 -- `AgentSessionPresenter` 保留 session CRUD、draft/window binding、title、transfer/subagent、legacy - import、search/export/dashboard 及 shared projection application logic;它不猜 backend kind。 +- `SessionLifecycleCoordinator`、`SessionTurnCoordinator`、`SessionAgentAssignmentCoordinator` 和 + `SessionProjectionCoordinator` 分别拥有 core session application invariants;typed Session/Chat、Remote + 和 Cron 通过 consumer-owned narrow ports 调用它们。 +- `AgentSessionPresenter` 保留 compatibility public surface;core session methods 只转发到 composition-owned + coordinators。当前 `dev` 上尚未拆出的 import/search/export/dashboard 等 foreign capabilities 仍在 façade, + 不属于四 coordinator。 - `AgentRuntimePresenter` 保留 DeepChat state/delegate façade,初始化 `DeepChatAgentRuntime`,并接线现有 message/Tape/prompt/provider/tool/permission adapters。它不再实现 unified agent interface,也不构造 `AcpAgentRuntime`。 - composition root 负责 backend wiring 和 `AcpAgentRuntime` construction;runtime/instance 实现分别位于 - `agent/deepchat` 与 `agent/acp`。 + `agent/deepchat` 与 `agent/acp`,并且只构造一组 session application coordinators。 ## 目录与职责 @@ -163,7 +169,7 @@ DeepChat app projection;`acp_turns` 只是 protocol metadata。 仍保留: -- `AgentSessionPresenter` 和 `AgentRuntimePresenter` 两个薄而明确的 application/state-delegate façade; +- `AgentSessionPresenter` compatibility façade 和 `AgentRuntimePresenter` state/delegate façade; - `AcpProvider` 的 DeepChat + ACP-provider compatibility; - `LegacyChatImportService`、旧 conversations/messages、`SessionPresenter` export/thread/data compatibility; - 现有 route/event/DTO/schema/table。 @@ -181,10 +187,11 @@ DeepChat app projection;`acp_turns` 只是 protocol metadata。 按问题选择入口: 1. kind/session routing:`src/main/agent/manager/agentManager.ts` -2. route/application behavior:`src/main/presenter/agentSessionPresenter/index.ts` -3. DeepChat session state:`src/main/agent/deepchat/instance/` -4. provider/tool round:`src/main/agent/deepchat/loop/`,再看 retained presenter adapters -5. direct ACP:`src/main/agent/acp/instance/` 与 `src/main/agent/acp/runtime/` -6. tool source/dispatch:`src/main/presenter/toolPresenter/` -7. Tape/message projection:`src/main/presenter/agentRuntimePresenter/{tapeService,messageStore}.ts` -8. Memory runtime seam:`src/main/agent/deepchat/memory/memoryRuntimeCoordinator.ts` +2. core session application behavior:`src/main/presenter/sessionApplication/` +3. compatibility forwarding / foreign routes:`src/main/presenter/agentSessionPresenter/index.ts` +4. DeepChat session state:`src/main/agent/deepchat/instance/` +5. provider/tool round:`src/main/agent/deepchat/loop/`,再看 retained presenter adapters +6. direct ACP:`src/main/agent/acp/instance/` 与 `src/main/agent/acp/runtime/` +7. tool source/dispatch:`src/main/presenter/toolPresenter/` +8. Tape/message projection:`src/main/presenter/agentRuntimePresenter/{tapeService,messageStore}.ts` +9. Memory runtime seam:`src/main/agent/deepchat/memory/memoryRuntimeCoordinator.ts` diff --git a/docs/architecture/session-application-coordinators/tasks.md b/docs/architecture/session-application-coordinators/tasks.md index ea5ccd660..9ad07bf88 100644 --- a/docs/architecture/session-application-coordinators/tasks.md +++ b/docs/architecture/session-application-coordinators/tasks.md @@ -15,7 +15,7 @@ - [x] Lock assignment transfer, setting mutation, and subagent Tape behavior. - [x] Lock projection cache/window/title/read fallback behavior. - [x] Lock Remote status/output and Cron metadata/max-turn/output behavior. -- [ ] Define consumer-owned narrow ports without `Pick`. +- [x] Define consumer-owned narrow ports without `Pick`. ## 2. SessionProjectionCoordinator @@ -51,10 +51,10 @@ ## 6. SessionService and ChatService -- [ ] Inject Lifecycle/Projection ports into `SessionService`. -- [ ] Inject Turn/Projection and existing permission/catalog ports into `ChatService`. -- [ ] Remove the `IAgentSessionPresenter` hot-path adapter, unused message adapter, and permission cast. -- [ ] Preserve route schemas, timeout/retry/lock/cleanup semantics, and add integration tests. +- [x] Inject Lifecycle/Projection ports into `SessionService`. +- [x] Inject Turn/Projection and existing permission/catalog ports into `ChatService`. +- [x] Remove the `IAgentSessionPresenter` hot-path adapter, unused message adapter, and permission cast. +- [x] Preserve route schemas, timeout/retry/lock/cleanup semantics, and add integration tests. ## 7. Remote and Cron @@ -72,7 +72,7 @@ - [x] Exhaust production/test searches for presenter dependencies in migrated consumers. - [x] Add architecture guards for consumer imports, duplicate construction, foreign-owner imports, and combined façade regression. -- [ ] Update current architecture, session management, flows, and code navigation. +- [x] Update current architecture, session management, flows, and code navigation. - [ ] Review the dependency diff and regenerate maintained baselines only when intentional. ## 9. Validation diff --git a/docs/architecture/session-management.md b/docs/architecture/session-management.md index d520a5fb0..55cfe2def 100644 --- a/docs/architecture/session-management.md +++ b/docs/architecture/session-management.md @@ -1,10 +1,13 @@ # 会话管理架构详解 -当前会话管理分成三个明确边界: +当前会话管理分成四个明确边界: - app-session shell:`AppSessionService` 管理 `new_sessions`、window binding 和 shared CRUD; +- application coordination:四个 `sessionApplication` coordinator 分别拥有 Lifecycle、Turn、 + AgentAssignment 和 Projection 不变量; - agent execution routing:`AgentManager` 按 executable descriptor kind 返回 typed handle; -- route/application façade:`AgentSessionPresenter` 保留 renderer API、shared projection 和跨模块操作。 +- compatibility façade:`AgentSessionPresenter` 保留尚未退休的 public surface,并把 core session 方法 + 转发到四个 coordinator。 `SessionPresenter` 是旧 conversations/messages 的 compatibility/data façade,不在当前 agent execution 链路中。 @@ -13,7 +16,8 @@ | 组件 | 位置 | 当前职责 | | --- | --- | --- | -| `AgentSessionPresenter` | `src/main/presenter/agentSessionPresenter/index.ts` | renderer session route/application façade;CRUD、draft、title、transfer/subagent、import/search/export/dashboard | +| Session application coordinators | `src/main/presenter/sessionApplication/` | Lifecycle、Turn、AgentAssignment、Projection application invariants | +| `AgentSessionPresenter` | `src/main/presenter/agentSessionPresenter/index.ts` | compatibility forwarding 与尚未迁移的 foreign route capabilities | | `AppSessionService` | `src/main/agent/shared/appSessionService.ts` | `new_sessions` row、window binding、activate/list/filter/shared CRUD | | `AgentManager` | `src/main/agent/manager/agentManager.ts` | session agent id -> strict descriptor -> explicit backend kind router | | DeepChat backend | `src/main/agent/manager/deepChatAgentBackend.ts` | typed handle over `DeepChatAgentRuntime`/instance和 required DeepChat delegate port | @@ -29,22 +33,22 @@ ```mermaid sequenceDiagram participant R as Renderer - participant F as AgentSessionPresenter + participant SS as SessionService / ChatService + participant L as Lifecycle / Turn + participant A as Assignment / Projection participant S as AppSessionService participant M as AgentManager participant B as Typed Backend Handle - R->>F: createSession(input) - F->>M: resolveBackend(agentId) - M-->>F: strict descriptor + backend - F->>S: create app-session row - F->>B: initialize effective config/workdir - F->>S: bindWindow() - R->>F: sendMessage(sessionId) - F->>M: resolveSessionHandle(sessionId) + R->>SS: sessions.create / chat.sendMessage + SS->>L: createSession / sendMessage + L->>A: resolve assignment / update projection + L->>M: resolve backend or session handle + M-->>L: strict descriptor + typed handle + L->>S: create/update app-session row + L->>B: initialize or send + A->>S: bind/materialize/notify M->>S: read current agentId - M-->>F: DeepChatSessionHandle or DirectAcpSessionHandle - F->>B: send(input) ``` `new_sessions.session_kind` 只表示 `regular | subagent`。DeepChat/ACP routing 只来自当前 @@ -91,7 +95,7 @@ manager cleanup both backend caches without hydration -> delete app-session row ``` -façade 最后调用 `AppSessionService.delete()` 才删除 `new_sessions` row;因此即使 backend close 已完成,app +Lifecycle deletion transaction 最后调用 `AppSessionService.delete()` 才删除 `new_sessions` row;因此即使 backend close 已完成,app session shell 仍存在,直到 full delete 明确提交。这允许 missing/disabled/malformed agent row 的旧 session 仍可删除。ACP -> DeepChat transfer 先完成 target validation和 ownership commit,再关闭旧 direct ACP runtime;ACP target 在 mutation 前拒绝。DeepChat + @@ -101,8 +105,10 @@ ACP-provider source 只清 compatibility binding,不被误判成 direct ACP。 - Subagent 与普通 session 共享 app/message schema,用 `sessionKind`、`parentSessionId`、`subagentMeta` 区分;child backend 仍由 manager 选择,父 session 通过 Tape merge/discard 接收结果。 -- Remote active-generation lookup/cancel 使用 `AgentManagerGenerationPort`,不扫描 presenter runtime maps。 -- Cron 每次执行创建 detached app session,再通过 façade/manager handle send;Remote delivery 只是通知。 +- 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; + Remote delivery 只是通知,route runtime 不参与 starter 接线。 ## `SessionPresenter` compatibility @@ -114,5 +120,7 @@ ACP-provider source 只清 compatibility binding,不被误判成 direct ACP。 - tab/window close compatibility; - exporter 的旧消息格式化。 -当前 session create/send/cancel/tool interaction 从 `AgentSessionPresenter -> AgentManager -> typed backend` -开始追踪;DeepChat state/loop 看 `agent/deepchat`,direct ACP 看 `agent/acp/instance`。 +当前 session create/send/cancel/tool interaction 从 `SessionService/ChatService -> sessionApplication +coordinator -> AgentManager -> typed backend` 开始追踪。兼容调用可能先进入 `AgentSessionPresenter`,但其 +core session methods 只转发到同一 coordinator 实例。DeepChat state/loop 看 `agent/deepchat`,direct ACP +看 `agent/acp/instance`。 diff --git a/docs/guides/code-navigation.md b/docs/guides/code-navigation.md index 6b3e172ed..18f418144 100644 --- a/docs/guides/code-navigation.md +++ b/docs/guides/code-navigation.md @@ -20,10 +20,11 @@ copy/file/openExternal 等 dedicated preload 能力,并通过 renderer client 5. `src/main/routes/index.ts` 6. `src/main/routes/sessions/sessionService.ts` 7. `src/main/routes/chat/chatService.ts` -8. `src/main/routes/providers/providerService.ts` -9. `src/main/routes/hotPathPorts.ts` -10. `src/main/presenter/agentSessionPresenter/index.ts` -11. `src/main/presenter/agentRuntimePresenter/index.ts` +8. `src/main/presenter/sessionApplication/` +9. `src/main/routes/providers/providerService.ts` +10. `src/main/routes/hotPathPorts.ts` +11. `src/main/presenter/agentSessionPresenter/index.ts` +12. `src/main/presenter/agentRuntimePresenter/index.ts` ## 按边界找代码 @@ -53,11 +54,12 @@ copy/file/openExternal 等 dedicated preload 能力,并通过 renderer client | 功能 | 位置 | 备注 | | --- | --- | --- | | session route dispatch | `src/main/routes/index.ts` | `sessions.create` / `restore` / `listMessagesPage` / `activate` / `deactivate` / `getActive` | -| session orchestration | `src/main/routes/sessions/sessionService.ts` | `Scheduler` + session/message repositories | +| session orchestration | `src/main/routes/sessions/sessionService.ts` | `Scheduler` + Lifecycle/Projection consumer ports | | chat route dispatch | `src/main/routes/index.ts` | `chat.sendMessage` / `stopStream` / `respondToolInteraction` | -| chat orchestration | `src/main/routes/chat/chatService.ts` | send / stop / permission response owner | +| chat orchestration | `src/main/routes/chat/chatService.ts` | request lock/timeout/stop + Turn/Projection/permission ports | | scheduler | `src/main/routes/scheduler.ts` | timeout / retry / abort 统一入口 | -| presenter-backed ports | `src/main/routes/hotPathPorts.ts` | route services 依赖的最小 runtime port | +| session application | `src/main/presenter/sessionApplication/` | Lifecycle、Turn、AgentAssignment、Projection owner | +| provider hot-path ports | `src/main/routes/hotPathPorts.ts` | provider catalog/connection adapter;不承载 session façade | ### Provider / Permission @@ -73,7 +75,8 @@ copy/file/openExternal 等 dedicated preload 能力,并通过 renderer client | 功能 | 位置 | 备注 | | --- | --- | --- | -| session runtime entry | `src/main/presenter/agentSessionPresenter/index.ts` | window/session 绑定、runtime delegation、legacy import | +| session application entry | `src/main/presenter/sessionApplication/` | core session transaction/policy/projection owners | +| compatibility session façade | `src/main/presenter/agentSessionPresenter/index.ts` | core forwarding 与 legacy/foreign route compatibility | | message runtime entry | `src/main/presenter/agentRuntimePresenter/index.ts` | `processMessage()`、暂停恢复、stream 生命周期 | | 主循环 | `src/main/presenter/agentRuntimePresenter/process.ts` | stream + tool loop | | 工具调度 | `src/main/presenter/agentRuntimePresenter/dispatch.ts` | tool call / paused interaction | @@ -115,7 +118,7 @@ rg "settingsChangedEvent|sessionsUpdatedEvent|chatStream" src/shared src/main sr | --- | --- | | `renderer/api/*Client` | migrated renderer boundary 的一线入口 | | `src/main/routes/*` | migrated settings/session/chat/provider path 的 active owner | -| `agentSessionPresenter` | presenter-backed runtime collaborator,不是 migrated renderer 的直连入口 | +| `agentSessionPresenter` | core session compatibility forwarding 与 foreign route façade,不是 migrated renderer 的直连入口 | | `agentRuntimePresenter` | 当前聊天 runtime 与持久化 owner | | `SessionPresenter` | legacy conversation 兼容层,不是 migrated chat 主链路 | | `agentPresenter` | 已退休;只应出现在旧提交或已删除的历史 spec 里 | diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index 8b8d325e0..a3f7c0338 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -42,8 +42,9 @@ Renderer -> window.deepchat -> shared/contracts/routes + shared/contracts/events -> src/main/routes/* - -> presenter-backed hot path ports - -> agentSessionPresenter / agentRuntimePresenter / toolPresenter / llmProviderPresenter + -> SessionService / ChatService consumer-owned ports + -> sessionApplication coordinators + -> AgentManager / typed backend / retained resource presenters ``` 如果你在旧提交里看到 `AgentPresenter`、`startStreamCompletion`、`agentLoopHandler`, @@ -61,7 +62,8 @@ Renderer src/ ├── main/ │ ├── presenter/ -│ │ ├── agentSessionPresenter/ # 当前会话入口 +│ │ ├── sessionApplication/ # core session application owners +│ │ ├── agentSessionPresenter/ # compatibility forwarding / foreign routes │ │ ├── agentRuntimePresenter/ # 当前聊天 runtime │ │ ├── toolPresenter/ # 工具路由 │ │ │ └── agentTools/ # 本地 agent tools @@ -90,9 +92,10 @@ src/ 5. `src/main/routes/index.ts` 6. `src/main/routes/sessions/sessionService.ts` 7. `src/main/routes/chat/chatService.ts` -8. `src/main/routes/providers/providerService.ts` -9. `src/main/presenter/agentSessionPresenter/index.ts` -10. `src/main/presenter/agentRuntimePresenter/index.ts` +8. `src/main/presenter/sessionApplication/` +9. `src/main/routes/providers/providerService.ts` +10. `src/main/presenter/agentSessionPresenter/index.ts` +11. `src/main/presenter/agentRuntimePresenter/index.ts` ## 常见开发任务 @@ -100,7 +103,9 @@ src/ 优先看: -- `src/main/presenter/agentSessionPresenter/index.ts` +- `src/main/routes/chat/chatService.ts` +- `src/main/presenter/sessionApplication/turnCoordinator.ts` +- `src/main/agent/manager/agentManager.ts` - `src/main/presenter/agentRuntimePresenter/process.ts` - `src/main/presenter/agentRuntimePresenter/dispatch.ts` From 5d65d0f1553f2ab4a8bf6d385950d76685e52c7f Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 13 Jul 2026 20:26:14 +0800 Subject: [PATCH 18/29] docs(architecture): refresh baseline --- .../agent-system-layered-runtime-baseline.json | 8 +++++--- docs/architecture/baselines/dependency-report.md | 16 ++++++++-------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/docs/architecture/baselines/agent-system-layered-runtime-baseline.json b/docs/architecture/baselines/agent-system-layered-runtime-baseline.json index ae00874d7..66b68e299 100644 --- a/docs/architecture/baselines/agent-system-layered-runtime-baseline.json +++ b/docs/architecture/baselines/agent-system-layered-runtime-baseline.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, "goal": "agent-system-layered-runtime", - "headCommit": "1e79ddc017a0e71d851f50d536b0ac59b5881105", + "headCommit": "17f3d2da1268f490518703987283d980db090ca0", "relevantWorkingTree": { "dirty": false, "files": [] @@ -75,6 +75,7 @@ "src/main/agent/shared/agentRowStore.ts", "src/main/agent/shared/agentSessionHandle.ts", "src/main/agent/shared/agentSessionIds.ts", + "src/main/agent/shared/agentSessionNormalization.ts", "src/main/agent/shared/agentSharedData.ts", "src/main/agent/shared/appSessionService.ts", "src/main/agent/shared/process/backgroundExecLogger.ts", @@ -315,6 +316,7 @@ "src/main/presenter/sqlitePresenter/tables/agentMemory.ts", "src/main/presenter/sqlitePresenter/tables/agentMemoryAudit.ts", "src/main/presenter/sqlitePresenter/tables/agentMemoryFtsPolicy.ts", + "src/main/presenter/sqlitePresenter/tables/agentMemoryStateSql.ts", "src/main/presenter/sqlitePresenter/tables/agents.ts", "src/main/presenter/sqlitePresenter/tables/attachments.ts", "src/main/presenter/sqlitePresenter/tables/baseTable.ts", @@ -403,7 +405,7 @@ "providers", "settings_activity" ], - "sha256": "eb97de4264ffe061beaaaa7870051c8a808b81a056221de1f552e0c14f123e7f" + "sha256": "ca9306d19cb9066d6447268aac2e9485ca867fc65f89abda5a1e4b54d4f21974" }, "memoryDuckDbSidecar": { "files": [ @@ -424,7 +426,7 @@ "src/main/presenter/lifecyclePresenter/hooks/beforeQuit/presenterDestroyHook.ts", "src/main/presenter/lifecyclePresenter/index.ts" ], - "sha256": "daf7a167e849af16f739ac5244ca1a444caecd6250c71f7e19af3fc75763732a" + "sha256": "42de1624eb312277aa22d874b54368e5a7e1b60ded01f10769612fcaae7618fc" }, "dependencyMetrics": { "loopFiles": [ diff --git a/docs/architecture/baselines/dependency-report.md b/docs/architecture/baselines/dependency-report.md index 8d9297742..332599fff 100644 --- a/docs/architecture/baselines/dependency-report.md +++ b/docs/architecture/baselines/dependency-report.md @@ -4,24 +4,24 @@ Generated on 2026-07-13. ## main -- Total files: 541 -- Internal dependency edges: 1455 +- Total files: 552 +- Internal dependency edges: 1495 - Cycles detected: 36 ### Top outgoing dependencies -- `presenter/index.ts`: 56 +- `presenter/index.ts`: 62 - `presenter/agentRuntimePresenter/index.ts`: 45 - `presenter/sqlitePresenter/index.ts`: 40 - `presenter/sqlitePresenter/schemaCatalog.ts`: 37 -- `routes/index.ts`: 28 +- `routes/index.ts`: 29 - `presenter/configPresenter/index.ts`: 27 - `presenter/lifecyclePresenter/hooks/index.ts`: 23 - `presenter/toolPresenter/agentTools/agentToolManager.ts`: 22 - `presenter/memoryPresenter/index.ts`: 19 - `presenter/llmProviderPresenter/index.ts`: 17 - `agent/acp/runtime/index.ts`: 15 -- `presenter/agentSessionPresenter/index.ts`: 14 +- `presenter/agentSessionPresenter/index.ts`: 15 - `presenter/filePresenter/mime.ts`: 14 - `presenter/remoteControlPresenter/index.ts`: 14 - `presenter/agentRuntimePresenter/dispatch.ts`: 12 @@ -29,12 +29,12 @@ Generated on 2026-07-13. ### Top incoming dependencies - `presenter/index.ts`: 49 -- `routes/publishDeepchatEvent.ts`: 44 +- `routes/publishDeepchatEvent.ts`: 42 - `presenter/remoteControlPresenter/types.ts`: 38 - `presenter/sqlitePresenter/tables/baseTable.ts`: 38 +- `agent/shared/agentSessionIds.ts`: 31 - `events.ts`: 30 - `eventbus.ts`: 29 -- `agent/shared/agentSessionIds.ts`: 25 - `presenter/memoryPresenter/types.ts`: 22 - `presenter/remoteControlPresenter/services/remoteBindingStore.ts`: 22 - `presenter/memoryPresenter/ports.ts`: 20 @@ -42,7 +42,7 @@ Generated on 2026-07-13. - `presenter/remoteControlPresenter/services/remoteConversationRunner.ts`: 16 - `presenter/filePresenter/BaseFileAdapter.ts`: 13 - `presenter/memoryPresenter/context.ts`: 12 -- `presenter/sqlitePresenter/tables/deepchatTapeEntries.ts`: 12 +- `presenter/runtimePorts.ts`: 12 ### Cycle samples From 5091f4d55dd3b23d4fc176b5d78afaebb444af39 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 13 Jul 2026 20:36:05 +0800 Subject: [PATCH 19/29] fix(architecture): scope session guards --- scripts/architecture-guard.mjs | 249 +++++++++++++++----- test/main/scripts/architectureGuard.test.ts | 176 +++++++++++++- 2 files changed, 357 insertions(+), 68 deletions(-) diff --git a/scripts/architecture-guard.mjs b/scripts/architecture-guard.mjs index d49496ef5..94166cfc2 100644 --- a/scripts/architecture-guard.mjs +++ b/scripts/architecture-guard.mjs @@ -391,103 +391,220 @@ function findIdentifierNames(sourceFile, names) { return found } -function resolveSessionApplicationOwner(expression, aliases) { - const unwrapped = unwrapExpression(expression) - if (ts.isIdentifier(unwrapped)) { - const owner = aliases.get(unwrapped.text) ?? unwrapped.text - return SESSION_APPLICATION_OWNER_NAMES.has(owner) ? owner : null +function findSessionApplicationOwnerConstructions(sourceFile, importRecords) { + const constructions = new Map() + + const lookup = (scope, name) => { + for (let current = scope; current; current = current.parent) { + if (current.bindings.has(name)) return current.bindings.get(name) + } + return SESSION_APPLICATION_OWNER_NAMES.has(name) ? name : null } - const owner = accessMemberName(unwrapped) - return owner && SESSION_APPLICATION_OWNER_NAMES.has(owner) ? owner : null -} + const resolveOwner = (expression, scope) => { + const unwrapped = unwrapExpression(expression) + if (ts.isIdentifier(unwrapped)) return lookup(scope, unwrapped.text) -function findSessionApplicationOwnerConstructions(sourceFile, importRecords) { - const aliases = new Map() - for (const record of importRecords) { - if (SESSION_APPLICATION_OWNER_NAMES.has(record.importedName)) { - aliases.set(record.localName, record.importedName) - } + const owner = accessMemberName(unwrapped) + return owner && SESSION_APPLICATION_OWNER_NAMES.has(owner) ? owner : null } - const constDeclarations = [] - const collectAliases = (node) => { - if ( - ts.isVariableDeclaration(node) && - ts.isIdentifier(node.name) && - node.initializer && - ts.isVariableDeclarationList(node.parent) && - (node.parent.flags & ts.NodeFlags.Const) !== 0 - ) { - constDeclarations.push(node) - } - ts.forEachChild(node, collectAliases) + const addDeclaration = (scope, declarations, declaration) => { + if (!ts.isIdentifier(declaration.name)) return + scope.bindings.set(declaration.name.text, null) + if (declaration.initializer) declarations.push(declaration) } - collectAliases(sourceFile) - let changed = true - while (changed) { - changed = false - for (const declaration of constDeclarations) { - const owner = resolveSessionApplicationOwner(declaration.initializer, aliases) - if (owner && aliases.get(declaration.name.text) !== owner) { - aliases.set(declaration.name.text, owner) - changed = true + const directStatements = (node) => + ts.isSourceFile(node) || ts.isBlock(node) || ts.isModuleBlock(node) ? node.statements : [] + + const visitScope = (node, parent) => { + const scope = { parent, bindings: new Map() } + const declarations = [] + + if (ts.isSourceFile(node)) { + for (const record of importRecords) { + scope.bindings.set( + record.localName, + SESSION_APPLICATION_OWNER_NAMES.has(record.importedName) ? record.importedName : null + ) } } - } - const constructions = new Map() - const visit = (node) => { - if (ts.isNewExpression(node)) { - const owner = resolveSessionApplicationOwner(node.expression, aliases) - if (owner) { - constructions.set(owner, (constructions.get(owner) ?? 0) + 1) + if (ts.isFunctionLike(node)) { + for (const parameter of node.parameters) { + if (ts.isIdentifier(parameter.name)) scope.bindings.set(parameter.name.text, null) + } + } + + for (const statement of directStatements(node)) { + if ( + (ts.isFunctionDeclaration(statement) || + ts.isClassDeclaration(statement) || + ts.isEnumDeclaration(statement)) && + statement.name + ) { + scope.bindings.set(statement.name.text, null) + } + if ( + ts.isVariableStatement(statement) && + (statement.declarationList.flags & ts.NodeFlags.BlockScoped) !== 0 + ) { + for (const declaration of statement.declarationList.declarations) { + addDeclaration(scope, declarations, declaration) + } + } + } + + if (ts.isSourceFile(node) || ts.isFunctionLike(node)) { + const collectVarDeclarations = (child) => { + if (child !== node && ts.isFunctionLike(child)) return + if ( + ts.isVariableDeclarationList(child) && + (child.flags & ts.NodeFlags.BlockScoped) === 0 + ) { + for (const declaration of child.declarations) { + addDeclaration(scope, declarations, declaration) + } + } + ts.forEachChild(child, collectVarDeclarations) + } + collectVarDeclarations(node) + } + + let changed = true + while (changed) { + changed = false + for (const declaration of declarations) { + const owner = resolveOwner(declaration.initializer, scope) + if (owner && scope.bindings.get(declaration.name.text) !== owner) { + scope.bindings.set(declaration.name.text, owner) + changed = true + } } } + + const visit = (child) => { + if (child !== node && (ts.isBlock(child) || ts.isFunctionLike(child))) { + visitScope(child, scope) + return + } + if (ts.isNewExpression(child)) { + const owner = resolveOwner(child.expression, scope) + if (owner) constructions.set(owner, (constructions.get(owner) ?? 0) + 1) + } + ts.forEachChild(child, visit) + } ts.forEachChild(node, visit) } - visit(sourceFile) + + visitScope(sourceFile, null) return constructions } -function findCombinedSessionFacadeDeclarations(sourceFile, importRecords) { - const aliases = new Map( - importRecords - .filter((record) => SESSION_FACADE_CAPABILITY_CATEGORIES.has(record.importedName)) - .map((record) => [record.localName, record.importedName]) - ) +function findCombinedSessionFacadeDeclarations(sourceFile, importRecords, includeExportedObjects) { + const capabilities = new Map() + for (const record of importRecords) { + const category = SESSION_FACADE_CAPABILITY_CATEGORIES.get(record.importedName) + if (category) capabilities.set(record.localName, new Set([category])) + } + + const typeAliases = [] const facades = [] - const capabilityCategories = (nodes) => { + const categoriesFor = (node) => { + if (ts.isUnionTypeNode(node)) { + const [first = new Set(), ...rest] = node.types.map(categoriesFor) + return new Set([...first].filter((category) => rest.every((set) => set.has(category)))) + } + if (ts.isIntersectionTypeNode(node)) { + return new Set(node.types.flatMap((type) => [...categoriesFor(type)])) + } + if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName)) { + return new Set( + capabilities.get(node.typeName.text) ?? + [SESSION_FACADE_CAPABILITY_CATEGORIES.get(node.typeName.text)].filter(Boolean) + ) + } + if (ts.isExpressionWithTypeArguments(node) && ts.isIdentifier(node.expression)) { + return new Set( + capabilities.get(node.expression.text) ?? + [SESSION_FACADE_CAPABILITY_CATEGORIES.get(node.expression.text)].filter(Boolean) + ) + } + const categories = new Set() - const visit = (node) => { - if (ts.isIdentifier(node)) { - const name = aliases.get(node.text) ?? node.text - const category = SESSION_FACADE_CAPABILITY_CATEGORIES.get(name) - if (category) categories.add(category) + ts.forEachChild(node, (child) => { + for (const category of categoriesFor(child)) categories.add(category) + }) + return categories + } + + const collectTypeAliases = (node) => { + if (ts.isTypeAliasDeclaration(node)) typeAliases.push(node) + ts.forEachChild(node, collectTypeAliases) + } + collectTypeAliases(sourceFile) + for (const alias of typeAliases) capabilities.set(alias.name.text, new Set()) + + let changed = true + while (changed) { + changed = false + for (const alias of typeAliases) { + const next = categoriesFor(alias.type) + const current = capabilities.get(alias.name.text) + if (next.size !== current.size || [...next].some((category) => !current.has(category))) { + capabilities.set(alias.name.text, next) + changed = true } - ts.forEachChild(node, visit) } - for (const node of nodes) visit(node) - return categories + } + + const propertyCategory = (name) => { + const normalized = name.replace(/^session/i, '').toLowerCase() + if (normalized === 'agentassignment') return 'assignment' + return ['lifecycle', 'turn', 'assignment', 'projection'].includes(normalized) + ? normalized + : null } const visit = (node) => { let name = null - let structure = [] + let categories = new Set() if (ts.isInterfaceDeclaration(node)) { name = node.name.text - structure = [...(node.heritageClauses ?? []), ...node.members] + for (const child of [...(node.heritageClauses ?? []), ...node.members]) { + for (const category of categoriesFor(child)) categories.add(category) + } } else if (ts.isTypeAliasDeclaration(node)) { name = node.name.text - structure = [node.type] + categories = capabilities.get(name) } else if (ts.isClassDeclaration(node) && node.name) { name = node.name.text - structure = [...(node.heritageClauses ?? [])] + for (const child of node.heritageClauses ?? []) { + for (const category of categoriesFor(child)) categories.add(category) + } + } else if ( + includeExportedObjects && + ts.isVariableStatement(node) && + node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) + ) { + for (const declaration of node.declarationList.declarations) { + const initializer = declaration.initializer && unwrapExpression(declaration.initializer) + if (!ts.isIdentifier(declaration.name) || !initializer || !ts.isObjectLiteralExpression(initializer)) { + continue + } + const objectCategories = new Set( + initializer.properties + .map((property) => property.name && propertyNameText(property.name)) + .map((propertyName) => propertyName && propertyCategory(propertyName)) + .filter(Boolean) + ) + if (objectCategories.size === 4) facades.push(declaration.name.text) + } } - if (name && capabilityCategories(structure).size === 4) facades.push(name) + if (name && categories.size === 4) facades.push(name) ts.forEachChild(node, visit) } visit(sourceFile) @@ -1259,7 +1376,11 @@ export async function runArchitectureGuard({ virtualFiles = new Map(), memoryCom } const combinedFacades = findIdentifierNames(sourceFile, SESSION_COMBINED_FACADE_NAMES) - for (const facade of findCombinedSessionFacadeDeclarations(sourceFile, importRecords)) { + for (const facade of findCombinedSessionFacadeDeclarations( + sourceFile, + importRecords, + path.resolve(filePath) !== path.resolve(PRESENTER_ROOT_ENTRY) + )) { combinedFacades.add(facade) } for (const facade of combinedFacades) { diff --git a/test/main/scripts/architectureGuard.test.ts b/test/main/scripts/architectureGuard.test.ts index 8d915a5ea..72cb867f8 100644 --- a/test/main/scripts/architectureGuard.test.ts +++ b/test/main/scripts/architectureGuard.test.ts @@ -178,6 +178,11 @@ const SESSION_ROOT_DUPLICATE_CONSTRUCTION_FIXTURE = { new SessionDeletionTransaction() ] export const duplicate = new ChainedTurnOwner() + const lifecycle = {} + const turn = {} + const assignment = {} + const projection = {} + export const composition = { lifecycle, turn, assignment, projection } ` } @@ -186,6 +191,62 @@ const SESSION_DUPLICATE_CONSTRUCTION_FIXTURES = [ SESSION_ROOT_DUPLICATE_CONSTRUCTION_FIXTURE ] +const SESSION_SCOPED_OWNER_ALIAS_FIXTURES = [ + { + filePath: path.join( + SESSION_APPLICATION_ROOT, + '__architecture_guard_scoped_let_owner_alias_fixture__.ts' + ), + rule: '[session-application-duplicate-construction]', + expected: 'SessionTurnCoordinator', + source: ` + import { SessionTurnCoordinator as ImportedOwner } from './turnCoordinator' + let Local = ImportedOwner + export const fixture = new Local() + ` + }, + { + filePath: path.join( + SESSION_APPLICATION_ROOT, + '__architecture_guard_scoped_var_owner_alias_fixture__.ts' + ), + rule: '[session-application-duplicate-construction]', + expected: 'SessionTurnCoordinator', + source: ` + import { SessionTurnCoordinator as ImportedOwner } from './turnCoordinator' + export function fixture() { + if (true) { + var Local = ImportedOwner + } + return new Local() + } + ` + } +] + +const SESSION_SCOPED_OWNER_SHADOW_FIXTURE = { + filePath: path.join( + SESSION_APPLICATION_ROOT, + '__architecture_guard_scoped_owner_shadow_fixture__.ts' + ), + source: ` + import { SessionTurnCoordinator as ImportedOwner } from './turnCoordinator' + function rememberCandidate() { + const Candidate = ImportedOwner + return Candidate + } + function constructCandidate() { + const Candidate = class {} + return new Candidate() + } + function constructExactName() { + const SessionTurnCoordinator = class {} + return new SessionTurnCoordinator() + } + export { rememberCandidate, constructCandidate, constructExactName } + ` +} + const SESSION_FOREIGN_OWNER_FIXTURES = [ ['history', './history'], ['export', './exporter'], @@ -278,6 +339,47 @@ const SESSION_COMBINED_FACADE_FIXTURES = [ export interface SessionCapabilityHub extends Lifecycle, Turn, Assignment, Projection {} ` }) + .concat({ + filePath: path.join( + ROOT, + 'src/main/presenter/__architecture_guard_combined_type_alias_fixture__.ts' + ), + rule: '[session-application-combined-facade]', + expected: 'SessionCapabilityHub', + source: ` + import type { + SessionLifecyclePort as ImportedLifecycle, + SessionTurnPort as ImportedTurn, + SessionAgentAssignmentPort as ImportedAssignment, + SessionProjectionReadPort as ImportedProjection + } from './sessionApplication/ports' + type L = ImportedLifecycle + type T = ImportedTurn + type A = ImportedAssignment + type P = ImportedProjection + export type SessionCapabilityHub = L & T & A & P + ` + }) + .concat({ + filePath: path.join( + ROOT, + 'src/main/presenter/__architecture_guard_combined_exported_object_fixture__.ts' + ), + rule: '[session-application-combined-facade]', + expected: 'hub', + source: ` + const lifecycle = {} + const turn = {} + const assignment = {} + const projection = {} + export const hub = { + sessionLifecycle: lifecycle, + sessionTurn: turn, + sessionAgentAssignment: assignment, + sessionProjection: projection + } + ` + }) const SESSION_NARROW_PORT_FIXTURE = { filePath: path.join( @@ -290,12 +392,59 @@ const SESSION_NARROW_PORT_FIXTURE = { ` } +const SESSION_UNION_CAPABILITY_FIXTURE = { + filePath: path.join( + ROOT, + 'src/main/presenter/__architecture_guard_union_session_capability_fixture__.ts' + ), + source: ` + import type { + SessionLifecyclePort as ImportedLifecycle, + SessionTurnPort as ImportedTurn, + SessionAgentAssignmentPort as ImportedAssignment, + SessionProjectionReadPort as ImportedProjection + } from './sessionApplication/ports' + type L = ImportedLifecycle + type T = ImportedTurn + type A = ImportedAssignment + type P = ImportedProjection + export type SessionCapabilityVariant = L | T | A | P + ` +} + +const SESSION_REMOTE_DEPS_FIXTURE = { + filePath: path.join( + ROOT, + 'src/main/presenter/__architecture_guard_remote_session_deps_fixture__.ts' + ), + source: ` + interface RemoteSessionLifecyclePort {} + interface RemoteSessionTurnPort {} + interface RemoteSessionAssignmentPort {} + interface RemoteSessionProjectionPort {} + export interface RemotePresenterDeps { + lifecycle: RemoteSessionLifecyclePort + turn: RemoteSessionTurnPort + assignment: RemoteSessionAssignmentPort + projection: RemoteSessionProjectionPort + } + ` +} + +const SESSION_SAFE_AGGREGATE_FIXTURES = [ + { ...SESSION_NARROW_PORT_FIXTURE, expected: 'single-capability port' }, + { ...SESSION_UNION_CAPABILITY_FIXTURE, expected: 'capability union' }, + { ...SESSION_REMOTE_DEPS_FIXTURE, expected: 'Remote dependency bundle' } +] + const SESSION_ARCHITECTURE_FIXTURES = [ ...SESSION_DUPLICATE_CONSTRUCTION_FIXTURES, + ...SESSION_SCOPED_OWNER_ALIAS_FIXTURES, + SESSION_SCOPED_OWNER_SHADOW_FIXTURE, ...SESSION_FOREIGN_OWNER_FIXTURES, ...SESSION_WHOLE_DEPENDENCY_FIXTURES, ...SESSION_COMBINED_FACADE_FIXTURES, - SESSION_NARROW_PORT_FIXTURE + ...SESSION_SAFE_AGGREGATE_FIXTURES ] const retiredAgentRuntimeSymbols = [ @@ -706,6 +855,22 @@ describe('architecture guard', () => { } ) + it.each(SESSION_SCOPED_OWNER_ALIAS_FIXTURES)( + 'rejects scoped $expected aliases outside the composition root', + ({ filePath, rule, expected }) => { + const fixtureViolations = sessionViolationsForFile(violations, filePath) + expect(fixtureViolations).toHaveLength(1) + expect(fixtureViolations[0]).toContain(rule) + expect(fixtureViolations[0]).toContain(expected) + } + ) + + it('keeps sibling and exact-name local owner shadows isolated', () => { + expect( + sessionViolationsForFile(violations, SESSION_SCOPED_OWNER_SHADOW_FIXTURE.filePath) + ).toEqual([]) + }) + it.each(SESSION_FOREIGN_OWNER_FIXTURES)( 'keeps the $expected Phase-1 owner outside session coordinators', ({ filePath, rule, expected }) => { @@ -736,9 +901,12 @@ describe('architecture guard', () => { } ) - it('allows a single-capability session port', () => { - expect(sessionViolationsForFile(violations, SESSION_NARROW_PORT_FIXTURE.filePath)).toEqual([]) - }) + it.each(SESSION_SAFE_AGGREGATE_FIXTURES)( + 'allows $expected', + ({ filePath }) => { + expect(sessionViolationsForFile(violations, filePath)).toEqual([]) + } + ) it('keeps renderer legacy boundaries enforced without writing source fixtures', () => { const fixtureViolations = forFile(violations, SETTINGS_FIXTURE).join('\n') From d045d3fe969e09ffca990142ff58e5e3f7a7d167 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 13 Jul 2026 20:53:20 +0800 Subject: [PATCH 20/29] fix(architecture): verify session aggregates --- scripts/architecture-guard.mjs | 214 +++++++++++++++++--- test/main/scripts/architectureGuard.test.ts | 95 ++++++++- 2 files changed, 269 insertions(+), 40 deletions(-) diff --git a/scripts/architecture-guard.mjs b/scripts/architecture-guard.mjs index 94166cfc2..87ba89623 100644 --- a/scripts/architecture-guard.mjs +++ b/scripts/architecture-guard.mjs @@ -503,6 +503,11 @@ function findSessionApplicationOwnerConstructions(sourceFile, importRecords) { } function findCombinedSessionFacadeDeclarations(sourceFile, importRecords, includeExportedObjects) { + const namespaceImports = new Set( + importRecords + .filter((record) => record.importedName === '*') + .map((record) => record.localName) + ) const capabilities = new Map() for (const record of importRecords) { const category = SESSION_FACADE_CAPABILITY_CATEGORIES.get(record.importedName) @@ -512,25 +517,46 @@ function findCombinedSessionFacadeDeclarations(sourceFile, importRecords, includ const typeAliases = [] const facades = [] + const mergeCategories = (sets) => new Set(sets.flatMap((set) => [...set])) + const sameCategories = (left, right) => + left.size === right.size && [...left].every((category) => right.has(category)) + + const qualifiedCategory = (node) => { + let namespace = null + let member = null + if (ts.isQualifiedName(node) && ts.isIdentifier(node.left)) { + namespace = node.left.text + member = node.right.text + } else if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)) { + namespace = node.expression.text + member = node.name.text + } + if (!namespace || !namespaceImports.has(namespace)) return new Set() + const category = SESSION_FACADE_CAPABILITY_CATEGORIES.get(member) + return category ? new Set([category]) : new Set() + } + + const referenceCategories = (node, bindings) => { + if (ts.isIdentifier(node)) { + const category = SESSION_FACADE_CAPABILITY_CATEGORIES.get(node.text) + return new Set(bindings.get(node.text) ?? (category ? [category] : [])) + } + return qualifiedCategory(node) + } + const categoriesFor = (node) => { if (ts.isUnionTypeNode(node)) { const [first = new Set(), ...rest] = node.types.map(categoriesFor) return new Set([...first].filter((category) => rest.every((set) => set.has(category)))) } if (ts.isIntersectionTypeNode(node)) { - return new Set(node.types.flatMap((type) => [...categoriesFor(type)])) + return mergeCategories(node.types.map(categoriesFor)) } - if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName)) { - return new Set( - capabilities.get(node.typeName.text) ?? - [SESSION_FACADE_CAPABILITY_CATEGORIES.get(node.typeName.text)].filter(Boolean) - ) + if (ts.isTypeReferenceNode(node)) { + return referenceCategories(node.typeName, capabilities) } - if (ts.isExpressionWithTypeArguments(node) && ts.isIdentifier(node.expression)) { - return new Set( - capabilities.get(node.expression.text) ?? - [SESSION_FACADE_CAPABILITY_CATEGORIES.get(node.expression.text)].filter(Boolean) - ) + if (ts.isExpressionWithTypeArguments(node)) { + return referenceCategories(node.expression, capabilities) } const categories = new Set() @@ -553,7 +579,7 @@ function findCombinedSessionFacadeDeclarations(sourceFile, importRecords, includ for (const alias of typeAliases) { const next = categoriesFor(alias.type) const current = capabilities.get(alias.name.text) - if (next.size !== current.size || [...next].some((category) => !current.has(category))) { + if (!sameCategories(next, current)) { capabilities.set(alias.name.text, next) changed = true } @@ -568,6 +594,152 @@ function findCombinedSessionFacadeDeclarations(sourceFile, importRecords, includ : null } + const hasAllPropertyCategories = (object) => + new Set( + object.properties + .map((property) => property.name && propertyNameText(property.name)) + .map((name) => name && propertyCategory(name)) + .filter(Boolean) + ).size === 4 + + const exportedObjects = [] + if (includeExportedObjects) { + const localObjects = new Map() + for (const statement of sourceFile.statements) { + if (!ts.isVariableStatement(statement)) continue + const exported = statement.modifiers?.some( + (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword + ) + for (const declaration of statement.declarationList.declarations) { + const initializer = declaration.initializer && unwrapExpression(declaration.initializer) + if ( + !ts.isIdentifier(declaration.name) || + !initializer || + !ts.isObjectLiteralExpression(initializer) + ) { + continue + } + localObjects.set(declaration.name.text, { object: initializer, type: declaration.type }) + if (exported && hasAllPropertyCategories(initializer)) { + exportedObjects.push({ + name: declaration.name.text, + object: initializer, + type: declaration.type + }) + } + } + } + + for (const statement of sourceFile.statements) { + if ( + ts.isExportDeclaration(statement) && + !statement.moduleSpecifier && + statement.exportClause && + ts.isNamedExports(statement.exportClause) + ) { + for (const element of statement.exportClause.elements) { + const localName = element.propertyName?.text ?? element.name.text + const local = localObjects.get(localName) + if (local && hasAllPropertyCategories(local.object)) { + exportedObjects.push({ name: element.name.text, ...local }) + } + } + } + if (ts.isExportAssignment(statement) && !statement.isExportEquals) { + const object = unwrapExpression(statement.expression) + if (ts.isObjectLiteralExpression(object) && hasAllPropertyCategories(object)) { + exportedObjects.push({ name: 'default', object, type: null }) + } + } + } + } + + if (exportedObjects.length > 0) { + const values = new Map() + for (const record of importRecords) { + const category = SESSION_FACADE_CAPABILITY_CATEGORIES.get(record.importedName) + if (category) values.set(record.localName, new Set([category])) + } + + const expressionCategories = (node) => { + if (ts.isIdentifier(node) || ts.isPropertyAccessExpression(node)) { + return referenceCategories(node, values) + } + if (ts.isNewExpression(node)) return expressionCategories(node.expression) + if (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)) { + return mergeCategories([categoriesFor(node.type), expressionCategories(node.expression)]) + } + if (ts.isParenthesizedExpression(node) || ts.isNonNullExpression(node)) { + return expressionCategories(node.expression) + } + return new Set() + } + + const valueDeclarations = [] + const collectValueDeclarations = (node) => { + if ( + (ts.isVariableDeclaration(node) || ts.isParameter(node)) && + ts.isIdentifier(node.name) + ) { + valueDeclarations.push(node) + values.set(node.name.text, new Set()) + } + ts.forEachChild(node, collectValueDeclarations) + } + collectValueDeclarations(sourceFile) + + changed = true + while (changed) { + changed = false + for (const declaration of valueDeclarations) { + const next = mergeCategories([ + values.get(declaration.name.text), + declaration.type ? categoriesFor(declaration.type) : new Set(), + declaration.initializer ? expressionCategories(declaration.initializer) : new Set() + ]) + const current = values.get(declaration.name.text) + if (!sameCategories(next, current)) { + values.set(declaration.name.text, next) + changed = true + } + } + } + + const objectCategories = (object, declaredType) => { + const declaredProperties = new Map() + if (declaredType && ts.isTypeLiteralNode(declaredType)) { + for (const member of declaredType.members) { + const name = member.name && propertyNameText(member.name) + if (name && member.type) declaredProperties.set(name, categoriesFor(member.type)) + } + } + + const categories = new Set() + for (const property of object.properties) { + const name = property.name && propertyNameText(property.name) + const category = name && propertyCategory(name) + if (!category) continue + + let evidence = new Set() + if (ts.isShorthandPropertyAssignment(property)) { + evidence = expressionCategories(property.name) + } else if (ts.isPropertyAssignment(property)) { + evidence = expressionCategories(property.initializer) + } + if (evidence.has(category) || declaredProperties.get(name)?.has(category)) { + categories.add(category) + } + } + return categories + } + + for (const exported of exportedObjects) { + if (objectCategories(exported.object, exported.type).size === 4) { + facades.push(exported.name) + } + } + } + const visit = (node) => { let name = null let categories = new Set() @@ -584,24 +756,6 @@ function findCombinedSessionFacadeDeclarations(sourceFile, importRecords, includ for (const child of node.heritageClauses ?? []) { for (const category of categoriesFor(child)) categories.add(category) } - } else if ( - includeExportedObjects && - ts.isVariableStatement(node) && - node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) - ) { - for (const declaration of node.declarationList.declarations) { - const initializer = declaration.initializer && unwrapExpression(declaration.initializer) - if (!ts.isIdentifier(declaration.name) || !initializer || !ts.isObjectLiteralExpression(initializer)) { - continue - } - const objectCategories = new Set( - initializer.properties - .map((property) => property.name && propertyNameText(property.name)) - .map((propertyName) => propertyName && propertyCategory(propertyName)) - .filter(Boolean) - ) - if (objectCategories.size === 4) facades.push(declaration.name.text) - } } if (name && categories.size === 4) facades.push(name) diff --git a/test/main/scripts/architectureGuard.test.ts b/test/main/scripts/architectureGuard.test.ts index 72cb867f8..6f753c3bf 100644 --- a/test/main/scripts/architectureGuard.test.ts +++ b/test/main/scripts/architectureGuard.test.ts @@ -368,16 +368,75 @@ const SESSION_COMBINED_FACADE_FIXTURES = [ rule: '[session-application-combined-facade]', expected: 'hub', source: ` - const lifecycle = {} - const turn = {} - const assignment = {} - const projection = {} - export const hub = { - sessionLifecycle: lifecycle, - sessionTurn: turn, - sessionAgentAssignment: assignment, - sessionProjection: projection + import type { + SessionLifecyclePort, + SessionTurnPort, + SessionAgentAssignmentPort, + SessionProjectionReadPort + } from './sessionApplication/ports' + declare const lifecycle: SessionLifecyclePort + declare const turn: SessionTurnPort + declare const assignment: SessionAgentAssignmentPort + declare const projection: SessionProjectionReadPort + export const hub = { lifecycle, turn, assignment, projection } + ` + }) + .concat({ + filePath: path.join( + ROOT, + 'src/main/presenter/__architecture_guard_combined_namespace_fixture__.ts' + ), + rule: '[session-application-combined-facade]', + expected: 'SessionCapabilityHub', + source: ` + import type * as Ports from './sessionApplication/ports' + export type SessionCapabilityHub = + Ports.SessionLifecyclePort & + Ports.SessionTurnPort & + Ports.SessionAgentAssignmentPort & + Ports.SessionProjectionReadPort + ` + }) + .concat({ + filePath: path.join( + ROOT, + 'src/main/presenter/__architecture_guard_combined_export_list_fixture__.ts' + ), + rule: '[session-application-combined-facade]', + expected: 'hub', + source: ` + import type { + SessionLifecyclePort, + SessionTurnPort, + SessionAgentAssignmentPort, + SessionProjectionReadPort + } from './sessionApplication/ports' + declare const lifecyclePort: SessionLifecyclePort + declare const turnPort: SessionTurnPort + declare const assignmentPort: SessionAgentAssignmentPort + declare const projectionPort: SessionProjectionReadPort + const hub = { + lifecycle: lifecyclePort, + turn: turnPort, + assignment: assignmentPort, + projection: projectionPort } + export { hub } + ` + }) + .concat({ + filePath: path.join( + ROOT, + 'src/main/presenter/__architecture_guard_combined_default_export_fixture__.ts' + ), + rule: '[session-application-combined-facade]', + expected: 'default', + source: ` + import { SessionLifecycleCoordinator as lifecycle } from './sessionApplication/lifecycleCoordinator' + import { SessionTurnCoordinator as turn } from './sessionApplication/turnCoordinator' + import { SessionAgentAssignmentCoordinator as assignment } from './sessionApplication/agentAssignmentCoordinator' + import { SessionProjectionCoordinator as projection } from './sessionApplication/projectionCoordinator' + export default { lifecycle, turn, assignment, projection } ` }) @@ -431,10 +490,26 @@ const SESSION_REMOTE_DEPS_FIXTURE = { ` } +const SESSION_TELEMETRY_OBJECT_FIXTURE = { + filePath: path.join( + ROOT, + 'src/main/presenter/__architecture_guard_session_telemetry_object_fixture__.ts' + ), + source: ` + export const telemetry = { + lifecycle: 'stable', + turn: 1, + assignment: 'round-robin', + projection: 'orthographic' + } + ` +} + const SESSION_SAFE_AGGREGATE_FIXTURES = [ { ...SESSION_NARROW_PORT_FIXTURE, expected: 'single-capability port' }, { ...SESSION_UNION_CAPABILITY_FIXTURE, expected: 'capability union' }, - { ...SESSION_REMOTE_DEPS_FIXTURE, expected: 'Remote dependency bundle' } + { ...SESSION_REMOTE_DEPS_FIXTURE, expected: 'Remote dependency bundle' }, + { ...SESSION_TELEMETRY_OBJECT_FIXTURE, expected: 'session telemetry object' } ] const SESSION_ARCHITECTURE_FIXTURES = [ From 21785f47a7b6ca4ee9c4a3f570466dff9ff5d471 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 13 Jul 2026 20:57:04 +0800 Subject: [PATCH 21/29] docs(architecture): sync baseline checkpoint --- .../baselines/agent-system-layered-runtime-baseline.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture/baselines/agent-system-layered-runtime-baseline.json b/docs/architecture/baselines/agent-system-layered-runtime-baseline.json index 66b68e299..e791eefb9 100644 --- a/docs/architecture/baselines/agent-system-layered-runtime-baseline.json +++ b/docs/architecture/baselines/agent-system-layered-runtime-baseline.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, "goal": "agent-system-layered-runtime", - "headCommit": "17f3d2da1268f490518703987283d980db090ca0", + "headCommit": "d045d3fe969e09ffca990142ff58e5e3f7a7d167", "relevantWorkingTree": { "dirty": false, "files": [] From 0cd62efdba6b9cf8ecc5d1e4c72b37f9873dc81d Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 13 Jul 2026 21:08:46 +0800 Subject: [PATCH 22/29] docs(session): close coordinator work --- .../session-application-coordinators/spec.md | 2 +- .../session-application-coordinators/tasks.md | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/architecture/session-application-coordinators/spec.md b/docs/architecture/session-application-coordinators/spec.md index 6f9398a01..561d32346 100644 --- a/docs/architecture/session-application-coordinators/spec.md +++ b/docs/architecture/session-application-coordinators/spec.md @@ -1,6 +1,6 @@ # Session Application Coordinators -> Status: approved for implementation +> Status: implemented and validated > Base: `dev@28e2a0e92` > Branch: `task/session-application-coordinators` diff --git a/docs/architecture/session-application-coordinators/tasks.md b/docs/architecture/session-application-coordinators/tasks.md index 9ad07bf88..48e8f5e96 100644 --- a/docs/architecture/session-application-coordinators/tasks.md +++ b/docs/architecture/session-application-coordinators/tasks.md @@ -73,15 +73,15 @@ - [x] Add architecture guards for consumer imports, duplicate construction, foreign-owner imports, and combined façade regression. - [x] Update current architecture, session management, flows, and code navigation. -- [ ] Review the dependency diff and regenerate maintained baselines only when intentional. +- [x] Review the dependency diff and regenerate maintained baselines only when intentional. ## 9. Validation -- [ ] Run focused coordinator, service, route, Remote, Cron, composition, and guard tests. -- [ ] Run `pnpm run format`. -- [ ] Run `pnpm run i18n`. -- [ ] Run `pnpm run lint`. -- [ ] Run `pnpm run typecheck`. -- [ ] Run `pnpm run test:main`. -- [ ] Run `pnpm run lint:architecture` and `git diff --check`. -- [ ] Confirm every acceptance criterion in `spec.md` and close this task list. +- [x] Run focused coordinator, service, route, Remote, Cron, composition, and guard tests. +- [x] Run `pnpm run format`. +- [x] Run `pnpm run i18n`. +- [x] Run `pnpm run lint`. +- [x] Run `pnpm run typecheck`. +- [x] Run `pnpm run test:main`. +- [x] Run `pnpm run lint:architecture` and `git diff --check`. +- [x] Confirm every acceptance criterion in `spec.md` and close this task list. From ea44750ed8b6789425ef907e5730801904cdbda1 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 13 Jul 2026 21:23:09 +0800 Subject: [PATCH 23/29] docs(session): plan stage one integration --- .../session-application-coordinators/tasks.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/architecture/session-application-coordinators/tasks.md b/docs/architecture/session-application-coordinators/tasks.md index 48e8f5e96..728adab02 100644 --- a/docs/architecture/session-application-coordinators/tasks.md +++ b/docs/architecture/session-application-coordinators/tasks.md @@ -85,3 +85,12 @@ - [x] Run `pnpm run test:main`. - [x] Run `pnpm run lint:architecture` and `git diff --check`. - [x] Confirm every acceptance criterion in `spec.md` and close this task list. + +## 10. Stage 1 Integration + +- [ ] Merge the latest `dev`, including Stage 1 PR #1957 and subsequent #1958/#1960 changes. +- [ ] Preserve Stage 1 foreign owners and Stage 2 coordinators across composition, routes, and the + compatibility façade. +- [ ] Reconcile tests, guards, current docs, and architecture baselines without restoring a broad + presenter dependency. +- [ ] Run focused and full validation, push the merge commit, and confirm PR #1961 is mergeable. From 2f3d5a56a622ac7861fe5baf96eff67d9bf0a53a Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 13 Jul 2026 21:50:53 +0800 Subject: [PATCH 24/29] docs(architecture): refresh merged baseline --- .../agent-system-layered-runtime-baseline.json | 9 +++++---- .../baselines/dependency-report.md | 18 +++++++++--------- .../baselines/main-kernel-boundary-baseline.md | 4 ++-- .../main-kernel-migration-scoreboard.json | 2 +- .../main-kernel-migration-scoreboard.md | 2 +- 5 files changed, 18 insertions(+), 17 deletions(-) diff --git a/docs/architecture/baselines/agent-system-layered-runtime-baseline.json b/docs/architecture/baselines/agent-system-layered-runtime-baseline.json index eefb2b78b..05a62c13a 100644 --- a/docs/architecture/baselines/agent-system-layered-runtime-baseline.json +++ b/docs/architecture/baselines/agent-system-layered-runtime-baseline.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, "goal": "agent-system-layered-runtime", - "headCommit": "56fac76458c97ca0055417d8bcd20286be82d509", + "headCommit": "1122b240619afae1bd97c2395f0c820c41ecf048", "relevantWorkingTree": { "dirty": false, "files": [] @@ -75,6 +75,7 @@ "src/main/agent/shared/agentRowStore.ts", "src/main/agent/shared/agentSessionHandle.ts", "src/main/agent/shared/agentSessionIds.ts", + "src/main/agent/shared/agentSessionNormalization.ts", "src/main/agent/shared/agentSharedData.ts", "src/main/agent/shared/appSessionService.ts", "src/main/agent/shared/assistantModelSelection.ts", @@ -304,7 +305,7 @@ "src/shared/contracts/routes/window.routes.ts", "src/shared/contracts/routes/workspace.routes.ts" ], - "sha256": "ab4fcf04f5091ca2f897def98f392ece99a85aea3e9a9f4a569be419a4caa9d9" + "sha256": "a7eef369f151c3679ba6f36e12041fed36fab124f5cd33a897c2c06c9925bcb8" }, "storage": { "sqlite": { @@ -417,7 +418,7 @@ "memory_vector" ], "versionContract": "embedding identity stored in embedding_meta; no numeric schema version", - "sha256": "56d7d6a66e770ff389431935b67505199a7b8c01abc2fdf7be20f98bde8cf0c6" + "sha256": "fdcb17375f3c938763aff162c179f04bc98f0392feebdc0b30b1fd9f48cdc025" } }, "compositionAndShutdown": { @@ -427,7 +428,7 @@ "src/main/presenter/lifecyclePresenter/hooks/beforeQuit/presenterDestroyHook.ts", "src/main/presenter/lifecyclePresenter/index.ts" ], - "sha256": "de762ce51ecfdecca2501bb24e6039900940d8775e16719c76369b2dd7eadd77" + "sha256": "90836b4d0ef4078293ac7d001ef2992f572809a10c67d35fdd8a972e8a66fb86" }, "dependencyMetrics": { "loopFiles": [ diff --git a/docs/architecture/baselines/dependency-report.md b/docs/architecture/baselines/dependency-report.md index a00e12475..22dc3f6b8 100644 --- a/docs/architecture/baselines/dependency-report.md +++ b/docs/architecture/baselines/dependency-report.md @@ -4,13 +4,13 @@ Generated on 2026-07-13. ## main -- Total files: 550 -- Internal dependency edges: 1488 +- Total files: 563 +- Internal dependency edges: 1526 - Cycles detected: 36 ### Top outgoing dependencies -- `presenter/index.ts`: 63 +- `presenter/index.ts`: 70 - `presenter/agentRuntimePresenter/index.ts`: 45 - `presenter/sqlitePresenter/index.ts`: 40 - `presenter/sqlitePresenter/schemaCatalog.ts`: 37 @@ -18,7 +18,7 @@ Generated on 2026-07-13. - `presenter/configPresenter/index.ts`: 27 - `presenter/lifecyclePresenter/hooks/index.ts`: 23 - `presenter/toolPresenter/agentTools/agentToolManager.ts`: 22 -- `presenter/memoryPresenter/index.ts`: 19 +- `presenter/memoryPresenter/index.ts`: 20 - `presenter/llmProviderPresenter/index.ts`: 17 - `agent/acp/runtime/index.ts`: 15 - `presenter/filePresenter/mime.ts`: 14 @@ -29,20 +29,20 @@ Generated on 2026-07-13. ### Top incoming dependencies - `presenter/index.ts`: 49 -- `routes/publishDeepchatEvent.ts`: 44 +- `routes/publishDeepchatEvent.ts`: 42 - `presenter/remoteControlPresenter/types.ts`: 38 - `presenter/sqlitePresenter/tables/baseTable.ts`: 38 +- `agent/shared/agentSessionIds.ts`: 31 - `events.ts`: 30 - `eventbus.ts`: 29 -- `agent/shared/agentSessionIds.ts`: 26 -- `presenter/memoryPresenter/types.ts`: 22 +- `presenter/memoryPresenter/types.ts`: 23 - `presenter/remoteControlPresenter/services/remoteBindingStore.ts`: 22 -- `presenter/sqlitePresenter/index.ts`: 22 +- `presenter/sqlitePresenter/index.ts`: 21 - `presenter/memoryPresenter/ports.ts`: 20 - `presenter/remoteControlPresenter/services/remoteConversationRunner.ts`: 16 +- `presenter/memoryPresenter/domain/types.ts`: 14 - `presenter/filePresenter/BaseFileAdapter.ts`: 13 - `presenter/memoryPresenter/context.ts`: 12 -- `presenter/sqlitePresenter/tables/deepchatTapeEntries.ts`: 12 ### Cycle samples diff --git a/docs/architecture/baselines/main-kernel-boundary-baseline.md b/docs/architecture/baselines/main-kernel-boundary-baseline.md index fe4281422..36bf710cd 100644 --- a/docs/architecture/baselines/main-kernel-boundary-baseline.md +++ b/docs/architecture/baselines/main-kernel-boundary-baseline.md @@ -18,7 +18,7 @@ Current phase: P5. | `renderer.quarantine.windowApi.count` | 0 | | `renderer.quarantine.sourceFile.count` | 0 | | `hotpath.presenterEdge.count` | 9 | -| `runtime.rawTimer.count` | 205 | +| `runtime.rawTimer.count` | 206 | | `migrated.rawChannel.count` | 0 | | `bridge.active.count` | 0 | | `bridge.expired.count` | 0 | @@ -87,7 +87,7 @@ Current phase: P5. ## Raw Timers -- Total count: 205 +- Total count: 206 - `src/main/presenter/githubCopilotDeviceFlow.ts`: 6 - `src/main/presenter/browser/BrowserTab.ts`: 5 diff --git a/docs/architecture/baselines/main-kernel-migration-scoreboard.json b/docs/architecture/baselines/main-kernel-migration-scoreboard.json index 59d3c6e4f..237af9d2b 100644 --- a/docs/architecture/baselines/main-kernel-migration-scoreboard.json +++ b/docs/architecture/baselines/main-kernel-migration-scoreboard.json @@ -14,7 +14,7 @@ "renderer.quarantine.windowApi.count": 0, "renderer.quarantine.sourceFile.count": 0, "hotpath.presenterEdge.count": 9, - "runtime.rawTimer.count": 205, + "runtime.rawTimer.count": 206, "migrated.rawChannel.count": 0, "bridge.active.count": 0, "bridge.expired.count": 0 diff --git a/docs/architecture/baselines/main-kernel-migration-scoreboard.md b/docs/architecture/baselines/main-kernel-migration-scoreboard.md index eb7a04324..5e409c2de 100644 --- a/docs/architecture/baselines/main-kernel-migration-scoreboard.md +++ b/docs/architecture/baselines/main-kernel-migration-scoreboard.md @@ -18,7 +18,7 @@ Phase 0 establishes the comparison baseline. Later phases should update this rep | `renderer.quarantine.windowApi.count` | 0 | baseline | | `renderer.quarantine.sourceFile.count` | 0 | baseline | | `hotpath.presenterEdge.count` | 9 | baseline | -| `runtime.rawTimer.count` | 205 | baseline | +| `runtime.rawTimer.count` | 206 | baseline | | `migrated.rawChannel.count` | 0 | baseline | | `bridge.active.count` | 0 | baseline | | `bridge.expired.count` | 0 | baseline | From cdd7f4ea50ae6e6e5e45dd42ba7fb207be4f70fc Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 13 Jul 2026 21:53:18 +0800 Subject: [PATCH 25/29] docs(session): record boundary integration --- .../session-application-coordinators/plan.md | 18 +++++++------ .../session-application-coordinators/spec.md | 25 ++++++++++--------- .../session-application-coordinators/tasks.md | 6 ++--- 3 files changed, 26 insertions(+), 23 deletions(-) diff --git a/docs/architecture/session-application-coordinators/plan.md b/docs/architecture/session-application-coordinators/plan.md index 3fe617b0a..058c94049 100644 --- a/docs/architecture/session-application-coordinators/plan.md +++ b/docs/architecture/session-application-coordinators/plan.md @@ -12,8 +12,9 @@ The migration uses a strangler sequence inside the existing compatibility façad 6. replace route, Remote, and Cron presenter dependencies; 7. reduce the presenter to forwarding, add guards, and update current docs. -The implementation is performed on `task/session-application-coordinators`, based directly on -`dev@28e2a0e92`. It does not cherry-pick stage 1. +The implementation was performed on `task/session-application-coordinators`, based directly on +`dev@28e2a0e92`, without cherry-picking stage 1. After independent implementation and review, the +branch merged `dev@135779210` and reconciled both ownership changes file by file. ## Planned Module Shape @@ -32,7 +33,7 @@ src/main/routes/ └── hotPathPorts.ts # provider-only adapters; no session presenter adapter src/main/presenter/ -├── agentSessionPresenter/index.ts # compatibility forwarding + stage-1 foreign behavior on dev +├── agentSessionPresenter/index.ts # compatibility forwarding; stage-1 owners stay independent ├── remoteControlPresenter/ # four explicit remote session ports └── cronJobs/ # starter factory over lifecycle + turn ports ``` @@ -166,7 +167,7 @@ integration tasks by themselves. 1. Move `sessionStatusSnapshots`, session/message/Tape/trace read projection, active window operations, rename/pin, event publication, UI refresh, materialization, lightweight mapping, and title generation into `SessionProjectionCoordinator`. -2. Keep stage-1 history search and export code outside this coordinator on the `dev` baseline. +2. Keep stage-1 history search and export code outside this coordinator after integration. 3. Add owner tests for full vs lightweight behavior, status cache, per-window binding, missing/malformed reads, title generation, and update events. 4. Add forwarding methods in `AgentSessionPresenter` without duplicating policy/state. @@ -234,8 +235,8 @@ integration tasks by themselves. ## Slice 8 — Façade Cleanup, Guards, and Documentation 1. Remove migrated state, policy, private helpers, imports, and direct implementations from - `AgentSessionPresenter`; leave only forwarding for the four domains plus stage-1 foreign behavior - still present on `dev`. + `AgentSessionPresenter`; leave only forwarding for the four domains while stage-1 foreign behavior + remains with its explicit owners. 2. Keep `IAgentSessionPresenter` public signatures unchanged in stage 2. 3. Move behavior tests to coordinator suites; presenter tests cover forwarding/compatibility only. 4. Add architecture guards that reject: @@ -299,8 +300,9 @@ clean relevant tree. - Each coordinator slice keeps façade forwarding, so later slices can be reverted independently. - Do not remove a façade implementation until owner tests and all migrated consumers call the new owner. -- Stage-1 integration requires a deliberate rebase. Preserve stage-1 foreign owner wiring and stage-2 - coordinator wiring file by file; never accept either side wholesale for presenter/composition/routes. +- Stage-1 integration was completed by merging `dev@135779210`. The resolution preserved stage-1 + foreign owner wiring and stage-2 coordinator wiring file by file; no presenter, composition, or + route conflict was accepted wholesale. ## Exit Gate diff --git a/docs/architecture/session-application-coordinators/spec.md b/docs/architecture/session-application-coordinators/spec.md index 561d32346..c82f3ade2 100644 --- a/docs/architecture/session-application-coordinators/spec.md +++ b/docs/architecture/session-application-coordinators/spec.md @@ -1,7 +1,8 @@ # Session Application Coordinators -> Status: implemented and validated -> Base: `dev@28e2a0e92` +> Status: implemented, integrated, and pending final validation +> Original base: `dev@28e2a0e92` +> Integrated base: `dev@135779210` via merge commit `1122b2406` > Branch: `task/session-application-coordinators` ## Context @@ -26,14 +27,14 @@ only one or two operations. This goal is stage 2 of the session boundary cleanup sequence: -1. foreign capability cleanup (`codex/session-boundary-cleanup`, complete but not merged into `dev`); +1. foreign capability cleanup (`codex/session-boundary-cleanup`, merged in PR #1957); 2. **session application coordinators** (this goal); 3. presenter retirement after all remaining compatibility consumers are rewired. -This branch intentionally starts from `dev`, not from stage 1. Stage-1 capabilities remain untouched -here so both branches can be reviewed independently. Whichever branch merges second must preserve -both ownership splits while resolving composition-root, presenter, route, guard, test, and current-doc -conflicts. +This branch was developed from `dev@28e2a0e92`, independently of stage 1, so both ownership changes +could be reviewed separately. It later merged `dev@135779210`; the integration preserves both splits +across the composition root, presenter, routes, guards, tests, current docs, and architecture +baselines. ## Problem @@ -71,8 +72,8 @@ The current façade produces five concrete architecture failures: ## Non-goals -- Merging or modifying stage-1 history, import, migration, usage, RTK, export, translation, or - catalog ownership. Those methods remain on the compatibility façade on this `dev` baseline. +- Reimplementing or absorbing stage-1 history, import, migration, usage, RTK, export, translation, or + catalog ownership. After integration those capabilities remain with their explicit stage-1 owners. - Removing `AgentSessionPresenter`, deleting `IAgentSessionPresenter`, or rewiring every remaining compatibility caller. That is stage 3. - Moving runtime state out of `DeepChatAgentInstance`, `AcpAgentInstance`, or `LoopRun`. @@ -154,8 +155,8 @@ Owns renderer-facing projection state and projection operations: It is a composition-owned singleton. Creating one instance per consumer is forbidden because window bindings and status snapshots must remain coherent. -History search and export remain stage-1 foreign capabilities on this base and must not be absorbed -into Projection. +History search and export remain stage-1 foreign capabilities after integration and must not be +absorbed into Projection. ## Target Dependency Shape @@ -205,7 +206,7 @@ defined as `Pick` and must not be grouped under a r 8. Cron starter construction occurs in the composition root, not as a route-runtime side effect. 9. Required dependencies fail fast. No optional coordinator methods, capability probes, no-op fallbacks, or `as unknown as` wiring are permitted. -10. Phase-1 foreign methods are left unchanged on this branch and are not imported by the new +10. Stage-1 foreign owners remain independent after integration and are not imported by the new coordinators. ## Compatibility Invariants diff --git a/docs/architecture/session-application-coordinators/tasks.md b/docs/architecture/session-application-coordinators/tasks.md index 728adab02..d3e428cc0 100644 --- a/docs/architecture/session-application-coordinators/tasks.md +++ b/docs/architecture/session-application-coordinators/tasks.md @@ -88,9 +88,9 @@ ## 10. Stage 1 Integration -- [ ] Merge the latest `dev`, including Stage 1 PR #1957 and subsequent #1958/#1960 changes. -- [ ] Preserve Stage 1 foreign owners and Stage 2 coordinators across composition, routes, and the +- [x] Merge the latest `dev`, including Stage 1 PR #1957 and subsequent #1958/#1960 changes. +- [x] Preserve Stage 1 foreign owners and Stage 2 coordinators across composition, routes, and the compatibility façade. -- [ ] Reconcile tests, guards, current docs, and architecture baselines without restoring a broad +- [x] Reconcile tests, guards, current docs, and architecture baselines without restoring a broad presenter dependency. - [ ] Run focused and full validation, push the merge commit, and confirm PR #1961 is mergeable. From 8066de770937f7e76c5c7c1aaa16ec25feda8b55 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 13 Jul 2026 22:05:12 +0800 Subject: [PATCH 26/29] test(architecture): reuse guard scans --- scripts/architecture-guard.mjs | 2 +- test/main/scripts/architectureGuard.test.ts | 95 ++++++++------------- 2 files changed, 36 insertions(+), 61 deletions(-) diff --git a/scripts/architecture-guard.mjs b/scripts/architecture-guard.mjs index 55edcb613..64a852ae6 100644 --- a/scripts/architecture-guard.mjs +++ b/scripts/architecture-guard.mjs @@ -925,7 +925,7 @@ function newMapSignature(property, sourceFile) { return initializer.typeArguments.map((node) => node.getText(sourceFile).replaceAll(/\s/g, '')) } -function analyzeMemoryRuntimeCoordinatorStructure(source, filePath) { +export function analyzeMemoryRuntimeCoordinatorStructure(source, filePath) { const sourceFile = sourceFileForAst(source, filePath) const classes = findNamedClassDeclarations(sourceFile, 'MemoryRuntimeCoordinator') if (classes.length === 0) { diff --git a/test/main/scripts/architectureGuard.test.ts b/test/main/scripts/architectureGuard.test.ts index 24fd1a153..9af273130 100644 --- a/test/main/scripts/architectureGuard.test.ts +++ b/test/main/scripts/architectureGuard.test.ts @@ -3,7 +3,10 @@ import path from 'node:path' import ts from 'typescript' import { beforeAll, describe, expect, it } from 'vitest' -import { runArchitectureGuard } from '../../../scripts/architecture-guard.mjs' +import { + analyzeMemoryRuntimeCoordinatorStructure, + runArchitectureGuard +} from '../../../scripts/architecture-guard.mjs' import { analyzeMemoryArchitecture } from '../../../scripts/lib/memory-architecture-guard.mjs' const ROOT = process.cwd() @@ -115,26 +118,15 @@ const REMOTE_CONTROL_PRESENTER_PATH = path.join( 'src/main/presenter/remoteControlPresenter/index.ts' ) -const SESSION_PRODUCTION_CONSUMER_FIXTURES = [ - { - filePath: REMOTE_CONTROL_PRESENTER_PATH, - rule: '[session-consumer-presenter-type]', - expected: 'IAgentSessionPresenter', - source: ` - import type { IAgentSessionPresenter as SessionFacade } from '@shared/presenter' - export type Fixture = SessionFacade - ` - }, - { - filePath: REMOTE_CONTROL_PRESENTER_PATH, - rule: '[session-consumer-presenter-facade]', - expected: 'agentSessionPresenter', - source: ` - declare const presenter: Record - export const fixture = presenter['agentSessionPresenter'] - ` - } -] +const SESSION_PRODUCTION_CONSUMER_FIXTURE = { + filePath: REMOTE_CONTROL_PRESENTER_PATH, + source: ` + import type { IAgentSessionPresenter as SessionFacade } from '@shared/presenter' + declare const presenter: Record + export type Fixture = SessionFacade + export const fixture = presenter['agentSessionPresenter'] + ` +} const SESSION_OUTSIDE_ROOT_CONSTRUCTION_FIXTURES = [ 'SessionProjectionCoordinator', @@ -513,6 +505,7 @@ const SESSION_SAFE_AGGREGATE_FIXTURES = [ ] const SESSION_ARCHITECTURE_FIXTURES = [ + SESSION_PRODUCTION_CONSUMER_FIXTURE, ...SESSION_DUPLICATE_CONSTRUCTION_FIXTURES, ...SESSION_SCOPED_OWNER_ALIAS_FIXTURES, SESSION_SCOPED_OWNER_SHADOW_FIXTURE, @@ -553,6 +546,7 @@ const typeProperty = ['ty', 'pe'].join('') const virtualFiles = new Map([ ...SESSION_ARCHITECTURE_FIXTURES.map(({ filePath, source }) => [filePath, source] as const), + [DUPLICATE_MEMORY_COORDINATOR_FIXTURE, 'export class MemoryRuntimeCoordinator {}'], [ SETTINGS_FIXTURE, ` @@ -917,16 +911,6 @@ const VALID_MEMORY_COORDINATOR_FIXTURE = ` } ` -async function memoryCoordinatorFixtureViolations( - source: string, - additionalVirtualFiles: Map = new Map() -): Promise { - const violations = await runArchitectureGuard({ - virtualFiles: new Map([[MEMORY_COORDINATOR_PATH, source], ...additionalVirtualFiles]) - }) - return violations.filter((violation) => violation.includes('[memory-coordinator-')) -} - async function invalidCompilerViolations(memoryCompiler: Record) { return analyzeMemoryArchitecture({ root: ROOT, @@ -954,19 +938,16 @@ describe('architecture guard', () => { expect(result.stdout).toContain('Architecture guard passed.') }) - it.each(SESSION_PRODUCTION_CONSUMER_FIXTURES)( - 'guards the production Remote presenter path from $expected', - async ({ filePath, source, rule, expected }) => { - const fixtureViolations = sessionViolationsForFile( - await runArchitectureGuard({ virtualFiles: new Map([[filePath, source]]) }), - filePath - ) - expect(fixtureViolations).toHaveLength(1) - expect(fixtureViolations[0]).toContain(rule) - expect(fixtureViolations[0]).toContain(expected) - }, - 10_000 - ) + it('guards the production Remote presenter path from broad session presenter access', () => { + const fixtureViolations = sessionViolationsForFile( + violations, + SESSION_PRODUCTION_CONSUMER_FIXTURE.filePath + ) + expect(fixtureViolations).toEqual([ + expect.stringContaining('[session-consumer-presenter-type]'), + expect.stringContaining('[session-consumer-presenter-facade]') + ]) + }) it.each(SESSION_DUPLICATE_CONSTRUCTION_FIXTURES)( 'rejects duplicate construction of $expected', @@ -1137,7 +1118,7 @@ describe('architecture guard', () => { it( 'requires the coordinator owner structure without locking method bodies', - async () => { + () => { const emptyFixture = 'export class MemoryRuntimeCoordinator {}' const missingQueueFixture = VALID_MEMORY_COORDINATOR_FIXTURE.replace( /\s+private readonly extractionQueue = new Map<[\s\S]*?>\(\)/, @@ -1147,21 +1128,15 @@ describe('architecture guard', () => { '\n private nextExtractionQueueId = 0', '' ) - const [valid, empty, missingQueue, missingCounter, duplicate] = await Promise.all([ - memoryCoordinatorFixtureViolations(VALID_MEMORY_COORDINATOR_FIXTURE), - memoryCoordinatorFixtureViolations(emptyFixture), - memoryCoordinatorFixtureViolations(missingQueueFixture), - memoryCoordinatorFixtureViolations(missingCounterFixture), - memoryCoordinatorFixtureViolations( - VALID_MEMORY_COORDINATOR_FIXTURE, - new Map([ - [ - DUPLICATE_MEMORY_COORDINATOR_FIXTURE, - 'export class MemoryRuntimeCoordinator {}' - ] - ]) - ) - ]) + const fixtureViolations = (source: string) => + analyzeMemoryRuntimeCoordinatorStructure(source, MEMORY_COORDINATOR_PATH).violations + const valid = fixtureViolations(VALID_MEMORY_COORDINATOR_FIXTURE) + const empty = fixtureViolations(emptyFixture) + const missingQueue = fixtureViolations(missingQueueFixture) + const missingCounter = fixtureViolations(missingCounterFixture) + const duplicate = violations.filter((violation) => + violation.startsWith('[memory-coordinator-owner-count]') + ) expect(valid).toEqual([]) expect(empty.join('\n')).toContain('[memory-coordinator-missing-extraction-chain]') From 7182d796614822296a5ab0ca2b120ae54bd8b1cf Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 13 Jul 2026 22:05:36 +0800 Subject: [PATCH 27/29] docs(session): record integration validation --- docs/architecture/session-application-coordinators/spec.md | 2 +- docs/architecture/session-application-coordinators/tasks.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/architecture/session-application-coordinators/spec.md b/docs/architecture/session-application-coordinators/spec.md index c82f3ade2..3430e450f 100644 --- a/docs/architecture/session-application-coordinators/spec.md +++ b/docs/architecture/session-application-coordinators/spec.md @@ -1,6 +1,6 @@ # Session Application Coordinators -> Status: implemented, integrated, and pending final validation +> Status: implemented, integrated, and validated > Original base: `dev@28e2a0e92` > Integrated base: `dev@135779210` via merge commit `1122b2406` > Branch: `task/session-application-coordinators` diff --git a/docs/architecture/session-application-coordinators/tasks.md b/docs/architecture/session-application-coordinators/tasks.md index d3e428cc0..42fc168d1 100644 --- a/docs/architecture/session-application-coordinators/tasks.md +++ b/docs/architecture/session-application-coordinators/tasks.md @@ -93,4 +93,5 @@ compatibility façade. - [x] Reconcile tests, guards, current docs, and architecture baselines without restoring a broad presenter dependency. -- [ ] Run focused and full validation, push the merge commit, and confirm PR #1961 is mergeable. +- [x] Run focused and full validation. +- [ ] Push the integration commits and confirm PR #1961 is mergeable. From 7976a37dd863cf18d72c090c8c1c732d126305b8 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 13 Jul 2026 22:07:55 +0800 Subject: [PATCH 28/29] docs(session): close dev integration --- docs/architecture/session-application-coordinators/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture/session-application-coordinators/tasks.md b/docs/architecture/session-application-coordinators/tasks.md index 42fc168d1..19918c568 100644 --- a/docs/architecture/session-application-coordinators/tasks.md +++ b/docs/architecture/session-application-coordinators/tasks.md @@ -94,4 +94,4 @@ - [x] Reconcile tests, guards, current docs, and architecture baselines without restoring a broad presenter dependency. - [x] Run focused and full validation. -- [ ] Push the integration commits and confirm PR #1961 is mergeable. +- [x] Push the integration commits and confirm PR #1961 is mergeable. From b4dd7ec834e852a238a9d2967d6f8dc047953f56 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 13 Jul 2026 23:21:30 +0800 Subject: [PATCH 29/29] fix(session): address review feedback --- docs/FLOWS.md | 2 +- .../sessionApplication/lifecycleCoordinator.ts | 2 +- .../agentAssignmentCoordinator.test.ts | 11 ++++------- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/docs/FLOWS.md b/docs/FLOWS.md index a1154f7db..cfa6279f0 100644 --- a/docs/FLOWS.md +++ b/docs/FLOWS.md @@ -154,7 +154,7 @@ sequenceDiagram ```mermaid flowchart TD - Compat["AgentSessionPresenter
compatibility forwarding"] --> App["Session application coordinator"] + CompatFacade["AgentSessionPresenter
compatibility forwarding"] --> App["Session application coordinator"] App --> Manager["AgentManager"] Manager --> Kind{"descriptor.kind"} Kind -->|acp| Direct["DirectAcpSessionBackend"] diff --git a/src/main/presenter/sessionApplication/lifecycleCoordinator.ts b/src/main/presenter/sessionApplication/lifecycleCoordinator.ts index 5b1d037e7..464eb1619 100644 --- a/src/main/presenter/sessionApplication/lifecycleCoordinator.ts +++ b/src/main/presenter/sessionApplication/lifecycleCoordinator.ts @@ -75,7 +75,7 @@ export class SessionLifecycleCoordinator implements SessionLifecyclePort { disabledAgentTools, subagentEnabled }) - logger.info(`[SessionLifecycleCoordinator] session created id=${sessionId} title="${title}"`) + logger.info(`[SessionLifecycleCoordinator] session created id=${sessionId}`) try { await this.initializeSessionRuntime(sessionId, { diff --git a/test/main/presenter/sessionApplication/agentAssignmentCoordinator.test.ts b/test/main/presenter/sessionApplication/agentAssignmentCoordinator.test.ts index bb1e53cac..2c817eb56 100644 --- a/test/main/presenter/sessionApplication/agentAssignmentCoordinator.test.ts +++ b/test/main/presenter/sessionApplication/agentAssignmentCoordinator.test.ts @@ -242,16 +242,13 @@ describe('SessionAgentAssignmentCoordinator', () => { }) it('preflights every batch entry before the first transfer mutation', async () => { - const harness = createHarness([createSession({ id: 's1' }), createSession({ id: 's2' })]) + const harness = createHarness([ + createSession({ id: 's1' }), + createSession({ id: 's2', projectDir: '/blocked' }) + ]) harness.policy.resolveTransferTarget.mockImplementation( async (_agentId: string, projectDir: string | null) => { if (projectDir === '/blocked') throw new Error('blocked project') - if ( - projectDir === '/source' && - harness.policy.resolveTransferTarget.mock.calls.length > 2 - ) { - throw new Error('blocked project') - } return { agentId: 'target', providerId: 'openai',