diff --git a/.gitignore b/.gitignore index 8e93bff46c..0e0e4d02d4 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ .package-temp-dir .tmp/ +/tools/playwright/tmp/ # Logs logs diff --git a/packages/ai-native/CONTEXT.md b/packages/ai-native/CONTEXT.md index f40de88573..1ed7a2ed61 100644 --- a/packages/ai-native/CONTEXT.md +++ b/packages/ai-native/CONTEXT.md @@ -1,82 +1,82 @@ # Agentic Layout -This context describes enhancements to OpenSumi's existing workspace-local Agentic Layout. Its desktop workbench combines an Agent Task List, an ACP Main Conversation Area, the editor, and the file tree; it is not a separate Agent-management application. +This context describes OpenSumi's workspace-local Agentic Layout. Its desktop workbench combines an Agent Session Browser, an ACP Main Conversation Area, the editor, and the file tree; it is not a separate Agent-management application. ## Language **ACP Agent**: An Agent implementation that communicates with OpenSumi through the Agent Client Protocol. Every Agent available in Agentic Layout must be an ACP Agent. _Avoid_: Agent provider, non-ACP backend -**ACP Agent Identity**: The stable, non-reusable `agentId` that owns an ACP Agent's Session namespace across compatible Agent upgrades. A materially incompatible Agent implementation receives a new identity or migrates its existing Sessions; Agent Tasks retain only this identity rather than a snapshot of process configuration or credentials. _Avoid_: Agent command name, current Agent selection, process configuration fingerprint +**Standby ACP Process**: An initialized ACP Agent process that is not bound to an Agent Session and is available for a compatible Session Launch or Agent Session restoration. Maintaining standby capacity is best-effort under finite resource limits and does not guarantee that every ACP configuration can avoid process cold start. _Avoid_: Guaranteed warm session, pre-created Session, unlimited warm pool -**ACP Agent Catalog**: The host-platform-managed list of validated ACP Agents available for Task Launch. Catalog entries use stable ACP Agent Identities that must not be reassigned to incompatible Agent implementations. Developers may select from the catalog but cannot add arbitrary ACP endpoints or server commands in B-lite. If no ACP Agent is available, Task Launch is unavailable while Project Management remains available. _Avoid_: Agent marketplace, custom provider list +**ACP Standby Target**: The exact ACP runtime configuration selected by the current or last confirmed Session Launch context for which Agentic Layout attempts to maintain Standby ACP Process capacity throughout the IDE page lifecycle. Project Addition and historical Session Selection do not change it; changing the Session Draft's selected ACP Agent or Workspace Target replaces it, so an unbound standby or in-flight warmup for a superseded target may be reclaimed without affecting active Agent Sessions. _Avoid_: Global warm pool, any idle Agent process, per-Agent-only target -**Agentic Layout**: The existing workspace-local OpenSumi layout mode that presents Agent Task List and ACP Main Conversation Area inside the ACP Chat Slot alongside the existing editor and file tree workbench. _Avoid_: Cross-workspace task center, separate agent application +**ACP Agent Identity**: The stable, non-reusable `agentId` that owns an ACP Agent's Session namespace across compatible Agent upgrades. A materially incompatible Agent implementation receives a new identity or migrates its existing Sessions. _Avoid_: Agent command name, current Agent selection, process configuration fingerprint -**Agentic Layout Isolation**: The boundary that Agentic Layout changes must not alter IDE Layout lifecycle, Workspace behavior, or shared layout interactions. Agent-specific composition and behavior live within Agentic Layout; a shared component may be reused only when it is a stable, presentation-only primitive with no IDE Layout coupling. _Avoid_: IDE Layout modification, shared behavior regression, coupled layout component +**Agent Session Identity**: The raw ACP `sessionId` is assumed unique across configured ACP Agents for Agentic Layout routing. If Agent discovery violates that assumption, every colliding result is excluded for that refresh rather than being assigned to an arbitrary Agent. _Avoid_: Agent-local session key, guessed route -**Agent Task List**: The persistent, resizable left subregion of the ACP Chat Slot in Agentic Layout. It presents one unified cross-Agent collection, manages Project Groups, groups Agent Tasks by Project, searches immutable Task Titles, and selects a Task through Session-first Task Selection. An Agent selected for Task Launch does not implicitly filter this list; Agent identity is an attribute of each Task and may be used by an explicit filter. It is not a header popover or an IDE Layout sidebar. _Avoid_: Per-Agent task list, Task history menu, persistent IDE sidebar, separate task center +**ACP Agent Catalog**: The host-platform-managed list of validated ACP Agents available for Session Launch and discovery. Catalog entries use stable ACP Agent Identities that must not be reassigned to incompatible Agent implementations. Developers may select from the catalog but cannot add arbitrary ACP endpoints or server commands in B-lite. If no ACP Agent is available, Session Launch is unavailable while Project Management remains available. _Avoid_: Agent marketplace, custom provider list -**Task List Attention Indicator**: The attention state rendered on a non-active Task Row when its ACP Task Conversation has a pending ACP Attention Signal. It may be summarized in the Task List header, but is distinct from an unread marker. _Avoid_: Global application badge, unread count +**Agentic Layout**: The existing workspace-local OpenSumi layout mode that presents the Agent Session Browser and ACP Main Conversation Area inside the ACP Chat Slot alongside the existing editor and file tree workbench. _Avoid_: Cross-workspace task center, separate agent application -**Agent Task**: A durable unit of Agent work bound to exactly one Workspace Target, exactly one originating ACP Agent, and exactly one Task Conversation. Its ACP Agent binding does not automatically change when another Agent becomes current or the originating Agent becomes unavailable. Selecting a Task restores its Task Conversation in Agentic Layout without changing the IDE's current Workspace; the bound Workspace Target remains visible as the Task's execution context. _Avoid_: Chat, conversation, background job +**Agent Session Browser**: The persistent, resizable left subregion of Agentic Layout. It presents only ACP Sessions returned by configured Agents through `session/list` for currently available Known Workspace Targets. It groups Sessions in Workspace Catalog order and orders each group by Agent-provided `updatedAt`; missing titles use a generic unnamed-session label. A refresh atomically replaces the browser snapshot but does not invalidate an already loaded active Session when that Session is omitted or its Agent query fails. `session_info_update` may refresh metadata for a listed Session but does not independently add one; updates received during a refresh remain authoritative over the replacing discovery snapshot. Overlapping Session loads are latest-intent only: a superseded replay never changes the active Session. _Avoid_: Local task history, cached fallback list, prompt-derived title, synthetic current-session row -**Unavailable ACP Agent**: A non-durable condition of a retained Agent Task whose originating ACP Agent is absent from the current ACP Agent Catalog or cannot currently be started with its registered configuration. Catalog absence is derived while presenting the Task List, without starting an Agent or changing the retained Task; the Task remains bound to that Agent and must not fall back to another Agent. _Avoid_: Current Agent mismatch, missing session +**Agent Session Authority**: The originating ACP Agent is authoritative for Session existence, Session metadata, and durable conversation content. OpenSumi discovers metadata through `session/list` and reconstructs content from `session/load` updates and result. While an ACP Thread remains active, it also retains each client-submitted User Prompt as a replay-shaped `user_message_chunk` so a page reload does not lose that turn when the Agent omits the user echo; a matching Agent echo is suppressed as a duplicate. This page-lifecycle retention is not durable history and cannot restore a Prompt after the backing Thread is released. Local Durable Task metadata must not create, rename, restore, or otherwise substitute for an Agent Session. _Avoid_: Local transcript authority, Task-registry fallback, inferred history -**Unavailable Task Conversation**: A non-durable condition observed when an Agent Task's originating ACP Agent is reachable but cannot load the Task's bound ACP session. The condition is re-evaluated when the Task Row is selected again rather than retained as Task List Metadata; the Agent Task remains the same Task, and the condition is neither a separate Task type nor ACP Task Status. _Avoid_: Unavailable ACP Agent, deleted Task, ACP error status +**Agent Session Discovery Refresh**: A serial traversal of every available ACP Agent across every available Known Workspace Target. If any target query fails for an Agent, all results from that Agent are discarded for that refresh. Failures are silent in the UI and produce only bounded diagnostics. Refresh occurs on Agentic Layout entry, after a newly created Session accepts its first prompt, and on relevant catalog lifecycle changes; it is not manually triggered or polled. _Avoid_: Background polling, partial Agent snapshot, stale fallback -**Task Conversation**: The single ACP session through which the developer and Agent interact throughout an Agent Task. _Avoid_: Chat tab, subchat, conversation thread +**Agentic Layout Isolation**: The boundary that Agentic Layout changes must not alter IDE Layout lifecycle, Workspace behavior, or shared layout interactions. Agent-specific composition and behavior live within Agentic Layout; a shared component may be reused only when it is a stable, presentation-only primitive with no IDE Layout coupling. _Avoid_: IDE Layout modification, shared behavior regression, coupled layout component -**Durable Agent Task**: An Agent Task whose ACP session state and recoverable history outlive browser reloads, workspace navigation, temporary disconnection, and closure of the originating Web IDE page. _Avoid_: Persistent chat, detached process +**Legacy Durable Task Metadata**: Previously persisted Task, archived, unread, attention, status, pending activation, and remembered-active records. These records remain stored for compatibility but Agentic Layout no longer reads or writes them for Session discovery, selection, restoration, notification, or project-removal decisions. The separate Agent Session Archive Marker is not legacy Task metadata. _Avoid_: Session source, migration input, fallback history -**Shared Workspace Concurrency**: The execution model in which multiple Agent Tasks for the same Workspace Target run in separate ACP Threads while sharing the same backing directory. ACP Agents are responsible for coordinating their work; B-lite does not isolate, serialize, detect conflicts, or verify exclusive change attribution. _Avoid_: Parallel workspace, isolated task +**Agent Session Archive Marker**: A local, user-profile-scoped presentation preference keyed by the Agent Session's `{agentId, cwd, sessionId}` route. It moves an Agent-returned Session between the active and archived sections of the Agent Session Browser without closing, deleting, renaming, or otherwise mutating the Agent-owned Session. An archive marker is displayed only while the matching Session remains present in the current Agent discovery snapshot. _Avoid_: Agent archive state, Session deletion, Durable Task record -**Project Group**: The Agent Task List section for one Known Workspace Target. A Project Group remains visible even when it contains no Agent Tasks so that its Project-group New Task action is available. Project Groups are ordered by Catalog Joined At with the newest Project first; inside each group, Tasks are ordered by creation time with the newest Task first. _Avoid_: Workspace section, task category +**Unavailable Agent Session**: A page-local condition observed when the originating Agent cannot complete `session/load` for a selected Session. The previous active Session remains visible, the failed row is marked unavailable for the current page lifecycle, and selecting the row again retries the same raw `sessionId`. _Avoid_: Deleted local Task, permanent failure metadata, draft fallback -**Catalog Joined At**: The time a Known Workspace Target was added to the user's Workspace Catalog, used to keep Project Group ordering stable and place newly added projects first. _Avoid_: Directory creation time, last opened time +**Agent Session**: The ACP conversation through which the developer and Agent interact. Its existence, metadata, retained transcript, and durability are owned by the Agent; OpenSumi keeps page-local presentation state and the target route needed to load or continue it. _Avoid_: Local Task record, chat tab, detached process -**Task Row**: The compact Agent Task List representation containing Task Title, originating ACP Agent identity, primary ACP state text, and unread marker. Selecting a Task Row activates its Task Conversation; selecting it again after an Unavailable Task Conversation result retries the same activation without a separate retry action. A pending ACP Attention Signal replaces ACP Task Status in the state text. When an Archive or Unarchive action is revealed by hover or keyboard focus, the state text is visually hidden without changing row layout. _Avoid_: Task card, task summary +**Shared Workspace Concurrency**: The execution model in which multiple Agent Sessions for the same Workspace Target run in separate ACP Threads while sharing the same backing directory. ACP Agents are responsible for coordinating their work; Agentic Layout does not isolate, serialize, detect conflicts, or verify exclusive change attribution. _Avoid_: Parallel workspace, isolated task -**Task Archive**: The user action available when live ACP Task Status is `ready`, `stopped`, or `error`, when the Task Conversation is unavailable, or when the originating ACP Agent is unavailable, that removes an Agent Task from the active Agent Task List while preserving it for later discovery and restoration in Agentic Chat View. Archiving a Task with only Last-known ACP Task Status first validates the Task Conversation through its originating ACP Agent. Tasks confirmed as `initializing`, `running`, or `stopping` cannot be archived. When the Agent is unavailable and validation is impossible, archiving is allowed but does not claim to stop Agent work that might still exist. Archiving is the developer's explicit indication that no further task interaction is currently needed; B-lite does not permanently delete archived Tasks, Task Conversations, or Task Artifacts. _Avoid_: Delete task, complete task +**Project Group**: The Agent Session Browser section for one available Known Workspace Target. It remains visible when it contains no Sessions so its New Session action and project management remain available. Project Groups follow Workspace Catalog order; Sessions inside a group are ordered by Agent-provided `updatedAt` descending. _Avoid_: Task category, Agent-owned project catalog -**Task Retention**: The host-platform-managed policy that governs eventual storage and deletion of archived Agent Tasks, Task Conversations, and Task Artifacts. B-lite exposes archive and unarchive actions but no permanent deletion action. _Avoid_: Client-side deletion, archive expiration +**Catalog Joined At**: The time a Known Workspace Target was added to the user's Workspace Catalog and part of its stable catalog order. _Avoid_: Directory creation time, last opened time -**Archived Area**: The collapsed-by-default area at the bottom of the Agent Task List where archived tasks remain grouped by Project and can be unarchived. _Avoid_: Trash, completed section +**Session Row**: The compact Agent Session Browser representation containing the Agent-provided title, originating ACP Agent identity, and page-local pending or unavailable state. It may expose local archive or unarchive actions, but has no unread, attention, follow, or persisted task-status affordances. _Avoid_: Task card, prompt-derived summary, local status row -**Workspace Target**: The project, workspace, worktree, or remote development environment in which an Agent Task operates. _Avoid_: Current workspace, repository +**Workspace Target**: The project, workspace, worktree, or remote development environment used as an ACP Session's `cwd`. _Avoid_: Current workspace, repository -**Known Workspace Target**: A Workspace Target already authorized and made selectable in Agentic Layout. _Avoid_: Recent folder, arbitrary repository +**Known Workspace Target**: A Workspace Target explicitly authorized and retained in the local Workspace Catalog. Only available Known Workspace Targets participate in Agent Session discovery. _Avoid_: Recent folder, arbitrary repository -**Unavailable Workspace Target**: A Workspace Target that is no longer authorized or currently reachable. Its Agent Tasks and history are retained but its Project Group is hidden from the default Agent Task List and its Task Launch is unavailable until access is restored. This availability condition is independent of ACP Task Status. _Avoid_: ACP stopped state, deleted project +**Unavailable Workspace Target**: A Known Workspace Target that is no longer authorized or currently reachable. Its Project Group and Agent Sessions are omitted from the current browser snapshot until availability is restored; no local Task record keeps it visible. _Avoid_: ACP stopped state, deleted Agent Session -**Workspace Catalog**: The user-scoped, local-profile collection of explicitly managed Platform Workspaces and Personal Projects available as Known Workspace Targets. It is not stored in a repository or workspace configuration. The currently open Workspace Target is usable for a Header Task Launch but is not admitted solely by being opened; recent workspaces are not automatically admitted, while targets bound to retained Agent Tasks remain represented. _Avoid_: Recent workspace list, repository browser +**Workspace Catalog**: The user-scoped, local-profile collection of explicitly managed Platform Workspaces and Personal Projects. This project catalog is the only Agentic Layout data for which local storage remains authoritative. It is not stored in repository or workspace configuration, and recent workspaces are not automatically admitted. _Avoid_: Session catalog, recent workspace list, repository browser **Platform Workspace**: A Known Workspace Target supplied and authorized by the Web IDE host platform. _Avoid_: Built-in project, shared folder **Personal Project**: A user-created Known Workspace Target backed by a directory in platform-authorized storage and assigned a user-facing project name. _Avoid_: Custom workspace, arbitrary path -**Project Name**: A mutable user-facing label for a Project Group that is independent of the backing directory name and path. Clearing a Project Name returns the Project to its default label. When no Project Name is set, the Project displays the final directory segment of its normalized workspace path; the path's root directory displays `/`. If default names collide among all currently available Projects, the minimum parent-directory prefix needed to distinguish them is displayed; search filtering does not change these labels. These derived labels automatically recompute when the available Project set changes and are never persisted. A custom Project Name always displays exactly as entered, even if it matches another visible label; automatic disambiguation applies only between default names. For an unnamed Project, the Project Name input starts empty with the default label as its placeholder, while the full workspace path is presented separately. The full workspace path remains available on hover both before and after renaming. _Avoid_: Folder name, workspace path +**Project Name**: A mutable user-facing label for a Project Group that is independent of the backing directory name and path. Clearing it returns the Project to its derived default label. The full authorized path remains available on hover. _Avoid_: Folder name, Session title -**Task Launch**: The Agentic Layout flow that first opens a Task Draft and creates a Durable Agent Task when its first prompt is sent. Launching from the Agentic Chat View header uses the selected Task's Workspace Target when a Task Conversation is active, otherwise it uses the current IDE Workspace Target; launching from a Project Group uses that group's Workspace Target directly. The selected Workspace Target provides the Agent's working directory without changing the IDE's current Workspace. Its primary fields are task description and ACP Agent; model, permission mode, and other optional parameters are advanced settings. A failed Task Launch preserves the previous Active Task and its unsent draft rather than entering an unbound draft. _Avoid_: Unbound new chat, projectless task +**Session Launch**: The Agentic Layout flow that opens a page-local draft and creates an ACP Session for the selected Agent and Workspace Target. After the first prompt is accepted, OpenSumi ensures the target is in the Workspace Catalog and refreshes Agent discovery; it does not create a Durable Task record. Failure or cancellation preserves the previous active Session and unsent draft. _Avoid_: Durable Task creation, projectless chat -**Task Draft**: The Agent-bound composition state opened by Task Launch before an ACP session or Durable Agent Task exists. Its first Prompt creates the Task Conversation and makes the Agent Task durable. _Avoid_: New Session, empty Task +**Session Draft**: The Agent-bound composition state opened before the first prompt. It is page-local, is not a Session Browser row by itself, and is not restored from legacy pending-launch data after reload. It may own an ordinary draft-bound ACP Session. _Avoid_: Persisted Task Draft, local history row -**Project Management**: The Agent Task List capability for adding and maintaining Known Workspace Targets. Project selection is not part of ordinary New Task creation when its Workspace Target is already implied by the launch entry point. _Avoid_: New Task project picker, recent-workspace list +**Draft-bound ACP Session**: An ordinary ACP Session created for a Session Draft, including for Agent-provided slash-command metadata. It creates no local Task record and may appear in the Agent Session Browser when the Agent returns it from `session/list`. _Avoid_: Hidden Session, local Draft record -**Project Addition**: The Project Management action that authorizes a developer-selected directory as a Known Workspace Target. It does not switch the IDE Workspace. Adding a directory that already identifies a Known Workspace Target reuses and revalidates that Project rather than creating a duplicate. A newly added project initially displays its derived default Project Name and may be renamed afterwards. Multi-root workspace files are not Project Addition targets because an ACP Agent Task has one working directory. _Avoid_: Recent-workspace import, New Task project selection +**Project Management**: The Agent Session Browser capability for adding, renaming, revalidating, and removing Known Workspace Targets without changing the IDE Workspace. _Avoid_: Session mutation, recent-workspace import -**Project Removal**: The Project Management action that removes a manually added Project Group with no active or archived Agent Tasks. A Known Workspace Target that owns retained Agent Tasks cannot be removed, preserving the task history's project association. _Avoid_: Task deletion, task orphaning +**Project Addition**: The action that authorizes a developer-selected directory as a Known Workspace Target. Adding an existing target reuses and revalidates it rather than creating a duplicate. _Avoid_: New Session creation, Workspace switch -**Project Management Menu**: The overflow menu on a Project Group that contains its Rename and, when allowed, Project Removal actions. It is separate from the group's visible Project-group New Task action. _Avoid_: New Task menu, task actions +**Project Removal**: The action that removes a manually added Project from the local Workspace Catalog when the current Agent snapshot has no Sessions for it, including locally archived Sessions. Legacy Task records do not block removal and remain untouched. _Avoid_: Agent Session deletion, legacy-data migration -**Project-group New Task**: The single icon-only `+` action on a Project Group that opens a Task Draft in that group's Workspace Target with its resolved Project Agent Recall. The Task becomes durable only after its first prompt. It uses the group's path as the Agent working directory without changing the IDE's current Workspace, and never asks the developer to choose a different project. _Avoid_: Project switcher, add project +**Project Management Menu**: The overflow menu on a Project Group containing Rename and, when allowed, Project Removal. It is separate from the group's visible New Session action. _Avoid_: Session actions, task archive menu -**Open Agentic Layout**: The existing layout-switch action, sometimes described as “Open in Agents”, that activates the `agentic` layout for the current Workspace Target. It does not register, duplicate, move, or otherwise change an ACP session. _Avoid_: Session import, Task registration, cross-workspace navigation +**Project-group New Session**: The single icon action on a Project Group that opens a Session Draft in that group's Workspace Target with the resolved Agent. It does not change the IDE Workspace and does not write a local Task record. _Avoid_: Project switcher, add project -**Task Title**: The immutable short label rendered as the primary text in an Agent Task Row and in the Agentic Chat View header. In B-lite, it is derived from the first line of the Task Launch description and truncated for display; it is not a separate launch field, cannot be renamed, and is not ACP-provided. _Avoid_: Session title, Agent-generated title, task summary +**Open Agentic Layout**: The layout-switch action that activates the `agentic` layout and triggers an Agent Session discovery refresh. It does not register, duplicate, move, or otherwise change an ACP Session. _Avoid_: Session import, Task registration, cross-workspace navigation -**Project Task Defaults**: The ACP Agent, model, permission mode, and optional launch settings explicitly saved for one Known Workspace Target, with user-level defaults as fallback. A one-off Task Launch override does not change these defaults unless the developer explicitly saves it. _Avoid_: Last-used configuration, workspace settings +**Session Title**: The human-readable title returned by the Agent in `session/list`. When missing or blank, Agentic Layout displays a generic unnamed-session label and never derives a title from a local prompt. _Avoid_: Prompt-derived title, local task summary -**Project Agent Recall**: The ACP Agent most recently selected for Task Launch in a Project Group. A direct New Task action resolves its Agent from Project Agent Recall first, then the current Task Conversation's ACP Agent, then the user-level default. The Chat header's primary New Task action launches directly with this resolution; choosing an Agent from its separate Agent-choice action launches immediately, updates the Project Agent Recall, and does not change the user-level default. _Avoid_: Project Task Defaults, globally last-used Agent +**Project Agent Recall**: The ACP Agent most recently selected for a new Session in a Project Group. It influences future launch defaults only and never filters or reroutes discovered Sessions. _Avoid_: Session owner inference, globally last-used Agent **ACP Task Status**: The live task status rendered from an ACP session and thread currently observed by Agentic Layout: `initializing`, `ready`, `running`, `stopping`, `stopped`, or `error`. Agentic Layout does not infer a `completed` status; `ready` means the ACP Agent is waiting for another prompt. _Avoid_: Frontend task state, completion status, persisted status snapshot @@ -84,26 +84,38 @@ This context describes enhancements to OpenSumi's existing workspace-local Agent **ACP Task Action**: An action explicitly made available by ACP for a Task Conversation. Agentic Layout renders and forwards only these advertised actions; it does not add its own stop, retry, or lifecycle controls. _Avoid_: Frontend retry, synthetic task action +**ACP Slash Command Catalog**: The session-scoped set of slash commands advertised by the ACP Agent for a Task Conversation, including Agent-provided skills. The catalog is authoritative only for its originating Task Conversation and is distinct from OpenSumi built-in chat commands. _Avoid_: Global skill list, local SKILL.md scan, built-in slash command registry + +**ACP Agent Skill**: A skill whose installation and definition are owned by one ACP Agent. Each Task Conversation exposes only the ACP Slash Command Catalog that this Agent makes available in that conversation's context. _Avoid_: IDE-global skill, Session-owned skill definition + +**ACP Slash Command Catalog Update**: A complete replacement ACP Slash Command Catalog sent by the ACP Agent for a Task Conversation when its available commands change. It is also included when the Task Conversation becomes live after restoration. _Avoid_: Client pull, command delta, local skill scan + +**Slash Command Freshness**: The user-visible guarantee that opening the slash command menu for a Live Ready Task Conversation reflects the latest ACP Slash Command Catalog delivered or restored by its ACP Agent. _Avoid_: Client pull guarantee, background polling guarantee, installation-complete notification + +**Slash Command Execution Validation**: The ACP Agent's final decision whether a submitted slash command is currently executable. A stale or removed command preserves the developer's draft and yields a structured unavailable-command result rather than changing or discarding the draft. _Avoid_: Client-only authorization, silent command removal, draft reset + **ACP Attention Signal**: A structured ACP-originated request that requires developer action, such as a Permission Request or an Agent-provided input request. Agentic Layout renders pending signals and does not infer them from conversation content. _Avoid_: Attention state, notification **Permission Request**: An Agent-originated request for a user decision before a protected operation proceeds. The Agent supplies the available decisions, while OpenSumi routes the request, applies existing rules, and prevents the Agent from granting itself permission. _Avoid_: Permission prompt, Agent approval **Permission Decision**: The selected Agent-provided outcome such as allow once, allow always, reject once, or reject always, made by the developer or resolved by an existing OpenSumi permission rule. _Avoid_: Permission policy, tool result -**Task List Metadata**: The independent `unread` and `archived` markers used to organize an Agent Task in the Agent Task List without representing ACP Task Status or ACP Attention Signals. _Avoid_: ACP status, permission state, pin state +**Agentic Chat View**: The primary conversation area to the right of the Agent Session Browser. It restores and continues the selected Agent Session without changing the IDE Workspace. When the Session's Workspace Target differs from the current IDE Workspace, its header renders an Agent Execution Context Indicator. _Avoid_: Separate review application, local transcript replay + +**Agent Session Selection**: The interaction that resolves the Session's captured `{agentId, cwd}` route and calls the originating Agent's `session/load`. All load-time `session/update` notifications and retained client-submitted User Prompt replay updates from the active ACP Thread are buffered into a replacement model; only a successful load may atomically replace the previous active Session. Live attachment begins independently afterwards. When selections overlap, only the latest intent may become active and superseded loads release only their own resources. _Avoid_: Task-registry lookup, Workspace navigation, eager attachment commit -**Unread Task**: An Agent Task that received new Agent content, a Permission Request, or another structured ACP input request while its Agentic Chat View was not selected. Selecting that Task clears the unread marker; ordinary ACP status changes do not create it. _Avoid_: Pending task, attention task +**Pending Agent Session Selection**: The page-local selection intent while `session/load` is pending. It is represented by a spinner on the requested Session Row, does not replace the current active Session, and does not block selecting another row. _Avoid_: Active Session, ACP task status -**In-layout Task Notification**: The B-lite notification model in which new ACP output and pending ACP Attention Signals are communicated only through Agent Task List unread markers and Task List Attention Indicators, without browser or host-platform push notifications. _Avoid_: Web push, system notification +**Failed Agent Session Selection**: A page-local unavailable marker set when `session/load` fails. It preserves the previous active Session and is cleared by a later successful retry of the same Session. _Avoid_: Durable error record, automatic draft fallback -**Agentic Chat View**: The primary conversational content area in Agentic Layout, positioned to the right of the Agent Task List inside the ACP Chat Slot. It restores and continues the selected Task Conversation; selecting a Task replaces the view's conversation, pending permission or input requests, and review artifacts with those of that Task without changing the IDE workspace. It renders ACP Task Actions and shows file diffs plus test or command summaries alongside the current IDE file tree and editor, even when the selected Task's Workspace Target differs. In that case, its header renders an Agent Execution Context Indicator. _Avoid_: Separate Task Review application, chat replay +**Transcript Ready**: The Agent Session condition reached only after `session/load` has returned successfully and its buffered Agent history can be committed consistently. Transcript Ready is sufficient to replace the previous Agentic Chat View and does not imply that live attachment has resumed. _Avoid_: Metadata-only model, Live Ready, fully connected -**Session-first Task Selection**: The Task List interaction that lazily validates and restores the selected Task Conversation through its originating ACP Agent, including pending interactions and task artifacts, while leaving the IDE's current Workspace, file tree, and editor state unchanged. The Agent Task List does not eagerly start every related Agent to validate all retained sessions. A Task may remain fully interactive when its bound Workspace Target differs from the IDE's current Workspace; the Agentic Chat View must make that target visible as execution context. When selections overlap, only the latest selection may become active or clear a Task's unread marker. A failed selection preserves the previously active Task Conversation, Task Row, and unread state rather than opening an unbound draft. _Avoid_: Workspace-aware Task Switch, workspace navigation, eager session reconciliation, read-only task replay +**Live Ready**: The independent Agent Session condition in which new prompts and ACP progress can be exchanged with its originating ACP Agent. An Agent Session may be Transcript Ready before it is Live Ready; attachment failure must retain readable restored history and use the existing connection retry behavior. _Avoid_: Transcript Ready, page ready, history loaded -**Agent Execution Context Indicator**: The persistent Agentic Chat View header label shown only when the selected Task's Workspace Target differs from the current IDE Workspace. It presents the target's project label and exposes its complete path on hover, but does not navigate, change the IDE workspace, or alter the file tree and editor. _Avoid_: Workspace switcher, project picker, navigation breadcrumb +**Agent Execution Context Indicator**: The persistent Agentic Chat View header label shown only when the selected Session's Workspace Target differs from the current IDE Workspace. It presents the target's project label and exposes its complete path on hover, but does not navigate, change the IDE workspace, or alter the file tree and editor. _Avoid_: Workspace switcher, project picker, navigation breadcrumb **Task Artifact**: Server-retained supplemental data produced by an Agent Task, including file diffs and test or command summaries, that Agentic Chat View can present alongside the activated Workspace Target. _Avoid_: Workspace snapshot, task log **Read-only Task Diff**: The Task Artifact view of file changes that supports inspection only and does not independently verify exclusive authorship under Shared Workspace Concurrency. Editing, inline feedback, accepting, rejecting, and reverting changes are outside B-lite and require conversation follow-up or direct editing in the activated Workspace. _Avoid_: Review editor, change approval -**Workspace-aware Task Switch**: The explicit transition that opens a different Workspace Target in the IDE. It is not part of Session-first Task Selection. If used by a future explicit navigation action, it reuses the current browser page, opens the target Workspace path, and reloads the IDE; when the current Workspace has unsaved edits, the developer must choose to save all, discard changes, or cancel. It never creates a new ACP session. _Avoid_: Task selection, chat-only switch, new browser window, Workspace Handoff +**Workspace-aware Session Switch**: The explicit transition that opens a different Workspace Target in the IDE. It is not part of Agent Session Selection. If used by a future explicit navigation action, it reuses the current browser page, opens the target Workspace path, and reloads the IDE; when the current Workspace has unsaved edits, the developer must choose to save all, discard changes, or cancel. It never creates a new ACP Session. _Avoid_: Session selection, chat-only switch, new browser window, Workspace Handoff diff --git a/packages/ai-native/__test__/browser/acp-chat-mention-input-ref.test.tsx b/packages/ai-native/__test__/browser/acp-chat-mention-input-ref.test.tsx index d94f93ab64..c8ce8da198 100644 --- a/packages/ai-native/__test__/browser/acp-chat-mention-input-ref.test.tsx +++ b/packages/ai-native/__test__/browser/acp-chat-mention-input-ref.test.tsx @@ -1380,6 +1380,64 @@ describe('AcpChatMentionInput ref contract', () => { }); }); + it('clears an accepted first-task draft after it is promoted to an active session', async () => { + mockUseActualMentionInput = true; + jest.requireMock('@opensumi/ide-core-browser').useInjectable.mockReturnValue(createMockService()); + const ref = React.createRef(); + const submission = deferred(); + const submit = jest.fn(() => submission.promise); + const onDraftChange = jest.fn(); + let promoteSession!: (sessionId: string) => void; + + const Harness = () => { + const [activeSessionId, setActiveSessionId] = React.useState(); + promoteSession = setActiveSessionId; + return React.createElement(AcpTurnEditor, { + ref, + variant: 'main', + activeSessionId, + initialDraft: { message: 'first task draft' }, + onDraftChange, + onSend: jest.fn(), + setTheme: jest.fn(), + agentId: '', + setAgentId: jest.fn(), + command: '', + setCommand: jest.fn(), + turnActions: { + submit, + stop: jest.fn(), + fastTrack: jest.fn(), + invalidateFastTrack: jest.fn(), + takeBackLastQueuedTurn: jest.fn(), + }, + }); + }; + + act(() => { + render(React.createElement(Harness), container); + }); + + const editor = container.querySelector('[contenteditable="true"]') as HTMLDivElement; + act(() => { + dispatchEditorKey(editor, { key: 'Enter' }); + promoteSession('promoted-session'); + }); + + await act(async () => { + submission.resolve({ accepted: true, outcome: 'started' }); + await submission.promise; + await Promise.resolve(); + }); + + expect(submit).toHaveBeenCalledWith( + { message: 'first task draft', images: [], agentId: '', command: '' }, + 'normal', + ); + expect(ref.current!.getDraft()).toEqual({ message: '', images: [], agentId: '', command: '' }); + expect(onDraftChange).toHaveBeenLastCalledWith({ message: '', images: [], agentId: '', command: '' }); + }); + it('preserves text, pasted attachment, agent, and command changes made while submission is pending', async () => { mockUseActualMentionInput = true; const upload = jest.fn(async () => 'data:image/png;base64,new'); diff --git a/packages/ai-native/__test__/browser/acp-chat-view-header.test.tsx b/packages/ai-native/__test__/browser/acp-chat-view-header.test.tsx index b3d30ff8e7..afd8c4872e 100644 --- a/packages/ai-native/__test__/browser/acp-chat-view-header.test.tsx +++ b/packages/ai-native/__test__/browser/acp-chat-view-header.test.tsx @@ -3,7 +3,24 @@ import { Root, createRoot } from 'react-dom/client'; import { act } from 'react-dom/test-utils'; jest.mock('react-chat-elements', () => ({ - MessageList: () => null, + MessageBox: ({ id, text }: any) => + require('react').createElement('div', { 'data-message-box-id': id }, require('react').Children.toArray(text)), + MessageList: () => require('react').createElement('div', { 'data-testid': 'community-message-list' }), +})); + +jest.mock('react-virtuoso', () => ({ + Virtuoso: require('react').forwardRef(({ data, itemContent }: any, ref: unknown) => { + void ref; + return require('react').createElement( + 'div', + { 'data-testid': 'agentic-virtuoso' }, + data + .slice(0, 20) + .map((item: any, index: number) => + require('react').createElement('div', { key: item.id ?? index }, itemContent(index, item)), + ), + ); + }), })); jest.mock('@opensumi/ide-core-browser', () => ({ @@ -20,8 +37,7 @@ jest.mock('@opensumi/ide-core-browser', () => ({ PreferenceService: Symbol('PreferenceService'), QuickPickService: Symbol('QuickPickService'), getIcon: (name: string) => `icon-${name}`, - localize: (_key: string, defaultValue?: string, ...args: string[]) => - (defaultValue || _key).replace(/\{(\d+)\}/g, (_, index) => args[Number(index)] || ''), + localize: (_key: string, defaultValue?: string) => defaultValue || _key, useInjectable: jest.fn(), useUpdateOnEvent: jest.fn(), })); @@ -107,6 +123,10 @@ jest.mock('../../src/browser/acp/components/AgenticTaskList', () => ({ AgenticTaskList: () => require('react').createElement('aside', { 'data-testid': 'agentic-task-list' }), })); +jest.mock('../../src/browser/acp/components/AgenticSessionList', () => ({ + AgenticSessionList: () => require('react').createElement('aside', { 'data-testid': 'agentic-session-list' }), +})); + jest.mock('../../src/browser/components/ChatHistory', () => ({ __esModule: true, default: ({ title, historyList = [], onHistoryItemSelect, onNewChat }: any) => @@ -247,6 +267,7 @@ import { AINativeSettingSectionsId } from '@opensumi/ide-core-common/lib/setting import { AcpChatViewHeader } from '../../src/browser/acp/components/AcpChatViewHeader'; import { AI_CHAT_NEW_CHAT, AI_CHAT_NEW_TASK } from '../../src/browser/chat/acp-new-draft.commands'; +import { AgenticChatPanelHeader } from '../../src/browser/chat/AgenticChatPanelHeader'; import { DefaultChatViewHeader } from '../../src/browser/chat/chat.view'; import { AIChatViewACPContent, DefaultChatViewHeaderACP } from '../../src/browser/chat/chat.view.acp'; @@ -425,8 +446,13 @@ function createMockServices({ createSessionModel: createSessionModel || jest.fn(), enterDraftSession: enterDraftSession || jest.fn(), getDraftSessionState: jest.fn(() => ({ isDraft: false })), + getAgenticSessionLiveReadyStatus: jest.fn(() => 'ready'), + getPendingAgenticSessionId: jest.fn(() => undefined), + retryAgenticSessionConnection: jest.fn(() => Promise.resolve()), getInputDraft: jest.fn(() => undefined), + getActiveAgenticTaskTarget: jest.fn(() => undefined), getActiveAgenticTaskAgentId: jest.fn(() => undefined), + isActiveAgenticTaskDraft: jest.fn(() => false), ensureSessionModel: jest.fn(async () => { const ensuredSession = ensureSessionModel ? await ensureSessionModel() @@ -453,7 +479,10 @@ function createMockServices({ sessionLoadingListeners.add(listener); return { dispose: jest.fn(() => sessionLoadingListeners.delete(listener)) }; }), - sendRequest: sendRequest || jest.fn(), + sendRequest: jest.fn((request, regenerate, onRequestAccepted) => { + onRequestAccepted?.(); + return sendRequest ? sendRequest(request, regenerate, onRequestAccepted) : Promise.resolve(); + }), setLatestRequestId: jest.fn(), updateInputDraft: jest.fn(), }; @@ -1087,7 +1116,7 @@ describe('ACP chat view headers', () => { ).toBe('67890'); }); - it('renders the persistent Task List instead of inline ACP history in the Agentic Layout', async () => { + it('renders the persistent Agent Session Browser instead of inline ACP history in the Agentic Layout', async () => { const services = createMockServices({ panelLayout: 'agentic' }); installInjectableMocks(services); @@ -1098,7 +1127,7 @@ describe('ACP chat view headers', () => { }), ); - expect(container.querySelector('[data-testid="agentic-task-list"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="agentic-session-list"]')).not.toBeNull(); expect(container.querySelector('[data-testid="acp-chat-history-inline"]')).toBeNull(); expect(container.querySelector('[data-testid="acp-chat-history"]')).toBeNull(); expect(services.workspaceSwitch.restorePendingWork).toHaveBeenCalledTimes(1); @@ -1119,23 +1148,15 @@ describe('ACP chat view headers', () => { }), chatViewHeaderRender: AcpChatViewHeader, }); - services.agenticTaskRegistry.getTask.mockResolvedValue({ - sessionId: 'acp:current', - projectId: 'project-current', - agentId: 'agent-a', - title: 'UX validation task for Agent Layout', - createdAt: 1, - archived: false, - unread: false, - }); installInjectableMocks(services); await renderHeader(React.createElement(AIChatViewACPContent)); expect(container.querySelector('[data-testid="agentic-chat-panel-header-title"]')?.textContent).toBe( - 'UX validation task for Agent Layout', + 'ACP-specific server title', ); - expect(container.querySelector('[data-testid="agentic-task-list"]')).not.toBeNull(); + expect(services.agenticTaskRegistry.getTask).not.toHaveBeenCalled(); + expect(container.querySelector('[data-testid="agentic-session-list"]')).not.toBeNull(); expect(container.querySelector('[data-testid="acp-chat-history-inline"]')).toBeNull(); expect(container.querySelector('#ai-chat-header-maximize')).toBeNull(); expect(container.querySelector('[data-testid="agentic-chat-new-session-button"]')).toBeNull(); @@ -1146,7 +1167,7 @@ describe('ACP chat view headers', () => { const taskLaunchButton = actions.querySelector('[data-testid="agentic-task-launch-button"]'); expect(taskLaunchButton).not.toBeNull(); expect(actions.children[0]?.contains(taskLaunchButton)).toBe(true); - expect(actions.children[1]?.querySelector('#agentic-chat-panel-header-maximize')).not.toBeNull(); + expect(actions.querySelector('#agentic-chat-panel-header-maximize')).not.toBeNull(); const getAction = () => container.querySelector('#agentic-chat-panel-header-maximize button') as HTMLButtonElement | null; @@ -1169,7 +1190,8 @@ describe('ACP chat view headers', () => { expect(getAction()?.className).toBe('icon-fullescreen'); }); - it('replaces the ACP message area with an accessible loading state while switching sessions', async () => { + it('keeps the Agentic transcript visible while Live Ready is pending', async () => { + jest.useFakeTimers(); const services = createMockServices({ panelLayout: 'agentic' }); installInjectableMocks(services); @@ -1179,13 +1201,19 @@ describe('ACP chat view headers', () => { services.setSessionLoadingForTest(true); }); - const loadingState = container.querySelector('[data-testid="acp-session-loading"]'); - expect(loadingState?.getAttribute('role')).toBe('status'); - expect(loadingState?.textContent).toBe('Loading chat…'); + expect(container.querySelector('[data-testid="agentic-virtual-message-list"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="community-message-list"]')).toBeNull(); + expect(container.querySelector('[data-testid="acp-session-loading"]')).toBeNull(); expect(container.querySelector('[aria-busy="true"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="acp-live-connecting"]')).toBeNull(); + act(() => jest.advanceTimersByTime(500)); + const connectingState = container.querySelector('[data-testid="acp-live-connecting"]'); + expect(connectingState?.getAttribute('role')).toBe('status'); + expect(connectingState?.textContent).toBe('Restoring session. You can send when it is ready.'); expect(services.getLatestChatInputProps()).toEqual( expect.objectContaining({ - disabled: true, + disabled: false, + submitDisabled: true, loading: false, disableModelSelector: true, }), @@ -1195,13 +1223,40 @@ describe('ACP chat view headers', () => { services.setSessionLoadingForTest(false); }); - expect(container.querySelector('[data-testid="acp-session-loading"]')).toBeNull(); + expect(container.querySelector('[data-testid="agentic-virtual-message-list"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="acp-live-connecting"]')).toBeNull(); expect(services.getLatestChatInputProps()).toEqual( expect.objectContaining({ disabled: false, + submitDisabled: false, loading: false, }), ); + jest.useRealTimers(); + }); + + it('keeps Send disabled and reports the connection when Live Ready fails', async () => { + const services = createMockServices({ panelLayout: 'agentic' }); + services.aiChatService.getAgenticSessionLiveReadyStatus.mockReturnValue('failed'); + installInjectableMocks(services); + + await renderHeader(React.createElement(AIChatViewACPContent)); + + expect(container.querySelector('[data-testid="agentic-virtual-message-list"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="acp-session-loading"]')).toBeNull(); + expect(container.querySelector('[data-testid="acp-live-connecting"]')?.textContent).toBe( + 'Unable to restore the session.Retry', + ); + await act(async () => { + (container.querySelector('[data-testid="acp-live-connection-retry"]') as HTMLButtonElement).click(); + }); + expect(services.aiChatService.retryAgenticSessionConnection).toHaveBeenCalledWith('acp:current'); + expect(services.getLatestChatInputProps()).toEqual( + expect.objectContaining({ + disabled: false, + submitDisabled: true, + }), + ); }); it('keeps the Agentic welcome page without rendering the Task selection guidance', async () => { @@ -1240,7 +1295,11 @@ describe('ACP chat view headers', () => { }; services.workspaceService.workspace = { uri: currentProject.workspaceUri }; services.agenticTaskRegistry.getProject.mockResolvedValue(currentProject); - services.agenticTaskRegistry.getTask.mockResolvedValue({ agentId: 'agent-b' }); + services.agenticTaskRegistry.listProjects.mockResolvedValue([currentProject]); + services.aiChatService.getActiveAgenticTaskTarget.mockReturnValue({ + agentId: 'agent-b', + cwd: currentProject.workspacePath, + }); installInjectableMocks(services); await renderHeader(React.createElement(AIChatViewACPContent)); @@ -1266,7 +1325,7 @@ describe('ACP chat view headers', () => { expect(services.commandService.executeCommand).toHaveBeenCalledWith(AI_CHAT_NEW_TASK.id, 'agent-b'); }); - it('uses the active Task Project for Header New Task and displays a foreign execution context', async () => { + it('uses the active Agent Session target for Header New Session and displays a foreign execution context', async () => { const services = createMockServices({ agentConfigs: { 'agent-a': { command: 'agent-a', description: 'Agent A' }, @@ -1293,11 +1352,11 @@ describe('ACP chat view headers', () => { workspaceUri: 'file:///work/other', }; services.workspaceService.workspace = { uri: currentProject.workspaceUri }; - services.agenticTaskRegistry.getTask.mockResolvedValue({ + services.aiChatService.getActiveAgenticTaskTarget.mockReturnValue({ agentId: 'agent-b', - projectId: otherProject.id, - sessionId: 'acp:current', + cwd: otherProject.workspacePath, }); + services.agenticTaskRegistry.listProjects.mockResolvedValue([currentProject, otherProject]); services.agenticTaskRegistry.getProject.mockImplementation((projectId: string) => Promise.resolve(projectId === otherProject.id ? otherProject : currentProject), ); @@ -1314,7 +1373,7 @@ describe('ACP chat view headers', () => { expect(executionContext?.getAttribute('aria-label')).toBe(`Agent working directory: ${otherProject.workspacePath}`); expect(executionContext?.textContent).toContain('Agent working directory:'); expect(executionContext?.textContent).toContain(otherProject.label); - expect(services.agenticTaskRegistry.getProject).toHaveBeenCalledWith(otherProject.id); + expect(services.agenticTaskRegistry.getTask).not.toHaveBeenCalled(); await act(async () => { (container.querySelector('[data-testid="agentic-task-agent-menu-button"]') as HTMLButtonElement).click(); @@ -1429,7 +1488,9 @@ describe('ACP chat view headers', () => { }; services.workspaceService.workspace = { uri: currentProject.workspaceUri }; services.agenticTaskRegistry.getProject.mockResolvedValue(currentProject); + services.aiChatService.getActiveAgenticTaskTarget.mockReturnValue({ agentId: 'agent-b', cwd: '/work/current' }); services.aiChatService.getActiveAgenticTaskAgentId.mockReturnValue('agent-b'); + services.aiChatService.isActiveAgenticTaskDraft.mockReturnValue(true); installInjectableMocks(services); await renderHeader(React.createElement(AIChatViewACPContent)); @@ -1437,6 +1498,11 @@ describe('ACP chat view headers', () => { await flushPromises(); await flushPromises(); }); + expect(container.querySelector('[data-testid="agentic-task-draft-context"]')?.textContent).toContain('Agent B'); + expect(container.querySelector('[data-testid="agentic-task-draft-context"]')?.textContent).toContain('current'); + expect(container.querySelector('[data-testid="agentic-task-draft-context"]')?.textContent).not.toContain( + 'Send your first message', + ); await act(async () => { (container.querySelector('[data-testid="agentic-task-agent-menu-button"]') as HTMLButtonElement).click(); }); @@ -1446,6 +1512,50 @@ describe('ACP chat view headers', () => { expect(agentBButton.getAttribute('aria-current')).toBe('true'); }); + it('keeps draft controls visible after an unaccepted Agent Session model is prepared', async () => { + const services = createMockServices({ + agentConfigs: { + 'agent-b': { command: 'agent-b', description: 'Agent B' }, + }, + panelLayout: 'agentic', + session: createMockSession({ messages: [], title: 'New Session' }), + chatViewHeaderRender: AcpChatViewHeader, + }); + services.aiChatService.isActiveAgenticTaskDraft.mockReturnValue(true); + services.aiChatService.getActiveAgenticTaskTarget.mockReturnValue({ agentId: 'agent-b', cwd: '/work/current' }); + services.aiChatService.getActiveAgenticTaskAgentId.mockReturnValue('agent-b'); + installInjectableMocks(services); + + await renderHeader(React.createElement(AIChatViewACPContent)); + await act(async () => { + await flushPromises(); + }); + + expect(container.querySelector('[data-testid="agentic-chat-panel-header-title"]')?.textContent).toBe('New Session'); + expect(container.querySelector('[data-testid="agentic-task-draft-context"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="agentic-task-draft-prompt"]')?.textContent).toBe( + 'What would you like the Agent to do?', + ); + expect(container.querySelector('[data-testid="agentic-task-draft-close"]')).not.toBeNull(); + }); + + it('refreshes the panel title when the active Session model title changes in place', async () => { + const session = createMockSession({ title: 'BDD Turn 1' }); + const services = createMockServices({ panelLayout: 'agentic', session }); + installInjectableMocks(services); + + await renderHeader( + React.createElement(AgenticChatPanelHeader, { preferSessionTitle: true, sessionModel: session as any }), + ); + expect(container.querySelector('[data-testid="agentic-chat-panel-header-title"]')?.textContent).toBe('BDD Turn 1'); + + session.title = 'BDD Turn 2'; + await renderHeader( + React.createElement(AgenticChatPanelHeader, { preferSessionTitle: true, sessionModel: session as any }), + ); + expect(container.querySelector('[data-testid="agentic-chat-panel-header-title"]')?.textContent).toBe('BDD Turn 2'); + }); + it('maximizes the default chat header in agentic layout', async () => { const services = createMockServices({ panelLayout: 'agentic' }); installInjectableMocks(services); @@ -1468,7 +1578,7 @@ describe('ACP chat view headers', () => { expect(services.panelLayoutService.toggleAgenticWorkbenchVisibility).toHaveBeenCalledWith(false); }); - it('switches the ACP Chat Slot between Classic history and the Agentic Task List at runtime', async () => { + it('switches the ACP Chat Slot between Classic history and the Agent Session Browser at runtime', async () => { const services = createMockServices({ panelLayout: 'classic' }); installInjectableMocks(services); @@ -1486,7 +1596,7 @@ describe('ACP chat view headers', () => { await Promise.resolve(); }); - expect(container.querySelector('[data-testid="agentic-task-list"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="agentic-session-list"]')).not.toBeNull(); expect(container.querySelector('[data-testid="acp-chat-history"]')).toBeNull(); expect(services.workspaceSwitch.restorePendingWork).toHaveBeenCalledTimes(1); }); @@ -1583,7 +1693,7 @@ describe('ACP chat view headers', () => { requestId: 'request-1', }), ); - expect(sendRequest).toHaveBeenCalledWith(createRequest.mock.results[0].value); + expect(sendRequest).toHaveBeenCalledWith(createRequest.mock.results[0].value, false, expect.any(Function)); expect(services.mcpServerRegistry).toEqual({ activeMessageInfo: { messageId: 'assistant-message', @@ -3423,6 +3533,6 @@ describe('ACP chat view headers', () => { expect(ensureSessionModel).toHaveBeenCalledTimes(1); expect(createRequest).toHaveBeenCalledWith(' ', 'default-agent', undefined, 'generate'); - expect(sendRequest).toHaveBeenCalledWith(createRequest.mock.results[0].value); + expect(sendRequest).toHaveBeenCalledWith(createRequest.mock.results[0].value, false, expect.any(Function)); }); }); diff --git a/packages/ai-native/__test__/browser/acp-queued-turns.test.tsx b/packages/ai-native/__test__/browser/acp-queued-turns.test.tsx index 4b3cabb1f7..c1cebbaaa9 100644 --- a/packages/ai-native/__test__/browser/acp-queued-turns.test.tsx +++ b/packages/ai-native/__test__/browser/acp-queued-turns.test.tsx @@ -29,6 +29,7 @@ const baseSnapshot: AcpQueuedTurnSnapshot = { entries: [{ id: 'turn-1', message: 'first' }], canResume: false, canFastTrack: false, + initialStartPending: false, }; const query = (selector: string) => container.querySelector(selector); @@ -64,6 +65,7 @@ function renderQueue( onDelete?: jest.Mock; onImmediateSend?: jest.Mock; onEditorReady?: jest.Mock; + onOpenCapacitySettings?: jest.Mock; } = {}, ) { const snapshot = { ...baseSnapshot, ...overrides }; @@ -97,12 +99,24 @@ function renderQueue( onDelete={handlers.onDelete} onImmediateSend={handlers.onImmediateSend} onEditorReady={handlers.onEditorReady} + onOpenCapacitySettings={overrides.onOpenCapacitySettings} />, ); }); return handlers; } +it('shows a loading status while the first Task is launching', () => { + renderQueue({ + activeSessionId: undefined, + entries: [], + initialStartPending: true, + }); + + expect(query('[data-testid="acp-task-launch-status"]')?.textContent).toContain('Starting task'); + expect(query('[data-testid="acp-task-launch-status"] button')).toBeNull(); +}); + it('renders paused state and resumes', () => { renderQueue({ phase: 'paused', pauseReason: 'manual-stop', canResume: true }); expect(query('[data-testid="acp-queued-turn-status"]')?.textContent).toContain('Paused'); @@ -111,6 +125,31 @@ it('renders paused state and resumes', () => { expect(onResume).toHaveBeenCalledTimes(1); }); +it('renders capacity exhaustion as a persistent actionable alert', () => { + const onOpenCapacitySettings = jest.fn(); + renderQueue({ + phase: 'paused', + pauseReason: 'start-failed', + pauseError: { + name: 'ACP_THREAD_POOL_SATURATED', + message: 'ACP concurrent tasks have reached the configured limit of 2.', + limit: 2, + }, + canResume: true, + onOpenCapacitySettings, + }); + + expect(query('[data-testid="acp-capacity-error"]')?.getAttribute('role')).toBe('alert'); + expect(query('[data-testid="acp-capacity-error"]')?.textContent).toContain('capacity limit (2) has been reached'); + expect(query('[data-testid="acp-capacity-error"]')?.textContent).toContain( + 'Your task draft and unsent content have been preserved', + ); + click('[data-testid="acp-capacity-retry"]'); + click('[data-testid="acp-capacity-open-settings"]'); + expect(onResume).toHaveBeenCalledTimes(1); + expect(onOpenCapacitySettings).toHaveBeenCalledTimes(1); +}); + it.each([ [0, '0 Queued Turns'], [1, '1 Queued Turn'], diff --git a/packages/ai-native/__test__/browser/acp/agentic-session-list.test.tsx b/packages/ai-native/__test__/browser/acp/agentic-session-list.test.tsx new file mode 100644 index 0000000000..9f12b6ad90 --- /dev/null +++ b/packages/ai-native/__test__/browser/acp/agentic-session-list.test.tsx @@ -0,0 +1,407 @@ +import * as React from 'react'; +import { Root, createRoot } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; + +jest.mock('@opensumi/ide-core-browser', () => ({ + PreferenceService: class PreferenceService {}, + localize: jest.fn((key: string, fallback?: string) => + key === 'aiNative.agentic.project.manage' ? 'Manage {0}' : fallback || key, + ), + useInjectable: jest.fn(), +})); + +jest.mock('@opensumi/ide-overlay', () => ({ + IMessageService: Symbol('IMessageService'), + IWindowDialogService: Symbol('IWindowDialogService'), +})); + +jest.mock('../../../src/browser/acp/agentic-task-registry.service', () => ({ + AgenticTaskRegistryService: class AgenticTaskRegistryService {}, +})); + +jest.mock('../../../src/browser/acp/agentic-workspace-switch.service', () => ({ + AgenticWorkspaceSwitchService: class AgenticWorkspaceSwitchService {}, +})); + +jest.mock('../../../src/browser/acp/components/AgenticTaskList', () => ({ + ProjectRenameModal: () => null, + TaskListResizeHandle: () => require('react').createElement('div', { 'data-testid': 'session-list-resize' }), +})); + +jest.mock('../../../src/browser/acp/components/AgenticTaskLaunchMenu', () => ({ + AgenticTaskLaunchMenu: ({ preferredAgentId }: { preferredAgentId?: string }) => + require('react').createElement('button', { + 'data-preferred-agent-id': preferredAgentId, + 'data-testid': 'agentic-task-launch-button', + type: 'button', + }), +})); + +jest.mock('../../../src/browser/chat/get-default-agent-type', () => ({ + getAvailableAgentConfigs: jest.fn(() => ({ 'agent-a': { command: 'agent-a' } })), + getDefaultAgentType: jest.fn(() => 'opencode'), +})); + +import { PreferenceService } from '@opensumi/ide-core-browser'; +import { localizationBundle as enUSLocalizationBundle } from '@opensumi/ide-i18n/lib/common/en-US.lang'; +import { localizationBundle as zhCNLocalizationBundle } from '@opensumi/ide-i18n/lib/common/zh-CN.lang'; +import { IMessageService, IWindowDialogService } from '@opensumi/ide-overlay'; + +import { AgenticTaskRegistryService } from '../../../src/browser/acp/agentic-task-registry.service'; +import { AgenticWorkspaceSwitchService } from '../../../src/browser/acp/agentic-workspace-switch.service'; +import { AgenticSessionList } from '../../../src/browser/acp/components/AgenticSessionList'; +import { IChatInternalService } from '../../../src/common'; + +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); + +const agenticLocalizationKeys = [ + 'aiNative.chat.acp.skills.loading', + 'aiNative.chat.acp.skills.empty', + 'aiNative.agentic.session.discardDraftAndSwitch', + 'aiNative.agentic.session.switchDiscardDraft', + 'aiNative.chat.session.connectionUnavailable', + 'aiNative.chat.session.restoringConnection', +] as const; + +const projectA = { + id: 'project-a', + workspaceUri: 'file:///work/a', + workspacePath: '/work/a', + label: 'Project A', + joinedAt: 20, + availability: 'available' as const, +}; + +const projectB = { + id: 'project-b', + workspaceUri: 'file:///work/b', + workspacePath: '/work/b', + label: 'Project B', + joinedAt: 10, + availability: 'available' as const, +}; + +function session(sessionId: string, cwd: string, title?: string, updatedAt?: string) { + return { + sessionId: `acp:${sessionId}`, + agentSessionId: sessionId, + agentId: 'agent-a', + cwd, + title, + updatedAt, + }; +} + +function createServices(initialSessions = [session('one', '/work/a', 'Agent title', '2026-08-19T10:00:00Z')]) { + const sessionListeners = new Set<(sessionId: string) => void>(); + const catalogListeners = new Set<(sessions: ReturnType[]) => void>(); + const registryListeners = new Set<() => void>(); + let archivedSessions: Array<{ sessionId: string; agentId: string; cwd: string; archivedAt: number }> = []; + return { + registry: { + archiveAgentSession: jest.fn(async (archivedSession) => { + if ( + archivedSessions.some( + (candidate) => + candidate.sessionId === archivedSession.sessionId && + candidate.agentId === archivedSession.agentId && + candidate.cwd === archivedSession.cwd, + ) + ) { + return false; + } + archivedSessions = [...archivedSessions, { ...archivedSession, archivedAt: Date.now() }]; + return true; + }), + listActiveGroups: jest.fn(), + listArchivedAgentSessions: jest.fn(async () => archivedSessions), + listProjects: jest.fn().mockResolvedValue([projectA, projectB]), + onDidChange: jest.fn((listener: () => void) => { + registryListeners.add(listener); + return { dispose: () => registryListeners.delete(listener) }; + }), + removeManagedSessionProject: jest.fn().mockResolvedValue(true), + renameProject: jest.fn().mockResolvedValue(projectA), + unarchiveAgentSession: jest.fn(async (archivedSession) => { + const previousLength = archivedSessions.length; + archivedSessions = archivedSessions.filter( + (candidate) => + candidate.sessionId !== archivedSession.sessionId || + candidate.agentId !== archivedSession.agentId || + candidate.cwd !== archivedSession.cwd, + ); + return archivedSessions.length !== previousLength; + }), + }, + workspaceSwitch: { + addProject: jest.fn().mockResolvedValue(projectA), + refreshProjectAvailability: jest.fn().mockResolvedValue(undefined), + seedProjectCatalog: jest.fn().mockResolvedValue(undefined), + }, + aiChatService: { + activateAgentSession: jest.fn().mockResolvedValue({ status: 'activated' }), + discardAgenticTaskDraft: jest.fn().mockResolvedValue(undefined), + getActiveAgenticTaskTarget: jest.fn(), + getAgentSessions: jest.fn(() => initialSessions), + getInputDraft: jest.fn(() => undefined), + isActiveAgenticTaskDraft: jest.fn(() => false), + onChangeSession: jest.fn((listener: (sessionId: string) => void) => { + sessionListeners.add(listener); + return { dispose: () => sessionListeners.delete(listener) }; + }), + onDidChangeAgentSessions: jest.fn((listener: (sessions: ReturnType[]) => void) => { + catalogListeners.add(listener); + return { dispose: () => catalogListeners.delete(listener) }; + }), + refreshAgentSessions: jest.fn().mockResolvedValue(initialSessions), + sessionModel: undefined, + }, + preferenceService: { get: jest.fn(() => ({})) }, + messageService: { info: jest.fn(), warning: jest.fn().mockResolvedValue(undefined) }, + windowDialogService: { showOpenDialog: jest.fn().mockResolvedValue(undefined) }, + emitRegistryChange: () => registryListeners.forEach((listener) => listener()), + }; +} + +describe('AgenticSessionList', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + jest.clearAllMocks(); + }); + + async function renderList(services = createServices()) { + jest.requireMock('@opensumi/ide-core-browser').useInjectable.mockImplementation((token: unknown) => { + if (token === AgenticTaskRegistryService) { + return services.registry; + } + if (token === AgenticWorkspaceSwitchService) { + return services.workspaceSwitch; + } + if (token === IChatInternalService) { + return services.aiChatService; + } + if (token === IWindowDialogService) { + return services.windowDialogService; + } + if (token === IMessageService) { + return services.messageService; + } + if (token === PreferenceService) { + return services.preferenceService; + } + throw new Error(`Unexpected injectable: ${String(token)}`); + }); + await act(async () => { + root.render(); + await flushPromises(); + }); + return services; + } + + it('renders only Agent-returned sessions in project order without legacy Task controls', async () => { + const services = createServices([ + session('older', '/work/a', 'Older Agent title', '2026-08-18T10:00:00Z'), + session('newer', '/work/a', 'Newer Agent title', '2026-08-19T10:00:00Z'), + session('untitled', '/work/b'), + ]); + await renderList(services); + + expect(container.textContent).toContain('Agent Sessions'); + expect(container.textContent).toContain('Newer Agent title'); + expect(container.textContent).toContain('Older Agent title'); + expect(container.textContent).toContain('Untitled session'); + expect(container.textContent).not.toContain('Legacy local prompt title'); + expect(services.registry.listActiveGroups).not.toHaveBeenCalled(); + expect(container.querySelector('[data-testid="agentic-session-archive-acp:newer"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="agentic-session-refresh-button"]')).toBeNull(); + expect(container.querySelector('[aria-label*="Unread"]')).toBeNull(); + expect(container.querySelector('[aria-label*="attention"]')).toBeNull(); + expect(container.querySelector('.codicon-hubot')).toBeNull(); + expect(container.querySelector('[aria-label="Manage Project A"]')).not.toBeNull(); + + const rows = Array.from(container.querySelectorAll('[data-testid^="agentic-session-row-"]')); + expect(rows.map((row) => row.textContent)).toEqual([ + expect.stringContaining('Newer Agent title'), + expect.stringContaining('Older Agent title'), + expect.stringContaining('Untitled session'), + ]); + }); + + it('registers the Agent Session UI strings in both supported language bundles', () => { + for (const key of agenticLocalizationKeys) { + expect(enUSLocalizationBundle.contents[key]).toBeTruthy(); + expect(zhCNLocalizationBundle.contents[key]).toBeTruthy(); + } + }); + + it('uses the configured default Agent when no active session supplies one', async () => { + await renderList(createServices([])); + + expect( + container.querySelector('[data-testid="agentic-task-launch-button"]')?.getAttribute('data-preferred-agent-id'), + ).toBe('opencode'); + }); + + it('archives and restores Agent sessions without closing or deleting them', async () => { + const services = createServices([session('one', '/work/a', 'Agent title')]); + await renderList(services); + + await act(async () => { + (container.querySelector('[data-testid="agentic-session-archive-acp:one"]') as HTMLButtonElement).click(); + await flushPromises(); + }); + + expect(services.registry.archiveAgentSession).toHaveBeenCalledWith({ + sessionId: 'acp:one', + agentId: 'agent-a', + cwd: '/work/a', + }); + expect(container.querySelector('[data-testid="agentic-session-row-acp:one"]')).toBeNull(); + + const archivedArea = container.querySelector('[data-testid="agentic-archived-session-area"]'); + expect(archivedArea).not.toBeNull(); + await act(async () => { + (archivedArea?.querySelector('button') as HTMLButtonElement).click(); + await flushPromises(); + }); + + expect(container.querySelector('[data-testid="agentic-session-row-acp:one"]')).not.toBeNull(); + await act(async () => { + (container.querySelector('[data-testid="agentic-session-unarchive-acp:one"]') as HTMLButtonElement).click(); + await flushPromises(); + }); + + expect(services.registry.unarchiveAgentSession).toHaveBeenCalledWith({ + sessionId: 'acp:one', + agentId: 'agent-a', + cwd: '/work/a', + }); + expect(container.querySelector('[data-testid="agentic-session-archive-acp:one"]')).not.toBeNull(); + expect(services.aiChatService.discardAgenticTaskDraft).not.toHaveBeenCalled(); + }); + + it('shows a listed session when its project is registered after the initial layout refresh', async () => { + const newlyDiscoveredSession = session('new', '/work/b', 'New Agent session'); + const services = createServices([]); + services.registry.listProjects.mockResolvedValue([projectA]); + await renderList(services); + expect(container.textContent).not.toContain('New Agent session'); + + services.registry.listProjects.mockResolvedValue([projectA, projectB]); + services.aiChatService.refreshAgentSessions.mockResolvedValue([newlyDiscoveredSession]); + await act(async () => { + services.emitRegistryChange(); + await flushPromises(); + await flushPromises(); + }); + + expect(services.aiChatService.refreshAgentSessions).toHaveBeenCalledTimes(2); + expect(container.textContent).toContain('New Agent session'); + }); + + it('confirms before abandoning an unsent Agentic draft and cleans it up only after switching', async () => { + const services = createServices([session('restored', '/work/a', 'Restored')]); + services.aiChatService.sessionModel = { sessionId: 'acp:draft' }; + services.aiChatService.isActiveAgenticTaskDraft.mockReturnValue(true); + services.aiChatService.getInputDraft.mockReturnValue({ message: 'keep me unless confirmed' }); + services.messageService.warning.mockResolvedValue('Discard Draft and Switch'); + await renderList(services); + + await act(async () => { + (container.querySelector('[data-testid="agentic-session-row-acp:restored"]') as HTMLButtonElement).click(); + await flushPromises(); + }); + + expect(services.messageService.warning).toHaveBeenCalledTimes(1); + expect(services.aiChatService.activateAgentSession).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'acp:restored' }), + expect.any(Function), + ); + expect(services.aiChatService.discardAgenticTaskDraft).toHaveBeenCalledTimes(1); + }); + + it('does not prompt or abandon the current draft-bound Session', async () => { + const draftSession = session('draft', '/work/a', 'Draft session'); + const services = createServices([draftSession]); + services.aiChatService.sessionModel = { sessionId: draftSession.sessionId }; + services.aiChatService.isActiveAgenticTaskDraft.mockReturnValue(true); + services.aiChatService.getInputDraft.mockReturnValue({ message: 'keep editing' }); + await renderList(services); + + await act(async () => { + (container.querySelector('[data-testid="agentic-session-row-acp:draft"]') as HTMLButtonElement).click(); + await flushPromises(); + }); + + expect(services.messageService.warning).not.toHaveBeenCalled(); + expect(services.aiChatService.activateAgentSession).not.toHaveBeenCalled(); + expect(services.aiChatService.discardAgenticTaskDraft).not.toHaveBeenCalled(); + }); + + it('marks a failed restore unavailable and retries the same session on another click', async () => { + const services = createServices(); + services.aiChatService.activateAgentSession + .mockResolvedValueOnce({ status: 'failed' }) + .mockResolvedValueOnce({ status: 'activated' }); + await renderList(services); + const row = container.querySelector('[data-testid="agentic-session-row-acp:one"]') as HTMLButtonElement; + + await act(async () => { + row.click(); + await flushPromises(); + }); + expect(row.querySelector('[aria-label="Session unavailable"]')).not.toBeNull(); + expect(row.querySelector('.codicon-error')).not.toBeNull(); + + await act(async () => { + row.click(); + await flushPromises(); + }); + expect(services.aiChatService.activateAgentSession).toHaveBeenCalledTimes(2); + expect(row.querySelector('[aria-label="Session unavailable"]')).toBeNull(); + }); + + it('allows overlapping selections so the service can apply only the latest intent', async () => { + const services = createServices([session('one', '/work/a', 'First'), session('two', '/work/a', 'Second')]); + let resolveFirst!: (value: { status: 'superseded' }) => void; + let resolveSecond!: (value: { status: 'activated' }) => void; + services.aiChatService.activateAgentSession.mockImplementation( + (selected: ReturnType) => + new Promise((resolve) => { + if (selected.sessionId === 'acp:one') { + resolveFirst = resolve as typeof resolveFirst; + } else { + resolveSecond = resolve as typeof resolveSecond; + } + }), + ); + await renderList(services); + + await act(async () => { + (container.querySelector('[data-testid="agentic-session-row-acp:one"]') as HTMLButtonElement).click(); + (container.querySelector('[data-testid="agentic-session-row-acp:two"]') as HTMLButtonElement).click(); + await Promise.resolve(); + }); + expect(services.aiChatService.activateAgentSession).toHaveBeenCalledTimes(2); + expect(container.querySelector('[data-testid="agentic-session-row-acp:two"] .codicon-loading')).not.toBeNull(); + + await act(async () => { + resolveSecond({ status: 'activated' }); + resolveFirst({ status: 'superseded' }); + await flushPromises(); + }); + expect(container.querySelector('[data-testid="agentic-session-row-acp:two"]')?.getAttribute('aria-current')).toBe( + 'true', + ); + }); +}); diff --git a/packages/ai-native/__test__/browser/acp/agentic-task-launch-menu.test.tsx b/packages/ai-native/__test__/browser/acp/agentic-task-launch-menu.test.tsx index 1ae7f405f7..81498caa14 100644 --- a/packages/ai-native/__test__/browser/acp/agentic-task-launch-menu.test.tsx +++ b/packages/ai-native/__test__/browser/acp/agentic-task-launch-menu.test.tsx @@ -7,6 +7,7 @@ jest.mock('@opensumi/ide-core-browser', () => ({ CommandService: class CommandService {}, KeybindingRegistry: class KeybindingRegistry {}, PreferenceService: class PreferenceService {}, + fastdom: { measureAtNextFrame: (callback: () => void) => callback() }, getIcon: (icon: string) => `codicon codicon-${icon}`, localize: jest.fn((_key: string, fallback: string) => fallback), useInjectable: jest.fn(), @@ -23,6 +24,7 @@ jest.mock('../../../src/browser/chat/get-default-agent-type', () => ({ })); import { CommandService, KeybindingRegistry, PreferenceService, localize } from '@opensumi/ide-core-browser'; +import { ChatInputRegistryToken } from '@opensumi/ide-core-common'; import { AINativeSettingSectionsId } from '@opensumi/ide-core-common/lib/settings/ai-native'; import { AgenticWorkspaceSwitchService } from '../../../src/browser/acp/agentic-workspace-switch.service'; @@ -64,6 +66,10 @@ describe('AgenticTaskLaunchMenu', () => { function configureServices(agentConfigs: Record) { const commandService = { executeCommand: jest.fn() }; + const chatInputRegistry = { + focusActiveInput: jest.fn(), + isActiveInputFocused: jest.fn(() => true), + }; const taskLaunchListeners = new Set<(pending: boolean) => void>(); let taskLaunchPending = false; const workspaceSwitch = { @@ -92,6 +98,9 @@ describe('AgenticTaskLaunchMenu', () => { if (token === CommandService) { return commandService; } + if (token === ChatInputRegistryToken) { + return chatInputRegistry; + } if (token === KeybindingRegistry) { return keybindingRegistry; } @@ -101,6 +110,7 @@ describe('AgenticTaskLaunchMenu', () => { (getConfiguredAgentConfigs as jest.Mock).mockReturnValue(agentConfigs); (getDefaultAgentType as jest.Mock).mockReturnValue('agent-a'); return { + chatInputRegistry, commandService, preferenceService, setTaskLaunchPending: (pending: boolean) => { @@ -111,8 +121,8 @@ describe('AgenticTaskLaunchMenu', () => { }; } - it('launches its contextual Project directly with its recalled Agent', async () => { - const { workspaceSwitch } = configureServices({ + it('launches its contextual Project with the current Agent selection', async () => { + const { chatInputRegistry, workspaceSwitch } = configureServices({ 'agent-a': { command: 'agent-a', description: 'Agent A' }, 'agent-b': { command: 'agent-b', description: 'Agent B' }, }); @@ -125,11 +135,12 @@ describe('AgenticTaskLaunchMenu', () => { await Promise.resolve(); }); - expect(workspaceSwitch.launchTask).toHaveBeenCalledWith(project, 'agent-b'); + expect(workspaceSwitch.launchTask).toHaveBeenCalledWith(project, 'agent-a'); + expect(chatInputRegistry.focusActiveInput).toHaveBeenCalled(); expect(container.textContent).not.toContain('Choose Project'); }); - it('launches the recalled Agent directly and keeps a separate Agent override menu', async () => { + it('launches the default Agent directly and keeps a separate Agent override menu', async () => { const { commandService, workspaceSwitch } = configureServices({ 'agent-a': { command: 'agent-a', description: 'Agent A' }, 'agent-b': { command: 'agent-b', description: 'Agent B' }, @@ -138,15 +149,13 @@ describe('AgenticTaskLaunchMenu', () => { await act(async () => { root.render(); }); - expect((container.querySelector('[data-testid="agentic-task-launch-button"]') as HTMLButtonElement).title).toBe( - 'New Task with Agent B (Ctrl+Alt+N)', - ); + expect((container.querySelector('[data-testid="agentic-task-launch-button"]') as HTMLButtonElement).title).toBe(''); await act(async () => { (container.querySelector('[data-testid="agentic-task-launch-button"]') as HTMLButtonElement).click(); await Promise.resolve(); }); - expect(commandService.executeCommand).toHaveBeenCalledWith(AI_CHAT_NEW_TASK.id, 'agent-b'); + expect(commandService.executeCommand).toHaveBeenCalledWith(AI_CHAT_NEW_TASK.id, 'agent-a'); expect(workspaceSwitch.launchTask).not.toHaveBeenCalled(); expect(container.querySelector('[data-testid="agentic-task-agent-menu"]')).toBeNull(); @@ -171,7 +180,7 @@ describe('AgenticTaskLaunchMenu', () => { const launchButton = container.querySelector('[data-testid="agentic-task-launch-button"]') as HTMLButtonElement; const menuButton = container.querySelector('[data-testid="agentic-task-agent-menu-button"]') as HTMLButtonElement; expect(launchButton.disabled).toBe(true); - expect(launchButton.title).toBe('No ACP Agent available'); + expect(launchButton.title).toBe(''); expect(menuButton.disabled).toBe(false); await act(async () => { menuButton.click(); @@ -242,7 +251,7 @@ describe('AgenticTaskLaunchMenu', () => { const launchButton = container.querySelector('[data-testid="agentic-task-launch-button"]') as HTMLButtonElement; expect(launchButton.querySelector('.codicon-add')).not.toBeNull(); - expect(launchButton.getAttribute('aria-label')).toBe('New Task for Project A'); + expect(launchButton.getAttribute('aria-label')).toBe('New session for Project A'); expect(container.querySelector('[data-testid="agentic-task-agent-menu-button"]')).toBeNull(); }); }); diff --git a/packages/ai-native/__test__/browser/acp/agentic-task-list.test.tsx b/packages/ai-native/__test__/browser/acp/agentic-task-list.test.tsx index 55942c34f8..5532da4417 100644 --- a/packages/ai-native/__test__/browser/acp/agentic-task-list.test.tsx +++ b/packages/ai-native/__test__/browser/acp/agentic-task-list.test.tsx @@ -5,6 +5,7 @@ import { act } from 'react-dom/test-utils'; jest.mock('@opensumi/ide-core-browser', () => ({ KeybindingRegistry: class KeybindingRegistry {}, PreferenceService: class PreferenceService {}, + localize: jest.fn((_key: string, fallback?: string) => fallback || _key), useInjectable: jest.fn(), })); @@ -63,7 +64,7 @@ jest.mock('@opensumi/ide-components/lib/popover', () => ({ import { KeybindingRegistry } from '@opensumi/ide-core-browser'; import { URI } from '@opensumi/ide-core-common'; -import { IWindowDialogService } from '@opensumi/ide-overlay'; +import { IMessageService, IWindowDialogService } from '@opensumi/ide-overlay'; import { AgenticTaskRegistryService } from '../../../src/browser/acp/agentic-task-registry.service'; import { AgenticWorkspaceSwitchService } from '../../../src/browser/acp/agentic-workspace-switch.service'; @@ -145,6 +146,9 @@ function createServices() { get: jest.fn(() => ({})), set: jest.fn(), }, + messageService: { + info: jest.fn(), + }, windowDialogService: { showOpenDialog: jest.fn(() => Promise.resolve([URI.file('/work/added-project')])), }, @@ -157,6 +161,9 @@ describe('AgenticTaskList', () => { let root: Root; beforeEach(() => { + jest + .requireMock('@opensumi/ide-core-browser') + .localize.mockImplementation((_key: string, fallback?: string) => fallback || _key); (getAvailableAgentConfigs as jest.Mock).mockReturnValue({ 'agent-a': { command: 'agent-a', description: 'Agent A' }, 'agent-b': { command: 'agent-b', description: 'Agent B' }, @@ -203,6 +210,9 @@ describe('AgenticTaskList', () => { if (token === IWindowDialogService) { return services.windowDialogService; } + if (token === IMessageService) { + return services.messageService; + } if (token === KeybindingRegistry) { return services.keybindingRegistry; } @@ -274,10 +284,54 @@ describe('AgenticTaskList', () => { const launchButton = projectGroup?.querySelector('[data-testid="agentic-task-launch-button"]'); expect(launchButton?.textContent).toBe(''); expect(launchButton?.querySelector('.codicon.codicon-add')).not.toBeNull(); - expect(launchButton?.getAttribute('aria-label')).toBe('New Task for a'); + expect(launchButton?.getAttribute('aria-label')).toBe('New session for a'); expect(projectGroup?.querySelector('[data-testid="agentic-task-agent-menu-button"]')).toBeNull(); }); + it('localizes Agent Task List and empty Project management operations', async () => { + const translations: Record = { + 'aiNative.agentic.taskList.title': 'Agent 任务', + 'aiNative.agentic.taskList.resize': '调整 Agent 任务列表宽度', + 'aiNative.agentic.taskList.search': '搜索任务', + 'aiNative.agentic.project.add': '添加项目', + 'aiNative.agentic.project.newTask': '在 {0} 中新建任务', + 'aiNative.agentic.project.manage': '管理 {0}', + 'aiNative.agentic.project.rename.action': '重命名', + 'aiNative.agentic.project.rename.title': '重命名 {0}', + 'aiNative.agentic.project.remove': '移除 {0}', + 'aiNative.agentic.project.removeAction': '移除项目', + }; + jest + .requireMock('@opensumi/ide-core-browser') + .localize.mockImplementation((key: string, fallback?: string) => translations[key] || fallback || key); + const services = createServices(); + const emptyManagedProject = { ...projectA, label: undefined, managed: true as const }; + services.registry.listProjects.mockResolvedValue([emptyManagedProject]); + services.registry.listActiveGroups.mockResolvedValue([{ project: emptyManagedProject, tasks: [] }]); + + await renderTaskList(services); + + expect(container.querySelector('[data-testid="agentic-task-list"]')?.getAttribute('aria-label')).toBe('Agent 任务'); + expect(container.querySelector('h2')?.textContent).toBe('Agent 任务'); + expect(container.querySelector('[data-testid="agentic-task-list-resize-handle"]')?.getAttribute('aria-label')).toBe( + '调整 Agent 任务列表宽度', + ); + expect(container.querySelector('[data-testid="agentic-project-add-button"]')?.getAttribute('aria-label')).toBe( + '添加项目', + ); + expect(container.querySelector('input[type="search"]')?.getAttribute('placeholder')).toBe('搜索任务'); + expect(container.querySelector('[data-testid="agentic-task-launch-button"]')?.getAttribute('aria-label')).toBe( + '在 a 中新建任务', + ); + + const manage = container.querySelector('[aria-label="管理 a"]') as HTMLButtonElement; + await act(async () => { + manage.click(); + }); + expect(container.querySelector('[aria-label="重命名 a"]')?.textContent).toBe('重命名'); + expect(container.querySelector('[aria-label="移除 a"]')?.textContent).toBe('移除项目'); + }); + it('uses the active Task Agent before the user default for an empty Project Group', async () => { const services = createServices(); const emptyManagedProject = { ...projectA, label: undefined, managed: true as const }; @@ -329,6 +383,68 @@ describe('AgenticTaskList', () => { expect(services.registry.removeManagedProject).toHaveBeenCalledWith(unnamedManagedProject.id); }); + it('explains why a Project with retained Tasks cannot be removed', async () => { + const services = createServices(); + const managedProject = { ...projectA, managed: true as const }; + services.registry.listProjects.mockResolvedValue([managedProject]); + services.registry.listActiveGroups.mockResolvedValue([ + { project: managedProject, tasks: [createTask(managedProject, 'acp:retained', 'Retained task')] }, + ]); + await renderTaskList(services); + + await act(async () => { + (container.querySelector('[aria-label="Manage Project A"]') as HTMLButtonElement).click(); + }); + + const remove = container.querySelector('[aria-label="Remove Project A"]') as HTMLButtonElement; + expect(remove).not.toBeNull(); + expect(remove.disabled).toBe(true); + expect(remove.getAttribute('aria-describedby')).toBe('agentic-project-remove-reason-project-a'); + expect(container.querySelector('#agentic-project-remove-reason-project-a')?.textContent).toBe( + 'Projects with active or archived Tasks cannot be removed.', + ); + }); + + it('explains why a non-manually-added Project cannot be removed', async () => { + const services = createServices(); + services.registry.listProjects.mockResolvedValue([projectA]); + services.registry.listActiveGroups.mockResolvedValue([ + { project: projectA, tasks: [createTask(projectA, 'acp:current', 'Current task')] }, + ]); + await renderTaskList(services); + + await act(async () => { + (container.querySelector('[aria-label="Manage Project A"]') as HTMLButtonElement).click(); + }); + + const remove = container.querySelector('[aria-label="Remove Project A"]') as HTMLButtonElement; + expect(remove.disabled).toBe(true); + expect(container.querySelector('#agentic-project-remove-reason-project-a')?.textContent).toBe( + 'Only manually added Projects can be removed.', + ); + }); + + it('shows the retained Task reason when an empty Project removal is rejected', async () => { + const services = createServices(); + const emptyManagedProject = { ...projectA, managed: true as const }; + services.registry.listProjects.mockResolvedValue([emptyManagedProject]); + services.registry.listActiveGroups.mockResolvedValue([{ project: emptyManagedProject, tasks: [] }]); + services.registry.removeManagedProject.mockResolvedValue(false); + await renderTaskList(services); + + await act(async () => { + (container.querySelector('[aria-label="Manage Project A"]') as HTMLButtonElement).click(); + }); + await act(async () => { + (container.querySelector('[aria-label="Remove Project A"]') as HTMLButtonElement).click(); + await flushPromises(); + }); + + expect(services.messageService.info).toHaveBeenCalledWith( + 'Projects with active or archived Tasks cannot be removed.', + ); + }); + it('refreshes when the Task Registry records a newly created Task', async () => { const services = createServices(); let taskAdded = false; @@ -851,6 +967,16 @@ describe('AgenticTaskList', () => { unread: false, status: 'running' as const, }, + { + sessionId: 'acp:stopping', + projectId: projectA.id, + agentId: 'agent-a', + title: 'Stopping task', + createdAt: 3, + archived: false, + unread: false, + status: 'stopping' as const, + }, { sessionId: 'acp:stopped', projectId: projectA.id, @@ -922,14 +1048,20 @@ describe('AgenticTaskList', () => { expectMetadata('agentic-task-attention-acp:permission', 'permission', 'codicon-shield', 'task_meta_warning'); expectMetadata('agentic-task-attention-acp:input', 'input', 'codicon-edit', 'task_meta_warning'); - expectMetadata('agentic-task-status-acp:running', 'running', 'codicon-loading', 'task_meta_information'); + expectMetadata('agentic-task-status-acp:running', 'running', 'codicon-pulse', 'task_meta_information'); expect( - container - .querySelector('[data-testid="agentic-task-status-acp:running"] .codicon.codicon-modifier-spin') - ?.getAttribute('aria-hidden'), - ).toBe('true'); - expectMetadata('agentic-task-status-acp:stopped', 'stopped', 'codicon-circle-slash', 'task_meta_secondary'); - expectMetadata('agentic-task-status-acp:error', 'error', 'codicon-error', 'task_meta_error'); + container.querySelector('[data-testid="agentic-task-status-acp:running"] .codicon-modifier-spin'), + ).toBeNull(); + expectMetadata('agentic-task-status-acp:stopping', 'stopping', 'codicon-debug-pause', 'task_meta_warning'); + expect( + container.querySelector('[data-testid="agentic-task-status-acp:stopping"] .codicon-modifier-spin'), + ).toBeNull(); + expect(container.querySelector('[data-testid="agentic-task-archive-acp:stopping"]')).toBeNull(); + expect( + container.querySelector('[data-testid="agentic-task-row-acp:stopping"]')?.getAttribute('aria-label'), + ).toContain('Status: Stopping.'); + expect(container.querySelector('[data-testid="agentic-task-status-acp:stopped"]')).toBeNull(); + expect(container.querySelector('[data-testid="agentic-task-status-acp:error"]')).toBeNull(); expect(container.querySelector('[data-testid="agentic-task-status-acp:permission"]')).toBeNull(); expect(container.querySelector('[data-testid="agentic-task-unread-acp:permission"]')).not.toBeNull(); expect( @@ -1038,6 +1170,64 @@ describe('AgenticTaskList', () => { expect(services.workspaceSwitch.activateTask).toHaveBeenCalledTimes(3); }); + it('keeps the Active Task selected and shows only one Pending Task Selection', async () => { + const services = createServices(); + let resolvePendingActivation: (result: { status: 'activated' }) => void; + const pendingActivation = new Promise<{ status: 'activated' }>((resolve) => { + resolvePendingActivation = resolve; + }); + services.workspaceSwitch.activateTask + .mockResolvedValueOnce({ status: 'activated' }) + .mockReturnValueOnce(pendingActivation); + services.registry.listActiveGroups.mockResolvedValue([ + { + project: projectA, + tasks: [createTask(projectA, 'acp:active', 'Active task'), createTask(projectA, 'acp:pending', 'Pending task')], + }, + ]); + await renderTaskList(services); + + await act(async () => { + (container.querySelector('[data-testid="agentic-task-row-acp:active"]') as HTMLButtonElement).click(); + await flushPromises(); + }); + await act(async () => { + (container.querySelector('[data-testid="agentic-task-row-acp:pending"]') as HTMLButtonElement).click(); + await flushPromises(); + }); + + expect(container.querySelector('[data-testid="agentic-task-row-acp:active"]')?.getAttribute('aria-current')).toBe( + 'true', + ); + expect( + container.querySelector('[data-testid="agentic-task-row-acp:pending"]')?.getAttribute('aria-current'), + ).toBeNull(); + expect(container.querySelector('[data-testid="agentic-task-row-acp:pending"]')?.getAttribute('aria-busy')).toBe( + 'true', + ); + expect(container.querySelector('[data-testid="agentic-task-pending-acp:pending"]')).not.toBeNull(); + expect( + container.querySelector( + '[data-testid="agentic-task-pending-acp:pending"] .codicon-loading.codicon-modifier-spin', + ), + ).not.toBeNull(); + + act(() => { + (container.querySelector('[data-testid="agentic-task-row-acp:pending"]') as HTMLButtonElement).click(); + (container.querySelector('[data-testid="agentic-task-row-acp:active"]') as HTMLButtonElement).click(); + }); + expect(services.workspaceSwitch.activateTask).toHaveBeenCalledTimes(2); + + await act(async () => { + resolvePendingActivation!({ status: 'activated' }); + await flushPromises(); + }); + expect(container.querySelector('[data-testid="agentic-task-row-acp:pending"]')?.getAttribute('aria-current')).toBe( + 'true', + ); + expect(container.querySelector('[data-testid="agentic-task-pending-acp:pending"]')).toBeNull(); + }); + it('selects only the most recent Task Row after rapid successful activation requests', async () => { const services = createServices(); let resolveFirstActivation: (result: { status: 'activated' }) => void; @@ -1103,7 +1293,7 @@ describe('AgenticTaskList', () => { ); }); - it('shows the originating Agent and marks persisted ACP status as last known until the session is observed', async () => { + it('shows the originating Agent and keeps persisted running/stopping status static until observed', async () => { const services = createServices(); services.aiChatService.isAgenticTaskSessionObserved.mockReturnValue(false); services.registry.listActiveGroups.mockResolvedValue([ @@ -1120,6 +1310,16 @@ describe('AgenticTaskList', () => { unread: false, status: 'running' as const, }, + { + sessionId: 'acp:last-known-stopping', + projectId: projectA.id, + agentId: 'agent-b', + title: 'Stopping background task', + createdAt: 2, + archived: false, + unread: false, + status: 'stopping' as const, + }, ], }, ]); @@ -1135,6 +1335,12 @@ describe('AgenticTaskList', () => { expect( container.querySelector('[data-testid="agentic-task-row-acp:last-known"]')?.getAttribute('aria-label'), ).toContain('Status: Last known status: Running.'); + expect( + container.querySelector('[data-testid="agentic-task-status-acp:last-known-stopping"] .codicon-modifier-spin'), + ).toBeNull(); + expect( + container.querySelector('[data-testid="agentic-task-row-acp:last-known-stopping"]')?.getAttribute('aria-label'), + ).toContain('Status: Last known status: Stopping.'); const popover = container.querySelector('[data-popover-id="agentic-task-tooltip-acp:last-known"]') as HTMLElement; await act(async () => { diff --git a/packages/ai-native/__test__/browser/acp/agentic-task-registry.service.test.ts b/packages/ai-native/__test__/browser/acp/agentic-task-registry.service.test.ts index dc570e307f..05b1fd4819 100644 --- a/packages/ai-native/__test__/browser/acp/agentic-task-registry.service.test.ts +++ b/packages/ai-native/__test__/browser/acp/agentic-task-registry.service.test.ts @@ -210,7 +210,7 @@ describe('AgenticTaskRegistryService', () => { }); }); - it('drops legacy MRU-only Projects but retains task history and Project Agent recall', async () => { + it('retains Project-only authorization records without requiring legacy Task history', async () => { const mruOnlyProject = { ...project, id: 'file:///workspace/mru-only', @@ -238,7 +238,7 @@ describe('AgenticTaskRegistryService', () => { rememberProjectAgent: (projectId: string, agentId: string) => Promise; }; - await expect(registry.listProjects()).resolves.toEqual([taskProject]); + await expect(registry.listProjects()).resolves.toEqual([mruOnlyProject, taskProject]); await expect(projectManagementRegistry.rememberProjectAgent(taskProject.id, 'agent-b')).resolves.toMatchObject({ lastAgentId: 'agent-b', }); @@ -331,6 +331,30 @@ describe('AgenticTaskRegistryService', () => { disposable.dispose(); }); + it('stores Agent Session archive markers separately from legacy Task records', async () => { + const archivedSession = { + sessionId: 'acp:session-a', + agentId: 'agent-a', + cwd: '/workspace/project-a', + }; + + await expect(registry.archiveAgentSession(archivedSession)).resolves.toBe(true); + await expect(registry.archiveAgentSession(archivedSession)).resolves.toBe(false); + await expect(registry.listArchivedAgentSessions()).resolves.toEqual([ + expect.objectContaining({ ...archivedSession, archivedAt: expect.any(Number) }), + ]); + + expect(storage.set).toHaveBeenCalledWith( + 'agentic.archived-agent-sessions.v1', + expect.stringContaining('"sessionId":"acp:session-a"'), + ); + expect(storage.set.mock.calls.at(-1)?.[1]).not.toContain('title'); + + await expect(registry.unarchiveAgentSession(archivedSession)).resolves.toBe(true); + await expect(registry.unarchiveAgentSession(archivedSession)).resolves.toBe(false); + await expect(registry.listArchivedAgentSessions()).resolves.toEqual([]); + }); + it.each(['ready', 'stopped', 'error'])('retains loaded %s tasks and permits archiving them', async (status) => { storage.get.mockReturnValue({ version: 2, @@ -353,7 +377,7 @@ describe('AgenticTaskRegistryService', () => { await expect(registry.archive('acp:loaded')).resolves.toBe(true); }); - it.each([undefined, 'running', 'other'])('rejects archiving tasks with %s status', async (status) => { + it.each([undefined, 'running', 'stopping', 'other'])('rejects archiving tasks with %s status', async (status) => { const task = { sessionId: 'acp:loaded', projectId: project.id, @@ -369,6 +393,28 @@ describe('AgenticTaskRegistryService', () => { await expect(registry.archive('acp:loaded')).resolves.toBe(false); }); + it('retains a loaded stopping task as non-archivable last-known state', async () => { + storage.get.mockReturnValue({ + version: 3, + projects: [project], + tasks: [ + { + sessionId: 'acp:stopping', + projectId: project.id, + agentId: 'agent-a', + title: 'Stopping task', + createdAt: 1, + archived: false, + unread: false, + status: 'stopping', + }, + ], + }); + + await expect(registry.getTask('acp:stopping')).resolves.toMatchObject({ status: 'stopping' }); + await expect(registry.archive('acp:stopping')).resolves.toBe(false); + }); + it('archives an unavailable Task without changing its last-known ACP status', async () => { storage.get.mockReturnValue({ version: 3, @@ -414,6 +460,15 @@ describe('AgenticTaskRegistryService', () => { expect(registry.consumePendingLaunch()).toBeUndefined(); }); + it('clears a discarded pending Task launch without consuming it', () => { + registry.preparePendingLaunch({ projectId: 'project-b', agentId: 'agent-b' }); + + registry.clearPendingLaunch(); + + expect(window.sessionStorage.getItem('agentic.pending-task-launch.v2')).toBeNull(); + expect(registry.consumePendingLaunch()).toBeUndefined(); + }); + it('keeps the last active Task session across repeated reads', () => { registry.rememberActiveTaskSession('acp:active'); diff --git a/packages/ai-native/__test__/browser/acp/agentic-workspace-switch.service.test.ts b/packages/ai-native/__test__/browser/acp/agentic-workspace-switch.service.test.ts index b337d7a580..b33fe4cc87 100644 --- a/packages/ai-native/__test__/browser/acp/agentic-workspace-switch.service.test.ts +++ b/packages/ai-native/__test__/browser/acp/agentic-workspace-switch.service.test.ts @@ -59,6 +59,7 @@ describe('AgenticWorkspaceSwitchService', () => { ), activateSession: jest.fn().mockResolvedValue(undefined), enterAgenticTaskDraft: jest.fn(), + getActiveAgenticTaskTarget: jest.fn(), getActiveAgenticTaskAgentId: jest.fn(), getObservedAgenticTaskStatus: jest.fn(), isAgenticTaskSessionObserved: jest.fn(() => false), @@ -79,11 +80,13 @@ describe('AgenticWorkspaceSwitchService', () => { registry = { archive: jest.fn().mockResolvedValue(true), archiveUnavailable: jest.fn().mockResolvedValue(true), + clearRememberedActiveTaskSession: jest.fn(), consumePendingActivation: jest.fn(), consumePendingLaunch: jest.fn(), getRememberedActiveTaskSession: jest.fn(), getProject: jest.fn(), getTask: jest.fn(), + listProjects: jest.fn().mockResolvedValue([]), markProjectAvailability: jest.fn().mockResolvedValue(undefined), markUnread: jest.fn().mockResolvedValue(undefined), rememberProjectAgent: jest.fn().mockResolvedValue(undefined), @@ -257,30 +260,27 @@ describe('AgenticWorkspaceSwitchService', () => { await expect(switcher.launchTask(projectB, 'agent-b')).resolves.toBe(true); expect(aiChatService.enterAgenticTaskDraft).toHaveBeenCalledWith({ agentId: 'agent-b', cwd: '/work/b' }); - expect(registry.preparePendingLaunch).toHaveBeenCalledWith({ projectId: projectB.id, agentId: 'agent-b' }); + expect(registry.preparePendingLaunch).not.toHaveBeenCalled(); expect(windowService.openWorkspace).not.toHaveBeenCalled(); expect(dialogService.warning).not.toHaveBeenCalled(); expect(workspaceService.open).not.toHaveBeenCalled(); }); - it('resolves Header Task Launch from the selected Task Project and Project Agent Recall', async () => { + it('resolves Header Session Launch from the selected Session target and Project Agent Recall', async () => { aiChatService.sessionModel = { sessionId: 'acp:b', requests: [{ message: { agentId: 'agent-a' } }], }; - registry.getTask.mockResolvedValue(taskFor('/work/b')); - registry.getProject.mockImplementation((projectId: string) => { - if (projectId === projectB.id) { - return Promise.resolve({ ...projectB, lastAgentId: 'agent-b' }); - } - return Promise.resolve(undefined); - }); + aiChatService.getActiveAgenticTaskTarget.mockReturnValue({ agentId: 'agent-b', cwd: '/work/b' }); + registry.listProjects.mockResolvedValue([{ ...projectB, lastAgentId: 'agent-b' }]); + registry.getProject.mockResolvedValue(undefined); await expect(switcher.resolveHeaderTaskLaunchContext()).resolves.toMatchObject({ project: { id: projectB.id }, preferredAgentId: 'agent-b', executionContext: { id: projectB.id }, }); + expect(registry.getTask).not.toHaveBeenCalled(); }); it('single-flights Task Launch and publishes its pending state', async () => { @@ -305,12 +305,11 @@ describe('AgenticWorkspaceSwitchService', () => { expect(pendingStates).toEqual([true, false]); }); - it('rejects Header Task Launch when the selected Workspace Target is unavailable', async () => { + it('rejects Header Session Launch when the selected Workspace Target is unavailable', async () => { aiChatService.sessionModel = { sessionId: 'acp:b', requests: [] }; - registry.getTask.mockResolvedValue(taskFor('/work/b')); - registry.getProject.mockImplementation((projectId: string) => - Promise.resolve(projectId === projectB.id ? { ...projectB, availability: 'unavailable' } : projectA), - ); + aiChatService.getActiveAgenticTaskTarget.mockReturnValue({ agentId: 'agent-b', cwd: '/work/b' }); + registry.listProjects.mockResolvedValue([{ ...projectB, availability: 'unavailable' }]); + registry.getProject.mockResolvedValue(projectA); await expect(switcher.launchHeaderTask()).resolves.toEqual({ status: 'project-unavailable' }); expect(aiChatService.enterAgenticTaskDraft).not.toHaveBeenCalled(); @@ -326,12 +325,12 @@ describe('AgenticWorkspaceSwitchService', () => { expect(aiChatService.enterAgenticTaskDraft).not.toHaveBeenCalled(); }); - it('preserves the active conversation when Header Task Launch fails', async () => { + it('does not let legacy Agent-recall storage affect Header Session Launch', async () => { registry.getProject.mockResolvedValue(projectA); registry.rememberProjectAgent.mockRejectedValue(new Error('storage failed')); - await expect(switcher.launchHeaderTask('agent-a')).resolves.toEqual({ status: 'failed' }); - expect(aiChatService.enterAgenticTaskDraft).not.toHaveBeenCalled(); + await expect(switcher.launchHeaderTask('agent-a')).resolves.toEqual({ status: 'launched' }); + expect(aiChatService.enterAgenticTaskDraft).toHaveBeenCalledWith({ agentId: 'agent-a', cwd: '/work/a' }); }); it('creates a target draft immediately in the current Project', async () => { @@ -340,7 +339,7 @@ describe('AgenticWorkspaceSwitchService', () => { await switcher.launchTask(projectA, 'agent-a'); expect(aiChatService.enterAgenticTaskDraft).toHaveBeenCalledWith({ agentId: 'agent-a', cwd: '/work/a' }); - expect(registry.preparePendingLaunch).toHaveBeenCalledWith({ projectId: projectA.id, agentId: 'agent-a' }); + expect(registry.preparePendingLaunch).not.toHaveBeenCalled(); expect(workspaceService.open).not.toHaveBeenCalled(); }); @@ -355,12 +354,12 @@ describe('AgenticWorkspaceSwitchService', () => { expect(windowService.openWorkspace).not.toHaveBeenCalled(); }); - it('remembers the Agent after launching a registered Project', async () => { + it('does not persist an Agent choice when launching a registered Project', async () => { registry.getProject.mockResolvedValue(projectA); await switcher.launchTask(projectA, 'agent-a'); - expect(registry.rememberProjectAgent).toHaveBeenCalledWith(projectA.id, 'agent-a'); + expect(registry.rememberProjectAgent).not.toHaveBeenCalled(); }); it('does not switch to a forged Project that is absent from the registry', async () => { @@ -399,48 +398,19 @@ describe('AgenticWorkspaceSwitchService', () => { expect(workspaceService.open).not.toHaveBeenCalled(); }); - it('restores a legacy pending Task activation before consuming a pending launch', async () => { + it('does not read or mutate legacy Task restoration state', async () => { registry.consumePendingActivation.mockReturnValue({ sessionId: 'acp:a' }); - registry.getTask.mockResolvedValue(taskFor('/work/a')); - registry.getProject.mockResolvedValue(projectA); - - await switcher.restorePendingWork(); - - expect(aiChatService.activateAgenticTaskSession).toHaveBeenCalledWith('acp:a', expect.any(Function)); - expect(registry.markUnread).toHaveBeenCalledWith('acp:a', false); - expect(registry.consumePendingLaunch).not.toHaveBeenCalled(); - }); - - it('consumes a pending launch only after no activation is available', async () => { - registry.getRememberedActiveTaskSession.mockReturnValue({ sessionId: 'acp:a' }); registry.consumePendingLaunch.mockReturnValue({ projectId: projectB.id, agentId: 'agent-b' }); - registry.getProject.mockResolvedValue(projectB); - - await switcher.restorePendingWork(); - - expect(aiChatService.enterAgenticTaskDraft).toHaveBeenCalledWith({ agentId: 'agent-b', cwd: '/work/b' }); - expect(aiChatService.activateAgenticTaskSession).not.toHaveBeenCalled(); - }); - - it('restores the remembered active Task only when no pending activation or launch exists', async () => { registry.getRememberedActiveTaskSession.mockReturnValue({ sessionId: 'acp:a' }); - registry.getTask.mockResolvedValue(taskFor('/work/a')); - registry.getProject.mockResolvedValue(projectA); - - await switcher.restorePendingWork(); - - expect(aiChatService.activateAgenticTaskSession).toHaveBeenCalledWith('acp:a', expect.any(Function)); - expect(registry.markUnread).toHaveBeenCalledWith('acp:a', false); - }); - - it('does not restore a remembered session without its Agent Task binding', async () => { - registry.getRememberedActiveTaskSession.mockReturnValue({ sessionId: 'acp:orphaned' }); - registry.getTask.mockResolvedValue(undefined); await switcher.restorePendingWork(); + expect(registry.consumePendingActivation).not.toHaveBeenCalled(); + expect(registry.consumePendingLaunch).not.toHaveBeenCalled(); + expect(registry.clearRememberedActiveTaskSession).not.toHaveBeenCalled(); expect(aiChatService.activateAgenticTaskSession).not.toHaveBeenCalled(); - expect(registry.markUnread).not.toHaveBeenCalled(); + expect(aiChatService.enterAgenticTaskDraft).not.toHaveBeenCalled(); + expect(registry.getTask).not.toHaveBeenCalled(); }); it('does not admit the current Workspace or MRU entries while refreshing the Project catalog', async () => { diff --git a/packages/ai-native/__test__/browser/ai-layout.test.tsx b/packages/ai-native/__test__/browser/ai-layout.test.tsx index 17843e9f51..23455686d5 100644 --- a/packages/ai-native/__test__/browser/ai-layout.test.tsx +++ b/packages/ai-native/__test__/browser/ai-layout.test.tsx @@ -201,7 +201,7 @@ describe('AILayout BDD', () => { storedLayout = {}; storedLayouts = {}; panelLayoutChangeListener = undefined; - agenticWorkbenchVisible = false; + agenticWorkbenchVisible = true; agenticWorkbenchWidthConstrained = false; Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1366, writable: true }); agenticWorkbenchVisibilityListeners.clear(); @@ -325,7 +325,7 @@ describe('AILayout BDD', () => { expect(getSplitChildIds('main-horizontal-agentic')).toEqual(['main-vertical-agentic', 'view']); }); - it('Given agentic layout, when it renders by default, then AI chat is the only main split child', async () => { + it('Given agentic layout, when it renders by default, then AI chat and the workbench are both visible', async () => { panelLayoutMode = 'agentic'; const { AILayout } = await import('../../src/browser/layout/ai-layout'); @@ -333,9 +333,9 @@ describe('AILayout BDD', () => { root.render(); }); - expect(getSlots()).toEqual(['top', 'AI-Chat', 'statusBar']); - expect(getSplitChildIds('main-horizontal-ai-agentic')).toEqual(['AI-Chat']); - expect(container.querySelector('[data-split="main-horizontal-agentic"]')).toBeFalsy(); + expect(getSlots()).toEqual(['top', 'AI-Chat', 'main', 'panel', 'view', 'statusBar']); + expect(getSplitChildIds('main-horizontal-ai-agentic')).toEqual(['AI-Chat', 'main-horizontal-agentic']); + expect(getSplitChildIds('main-horizontal-agentic')).toEqual(['main-vertical-agentic', 'view']); }); it('Given agentic workbench is collapsed, when it becomes visible, then the editor and Explorer return', async () => { diff --git a/packages/ai-native/__test__/browser/ai-tabbar-layout.test.tsx b/packages/ai-native/__test__/browser/ai-tabbar-layout.test.tsx index e9162fcdb2..de5346cc5f 100644 --- a/packages/ai-native/__test__/browser/ai-tabbar-layout.test.tsx +++ b/packages/ai-native/__test__/browser/ai-tabbar-layout.test.tsx @@ -237,7 +237,7 @@ describe('AI tabbar layout BDD', () => { expect(mockTabbarServiceFactory).not.toHaveBeenCalledWith('extendView'); }); - it('Given agentic layout, when rendering side entries, then it allows only Explorer and SCM', async () => { + it('Given agentic layout, when rendering side entries, then it allows all view containers', async () => { panelLayoutMode = 'agentic'; mockViewTabbarService.visibleContainers = [ { @@ -272,10 +272,7 @@ describe('AI tabbar layout BDD', () => { root.render(); }); - const containerFilter = mockCapturedLeftTabbarProps.tabbarViewProps.containerFilter; - expect( - mockViewTabbarService.visibleContainers.filter(containerFilter).map((container) => container.options.containerId), - ).toEqual(['explorer', 'scm']); + expect(mockCapturedLeftTabbarProps.tabbarViewProps).toBeUndefined(); expect(mockCapturedLeftTabbarProps.renderOtherVisibleContainers).toBeUndefined(); }); diff --git a/packages/ai-native/__test__/browser/chat/AgenticVirtualMessageList.test.tsx b/packages/ai-native/__test__/browser/chat/AgenticVirtualMessageList.test.tsx new file mode 100644 index 0000000000..9cb7867754 --- /dev/null +++ b/packages/ai-native/__test__/browser/chat/AgenticVirtualMessageList.test.tsx @@ -0,0 +1,151 @@ +import * as React from 'react'; +import { Root, createRoot } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; + +const virtuosoProps: any[] = []; +const scrollToIndex = jest.fn(); +const autoscrollToBottom = jest.fn(); + +jest.mock('react-virtuoso', () => ({ + Virtuoso: React.forwardRef((props: any, ref: React.ForwardedRef) => { + virtuosoProps.push(props); + React.useImperativeHandle(ref, () => ({ autoscrollToBottom, scrollToIndex })); + const visible = props.data.slice(0, 20); + return React.createElement( + 'div', + { 'data-testid': 'virtuoso' }, + visible.map((item: any, index: number) => + React.createElement( + 'div', + { key: props.computeItemKey(index, item), 'data-item-key': props.computeItemKey(index, item) }, + props.itemContent(index, item), + ), + ), + ); + }), +})); + +jest.mock('react-chat-elements', () => ({ + MessageBox: (props: any) => React.createElement('div', { 'data-message-box-id': props.id }, props.text), +})); + +import { + AgenticVirtualMessageList, + AgenticVirtualMessageListHandle, +} from '../../../src/browser/chat/AgenticVirtualMessageList'; + +describe('AgenticVirtualMessageList', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + virtuosoProps.length = 0; + scrollToIndex.mockClear(); + autoscrollToBottom.mockClear(); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('mounts a bounded visible range with stable message keys through the community MessageBox', () => { + const messages = Array.from({ length: 1000 }, (_, index) => ({ id: `message-${index}` })); + const renderMessage = jest.fn((message: { id: string }) => ({ + id: message.id, + position: message.id === 'message-1' ? ('right' as const) : ('left' as const), + type: 'text' as const, + text: message.id, + })); + + act(() => { + root.render( + , + ); + }); + + expect(container.querySelectorAll('[data-message-box-id]')).toHaveLength(20); + expect(container.querySelector('[data-message-id="message-0"]')?.getAttribute('data-message-role')).toBe( + 'assistant', + ); + expect(container.querySelector('[data-message-id="message-1"]')?.getAttribute('data-message-role')).toBe('user'); + expect(renderMessage).toHaveBeenCalledTimes(20); + expect(container.querySelector('[data-item-key="message-0"]')).not.toBeNull(); + expect(container.querySelector('[data-item-key="message-19"]')).not.toBeNull(); + expect(virtuosoProps.at(-1)).toEqual( + expect.objectContaining({ + data: messages, + followOutput: expect.any(Function), + increaseViewportBy: { bottom: 480, top: 480 }, + overscan: expect.any(Object), + }), + ); + expect((container.querySelector('[data-message-id="message-0"]') as HTMLElement).style.display).toBe('flow-root'); + }); + + it('exposes bottom scrolling through its public handle', () => { + const ref = React.createRef(); + const messages = [{ id: 'message-0' }, { id: 'message-1' }]; + + act(() => { + root.render( + ({ + id: message.id, + position: 'left', + type: 'text', + text: message.id, + })} + sessionId='acp:scroll' + />, + ); + }); + act(() => ref.current?.scrollToBottom('smooth')); + + expect(scrollToIndex).toHaveBeenCalledWith({ align: 'end', behavior: 'smooth', index: 1 }); + }); + + it('does not restore the initial reading position again when messages update in the same session', () => { + const animationFrame = jest.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + callback(0); + return 1; + }); + const renderMessage = (message: { id: string; text: string }) => ({ + id: message.id, + position: 'left', + type: 'text', + text: message.text, + }); + + act(() => { + root.render( + , + ); + }); + const initialRestoreCount = scrollToIndex.mock.calls.length; + const initialItemContent = virtuosoProps.at(-1).itemContent; + + act(() => { + root.render( + , + ); + }); + + expect(scrollToIndex).toHaveBeenCalledTimes(initialRestoreCount); + expect(virtuosoProps.at(-1).itemContent).toBe(initialItemContent); + animationFrame.mockRestore(); + }); +}); diff --git a/packages/ai-native/__test__/browser/chat/acp-chat-agent.test.ts b/packages/ai-native/__test__/browser/chat/acp-chat-agent.test.ts index a8d5948943..83fcc50026 100644 --- a/packages/ai-native/__test__/browser/chat/acp-chat-agent.test.ts +++ b/packages/ai-native/__test__/browser/chat/acp-chat-agent.test.ts @@ -11,6 +11,9 @@ describe('AcpChatAgent', () => { return output; }); const preferenceGet = jest.fn((id: string, fallback?: unknown) => fallback); + const getSlashCommandHandler = jest.fn(); + const resolveConfigForTarget = jest.fn(async (target) => ({ ...target })); + const getSession = jest.fn(); const agent = Object.create(AcpChatAgent.prototype) as AcpChatAgent; Object.defineProperties(agent, { @@ -37,10 +40,11 @@ describe('AcpChatAgent', () => { args: [], cwd: '/workspace', })), + resolveConfigForTarget, }, }, chatFeatureRegistry: { - value: { getSlashCommandHandler: jest.fn() }, + value: { getSlashCommandHandler }, }, monacoCommandRegistry: { value: { getActiveCodeEditor: jest.fn() }, @@ -55,11 +59,18 @@ describe('AcpChatAgent', () => { value: { log: jest.fn(), error: jest.fn() }, }, chatManagerService: { - value: { getSession: jest.fn() }, + value: { getSession }, }, }); - return { agent, preferenceGet, requestStream }; + return { + agent, + preferenceGet, + requestStream, + getSlashCommandHandler, + resolveConfigForTarget, + getSession, + }; } it('uses stream as the ACP delivery mode when the preference is unset', async () => { @@ -86,4 +97,51 @@ describe('AcpChatAgent', () => { CancellationToken.None, ); }); + + it('accepts a custom slash command request before invoking its handler', async () => { + const { agent, requestStream, getSlashCommandHandler } = createAgent(); + const events: string[] = []; + const invoke = jest.fn(async () => { + events.push('handler'); + }); + getSlashCommandHandler.mockReturnValue({ invoke }); + + await agent.invoke( + { + requestId: 'request-slash-1', + sessionId: 'acp:session-slash-1', + message: 'run it', + command: 'custom', + } as any, + (progress) => { + events.push(progress.kind); + }, + [{ role: 'user', content: 'run it' }], + CancellationToken.None, + ); + + expect(events).toEqual(['requestAccepted', 'handler']); + expect(invoke).toHaveBeenCalledWith('run it', expect.any(Function), CancellationToken.None); + expect(requestStream).not.toHaveBeenCalled(); + }); + + it('routes the first request through the ACP target retained by the session model', async () => { + const { agent, resolveConfigForTarget, getSession } = createAgent(); + const acpTarget = { agentId: 'agent-b', cwd: '/work/b' }; + getSession.mockReturnValue({ acpTarget }); + + await agent.invoke( + { + requestId: 'request-target-1', + sessionId: 'acp:targeted-session', + message: 'continue', + } as any, + jest.fn(), + [{ role: 'user', content: 'continue' }], + CancellationToken.None, + ); + + expect(getSession).toHaveBeenCalledWith('acp:targeted-session'); + expect(resolveConfigForTarget).toHaveBeenCalledWith(acpTarget); + }); }); diff --git a/packages/ai-native/__test__/browser/chat/acp-chat-internal.service.test.ts b/packages/ai-native/__test__/browser/chat/acp-chat-internal.service.test.ts index 96c5cacbad..d269e6faa5 100644 --- a/packages/ai-native/__test__/browser/chat/acp-chat-internal.service.test.ts +++ b/packages/ai-native/__test__/browser/chat/acp-chat-internal.service.test.ts @@ -79,6 +79,36 @@ describe('AcpChatInternalService', () => { expect(modeChanges).toEqual(['code']); }); + it('rebinds the active session when an attachment snapshot replaces its model', () => { + const service = new AcpChatInternalService() as any; + const stateEmitter = new Emitter(); + const originalModel = new ChatModel(new ChatFeatureRegistry(), { sessionId: 'acp:sess-1' }); + const restoredModel = new ChatModel(new ChatFeatureRegistry(), { sessionId: 'acp:sess-1' }); + const sessionModelChanges: any[] = []; + + Object.defineProperties(service, { + aiNativeConfigService: { value: { capabilities: { supportsAgentMode: true } } }, + chatManagerService: { + value: { + onDidApplySessionState: stateEmitter.event, + onStorageInit: jest.fn(() => disposable()), + }, + }, + }); + service._sessionModel = originalModel; + service.onSessionModelChange((sessionModel) => sessionModelChanges.push(sessionModel)); + + service.init(); + stateEmitter.fire({ + sessionId: originalModel.sessionId, + model: restoredModel, + modelReplaced: true, + }); + + expect(service.sessionModel).toBe(restoredModel); + expect(sessionModelChanges).toEqual([restoredModel]); + }); + it('does not treat lightweight Agentic session-list models as live observations on storage init', async () => { const service = new AcpChatInternalService() as any; const model = new ChatModel(new ChatFeatureRegistry(), { sessionId: 'acp:retained' }); @@ -98,6 +128,7 @@ describe('AcpChatInternalService', () => { onStorageInit = listener; return disposable(); }), + refreshAgentSessionCatalog: jest.fn().mockResolvedValue([]), }, }, panelLayoutService: { value: { getLayoutMode: jest.fn(() => 'agentic') } }, @@ -110,6 +141,32 @@ describe('AcpChatInternalService', () => { expect(registry.updateStatus).not.toHaveBeenCalled(); }); + it('returns the refreshed Agent Session snapshot without stale local models in Agentic Layout', async () => { + const service = new AcpChatInternalService() as any; + const listed = new ChatModel(new ChatFeatureRegistry(), { sessionId: 'acp:listed' }); + const stale = new ChatModel(new ChatFeatureRegistry(), { sessionId: 'acp:stale' }); + const chatManagerService = { + getAgentSessionCatalog: jest.fn(() => [ + { + sessionId: listed.sessionId, + agentSessionId: 'listed', + agentId: 'agent-a', + cwd: '/work/a', + }, + ]), + getSession: jest.fn((sessionId: string) => (sessionId === listed.sessionId ? listed : stale)), + getSessions: jest.fn(() => [listed, stale]), + refreshAgentSessionCatalog: jest.fn().mockResolvedValue(undefined), + }; + Object.defineProperties(service, { + chatManagerService: { value: chatManagerService }, + panelLayoutService: { value: { getLayoutMode: jest.fn(() => 'agentic') } }, + }); + + await expect(service.getSessionsByAcp()).resolves.toEqual([listed]); + expect(chatManagerService.refreshAgentSessionCatalog).toHaveBeenCalledTimes(1); + }); + it('notifies session model listeners for non-mode ACP session state changes', () => { const service = new AcpChatInternalService() as any; const stateEmitter = new Emitter(); @@ -214,16 +271,22 @@ describe('AcpChatInternalService', () => { const chatManagerService = { cancelRequest: jest.fn(), clearSession: jest.fn(), + disposeSession: jest.fn(() => Promise.resolve()), getAvailableCommands: jest.fn(() => [{ name: 'help', description: 'Help' }]), getSession: jest.fn(() => model), getSessions: jest.fn(() => [model]), loadSession: jest.fn(() => Promise.resolve()), onDidApplySessionState: stateEmitter.event, onStorageInit: jest.fn(() => disposable()), - sendRequest: jest.fn(() => Promise.resolve()), + refreshAgentSessionCatalog: jest.fn().mockResolvedValue([]), + sendRequest: jest.fn((_sessionId, _request, _regenerate, onRequestAccepted?: () => void) => { + onRequestAccepted?.(); + return Promise.resolve(); + }), startSession: jest.fn(() => Promise.resolve(model)), }; const registry = { + clearPendingLaunch: jest.fn(), clearRememberedActiveTaskSession: jest.fn(), consumePendingLaunch: jest.fn(), getTask: jest.fn(), @@ -249,6 +312,11 @@ describe('AcpChatInternalService', () => { info: jest.fn(), }; const aiBackService = { + cancelSessionCreation: jest.fn(() => Promise.resolve()), + closeSession: jest.fn(() => Promise.resolve()), + deleteSession: jest.fn(() => Promise.resolve()), + getSessionCapabilities: jest.fn(() => Promise.resolve({ close: false, delete: false })), + setAcpStandbyTarget: jest.fn(() => Promise.resolve()), setSessionConfigOption: jest.fn(() => Promise.resolve()), setSessionMode: jest.fn(() => Promise.resolve()), setSessionModel: jest.fn(() => Promise.resolve()), @@ -266,6 +334,15 @@ describe('AcpChatInternalService', () => { Object.defineProperty(service, 'aiBackService', { value: aiBackService, }); + Object.defineProperty(service, 'configProvider', { + value: { + resolveConfigForTarget: jest.fn(async (target) => ({ + ...target, + command: 'agent-cli', + args: [], + })), + }, + }); Object.defineProperty(service, 'aiNativeConfigService', { value: { capabilities: { supportsAgentMode: true } }, }); @@ -327,124 +404,142 @@ describe('AcpChatInternalService', () => { }); }); - it('creates one bootstrap ACP session and exposes its footer metadata', async () => { - const { chatManagerService, model, permissionBridgeService, service } = createService(); + it('declares only the latest Task Draft standby target after 500 milliseconds', async () => { + jest.useFakeTimers(); + const { aiBackService, service } = createService(); - await expect(service.ensureBootstrapSessionModel()).resolves.toBe(model); - await expect(service.ensureBootstrapSessionModel()).resolves.toBe(model); + service.enterAgenticTaskDraft({ agentId: 'agent-a', cwd: '/work/a' }); + service.enterAgenticTaskDraft({ agentId: 'agent-b', cwd: '/work/b' }); + await jest.advanceTimersByTimeAsync(499); + expect(aiBackService.setAcpStandbyTarget).not.toHaveBeenCalled(); - expect(chatManagerService.startSession).toHaveBeenCalledTimes(1); - expect(permissionBridgeService.setActiveSession).toHaveBeenCalledWith('sess-1'); - expect(service.sessionModel).toBe(model); - expect(service.getAvailableCommands()).toEqual([{ name: 'help', description: 'Help' }]); - expect(service.getDraftSessionState()).toEqual({ - agentModes: model.agentModes, - currentModeId: 'code', - agentModels: model.agentModels, - modelId: 'model-a', - configOptions: model.configOptions, - }); + await jest.advanceTimersByTimeAsync(1); + + expect(aiBackService.setAcpStandbyTarget).toHaveBeenCalledTimes(1); + expect(aiBackService.setAcpStandbyTarget).toHaveBeenCalledWith( + expect.objectContaining({ agentId: 'agent-b', cwd: '/work/b' }), + ); + jest.useRealTimers(); }); - it('reuses the bootstrap ACP session on first send instead of creating another session', async () => { - const { chatManagerService, model, service } = createService(); + it('does not declare standby capacity after a Task Draft is discarded', async () => { + jest.useFakeTimers(); + const { aiBackService, registry, service } = createService(); - await expect(service.ensureBootstrapSessionModel()).resolves.toBe(model); - await expect(service.ensureSessionModel()).resolves.toBe(model); + service.enterAgenticTaskDraft({ agentId: 'agent-b', cwd: '/work/b' }); + await service.discardAgenticTaskDraft(); + await jest.advanceTimersByTimeAsync(500); - expect(chatManagerService.startSession).toHaveBeenCalledTimes(1); + expect(aiBackService.setAcpStandbyTarget).not.toHaveBeenCalled(); + expect(registry.clearPendingLaunch).toHaveBeenCalledTimes(1); + jest.useRealTimers(); }); - it('registers a default bootstrap Task with the resolved ACP Agent identity', async () => { - const { model, registry, service } = createService({ agentId: 'claude-agent-acp', cwd: '/work/a' }); - - await service.ensureBootstrapSessionModel(); - const request = model.addRequest({ - prompt: 'Recover this Task later', - agentId: 'Default_Chat_Agent', - command: '', - images: [], - }); + it('flushes the latest Task Draft standby target before foreground session creation', async () => { + jest.useFakeTimers(); + const { aiBackService, chatManagerService, service } = createService(); - await service.sendRequest(request); + service.enterAgenticTaskDraft({ agentId: 'agent-b', cwd: '/work/b' }); + await service.ensureSessionModel(); - expect(registry.registerFirstPrompt).toHaveBeenCalledWith( - expect.objectContaining({ - sessionId: model.sessionId, - agentId: 'claude-agent-acp', - }), + expect(aiBackService.setAcpStandbyTarget).toHaveBeenCalledWith( + expect.objectContaining({ agentId: 'agent-b', cwd: '/work/b' }), + ); + expect(aiBackService.setAcpStandbyTarget.mock.invocationCallOrder[0]).toBeLessThan( + chatManagerService.startSession.mock.invocationCallOrder[0], ); + await jest.runOnlyPendingTimersAsync(); + expect(aiBackService.setAcpStandbyTarget).toHaveBeenCalledTimes(1); + jest.useRealTimers(); }); - it('hides an unused bootstrap session from visible history until it receives user content', async () => { - const { model, service } = createService(); + it('does not create an unscoped bootstrap Session outside an explicit Agentic draft', async () => { + const { chatManagerService, service } = createService(); - await service.ensureBootstrapSessionModel(); + await expect(service.ensureBootstrapSessionModel()).resolves.toBeUndefined(); - expect(service.getSessions()).toEqual([model]); - expect(service.getVisibleSessions()).toEqual([]); + expect(chatManagerService.startSession).not.toHaveBeenCalled(); + }); - model.history.addUserMessage({ - content: 'hello', - agentId: 'default-agent', - agentCommand: '', - images: [], - relationId: 'request-1', - }); + it('creates one capability-gated draft-bound Session and exposes its Agent catalog', async () => { + const { aiBackService, chatManagerService, model, permissionBridgeService, service } = createService(); + aiBackService.getSessionCapabilities.mockResolvedValue({ close: true, delete: true }); + + service.enterAgenticTaskDraft({ agentId: 'agent-b', cwd: '/work/b' }); + await expect(service.ensureSessionModel()).resolves.toBe(model); + expect(aiBackService.getSessionCapabilities).toHaveBeenCalledWith( + expect.objectContaining({ agentId: 'agent-b', cwd: '/work/b' }), + ); + expect(chatManagerService.startSession).toHaveBeenCalledTimes(1); + expect(permissionBridgeService.setActiveSession).toHaveBeenCalledWith('sess-1'); + expect(service.getAvailableCommands()).toEqual([{ name: 'help', description: 'Help' }]); + expect(service.getSkillCatalogState()).toBe('ready'); expect(service.getVisibleSessions()).toEqual([model]); }); - it('keeps an unused bootstrap session active when starting a new chat', async () => { - const { chatManagerService, model, permissionBridgeService, service } = createService(); - - await service.ensureBootstrapSessionModel(); - permissionBridgeService.setActiveSession.mockClear(); + it('keeps first-Prompt creation lazy when standard Session closing is unavailable', async () => { + const { aiBackService, chatManagerService, model, service } = createService(); + aiBackService.getSessionCapabilities.mockResolvedValue({ close: false, delete: false }); - service.enterDraftSession(); + service.enterAgenticTaskDraft({ agentId: 'agent-b', cwd: '/work/b' }); + await Promise.resolve(); + await Promise.resolve(); - expect(service.sessionModel).toBe(model); + expect(chatManagerService.startSession).not.toHaveBeenCalled(); + expect(service.getSkillCatalogState()).toBe('unavailable'); + await expect(service.ensureSessionModel()).resolves.toBe(model); expect(chatManagerService.startSession).toHaveBeenCalledTimes(1); - expect(permissionBridgeService.setActiveSession).not.toHaveBeenCalledWith(undefined); }); - it('keeps later new chat lazy after the bootstrap session has been used', async () => { - const { chatManagerService, model, registry, service } = createService(); - const nextModel = new ChatModel(new ChatFeatureRegistry(), { - sessionId: 'acp:sess-2', - }); - chatManagerService.startSession.mockReset(); - chatManagerService.startSession.mockResolvedValueOnce(model).mockResolvedValueOnce(nextModel); - - await service.ensureBootstrapSessionModel(); - model.history.addUserMessage({ - content: 'hello', - agentId: 'default-agent', - agentCommand: '', - images: [], - relationId: 'request-1', - }); + it('creates a closable draft-bound Session without requiring Session deletion', async () => { + const { aiBackService, chatManagerService, model, service } = createService(); + aiBackService.getSessionCapabilities.mockResolvedValue({ close: true, delete: false }); - service.enterDraftSession(); + service.enterAgenticTaskDraft({ agentId: 'agent-b', cwd: '/work/b' }); + await expect(service.ensureSessionModel()).resolves.toBe(model); - expect(registry.clearRememberedActiveTaskSession).toHaveBeenCalledWith(model.sessionId); - expect(service.sessionModel).toBeUndefined(); expect(chatManagerService.startSession).toHaveBeenCalledTimes(1); + expect(service.getSkillCatalogState()).toBe('ready'); + await service.discardAgenticTaskDraft(); + expect(aiBackService.closeSession).toHaveBeenCalledWith('sess-1'); + expect(aiBackService.deleteSession).not.toHaveBeenCalled(); + }); - await expect(service.ensureSessionModel()).resolves.toBe(nextModel); - expect(chatManagerService.startSession).toHaveBeenCalledTimes(2); + it('closes and deletes only its unprompted draft-bound Session on discard', async () => { + const { aiBackService, chatManagerService, model, service } = createService(); + aiBackService.getSessionCapabilities.mockResolvedValue({ close: true, delete: true }); + + service.enterAgenticTaskDraft({ agentId: 'agent-b', cwd: '/work/b' }); + await service.ensureSessionModel(); + await service.discardAgenticTaskDraft(); + + expect(aiBackService.closeSession).toHaveBeenCalledWith('sess-1'); + expect(aiBackService.deleteSession).toHaveBeenCalledWith('sess-1'); + expect(chatManagerService.disposeSession).toHaveBeenCalledWith(model.sessionId); + expect(service.sessionModel).toBeUndefined(); }); - it('does not block first-send lazy session creation when bootstrap creation fails', async () => { - const { chatManagerService, model, service } = createService(); - chatManagerService.startSession.mockReset(); - chatManagerService.startSession.mockRejectedValueOnce(new Error('session/new failed')); - chatManagerService.startSession.mockResolvedValueOnce(model); + it('retains a draft-bound Session after its first Prompt is rejected so the draft can retry', async () => { + const { aiBackService, chatManagerService, model, service } = createService(); + aiBackService.getSessionCapabilities.mockResolvedValue({ close: true, delete: true }); + chatManagerService.sendRequest.mockResolvedValue(undefined); - await expect(service.ensureBootstrapSessionModel()).resolves.toBeUndefined(); - await expect(service.ensureSessionModel()).resolves.toBe(model); + service.enterAgenticTaskDraft({ agentId: 'agent-b', cwd: '/work/b' }); + await service.ensureSessionModel(); + const request = model.addRequest({ + prompt: 'Try again', + agentId: 'agent-b', + command: '', + images: [], + }); + + await service.sendRequest(request); - expect(chatManagerService.startSession).toHaveBeenCalledTimes(2); + expect(aiBackService.closeSession).not.toHaveBeenCalled(); + expect(aiBackService.deleteSession).not.toHaveBeenCalled(); + expect(chatManagerService.disposeSession).not.toHaveBeenCalled(); + expect(service.sessionModel).toBe(model); }); it('creates the ACP session only when ensuring from draft', async () => { @@ -496,15 +591,21 @@ describe('AcpChatInternalService', () => { service.enterAgenticTaskDraft({ agentId: 'agent-b', cwd: '/work/b' }); await service.ensureSessionModel(); - expect(chatManagerService.startSession).toHaveBeenCalledWith({ - acpTarget: { agentId: 'agent-b', cwd: '/work/b' }, - }); + expect(chatManagerService.startSession).toHaveBeenCalledWith( + expect.objectContaining({ + acpTarget: { agentId: 'agent-b', cwd: '/work/b' }, + operationId: expect.stringMatching(/^acp-launch-/), + }), + ); expect(service.sessionModel).toBe(model); expect(preferenceService.set).not.toHaveBeenCalled(); }); - it('stores the selected ACP Agent instead of the chat-agent message identity for a new Task', async () => { - const { model, registry, service } = createService({ agentId: 'claude-agent-acp', cwd: '/work/a' }); + it('uses the selected ACP target when registering the Project without creating a local Task', async () => { + const { chatManagerService, model, registry, service } = createService({ + agentId: 'claude-agent-acp', + cwd: '/work/a', + }); service.enterAgenticTaskDraft({ agentId: 'claude-agent-acp', cwd: '/work/a' }); await service.ensureSessionModel(); const request = model.addRequest({ @@ -516,37 +617,75 @@ describe('AcpChatInternalService', () => { await service.sendRequest(request); - expect(registry.registerFirstPrompt).toHaveBeenCalledWith( - expect.objectContaining({ - sessionId: 'acp:sess-1', - agentId: 'claude-agent-acp', - }), - ); - expect(registry.rememberActiveTaskSession).toHaveBeenCalledWith(model.sessionId); + expect(registry.registerProject).toHaveBeenCalledWith(expect.objectContaining({ workspacePath: '/work/a' })); + expect(registry.registerFirstPrompt).not.toHaveBeenCalled(); + expect(registry.rememberActiveTaskSession).not.toHaveBeenCalled(); + expect(chatManagerService.refreshAgentSessionCatalog).toHaveBeenCalledTimes(1); }); - it('remembers the activated Agentic Task as the reload target', async () => { + it('does not persist an activated Agent session as a legacy Task reload target', async () => { const { model, registry, service } = createService(); registry.getTask.mockResolvedValue({ sessionId: model.sessionId }); await expect(service.activateAgenticTaskSession(model.sessionId)).resolves.toEqual({ status: 'activated' }); - expect(registry.rememberActiveTaskSession).toHaveBeenCalledWith(model.sessionId); + expect(registry.rememberActiveTaskSession).not.toHaveBeenCalled(); }); - it('uses pending Project and Agent metadata when the active chat service creates the Task session', async () => { + it('commits an Agentic transcript before Live Ready and keeps submission loading active', async () => { + const { chatManagerService, model, service } = createService(); + const loadingChanges: boolean[] = []; + let resolveLiveReady!: (status: 'ready') => void; + const liveReady = new Promise<'ready'>((resolve) => { + resolveLiveReady = resolve; + }); + chatManagerService.loadSession.mockResolvedValue({ liveReady }); + service.onSessionLoadingChange((loading) => loadingChanges.push(loading)); + + await expect(service.activateAgenticTaskSession(model.sessionId)).resolves.toEqual({ status: 'activated' }); + + expect(service.sessionModel).toBe(model); + expect(service.isSessionLoading).toBe(true); + expect(service.getAgenticSessionLiveReadyStatus(model.sessionId)).toBe('pending'); + expect(loadingChanges).toEqual([true]); + + resolveLiveReady('ready'); + await liveReady; + await Promise.resolve(); + + expect(service.isSessionLoading).toBe(false); + expect(service.getAgenticSessionLiveReadyStatus(model.sessionId)).toBe('ready'); + expect(loadingChanges).toEqual([true, false]); + }); + + it('keeps a Transcript Ready Agentic session unsendable when Live Ready fails', async () => { + const { chatManagerService, model, service } = createService(); + chatManagerService.loadSession.mockResolvedValue({ liveReady: Promise.resolve('failed') }); + + await expect(service.activateAgenticTaskSession(model.sessionId)).resolves.toEqual({ status: 'activated' }); + await Promise.resolve(); + + expect(service.isSessionLoading).toBe(false); + expect(service.getAgenticSessionLiveReadyStatus(model.sessionId)).toBe('failed'); + }); + + it('does not use a legacy persisted Task Draft when creating a Session', async () => { const { chatManagerService, registry, service } = createService(); registry.consumePendingLaunch.mockReturnValue({ projectId: 'project-b', agentId: 'agent-b' }); registry.getProject.mockResolvedValue({ id: 'project-b', workspacePath: '/work/b' }); await service.ensureSessionModel(); - expect(chatManagerService.startSession).toHaveBeenCalledWith({ - acpTarget: { agentId: 'agent-b', cwd: '/work/b' }, - }); + expect(chatManagerService.startSession).toHaveBeenCalledWith( + expect.objectContaining({ + acpTarget: undefined, + operationId: expect.stringMatching(/^acp-launch-/), + }), + ); + expect(registry.consumePendingLaunch).not.toHaveBeenCalled(); }); - it('registers the first accepted Agentic prompt and marks background Agent content unread', async () => { + it('refreshes Agent sessions after the first accepted prompt without writing unread Task metadata', async () => { const { chatManagerService, model, registry, service } = createService(); const backgroundModel = new ChatModel(new ChatFeatureRegistry(), { sessionId: 'acp:background', @@ -566,17 +705,13 @@ describe('AcpChatInternalService', () => { await service.sendRequest(request); - expect(registry.registerFirstPrompt).toHaveBeenCalledWith( - expect.objectContaining({ - sessionId: 'acp:sess-1', - agentId: 'agent-b', - firstPrompt: 'Fix list\nprivate text', - }), - ); + expect(registry.registerProject).toHaveBeenCalledWith(expect.objectContaining({ workspacePath: '/work/a' })); + expect(registry.registerFirstPrompt).not.toHaveBeenCalled(); + expect(chatManagerService.refreshAgentSessionCatalog).toHaveBeenCalledTimes(1); backgroundModel.history.addAssistantMessage({ content: 'background reply' }); - expect(registry.markUnread).toHaveBeenCalledWith('acp:background', true); + expect(registry.markUnread).not.toHaveBeenCalled(); }); it('completes an unfinished response when sendRequest rejects before ACP request handling starts', async () => { @@ -617,7 +752,7 @@ describe('AcpChatInternalService', () => { expect(request.response.isComplete).toBe(true); }); - it('completes and resolves a synchronous ACP request failure after Agentic Task persistence', async () => { + it('does not persist an Agentic Task when request kickoff fails before acceptance', async () => { const { chatManagerService, model, registry, service } = createService(); service._sessionModel = model; const error = new Error('request kickoff threw'); @@ -633,412 +768,29 @@ describe('AcpChatInternalService', () => { await expect(service.sendRequest(request)).resolves.toBeUndefined(); - expect(registry.registerFirstPrompt).toHaveBeenCalledTimes(1); + expect(registry.registerFirstPrompt).not.toHaveBeenCalled(); + expect(chatManagerService.disposeSession).toHaveBeenCalledWith(model.sessionId); + expect(service.sessionModel).toBeUndefined(); expect(request.response.errorDetails).toEqual({ message: error.message }); expect(request.response.isComplete).toBe(true); }); - it('shares first Agentic Task persistence and preserves concurrent send kickoff order', async () => { - const { chatManagerService, model, registry, service } = createService(); - let resolveRegistration!: () => void; - let notifyRegistrationStarted!: () => void; - const registrationStarted = new Promise((resolve) => { - notifyRegistrationStarted = resolve; - }); - registry.registerFirstPrompt.mockImplementation( - () => - new Promise((resolve) => { - if (!resolveRegistration) { - resolveRegistration = resolve; - notifyRegistrationStarted(); - return; - } - resolve(); - }), - ); - service._sessionModel = model; - const firstRequest = model.addRequest({ - prompt: 'First queued send', - agentId: 'agent-b', - command: '', - images: [], - }); - const secondRequest = model.addRequest({ - prompt: 'Second queued send', - agentId: 'agent-b', - command: '', - images: [], - }); - - const firstSend = service.sendRequest(firstRequest); - await registrationStarted; - const secondSend = service.sendRequest(secondRequest); - - expect(registry.registerFirstPrompt).toHaveBeenCalledTimes(1); - expect(chatManagerService.sendRequest).not.toHaveBeenCalled(); - - resolveRegistration(); - await Promise.all([firstSend, secondSend]); - - expect(registry.registerFirstPrompt).toHaveBeenCalledTimes(1); - expect(chatManagerService.sendRequest.mock.calls.map(([, request]) => request.requestId)).toEqual([ - firstRequest.requestId, - secondRequest.requestId, - ]); - }); - - it('does not start a request after disposal while first Agentic Task persistence is pending', async () => { - const { chatManagerService, model, registry, service } = createService(); - let resolveRegistration!: () => void; - let notifyRegistrationStarted!: () => void; - const registrationStarted = new Promise((resolve) => { - notifyRegistrationStarted = resolve; - }); - registry.registerFirstPrompt.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveRegistration = resolve; - notifyRegistrationStarted(); - }), - ); - service._sessionModel = model; - const request = model.addRequest({ - prompt: 'Do not send after disposal', - agentId: 'agent-b', - command: '', - images: [], - }); - - const send = service.sendRequest(request); - await registrationStarted; - service.dispose(); - resolveRegistration(); - - await expect(send).resolves.toBeUndefined(); - - expect(chatManagerService.sendRequest).not.toHaveBeenCalled(); - expect(request.response.errorDetails).toEqual({ - message: 'ACP chat service was disposed before request kickoff.', - }); - expect(request.response.isComplete).toBe(true); - }); - - it('does not start a request canceled while first Agentic Task persistence is pending', async () => { - const { chatManagerService, model, registry, service } = createService(); - let resolveRegistration!: () => void; - let notifyRegistrationStarted!: () => void; - const registrationStarted = new Promise((resolve) => { - notifyRegistrationStarted = resolve; - }); - registry.registerFirstPrompt.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveRegistration = resolve; - notifyRegistrationStarted(); - }), - ); - service._sessionModel = model; - const request = model.addRequest({ - prompt: 'Cancel before request kickoff', - agentId: 'agent-b', - command: '', - images: [], - }); - - const send = service.sendRequest(request); - await registrationStarted; - service.cancelRequest(); - resolveRegistration(); - - await expect(send).resolves.toBeUndefined(); - - expect(chatManagerService.cancelRequest).toHaveBeenCalledWith(model.sessionId); - expect(chatManagerService.sendRequest).not.toHaveBeenCalled(); - expect(request.response.isComplete).toBe(true); - }); - - it('keeps an existing Agentic persistence barrier after switching to Classic layout', async () => { - const { chatManagerService, model, registry, service } = createService(); - let resolveRegistration!: () => void; - let notifyRegistrationStarted!: () => void; - const registrationStarted = new Promise((resolve) => { - notifyRegistrationStarted = resolve; - }); - registry.registerFirstPrompt.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveRegistration = resolve; - notifyRegistrationStarted(); - }), - ); - service._sessionModel = model; - const firstRequest = model.addRequest({ - prompt: 'Agentic send before layout switch', - agentId: 'agent-b', - command: '', - images: [], - }); - const secondRequest = model.addRequest({ - prompt: 'Classic send after layout switch', - agentId: 'agent-b', - command: '', - images: [], - }); - - const firstSend = service.sendRequest(firstRequest); - await registrationStarted; - service.panelLayoutService.getLayoutMode.mockReturnValue('classic'); - const secondSend = service.sendRequest(secondRequest); - - expect(chatManagerService.sendRequest).not.toHaveBeenCalled(); - - resolveRegistration(); - await Promise.all([firstSend, secondSend]); - - expect(chatManagerService.sendRequest.mock.calls.map(([, request]) => request.requestId)).toEqual([ - firstRequest.requestId, - secondRequest.requestId, - ]); - }); - - it('delivers a request through its captured session when the active session changes during persistence', async () => { - const { chatManagerService, model, registry, service } = createService(); - let resolveRegistration!: () => void; - let notifyRegistrationStarted!: () => void; - const registrationStarted = new Promise((resolve) => { - notifyRegistrationStarted = resolve; - }); - registry.registerFirstPrompt.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveRegistration = resolve; - notifyRegistrationStarted(); - }), - ); - service._sessionModel = model; - const request = model.addRequest({ - prompt: 'Stay with the original session', - agentId: 'agent-b', - command: '', - images: [], - }); - const send = service.sendRequest(request); - await registrationStarted; - service._sessionModel = new ChatModel(new ChatFeatureRegistry(), { - sessionId: 'acp:sess-2', - acpTarget: { agentId: 'agent-b', cwd: '/work/a' }, - }); - - resolveRegistration(); - await send; - - expect(chatManagerService.sendRequest).toHaveBeenCalledWith(model.sessionId, request, false); - }); - - it('waits for the first Agentic Task persistence before starting the ACP request stream', async () => { - const { chatManagerService, model, registry, service } = createService(); - let resolveRegistration!: () => void; - let notifyRegistrationStarted!: () => void; - const registrationStarted = new Promise((resolve) => { - notifyRegistrationStarted = resolve; - }); - registry.registerFirstPrompt.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveRegistration = resolve; - notifyRegistrationStarted(); - }), - ); - service._sessionModel = model; - const request = model.addRequest({ - prompt: 'Persist before streaming', - agentId: 'agent-b', - command: '', - images: [], - }); - - const send = service.sendRequest(request); - await registrationStarted; - - expect(chatManagerService.sendRequest).not.toHaveBeenCalled(); - - resolveRegistration(); - await send; - - expect(chatManagerService.sendRequest).toHaveBeenCalledTimes(1); - }); - - it('logs first Agentic Task persistence failures and still starts the ACP request stream', async () => { - const { chatManagerService, model, registry, service } = createService(); - const error = new Error('task persistence failed'); - registry.registerFirstPrompt.mockRejectedValueOnce(error); - service._sessionModel = model; - const request = model.addRequest({ - prompt: 'Continue despite persistence failure', - agentId: 'agent-b', - command: '', - images: [], - }); - - await expect(service.sendRequest(request)).resolves.toBeUndefined(); - - expect(service.logger.error).toHaveBeenCalledWith( - expect.stringContaining(`register Agentic task failed — sessionId=${model.sessionId}`), - ); - expect(service.logger.error).toHaveBeenCalledWith(expect.stringContaining(`error=${error.message}`)); - expect(chatManagerService.sendRequest).toHaveBeenCalledTimes(1); - }); - - it('seeds the registered Task status from the model current ACP thread status', async () => { + it('does not write legacy Task status, unread, attention, or active-session metadata', async () => { const { model, registry, service } = createService(); - service._sessionModel = model; - model.setThreadStatus('working'); - const request = model.addRequest({ - prompt: 'Fix list', - agentId: 'agent-b', - command: '', - images: [], - }); - - await service.sendRequest(request); - - expect(registry.updateStatus).toHaveBeenCalledWith('acp:sess-1', 'running'); - }); - - it('records the active session before waiting for a long-running first prompt', () => { - const { chatManagerService, model, registry, service } = createService(); - let resolveSend!: () => void; - chatManagerService.sendRequest.mockReturnValue( - new Promise((resolve) => { - resolveSend = resolve; - }), - ); - service._sessionModel = model; const request = model.addRequest({ - prompt: 'Long task', - agentId: 'agent-b', - command: '', - images: [], - }); - - const send = service.sendRequest(request); - - expect(registry.rememberActiveTaskSession).toHaveBeenCalledWith(model.sessionId); - resolveSend(); - return send; - }); - - it('首个 Agentic 请求等待权限时应先注册任务并显示权限关注状态', async () => { - const { chatManagerService, model, permissionRequestEmitter, registry, service } = createService(); - let resolveSend!: () => void; - let notifySendStarted!: () => void; - const sendStarted = new Promise((resolve) => { - notifySendStarted = resolve; - }); - chatManagerService.sendRequest.mockImplementationOnce(() => { - notifySendStarted(); - return new Promise((resolve) => { - resolveSend = resolve; - }); - }); - service._sessionModel = model; - const request = model.addRequest({ - prompt: 'Permission task', - agentId: 'agent-b', - command: '', - images: [], - }); - - const send = service.sendRequest(request); - await sendStarted; - - expect(registry.registerFirstPrompt).toHaveBeenCalledWith( - expect.objectContaining({ sessionId: model.sessionId, firstPrompt: 'Permission task' }), - ); - permissionRequestEmitter.fire({ requestId: 'permission-1', sessionId: 'sess-1' }); - expect(registry.updateAttention).toHaveBeenCalledWith(model.sessionId, 'permission'); - - resolveSend(); - await send; - }); - - it('does not infer input attention from a generic background assistant component', async () => { - const { chatManagerService, model, registry, service } = createService(); - const backgroundModel = new ChatModel(new ChatFeatureRegistry(), { - sessionId: 'acp:background', - acpTarget: { agentId: 'agent-b', cwd: '/work/a' }, - }); - chatManagerService.getSessions.mockReturnValue([model, backgroundModel]); - registry.getTask.mockImplementation((sessionId: string) => - Promise.resolve(sessionId === 'acp:background' ? { sessionId } : undefined), - ); - service._sessionModel = model; - const request = model.addRequest({ - prompt: 'Fix list', + prompt: 'Agent-owned session', agentId: 'agent-b', command: '', images: [], }); await service.sendRequest(request); - backgroundModel.history.addAssistantMessage({ content: 'rendered component', type: 'component' }); - expect(registry.markUnread).toHaveBeenCalledWith('acp:background', true); - expect(registry.updateAttention).not.toHaveBeenCalledWith('acp:background', 'input'); - }); - - it('maps ACP thread statuses and background permission attention only for registered Agentic Tasks', async () => { - const { chatManagerService, model, permissionRequestEmitter, registry, service } = createService(); - const backgroundModel = new ChatModel(new ChatFeatureRegistry(), { - sessionId: 'acp:background', - acpTarget: { agentId: 'agent-b', cwd: '/work/a' }, - }); - chatManagerService.getSessions.mockReturnValue([model, backgroundModel]); - registry.getTask.mockImplementation((sessionId: string) => - Promise.resolve(sessionId === 'acp:background' ? { sessionId } : undefined), - ); - service._sessionModel = model; - const request = model.addRequest({ - prompt: 'Fix list', - agentId: 'agent-b', - command: '', - images: [], - }); - await service.sendRequest(request); - - backgroundModel.setThreadStatus('working'); - permissionRequestEmitter.fire({ sessionId: 'background' }); - - expect(registry.updateStatus).toHaveBeenCalledWith('acp:background', 'running'); - expect(registry.updateAttention).toHaveBeenCalledWith('acp:background', 'permission'); - expect(registry.markUnread).toHaveBeenCalledWith('acp:background', true); - }); - - it('clears background permission attention when the ACP permission request resolves', async () => { - const { chatManagerService, model, permissionRequestEmitter, permissionResultEmitter, registry, service } = - createService(); - const backgroundModel = new ChatModel(new ChatFeatureRegistry(), { - sessionId: 'acp:background', - acpTarget: { agentId: 'agent-b', cwd: '/work/a' }, - }); - chatManagerService.getSessions.mockReturnValue([model, backgroundModel]); - registry.getTask.mockImplementation((sessionId: string) => - Promise.resolve(sessionId === 'acp:background' ? { sessionId } : undefined), - ); - service._sessionModel = model; - const request = model.addRequest({ - prompt: 'Fix list', - agentId: 'agent-b', - command: '', - images: [], - }); - await service.sendRequest(request); - - permissionRequestEmitter.fire({ requestId: 'permission-1', sessionId: 'background' }); - permissionResultEmitter.fire({ requestId: 'permission-1', decision: { type: 'allow' } }); - - expect(registry.updateAttention).toHaveBeenNthCalledWith(1, 'acp:background', 'permission'); - expect(registry.updateAttention).toHaveBeenLastCalledWith('acp:background', undefined); + expect(registry.registerFirstPrompt).not.toHaveBeenCalled(); + expect(registry.rememberActiveTaskSession).not.toHaveBeenCalled(); + expect(registry.updateStatus).not.toHaveBeenCalled(); + expect(registry.updateAttention).not.toHaveBeenCalled(); + expect(registry.markUnread).not.toHaveBeenCalled(); }); it('reuses the in-flight ACP session creation request', async () => { @@ -1095,7 +847,71 @@ describe('AcpChatInternalService', () => { expect(loadingChanges).toEqual([true, false]); }); - it('enters draft and preserves ACP footer state for the next input', () => { + it('cancels a pending first-launch Session and ignores a late successful result', async () => { + const { aiBackService, chatManagerService, model, service } = createService(); + let resolveStartSession!: (model: ChatModel) => void; + chatManagerService.startSession.mockImplementation( + () => + new Promise((resolve) => { + resolveStartSession = resolve; + }), + ); + + const creation = service.ensureSessionModel(); + await Promise.resolve(); + const cancellation = service.cancelPendingSessionCreation(); + + expect(aiBackService.cancelSessionCreation).toHaveBeenCalledWith(expect.stringMatching(/^acp-launch-/)); + resolveStartSession(model); + await cancellation; + + await expect(creation).rejects.toMatchObject({ name: 'ACP_SESSION_CREATION_CANCELLED' }); + expect(chatManagerService.disposeSession).toHaveBeenCalledWith(model.sessionId); + expect(service.sessionModel).toBeUndefined(); + }); + + it('invalidates a pending draft capability lookup before Session acquisition', async () => { + const { aiBackService, chatManagerService, registry, service } = createService(); + let resolveCapabilities!: (value: { close: boolean; delete: boolean }) => void; + aiBackService.getSessionCapabilities.mockImplementation( + () => + new Promise((resolve) => { + resolveCapabilities = resolve; + }), + ); + + service.enterAgenticTaskDraft({ agentId: 'agent-b', cwd: '/work/b' }); + await Promise.resolve(); + const discard = service.discardAgenticTaskDraft(); + resolveCapabilities({ close: true, delete: true }); + await discard; + await Promise.resolve(); + + expect(chatManagerService.startSession).not.toHaveBeenCalled(); + expect(registry.clearPendingLaunch).toHaveBeenCalledTimes(1); + expect(service.sessionModel).toBeUndefined(); + }); + + it('releases a temporary first-launch Session when cancellation wins before request acceptance', async () => { + const { chatManagerService, model, service } = createService(); + service._sessionModel = model; + + await service.cancelPendingSessionCreation(); + + expect(chatManagerService.cancelRequest).toHaveBeenCalledWith(model.sessionId); + expect(chatManagerService.disposeSession).toHaveBeenCalledWith(model.sessionId); + expect(service.sessionModel).toBeUndefined(); + }); + + it('keeps the remembered active Task when entering draft before a Session is established', () => { + const { registry, service } = createService(); + + service.enterDraftSession({ force: true }); + + expect(registry.clearRememberedActiveTaskSession).not.toHaveBeenCalled(); + }); + + it('enters draft without retaining the previous session slash commands', () => { const { model, permissionBridgeService, service } = createService(); const sessionModelChanges: any[] = []; const availableCommandsChanges: any[] = []; @@ -1119,10 +935,10 @@ describe('AcpChatInternalService', () => { modelId: 'model-a', configOptions: model.configOptions, }); - expect(service.getAvailableCommands()).toEqual([{ name: 'help', description: 'Help' }]); + expect(service.getAvailableCommands()).toEqual([]); expect(permissionBridgeService.setActiveSession).toHaveBeenCalledWith(undefined); expect(sessionModelChanges).toEqual([undefined]); - expect(availableCommandsChanges).toEqual([]); + expect(availableCommandsChanges).toEqual([[]]); expect(modeChanges).toEqual(['']); expect(sessionChanges).toEqual(['']); }); @@ -1149,7 +965,7 @@ describe('AcpChatInternalService', () => { await service.clearSessionModel(); - expect(chatManagerService.clearSession).toHaveBeenCalledWith('acp:sess-1'); + expect(chatManagerService.disposeSession).toHaveBeenCalledWith('acp:sess-1'); expect(chatManagerService.startSession).not.toHaveBeenCalled(); expect(permissionBridgeService.clearSessionDialogs).toHaveBeenCalledWith('sess-1'); expect(service.sessionModel).toBeUndefined(); @@ -1167,18 +983,13 @@ describe('AcpChatInternalService', () => { expect(service.sessionModel).toBeUndefined(); }); - it('registers an Agentic draft first prompt under its target Project instead of the IDE workspace', async () => { - const { model, registry, service } = createService({ agentId: 'agent-b', cwd: '/work/other' }); + it('registers an Agentic draft target Project instead of the IDE workspace', async () => { + const { chatManagerService, model, registry, service } = createService({ + agentId: 'agent-b', + cwd: '/work/other', + }); - await service.registerFirstAgenticPrompt( - { - message: { - agentId: 'agent-b', - prompt: 'Work in the other Project', - }, - }, - model.sessionId, - ); + await service.refreshCatalogAfterFirstAgenticPrompt(model.sessionId); expect(registry.registerProject).toHaveBeenCalledWith( expect.objectContaining({ @@ -1186,12 +997,8 @@ describe('AcpChatInternalService', () => { workspaceUri: URI.file('/work/other').toString(), }), ); - expect(registry.registerFirstPrompt).toHaveBeenCalledWith( - expect.objectContaining({ - agentId: 'agent-b', - project: expect.objectContaining({ workspacePath: '/work/other' }), - }), - ); + expect(registry.registerFirstPrompt).not.toHaveBeenCalled(); + expect(chatManagerService.refreshAgentSessionCatalog).toHaveBeenCalledTimes(1); }); it('falls back to draft when loading an ACP session fails', async () => { @@ -1258,7 +1065,7 @@ describe('AcpChatInternalService', () => { ); }); - it('validates an Agentic Task session for archive without changing the Active Session', async () => { + it('validates an Agent session without changing the Active Session or recording Task metadata', async () => { const { chatManagerService, model: currentModel, registry, service } = createService(); const validatedModel = new ChatModel(new ChatFeatureRegistry(), { sessionId: 'acp:validated' }); validatedModel.setThreadStatus('working'); @@ -1275,7 +1082,7 @@ describe('AcpChatInternalService', () => { expect(service.sessionModel).toBe(currentModel); expect(registry.rememberActiveTaskSession).not.toHaveBeenCalledWith(validatedModel.sessionId); - expect(service.isAgenticTaskSessionObserved(validatedModel.sessionId)).toBe(true); + expect(service.isAgenticTaskSessionObserved(validatedModel.sessionId)).toBe(false); }); it('activates only the latest overlapping Agentic Task selection', async () => { @@ -1305,6 +1112,7 @@ describe('AcpChatInternalService', () => { resolveFirst(); await expect(firstActivation).resolves.toEqual({ status: 'superseded' }); + expect(chatManagerService.disposeSession).toHaveBeenCalledWith('acp:first'); expect(service.sessionModel?.sessionId).toBe('acp:second'); expect(loadingChanges).toEqual([true, false]); expect(service.isSessionLoading).toBe(false); @@ -1341,84 +1149,11 @@ describe('AcpChatInternalService', () => { await firstActivation; expect(service.sessionModel).toBe(secondModel); + expect(chatManagerService.disposeSession).toHaveBeenCalledWith('acp:first'); expect(loadingChanges).toEqual([true, false]); expect(service.isSessionLoading).toBe(false); }); - it('does not publish an ordinary session selection invalidated while its task lookup is pending', async () => { - const { chatManagerService, permissionBridgeService, registry, service } = createService(); - const firstModel = new ChatModel(new ChatFeatureRegistry(), { sessionId: 'acp:first' }); - const secondModel = new ChatModel(new ChatFeatureRegistry(), { sessionId: 'acp:second' }); - let resolveFirstTask!: () => void; - let signalFirstTaskLookup!: () => void; - const firstTask = new Promise((resolve) => (resolveFirstTask = resolve)); - const firstTaskLookup = new Promise((resolve) => (signalFirstTaskLookup = resolve)); - const sessionChanges: string[] = []; - chatManagerService.getSession.mockImplementation((id: string) => { - if (id === 'acp:first') { - return firstModel; - } - return id === 'acp:second' ? secondModel : undefined; - }); - registry.getTask.mockImplementation((sessionId: string) => { - if (sessionId === 'acp:first') { - signalFirstTaskLookup(); - return firstTask; - } - return Promise.resolve(undefined); - }); - service.onChangeSession((sessionId: string) => sessionChanges.push(sessionId)); - permissionBridgeService.setActiveSession.mockClear(); - - const firstActivation = service.activateSession('acp:first'); - await firstTaskLookup; - await service.activateSession('acp:second'); - resolveFirstTask(); - await firstActivation; - - expect(service.sessionModel).toBe(secondModel); - expect(permissionBridgeService.setActiveSession).toHaveBeenCalledTimes(1); - expect(permissionBridgeService.setActiveSession).toHaveBeenCalledWith('second'); - expect(sessionChanges).toEqual(['acp:second']); - }); - - it('does not publish a stale Task selection after its task lookup overlaps a newer selection', async () => { - const { chatManagerService, permissionBridgeService, registry, service } = createService(); - const firstModel = new ChatModel(new ChatFeatureRegistry(), { sessionId: 'acp:first' }); - const secondModel = new ChatModel(new ChatFeatureRegistry(), { sessionId: 'acp:second' }); - let resolveFirstTask!: () => void; - let signalFirstTaskLookup!: () => void; - const firstTask = new Promise((resolve) => (resolveFirstTask = resolve)); - const firstTaskLookup = new Promise((resolve) => (signalFirstTaskLookup = resolve)); - const sessionChanges: string[] = []; - chatManagerService.getSession.mockImplementation((id: string) => { - if (id === 'acp:first') { - return firstModel; - } - return id === 'acp:second' ? secondModel : undefined; - }); - registry.getTask.mockImplementation((sessionId: string) => { - if (sessionId === 'acp:first') { - signalFirstTaskLookup(); - return firstTask; - } - return Promise.resolve(undefined); - }); - service.onChangeSession((sessionId: string) => sessionChanges.push(sessionId)); - permissionBridgeService.setActiveSession.mockClear(); - - const firstActivation = service.activateAgenticTaskSession('acp:first'); - await firstTaskLookup; - await expect(service.activateAgenticTaskSession('acp:second')).resolves.toEqual({ status: 'activated' }); - resolveFirstTask(); - - await expect(firstActivation).resolves.toEqual({ status: 'superseded' }); - expect(service.sessionModel).toBe(secondModel); - expect(permissionBridgeService.setActiveSession).toHaveBeenCalledTimes(1); - expect(permissionBridgeService.setActiveSession).toHaveBeenCalledWith('second'); - expect(sessionChanges).toEqual(['acp:second']); - }); - it('does not publish a Task selection invalidated by a newer Task action', async () => { const { chatManagerService, model: currentModel, service } = createService(); const selectedModel = new ChatModel(new ChatFeatureRegistry(), { sessionId: 'acp:selected' }); @@ -1437,6 +1172,26 @@ describe('AcpChatInternalService', () => { await expect(activation).resolves.toEqual({ status: 'superseded' }); expect(service.sessionModel).toBe(currentModel); + expect(chatManagerService.disposeSession).toHaveBeenCalledWith('acp:selected'); + }); + + it('releases a validation load superseded after it settles', async () => { + const { chatManagerService, service } = createService(); + const selectedModel = new ChatModel(new ChatFeatureRegistry(), { sessionId: 'acp:selected' }); + let resolveLoad!: () => void; + const load = new Promise((resolve) => { + resolveLoad = resolve; + }); + let shouldApply = true; + chatManagerService.loadSession.mockReturnValue(load); + chatManagerService.getSession.mockReturnValue(selectedModel); + + const validation = service.validateAgenticTaskSession('acp:selected', () => shouldApply); + shouldApply = false; + resolveLoad(); + + await expect(validation).resolves.toEqual({ status: 'superseded' }); + expect(chatManagerService.disposeSession).toHaveBeenCalledWith('acp:selected'); }); }); diff --git a/packages/ai-native/__test__/browser/chat/acp-chat-manager.service.test.ts b/packages/ai-native/__test__/browser/chat/acp-chat-manager.service.test.ts index 1636778984..829eb8eac2 100644 --- a/packages/ai-native/__test__/browser/chat/acp-chat-manager.service.test.ts +++ b/packages/ai-native/__test__/browser/chat/acp-chat-manager.service.test.ts @@ -1,12 +1,17 @@ -import { ChatMessageRole } from '@opensumi/ide-core-common'; +import { ChatMessageRole, Deferred, Emitter } from '@opensumi/ide-core-common'; import { SumiReadableStream } from '@opensumi/ide-utils/lib/stream'; import { ACPSessionProvider } from '../../../src/browser/chat/acp-session-provider'; import { AcpChatManagerService } from '../../../src/browser/chat/chat-manager.service.acp'; import { ChatModel } from '../../../src/browser/chat/chat-model'; import { ChatFeatureRegistry } from '../../../src/browser/chat/chat.feature.registry'; +import * as agentTypeModule from '../../../src/browser/chat/get-default-agent-type'; describe('AcpChatManagerService', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + const createService = () => { const service = Object.create(AcpChatManagerService.prototype) as AcpChatManagerService & { aiNativeConfig: any; @@ -56,6 +61,27 @@ describe('AcpChatManagerService', () => { Object.defineProperty(service, 'shouldFailBddAttachment', { value: () => false, }); + Object.defineProperty(service, 'agentSessionCatalog', { + value: [], + writable: true, + }); + Object.defineProperty(service, 'metadataOnlySessionIds', { + value: new Set(), + writable: true, + }); + Object.defineProperty(service, 'agentSessionMetadataRevision', { + value: 0, + writable: true, + }); + Object.defineProperty(service, 'agentSessionMetadataUpdates', { + value: new Map(), + }); + Object.defineProperty(service, 'onDidChangeAgentSessionCatalogEmitter', { + value: new Emitter(), + }); + Object.defineProperty(service, 'onDidChangeAgentSessionCatalog', { + value: (service as any).onDidChangeAgentSessionCatalogEmitter.event, + }); Object.defineProperty(service, 'acpSessionDisplayTitleOverrides', { value: {}, writable: true, @@ -153,6 +179,8 @@ describe('AcpChatManagerService', () => { configProvider: any; agenticTaskRegistry: any; loadedSessionMap: Map; + agentSessionCatalog: any[]; + agentSessionTargets: Map; messageService: any; convertAgentSessionToModel(sessionId: string, agentSession: any): any; }; @@ -170,6 +198,27 @@ describe('AcpChatManagerService', () => { Object.defineProperty(provider, 'loadedSessionMap', { value: new Map(), }); + Object.defineProperty(provider, 'agentSessionCatalog', { + value: [], + writable: true, + }); + Object.defineProperty(provider, 'agentSessionTargets', { + value: new Map(), + writable: true, + }); + Object.defineProperty(provider, 'logger', { + value: { warn: jest.fn() }, + }); + Object.defineProperty(provider, 'preferenceService', { + value: { get: jest.fn() }, + }); + Object.defineProperty(provider, 'agenticTaskRegistry', { + configurable: true, + value: { + listProjects: jest.fn().mockResolvedValue([]), + getTask: jest.fn().mockResolvedValue(undefined), + }, + }); return provider; }; @@ -212,6 +261,196 @@ describe('AcpChatManagerService', () => { }); }); + it('builds the Agent session catalog from every Agent and known Project, discarding a failed Agent', async () => { + const provider = createSessionProvider() as any; + jest.spyOn(agentTypeModule, 'getAvailableAgentConfigs').mockReturnValue({ + 'agent-a': { command: 'agent-a', args: [] }, + 'agent-b': { command: 'agent-b', args: [] }, + }); + provider.agenticTaskRegistry.listProjects.mockResolvedValue([ + { id: 'a', workspacePath: '/work/a', availability: 'available' }, + { id: 'b', workspacePath: '/work/b', availability: 'available' }, + { id: 'hidden', workspacePath: '/work/hidden', availability: 'unavailable' }, + ]); + provider.configProvider.resolveConfigForTarget = jest.fn(async ({ agentId, cwd }) => ({ + agentId, + cwd, + env: { ACP_SESSION_TEST_SECRET: 'never-log-this' }, + })); + Object.defineProperty(provider, 'aiBackService', { + value: { + listSessions: jest.fn(async ({ agentId, cwd }) => { + if (agentId === 'agent-b' && cwd === '/work/b') { + throw new Error('agent-b list failed'); + } + return { + sessions: [ + { + sessionId: `${agentId}-${cwd.slice(-1)}`, + cwd, + title: `${agentId} ${cwd}`, + updatedAt: '2026-01-01', + }, + { sessionId: 'unknown', cwd: '/not-authorized', title: 'Unknown' }, + ], + }; + }), + }, + }); + + await expect(provider.refreshAgentSessions()).resolves.toEqual([ + expect.objectContaining({ sessionId: 'acp:agent-a-a', agentId: 'agent-a', cwd: '/work/a' }), + expect.objectContaining({ sessionId: 'acp:agent-a-b', agentId: 'agent-a', cwd: '/work/b' }), + ]); + expect(provider.aiBackService.listSessions).toHaveBeenCalledTimes(4); + expect(provider.messageService.error).not.toHaveBeenCalled(); + expect(provider.logger.warn).toHaveBeenCalledWith(expect.stringContaining('agentId=agent-b')); + expect(provider.logger.warn.mock.calls.flat().join('\n')).not.toContain('never-log-this'); + expect(provider.getAgentSessions()).toHaveLength(2); + }); + + it('runs a follow-up discovery when a refresh is requested during an in-flight stale snapshot', async () => { + const provider = createSessionProvider() as any; + const firstList = new Deferred(); + jest.spyOn(agentTypeModule, 'getAvailableAgentConfigs').mockReturnValue({ + 'agent-a': { command: 'agent-a', args: [] }, + }); + provider.agenticTaskRegistry.listProjects + .mockResolvedValueOnce([{ id: 'old', workspacePath: '/work/old', availability: 'available' }]) + .mockResolvedValueOnce([{ id: 'new', workspacePath: '/work/new', availability: 'available' }]); + provider.configProvider.resolveConfigForTarget = jest.fn(async ({ agentId, cwd }) => ({ agentId, cwd })); + Object.defineProperty(provider, 'aiBackService', { + value: { + listSessions: jest + .fn() + .mockImplementationOnce(() => firstList.promise) + .mockResolvedValueOnce({ + sessions: [{ sessionId: 'new-session', cwd: '/work/new', title: 'New Session' }], + }), + }, + }); + + const initialRefresh = provider.refreshAgentSessions(); + await Promise.resolve(); + const overlappingRefresh = provider.refreshAgentSessions(); + firstList.resolve({ + sessions: [{ sessionId: 'old-session', cwd: '/work/old', title: 'Old Session' }], + }); + + await expect(initialRefresh).resolves.toEqual([ + expect.objectContaining({ sessionId: 'acp:new-session', cwd: '/work/new' }), + ]); + await expect(overlappingRefresh).resolves.toEqual([ + expect.objectContaining({ sessionId: 'acp:new-session', cwd: '/work/new' }), + ]); + expect(provider.agenticTaskRegistry.listProjects).toHaveBeenCalledTimes(2); + expect(provider.aiBackService.listSessions).toHaveBeenCalledTimes(2); + expect(provider.getAgentSessions()).toEqual([ + expect.objectContaining({ sessionId: 'acp:new-session', cwd: '/work/new' }), + ]); + }); + + it('excludes a raw Session ID returned by more than one Agent instead of guessing its route', async () => { + const provider = createSessionProvider() as any; + jest.spyOn(agentTypeModule, 'getAvailableAgentConfigs').mockReturnValue({ + 'agent-a': { command: 'agent-a', args: [] }, + 'agent-b': { command: 'agent-b', args: [] }, + }); + provider.agenticTaskRegistry.listProjects.mockResolvedValue([ + { id: 'a', workspacePath: '/work/a', availability: 'available' }, + ]); + provider.configProvider.resolveConfigForTarget = jest.fn(async ({ agentId, cwd }) => ({ agentId, cwd })); + Object.defineProperty(provider, 'aiBackService', { + value: { + listSessions: jest.fn(async ({ agentId, cwd }) => ({ + sessions: [ + { sessionId: 'collision', cwd, title: `${agentId} collision` }, + { sessionId: `${agentId}-unique`, cwd, title: `${agentId} unique` }, + ], + })), + }, + }); + + await expect(provider.refreshAgentSessions()).resolves.toEqual([ + expect.objectContaining({ sessionId: 'acp:agent-a-unique', agentId: 'agent-a' }), + expect.objectContaining({ sessionId: 'acp:agent-b-unique', agentId: 'agent-b' }), + ]); + expect(provider.getAgentSessions().map((entry) => entry.sessionId)).not.toContain('acp:collision'); + expect(provider.agentSessionTargets.has('acp:collision')).toBe(false); + expect(provider.logger.warn).toHaveBeenCalledWith(expect.stringContaining('agentCount=2')); + }); + + it('queries only available absolute Workspace Targets and ignores relative Agent metadata', async () => { + const provider = createSessionProvider() as any; + jest.spyOn(agentTypeModule, 'getAvailableAgentConfigs').mockReturnValue({ + 'agent-a': { command: 'agent-a', args: [] }, + }); + provider.agenticTaskRegistry.listProjects.mockResolvedValue([ + { id: 'relative', workspacePath: 'relative/path', availability: 'available' }, + { id: 'available', workspacePath: '/work/a', availability: 'available' }, + { id: 'unavailable', workspacePath: '/work/b', availability: 'unavailable' }, + ]); + provider.configProvider.resolveConfigForTarget = jest.fn(async ({ agentId, cwd }) => ({ agentId, cwd })); + Object.defineProperty(provider, 'aiBackService', { + value: { + listSessions: jest.fn().mockResolvedValue({ + sessions: [ + { sessionId: 'relative-result', cwd: 'relative/path', title: 'Relative' }, + { sessionId: 'authorized-result', cwd: '/work/a', title: 'Authorized' }, + ], + }), + }, + }); + + await expect(provider.refreshAgentSessions()).resolves.toEqual([ + expect.objectContaining({ sessionId: 'acp:authorized-result', cwd: '/work/a' }), + ]); + expect(provider.configProvider.resolveConfigForTarget).toHaveBeenCalledTimes(1); + expect(provider.configProvider.resolveConfigForTarget).toHaveBeenCalledWith({ agentId: 'agent-a', cwd: '/work/a' }); + }); + + it('loads an Agent-listed session through the Agent and cwd returned by session/list', async () => { + const provider = createSessionProvider() as any; + provider.agentSessionTargets.set('acp:s1', { agentId: 'agent-b', cwd: '/work/b' }); + provider.configProvider.resolveConfigForTarget = jest.fn(async (target) => ({ ...target })); + Object.defineProperty(provider, 'aiBackService', { + value: { + loadAgentSession: jest.fn().mockResolvedValue({ sessionId: 's1', messages: [] }), + }, + }); + + const session = await provider.loadSession('acp:s1'); + + expect(provider.configProvider.resolveConfigForTarget).toHaveBeenCalledWith({ agentId: 'agent-b', cwd: '/work/b' }); + expect(provider.aiBackService.loadAgentSession).toHaveBeenCalledWith({ agentId: 'agent-b', cwd: '/work/b' }, 's1'); + expect(session.extension).toEqual( + expect.objectContaining({ acpTarget: { agentId: 'agent-b', cwd: '/work/b' }, metadataOnly: false }), + ); + }); + + it('logs the complete Agent history update list before converting a loaded session', async () => { + const provider = createSessionProvider() as any; + const consoleLog = jest.spyOn(console, 'log').mockImplementation(() => undefined); + const historyUpdates = [ + { + sessionId: 's1', + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'first prompt' }, + }, + }, + ]; + Object.defineProperty(provider, 'aiBackService', { + value: { + loadAgentSession: jest.fn().mockResolvedValue({ sessionId: 's1', messages: [], historyUpdates }), + }, + }); + + await provider.loadSession('acp:s1'); + + expect(consoleLog).toHaveBeenCalledWith('[ACP Chat][session/load] Agent history updates:', historyUpdates); + }); + it('disposes an ACP provider session using its raw backend session id', async () => { const provider = createSessionProvider(); const disposeSession = jest.fn().mockResolvedValue(undefined); @@ -224,6 +463,18 @@ describe('AcpChatManagerService', () => { expect(disposeSession).toHaveBeenCalledWith('s1'); }); + it('force disposes an ACP provider session using its raw backend session id', async () => { + const provider = createSessionProvider(); + const disposeSession = jest.fn().mockResolvedValue(undefined); + Object.defineProperty(provider, 'aiBackService', { + value: { disposeSession }, + }); + + await provider.disposeSession('acp:s1', true); + + expect(disposeSession).toHaveBeenCalledWith('s1', true); + }); + it('releases the backend session before clearing the browser session model', async () => { const service = createService() as any; service.ownedBackendSessions.add('acp:s1'); @@ -243,6 +494,20 @@ describe('AcpChatManagerService', () => { expect(calls).toEqual(['backend', 'browser']); }); + it('force releases a restored backend session that is not browser-owned', async () => { + const service = createService() as any; + service.mainProvider = { + disposeSession: jest.fn().mockResolvedValue(undefined), + }; + service.clearSession = jest.fn(); + service.getSession = jest.fn(() => ({})); + + await service.disposeSession('acp:restored', true); + + expect(service.mainProvider.disposeSession).toHaveBeenCalledWith('acp:restored', true); + expect(service.clearSession).toHaveBeenCalledWith('acp:restored'); + }); + it('deduplicates overlapping backend session disposal and clears the browser model once', async () => { const service = createService() as any; service.ownedBackendSessions.add('acp:s1'); @@ -480,10 +745,10 @@ describe('AcpChatManagerService', () => { value: { createSession }, }); - await provider.createSession({ acpTarget: { agentId: 'agent-b', cwd: '/work/b' } }); + await provider.createSession({ acpTarget: { agentId: 'agent-b', cwd: '/work/b' }, operationId: 'launch-1' }); expect(resolveConfigForTarget).toHaveBeenCalledWith({ agentId: 'agent-b', cwd: '/work/b' }); - expect(createSession).toHaveBeenCalledWith(config); + expect(createSession).toHaveBeenCalledWith(config, 'launch-1'); }); it('keeps ACP target metadata on a newly created browser session', async () => { @@ -514,28 +779,32 @@ describe('AcpChatManagerService', () => { expect(service.toSessionData(model)).not.toHaveProperty('extension'); }); - it('reloads a registered Task through its stored Agent and Project target', async () => { + it('does not use a legacy Task record to route an unlisted ACP session', async () => { const provider = createSessionProvider(); - const config = { agentId: 'agent-b', cwd: '/work/b' }; - const resolveConfigForTarget = jest.fn().mockResolvedValue(config); + const config = { agentId: 'claude-agent-acp', cwd: '/workspace' }; const loadAgentSession = jest.fn().mockResolvedValue({ sessionId: 'b', messages: [], }); + const getTask = jest.fn().mockResolvedValue({ + sessionId: 'acp:b', + projectId: 'project-b', + agentId: 'agent-b', + }); Object.defineProperty(provider, 'agenticTaskRegistry', { value: { - getTask: jest.fn().mockResolvedValue({ sessionId: 'acp:b', projectId: 'project-b', agentId: 'agent-b' }), + getTask, getProject: jest.fn().mockResolvedValue({ id: 'project-b', workspacePath: '/work/b' }), }, }); - (provider as any).configProvider.resolveConfigForTarget = resolveConfigForTarget; Object.defineProperty(provider, 'aiBackService', { value: { loadAgentSession }, }); await provider.loadSession('acp:b'); - expect(resolveConfigForTarget).toHaveBeenCalledWith({ agentId: 'agent-b', cwd: '/work/b' }); + expect(getTask).not.toHaveBeenCalled(); + expect((provider as any).configProvider.resolveConfig).toHaveBeenCalledTimes(1); expect(loadAgentSession).toHaveBeenCalledWith(config, 'b'); }); @@ -615,23 +884,124 @@ describe('AcpChatManagerService', () => { expect(session?.history.messages[1]).toEqual(expect.objectContaining({ requestId: expect.any(String) })); }); - it('keeps an auth-required restored response open for later progress', () => { + it('preserves messageId boundaries without creating an empty user message during history restore', async () => { const provider = createSessionProvider(); + jest.spyOn(console, 'log').mockImplementation(() => undefined); + Object.defineProperty(provider, 'aiBackService', { + value: { + loadAgentSession: jest.fn().mockResolvedValue({ + sessionId: 'message-boundaries', + messages: [], + historyUpdates: [ + { + sessionId: 'message-boundaries', + update: { + sessionUpdate: 'agent_message_chunk', + messageId: 'assistant-greeting', + content: { type: 'text', text: 'How can I help?' }, + }, + }, + { + sessionId: 'message-boundaries', + update: { + sessionUpdate: 'user_message_chunk', + messageId: 'user-1', + content: { type: 'text', text: 'hello' }, + }, + }, + { + sessionId: 'message-boundaries', + update: { + sessionUpdate: 'agent_message_chunk', + messageId: 'assistant-1', + content: { type: 'text', text: 'first response' }, + }, + }, + { + sessionId: 'message-boundaries', + update: { + sessionUpdate: 'agent_message_chunk', + messageId: 'assistant-2', + content: { type: 'text', text: 'second response' }, + }, + }, + ], + }), + }, + }); + + const session = await provider.loadSession('acp:message-boundaries'); - const session = provider.restoreSessionSnapshot('acp:auth-required', { + expect(session?.history.messages.map(({ role, content }) => ({ role, content }))).toEqual([ + { role: ChatMessageRole.Assistant, content: 'How can I help?' }, + { role: ChatMessageRole.User, content: 'hello' }, + { role: ChatMessageRole.Assistant, content: 'first response' }, + { role: ChatMessageRole.Assistant, content: 'second response' }, + ]); + }); + + it('hides the internal WebMCP usage hint from a restored user message', () => { + const provider = createSessionProvider(); + const session = provider.restoreSessionSnapshot('acp:webmcp-hint', { kind: 'sessionSnapshot', - sessionId: 'auth-required', - threadStatus: 'auth_required', + sessionId: 'webmcp-hint', + threadStatus: 'awaiting_prompt', + historyUpdates: [ + { + sessionId: 'webmcp-hint', + update: { + sessionUpdate: 'user_message_chunk', + content: { + type: 'text', + text: '\nUse the opensumi-ide MCP catalog tools ', + }, + }, + }, + { + sessionId: 'webmcp-hint', + update: { + sessionUpdate: 'user_message_chunk', + content: { + type: 'text', + text: 'before invoking tools.\n\nACP 会话测试一', + }, + }, + }, + { + sessionId: 'webmcp-hint', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: '会话一正常' }, + }, + }, + ], + }); + + expect(session.history.messages.map(({ role, content }) => ({ role, content }))).toEqual([ + { role: ChatMessageRole.User, content: 'ACP 会话测试一' }, + { role: ChatMessageRole.Assistant, content: '会话一正常' }, + ]); + expect(session.requests[0].message.prompt).toBe('ACP 会话测试一'); + }); + + it.each(['auth_required', 'stopping'] as const)('keeps a %s restored response open for later progress', (status) => { + const provider = createSessionProvider(); + const sessionId = `acp:${status}`; + + const session = provider.restoreSessionSnapshot(sessionId, { + kind: 'sessionSnapshot', + sessionId: status, + threadStatus: status, historyUpdates: [ { - sessionId: 'auth-required', + sessionId: status, update: { sessionUpdate: 'user_message_chunk', - content: { type: 'text', text: 'continue after auth' }, + content: { type: 'text', text: 'continue after pending state' }, }, }, { - sessionId: 'auth-required', + sessionId: status, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'waiting' }, @@ -936,6 +1306,52 @@ describe('AcpChatManagerService', () => { expect(loadedModel?.history.getMessages()).toHaveLength(1); }); + it('preserves the ACP target when a loaded session snapshot omits runtime routing metadata', async () => { + const service = createService(); + const sessionId = 'acp:targeted-session'; + const acpTarget = { agentId: 'agent-b', cwd: '/work/b' }; + const metadataModel = service.fromAcpJSON([ + { + sessionId, + history: { + additional: {}, + messages: [], + }, + requests: [], + extension: { + availableCommands: [], + acpTarget, + }, + }, + ])[0]; + + service.sessionModels.set(sessionId, metadataModel); + Object.defineProperty(service, 'mainProvider', { + value: { + loadSession: jest.fn().mockResolvedValue({ + sessionId, + history: { + additional: {}, + messages: [ + { + id: `${sessionId}-msg-0`, + role: ChatMessageRole.User, + content: 'continue in the original project', + order: 0, + }, + ], + }, + requests: [], + }), + }, + }); + + await service.loadSession(sessionId); + + expect(service.getSession(sessionId)).not.toBe(metadataModel); + expect(service.getSession(sessionId)?.acpTarget).toEqual(acpTarget); + }); + it('reattaches a loaded ACP session and applies snapshot status plus later output', async () => { const service = createService(); const sessionId = 'acp:s-running'; @@ -999,12 +1415,14 @@ describe('AcpChatManagerService', () => { sessionId: 's-running', threadStatus: 'working', historyUpdates: [], + availableCommands: [{ name: 'new-skill', description: 'Run the new skill' }], }); attachment.emitData({ kind: 'content', content: 'continued output' }); const model = service.getSession(sessionId)!; expect(attachSession).toHaveBeenCalledWith(sessionId); expect(model.threadStatus).toBe('working'); + expect(service.getAvailableCommands(sessionId)).toEqual([{ name: 'new-skill', description: 'Run the new skill' }]); expect(model.getRequest('request-1')?.response.responseText).toBe('continued output'); attachment.end(); }); @@ -1025,6 +1443,9 @@ describe('AcpChatManagerService', () => { }, ], }, + extension: { + availableCommands: [{ name: 'restored-skill', description: 'Restored skill' }], + }, requests: [], }); const attachSession = jest @@ -1040,7 +1461,8 @@ describe('AcpChatManagerService', () => { value: { loadSession, attachSession }, }); - await expect(service.loadSession(sessionId)).resolves.toBeUndefined(); + const firstLoad = await service.loadSession(sessionId); + await expect(firstLoad.liveReady).resolves.toBe('failed'); const restoredModel = service.getSession(sessionId); expect(restoredModel).toBeDefined(); @@ -1048,13 +1470,17 @@ describe('AcpChatManagerService', () => { expect.objectContaining({ content: 'restore this history once' }), ]); expect(restoredModel?.requests).toHaveLength(0); + expect(service.getAvailableCommands(sessionId)).toEqual([ + { name: 'restored-skill', description: 'Restored skill' }, + ]); expect(loadSession).toHaveBeenCalledTimes(1); expect(attachSession).toHaveBeenCalledTimes(1); expect((service as any).logger.error).toHaveBeenCalledWith( '[ACP Chat][Manager] attach session failed after restoring history — errorType=Error', ); - await service.loadSession(sessionId); + const secondLoad = await service.loadSession(sessionId); + await expect(secondLoad.liveReady).resolves.toBe('ready'); expect(loadSession).toHaveBeenCalledTimes(1); expect(attachSession).toHaveBeenCalledTimes(2); @@ -1064,7 +1490,101 @@ describe('AcpChatManagerService', () => { expect(service.getSession(sessionId)?.requests).toHaveLength(0); }); - it('propagates queued attachment snapshot restoration failures', async () => { + it('makes a restored transcript available without waiting for Live Ready attachment', async () => { + const service = createService(); + const sessionId = 'acp:transcript-ready'; + let resolveAttachment: (stream: undefined) => void; + const attachment = new Promise((resolve) => { + resolveAttachment = resolve; + }); + const attachSession = jest.fn(() => attachment); + Object.defineProperty(service, 'mainProvider', { + value: { + loadSession: jest.fn().mockResolvedValue({ + sessionId, + history: { + additional: {}, + messages: [ + { + id: `${sessionId}-user`, + role: ChatMessageRole.User, + content: 'Transcript Ready content', + order: 0, + }, + ], + }, + requests: [], + }), + attachSession, + }, + }); + + const loading = service.loadSession(sessionId); + const outcome = await Promise.race([ + loading.then(() => 'transcript-ready' as const), + new Promise<'blocked'>((resolve) => setTimeout(() => resolve('blocked'), 0)), + ]); + + expect(outcome).toBe('transcript-ready'); + expect(service.getSession(sessionId)?.history.getMessages()).toEqual([ + expect.objectContaining({ content: 'Transcript Ready content' }), + ]); + expect(attachSession).toHaveBeenCalledWith(sessionId); + + resolveAttachment!(undefined); + const result = await loading; + await expect(result.liveReady).resolves.toBe('ready'); + }); + + it('does not attach a stale Live Ready stream after the transcript session was disposed', async () => { + const service = createService(); + const sessionModels = (service as any).sessionModels; + jest.spyOn(service, 'clearSession').mockImplementation((key: string) => { + sessionModels.delete(key); + }); + const sessionId = 'acp:disposed-before-live-ready'; + let resolveAttachment!: (stream: any) => void; + const pendingAttachment = new Promise((resolve) => { + resolveAttachment = resolve; + }); + const attachment = { + onData: jest.fn(() => ({ dispose: jest.fn() })), + onEnd: jest.fn(() => ({ dispose: jest.fn() })), + onError: jest.fn(() => ({ dispose: jest.fn() })), + end: jest.fn(), + }; + Object.defineProperty(service, 'mainProvider', { + value: { + loadSession: jest.fn().mockResolvedValue({ + sessionId, + history: { + additional: {}, + messages: [ + { + id: `${sessionId}-user`, + role: ChatMessageRole.User, + content: 'dispose after Transcript Ready', + order: 0, + }, + ], + }, + requests: [], + }), + attachSession: jest.fn(() => pendingAttachment), + disposeSession: jest.fn().mockResolvedValue(undefined), + }, + }); + + const result = await service.loadSession(sessionId); + await service.disposeSession(sessionId); + resolveAttachment(attachment); + + await expect(result.liveReady).resolves.toBe('failed'); + expect(attachment.end).toHaveBeenCalledTimes(1); + expect(service.getSession(sessionId)).toBeUndefined(); + }); + + it('reports queued attachment snapshot restoration failures through Live Ready', async () => { const service = createService(); const sessionId = 'acp:s-snapshot-failure'; const restoreError = new Error('snapshot conversion failed'); @@ -1085,6 +1605,21 @@ describe('AcpChatManagerService', () => { }; Object.defineProperty(service, 'mainProvider', { value: { + loadSession: jest.fn().mockResolvedValue({ + sessionId, + history: { + additional: {}, + messages: [ + { + id: `${sessionId}-user`, + role: ChatMessageRole.User, + content: 'restore before applying queued snapshot', + order: 0, + }, + ], + }, + requests: [], + }), attachSession: jest.fn().mockResolvedValue(attachment), restoreSessionSnapshot: jest.fn(() => { throw restoreError; @@ -1092,8 +1627,12 @@ describe('AcpChatManagerService', () => { }, }); - await expect(service.loadSession(sessionId)).rejects.toBe(restoreError); - expect((service as any).logger.error).not.toHaveBeenCalled(); + const result = await service.loadSession(sessionId); + + await expect(result.liveReady).resolves.toBe('failed'); + expect((service as any).logger.error).toHaveBeenCalledWith( + '[ACP Chat][Manager] attach session failed after restoring history — errorType=Error', + ); }); it('reattaches an already populated ACP session without reloading or resending its prompt', async () => { @@ -1152,13 +1691,6 @@ describe('AcpChatManagerService', () => { sessionId: 's-existing', threadStatus: 'working', historyUpdates: [ - { - sessionId: 's-existing', - update: { - sessionUpdate: 'user_message_chunk', - content: { type: 'text', text: 'keep working' }, - }, - }, { sessionId: 's-existing', update: { @@ -1175,6 +1707,134 @@ describe('AcpChatManagerService', () => { expect(service.getSession(sessionId)?.requests[0].response.responseText).toBe( 'before reload while offline after reload', ); + expect(service.getSession(sessionId)?.history.getMessages()).toEqual([ + expect.objectContaining({ role: ChatMessageRole.User, content: 'keep working' }), + expect.objectContaining({ role: ChatMessageRole.Assistant, content: 'before reload while offline' }), + ]); + attachment.end(); + }); + + it('keeps restored user turns attached to their Agent message identities when a snapshot adds a greeting', async () => { + const service = createService(); + const provider = createSessionProvider(); + const sessionId = 'acp:stable-message-identities'; + const attachment = new SumiReadableStream(); + const initialSession = provider.restoreSessionSnapshot(sessionId, { + kind: 'sessionSnapshot', + sessionId: 'stable-message-identities', + threadStatus: 'idle', + historyUpdates: [ + { + sessionId: 'stable-message-identities', + update: { + sessionUpdate: 'agent_message_chunk', + messageId: 'assistant-greeting', + content: { type: 'text', text: 'How can I help?' }, + }, + }, + { + sessionId: 'stable-message-identities', + update: { + sessionUpdate: 'user_message_chunk', + messageId: 'user-1', + content: { type: 'text', text: 'first prompt' }, + }, + }, + { + sessionId: 'stable-message-identities', + update: { + sessionUpdate: 'agent_message_chunk', + messageId: 'assistant-1', + content: { type: 'text', text: 'first response' }, + }, + }, + { + sessionId: 'stable-message-identities', + update: { + sessionUpdate: 'user_message_chunk', + messageId: 'user-2', + content: { type: 'text', text: 'second prompt' }, + }, + }, + { + sessionId: 'stable-message-identities', + update: { + sessionUpdate: 'agent_message_chunk', + messageId: 'assistant-2', + content: { type: 'text', text: 'second response' }, + }, + }, + ], + }); + Object.defineProperty(service, 'mainProvider', { + value: { + loadSession: jest.fn().mockResolvedValue(initialSession), + attachSession: jest.fn().mockResolvedValue(attachment), + restoreSessionSnapshot: provider.restoreSessionSnapshot.bind(provider), + }, + }); + + const result = await service.loadSession(sessionId); + await expect(result.liveReady).resolves.toBe('ready'); + attachment.emitData({ + kind: 'sessionSnapshot', + sessionId: 'stable-message-identities', + threadStatus: 'idle', + historyUpdates: [ + { + sessionId: 'stable-message-identities', + update: { + sessionUpdate: 'agent_message_chunk', + messageId: 'assistant-new-greeting', + content: { type: 'text', text: 'New greeting' }, + }, + }, + { + sessionId: 'stable-message-identities', + update: { + sessionUpdate: 'agent_message_chunk', + messageId: 'assistant-greeting', + content: { type: 'text', text: 'How can I help?' }, + }, + }, + { + sessionId: 'stable-message-identities', + update: { + sessionUpdate: 'agent_message_chunk', + messageId: 'assistant-1', + content: { type: 'text', text: 'first response' }, + }, + }, + { + sessionId: 'stable-message-identities', + update: { + sessionUpdate: 'agent_message_chunk', + messageId: 'assistant-2', + content: { type: 'text', text: 'second response' }, + }, + }, + ], + }); + + expect( + service + .getSession(sessionId) + ?.history.getMessages() + .map(({ role, content }) => ({ role, content })), + ).toEqual([ + { role: ChatMessageRole.Assistant, content: 'New greeting' }, + { role: ChatMessageRole.Assistant, content: 'How can I help?' }, + { role: ChatMessageRole.User, content: 'first prompt' }, + { role: ChatMessageRole.Assistant, content: 'first response' }, + { role: ChatMessageRole.User, content: 'second prompt' }, + { role: ChatMessageRole.Assistant, content: 'second response' }, + ]); + expect(service.getSession(sessionId)?.requests.map((request) => request.message.prompt)).toEqual([ + '', + '', + 'first prompt', + 'second prompt', + ]); attachment.end(); }); @@ -1191,6 +1851,19 @@ describe('AcpChatManagerService', () => { expect(cancelSession).toHaveBeenCalledWith(sessionId); }); + it('does not send duplicate cancellation for a stopping ACP session', () => { + const { service } = createConstructedService(); + const sessionId = 'acp:s-stopping'; + const model = new ChatModel(new ChatFeatureRegistry(), { sessionId }); + const cancelSession = jest.fn().mockResolvedValue(undefined); + model.setThreadStatus('stopping'); + (service as any).sessionModels.set(sessionId, model); + (service as any).mainProvider = { cancelSession }; + + expect(service.cancelRequest(sessionId)).toBe(false); + expect(cancelSession).not.toHaveBeenCalled(); + }); + it('normalizes the ACP session id when explicitly cancelling through the back service', async () => { const provider = createSessionProvider(); const cancelSession = jest.fn().mockResolvedValue(undefined); @@ -1520,6 +2193,96 @@ describe('AcpChatManagerService', () => { expect(service.getSession('acp:first')?.history.getMessages()).toHaveLength(1); }); + it('keeps a listed session metadata update when an older catalog refresh completes', async () => { + const service = createService() as any; + let resolveRefresh!: (sessions: any[]) => void; + service.mainProvider = { + refreshAgentSessions: jest.fn( + () => + new Promise((resolve) => { + resolveRefresh = resolve; + }), + ), + }; + service.agentSessionCatalog = [ + { + sessionId: 'acp:one', + agentId: 'agent-a', + cwd: '/workspace', + title: 'Before', + updatedAt: '2026-08-20T00:00:00.000Z', + }, + ]; + const catalogChanges: any[] = []; + service.onDidChangeAgentSessionCatalog((catalog: any[]) => catalogChanges.push(catalog)); + + const refresh = service.refreshAgentSessionCatalog(); + service.applySessionStateUpdate('one', { + title: 'Live update', + }); + service.applySessionStateUpdate('one', { + updatedAt: '2026-08-20T01:00:00.000Z', + }); + service.applySessionStateUpdate('not-listed', { + title: 'Must not create a row', + }); + resolveRefresh([ + { + sessionId: 'acp:one', + agentId: 'agent-a', + cwd: '/workspace', + title: 'Stale discovery title', + updatedAt: '2026-08-20T00:30:00.000Z', + }, + ]); + + await refresh; + + expect(service.getAgentSessionCatalog()).toEqual([ + expect.objectContaining({ + sessionId: 'acp:one', + title: 'Live update', + updatedAt: '2026-08-20T01:00:00.000Z', + }), + ]); + expect(catalogChanges).toHaveLength(3); + }); + + it('keeps a loaded session when a catalog refresh omits it', async () => { + const service = createService() as any; + const loadedSession = new ChatModel(new ChatFeatureRegistry(), { + sessionId: 'acp:active', + title: 'Active conversation', + }); + service.sessionModels.set(loadedSession.sessionId, loadedSession); + service.mainProvider = { + refreshAgentSessions: jest.fn().mockResolvedValue([]), + }; + + await service.refreshAgentSessionCatalog(); + + expect(service.getAgentSessionCatalog()).toEqual([]); + expect(service.getSession('acp:active')).toBe(loadedSession); + }); + + it('uses an early Agent metadata title when the live model is created after the update', () => { + const service = createService() as any; + + service.applySessionStateUpdate('new', { title: 'Agent-owned title' }); + const [model] = service.fromAcpJSON([ + { + sessionId: 'acp:new', + createdAt: 1, + title: 'Local prompt title', + history: { additional: {}, messages: [] }, + requests: [], + }, + ]); + + expect(model.title).toBe('Agent-owned title'); + expect(service.getAgentSessionCatalog()).toEqual([]); + }); + it('stores raw first user message as ACP display title when creating request', () => { const { service, storage } = createConstructedService(); const sessionId = 'acp:s1'; @@ -1727,7 +2490,7 @@ describe('AcpChatManagerService', () => { availableCommands, } as any); - expect(service.getAvailableCommands()).toEqual(availableCommands); + expect(service.getAvailableCommands(model.sessionId)).toEqual(availableCommands); expect(changes).toEqual([ expect.objectContaining({ sessionId: 'acp:sess-1', @@ -1735,4 +2498,26 @@ describe('AcpChatManagerService', () => { }), ]); }); + + it('keeps available command catalogs isolated by ACP session', () => { + const { service } = createConstructedService(); + const firstSession = new ChatModel(new ChatFeatureRegistry(), { sessionId: 'acp:first' }); + const secondSession = new ChatModel(new ChatFeatureRegistry(), { sessionId: 'acp:second' }); + (service as any).sessionModels.set(firstSession.sessionId, firstSession); + (service as any).sessionModels.set(secondSession.sessionId, secondSession); + + service.applySessionStateUpdate('first', { + availableCommands: [{ name: 'first-skill', description: 'First session skill' }], + } as any); + service.applySessionStateUpdate('second', { + availableCommands: [{ name: 'second-skill', description: 'Second session skill' }], + } as any); + + expect(service.getAvailableCommands(firstSession.sessionId)).toEqual([ + { name: 'first-skill', description: 'First session skill' }, + ]); + expect(service.getAvailableCommands(secondSession.sessionId)).toEqual([ + { name: 'second-skill', description: 'Second session skill' }, + ]); + }); }); diff --git a/packages/ai-native/__test__/browser/chat/acp-chat-queued-turns.test.ts b/packages/ai-native/__test__/browser/chat/acp-chat-queued-turns.test.ts index 772f788069..6676570869 100644 --- a/packages/ai-native/__test__/browser/chat/acp-chat-queued-turns.test.ts +++ b/packages/ai-native/__test__/browser/chat/acp-chat-queued-turns.test.ts @@ -44,12 +44,18 @@ class ControlledTurnPort implements AcpQueuedTurnPort { class ControlledStartTurnPort extends ControlledTurnPort { readonly startRequested = new Deferred(); readonly releaseStart = new Deferred(); + readonly pendingStartCancellations: Array = []; override async start(sessionId: string | undefined, draft: AcpTurnDraft): Promise { this.startRequested.resolve(); await this.releaseStart.promise; return super.start(sessionId, draft); } + + override async cancelPendingStart(sessionId: string | undefined): Promise { + this.pendingStartCancellations.push(sessionId); + this.releaseStart.reject(new Error('session creation cancelled')); + } } class RejectingStartTurnPort extends ControlledTurnPort { @@ -152,6 +158,36 @@ class RepeatableOutcomeTurnPort implements AcpQueuedTurnPort { } describe('AcpQueuedTurnModule', () => { + it('exposes and cancels a pending first Task launch without retaining a queued duplicate', async () => { + const port = new ControlledStartTurnPort(); + const turns = new AcpQueuedTurnModule(port); + + const submit = turns.submit({ message: 'keep this draft', agentId: 'agent-a' }); + await port.startRequested.promise; + + expect(turns.snapshot.initialStartPending).toBe(true); + await expect(turns.cancelInitialStart()).resolves.toEqual({ accepted: true, outcome: 'stopped' }); + await expect(submit).resolves.toEqual({ accepted: false, reason: 'start-cancelled' }); + expect(port.pendingStartCancellations).toEqual([undefined]); + expect(turns.snapshot.initialStartPending).toBe(false); + expect(turns.snapshot.entries).toEqual([]); + expect(turns.snapshot.phase).toBe('idle'); + }); + + it('retries a failed first launch with the latest visible Draft values', async () => { + const port = new RejectingNextStartTurnPort(); + port.failNextStart = true; + const turns = new AcpQueuedTurnModule(port); + + await turns.submit({ message: 'stale prompt', agentId: 'agent-a' }); + expect(turns.snapshot.pauseReason).toBe('start-failed'); + + expect(turns.replaceFailedStartDraft({ message: 'edited prompt', agentId: 'agent-b' })).toBe(true); + await turns.resume(); + + expect(port.starts[0].draft).toEqual(expect.objectContaining({ message: 'edited prompt', agentId: 'agent-b' })); + }); + it('rejects an Active Session turn without a sendable ACP payload', async () => { const port = new ControlledTurnPort(); const turns = new AcpQueuedTurnModule(port); diff --git a/packages/ai-native/__test__/browser/chat/agentic-conversation-view-model.test.ts b/packages/ai-native/__test__/browser/chat/agentic-conversation-view-model.test.ts new file mode 100644 index 0000000000..f72e3707e5 --- /dev/null +++ b/packages/ai-native/__test__/browser/chat/agentic-conversation-view-model.test.ts @@ -0,0 +1,100 @@ +import { ChatMessageRole } from '@opensumi/ide-core-common'; + +import { + AgenticConversationViewModelCache, + createAgenticConversationViewModel, + isAgenticConversationViewModelCurrent, + updateAgenticConversationViewModel, +} from '../../../src/browser/chat/agentic-conversation-view-model'; + +function message(id: string) { + return { + id, + order: Number(id.replace(/\D/g, '')) || 0, + role: ChatMessageRole.User, + content: `message ${id}`, + relationId: `relation-${id}`, + }; +} + +describe('Agentic Conversation View Model', () => { + it('keeps canonical Message identities without creating presentation nodes', () => { + const messages = [message('m1'), message('m2')]; + + const viewModel = createAgenticConversationViewModel('acp:task', messages); + + expect(viewModel.sessionId).toBe('acp:task'); + expect(viewModel.messages.map((item) => item.id)).toEqual(['m1', 'm2']); + expect(viewModel.messages[0]).not.toHaveProperty('text'); + }); + + it('evicts the least recently used unprotected conversation by conversation count', () => { + const cache = new AgenticConversationViewModelCache({ maxConversations: 2, maxMessages: 100 }); + cache.set(createAgenticConversationViewModel('acp:a', [message('a1')])); + cache.set(createAgenticConversationViewModel('acp:b', [message('b1')])); + cache.get('acp:a'); + + cache.set(createAgenticConversationViewModel('acp:c', [message('c1')])); + + expect(cache.has('acp:a')).toBe(true); + expect(cache.has('acp:b')).toBe(false); + expect(cache.has('acp:c')).toBe(true); + }); + + it('uses the message budget and protects Active and Pending conversations', () => { + const cache = new AgenticConversationViewModelCache({ maxConversations: 5, maxMessages: 3 }); + cache.set(createAgenticConversationViewModel('acp:active', [message('a1'), message('a2')])); + cache.set(createAgenticConversationViewModel('acp:old', [message('o1')])); + cache.protect(['acp:active', 'acp:pending']); + + cache.set(createAgenticConversationViewModel('acp:pending', [message('p1'), message('p2')])); + + expect(cache.has('acp:active')).toBe(true); + expect(cache.has('acp:pending')).toBe(false); + expect(cache.has('acp:old')).toBe(false); + }); + + it('keeps the message budget as a hard limit when one protected conversation is oversized', () => { + const cache = new AgenticConversationViewModelCache({ maxConversations: 5, maxMessages: 1 }); + cache.protect(['acp:active']); + + const cached = cache.set(createAgenticConversationViewModel('acp:active', [message('a1'), message('a2')])); + + expect(cached).toBe(false); + expect(cache.has('acp:active')).toBe(false); + }); + + it('detects same-count canonical history updates before reusing a cached view model', () => { + const messages = [message('m1')]; + const viewModel = createAgenticConversationViewModel('acp:task', messages); + messages[0].content = 'updated while inactive'; + + expect(isAgenticConversationViewModelCurrent(viewModel, messages)).toBe(false); + }); + + it('reuses unchanged message descriptors and replaces only updated live entries', () => { + const initialMessages = [message('m1'), message('m2')]; + const initial = createAgenticConversationViewModel('acp:task', initialMessages); + const updatedMessages = [initialMessages[0], { ...initialMessages[1], content: 'streamed update' }]; + + const updated = updateAgenticConversationViewModel('acp:task', updatedMessages, initial); + + expect(updated.messages[0]).toBe(initial.messages[0]); + expect(updated.messages[1]).not.toBe(initial.messages[1]); + expect(updated.messages[1].content).toBe('streamed update'); + }); + + it('reuses the whole view model when canonical history is unchanged', () => { + const messages = [message('m1'), message('m2')]; + const initial = createAgenticConversationViewModel('acp:task', messages); + + const updated = updateAgenticConversationViewModel( + 'acp:task', + messages.map((item) => ({ ...item })), + initial, + ); + + expect(updated).toBe(initial); + expect(updated.messages).toBe(initial.messages); + }); +}); diff --git a/packages/ai-native/__test__/browser/chat/chat-input-registry.test.ts b/packages/ai-native/__test__/browser/chat/chat-input-registry.test.ts index 02081e2f0b..3bbf511bb7 100644 --- a/packages/ai-native/__test__/browser/chat/chat-input-registry.test.ts +++ b/packages/ai-native/__test__/browser/chat/chat-input-registry.test.ts @@ -3,7 +3,7 @@ import path from 'path'; import * as React from 'react'; import ts from 'typescript'; -import { COMMON_COMMANDS } from '@opensumi/ide-core-browser'; +import { COMMON_COMMANDS, fastdom } from '@opensumi/ide-core-browser'; import { AINativeSettingSectionsId } from '@opensumi/ide-core-common/lib/settings/ai-native'; jest.mock('tiktoken', () => ({ @@ -323,6 +323,58 @@ describe('ChatInputRegistry ACP turn capabilities', () => { expect(inputHandle.focus).toHaveBeenCalledTimes(2); }); + it('retries draft focus across layout frames after revealing the chat view', async () => { + const frameCallbacks: Array<() => void> = []; + const measureAtNextFrame = jest.spyOn(fastdom, 'measureAtNextFrame').mockImplementation((callback) => { + frameCallbacks.push(callback); + return { dispose: jest.fn() }; + }); + const registry = new ChatInputRegistry(); + const inputHandle = { + focus: jest.fn(), + isFocused: jest.fn(() => inputHandle.focus.mock.calls.length >= 3), + }; + registry.setActiveInputHandle(inputHandle); + const contribution = Object.create(AINativeBrowserContribution.prototype) as AINativeBrowserContribution; + Object.defineProperties(contribution, { + aiChatService: { + configurable: true, + value: { + enterDraftSession: jest.fn(), + getInputDraft: jest.fn(() => undefined), + updateInputDraft: jest.fn(), + }, + }, + chatInputRegistry: { configurable: true, value: registry }, + mainLayoutService: { configurable: true, value: {} }, + panelLayoutService: { configurable: true, value: { showAIChatView: jest.fn() } }, + }); + let newChatHandler: { execute(): void } | undefined; + const commands = { + afterExecuteCommand: jest.fn(), + beforeExecuteCommand: jest.fn(), + registerCommand: jest.fn((command: { id: string }, handler: { execute(): void }) => { + if (command.id === AI_CHAT_NEW_CHAT.id) { + newChatHandler = handler; + } + }), + }; + + contribution.registerCommands(commands as any); + newChatHandler?.execute(); + + expect(inputHandle.focus).toHaveBeenCalledTimes(1); + expect(frameCallbacks).toHaveLength(1); + frameCallbacks.shift()?.(); + expect(inputHandle.focus).toHaveBeenCalledTimes(2); + expect(frameCallbacks).toHaveLength(1); + frameCallbacks.shift()?.(); + expect(inputHandle.focus).toHaveBeenCalledTimes(3); + expect(frameCallbacks).toHaveLength(0); + + measureAtNextFrame.mockRestore(); + }); + it('offers Agent Configuration when the New Task command has no available ACP Agent', async () => { const registry = new ChatInputRegistry(); const inputHandle = { focus: jest.fn() }; diff --git a/packages/ai-native/__test__/browser/panel-layout.service.test.ts b/packages/ai-native/__test__/browser/panel-layout.service.test.ts index 140ed64b0c..1c8c0a5a09 100644 --- a/packages/ai-native/__test__/browser/panel-layout.service.test.ts +++ b/packages/ai-native/__test__/browser/panel-layout.service.test.ts @@ -252,10 +252,10 @@ describe('AIPanelLayoutService', () => { expect(layoutService.toggleSlot).toHaveBeenCalledWith(AI_CHAT_VIEW_ID, true, AI_AGENTIC_CHAT_DEFAULT_SIZE); }); - it('should default the agentic workbench to hidden in agentic mode', () => { + it('should default the agentic workbench to visible in agentic mode', () => { const { service } = createService({ inspectValue: { globalValue: 'agentic' } }); - expect(service.isAgenticWorkbenchVisible()).toBe(false); + expect(service.isAgenticWorkbenchVisible()).toBe(true); }); it('should not handle agentic workbench visibility outside agentic mode', () => { @@ -271,21 +271,21 @@ describe('AIPanelLayoutService', () => { const listener = jest.fn(); const disposable = service.onDidChangeAgenticWorkbenchVisibility(listener); - expect(service.toggleAgenticWorkbenchVisibility()).toBe(true); - expect(service.isAgenticWorkbenchVisible()).toBe(true); - expect(listener).toHaveBeenCalledWith(true); - - expect(service.toggleAgenticWorkbenchVisibility(false)).toBe(false); + expect(service.toggleAgenticWorkbenchVisibility()).toBe(false); expect(service.isAgenticWorkbenchVisible()).toBe(false); expect(listener).toHaveBeenCalledWith(false); + expect(service.toggleAgenticWorkbenchVisibility(true)).toBe(true); + expect(service.isAgenticWorkbenchVisible()).toBe(true); + expect(listener).toHaveBeenCalledWith(true); + disposable.dispose(); }); it('should reveal the agentic workbench only in agentic mode', () => { const { service } = createService({ inspectValue: { globalValue: 'agentic' } }); - expect(service.isAgenticWorkbenchVisible()).toBe(false); + expect(service.toggleAgenticWorkbenchVisibility(false)).toBe(false); expect(service.revealAgenticWorkbench()).toBe(true); expect(service.isAgenticWorkbenchVisible()).toBe(true); }); @@ -294,7 +294,6 @@ describe('AIPanelLayoutService', () => { const { service } = createService({ inspectValue: { globalValue: 'agentic' } }); const listener = jest.fn(); service.onDidChangeAgenticWorkbenchVisibility(listener); - service.toggleAgenticWorkbenchVisibility(true); listener.mockClear(); expect(service.setAgenticWorkbenchWidthConstrained(true)).toBe(false); @@ -310,6 +309,7 @@ describe('AIPanelLayoutService', () => { const { service, workbenchEditorService } = createService({ inspectValue: { globalValue: 'agentic' } }); service.initialize(); + service.toggleAgenticWorkbenchVisibility(false); expect(service.isAgenticWorkbenchVisible()).toBe(false); @@ -322,6 +322,7 @@ describe('AIPanelLayoutService', () => { const { service, workbenchEditorService } = createService({ inspectValue: { globalValue: 'agentic' } }); service.initialize(); + service.toggleAgenticWorkbenchVisibility(false); await workbenchEditorService.open(new URI('file:///workspace/file.ts'), { backend: true }); expect(service.isAgenticWorkbenchVisible()).toBe(false); @@ -334,6 +335,7 @@ describe('AIPanelLayoutService', () => { }); service.initialize(); + service.toggleAgenticWorkbenchVisibility(false); await workbenchEditorService.open(new URI('file:///workspace/file.ts'), { preview: false }); expect(service.isAgenticWorkbenchVisible()).toBe(false); @@ -342,13 +344,13 @@ describe('AIPanelLayoutService', () => { it('should reset agentic workbench visibility when layout mode changes', async () => { const { service } = createService({ inspectValue: { globalValue: 'agentic' } }); - expect(service.toggleAgenticWorkbenchVisibility(true)).toBe(true); + expect(service.toggleAgenticWorkbenchVisibility(false)).toBe(false); await service.setLayoutMode('classic'); expect(service.isAgenticWorkbenchVisible()).toBeUndefined(); await service.setLayoutMode('agentic'); - expect(service.isAgenticWorkbenchVisible()).toBe(false); + expect(service.isAgenticWorkbenchVisible()).toBe(true); }); it('should toggle both layout modes', async () => { diff --git a/packages/ai-native/__test__/node/acp-agent.service.test.ts b/packages/ai-native/__test__/node/acp-agent.service.test.ts index 0be61f18d2..6a47457ef4 100644 --- a/packages/ai-native/__test__/node/acp-agent.service.test.ts +++ b/packages/ai-native/__test__/node/acp-agent.service.test.ts @@ -74,6 +74,9 @@ interface MockThread { prompt: jest.Mock; cancel: jest.Mock; listSessions: jest.Mock; + closeSession: jest.Mock; + deleteSession: jest.Mock; + agentCapabilities?: any; getEntries: jest.Mock; getSessionNotifications: jest.Mock; getSessionState: jest.Mock; @@ -127,6 +130,8 @@ function createMockThread(overrides: Record = {}): MockThread { prompt: jest.fn().mockResolvedValue({ stopReason: 'end_turn' }), cancel: jest.fn().mockResolvedValue(undefined), listSessions: jest.fn().mockResolvedValue({ sessions: [] }), + closeSession: jest.fn().mockResolvedValue(undefined), + deleteSession: jest.fn().mockResolvedValue(undefined), getEntries: jest.fn().mockReturnValue([]), getSessionNotifications: jest.fn().mockReturnValue([]), getSessionState: jest.fn().mockReturnValue({ @@ -210,6 +215,36 @@ describe('AcpAgentService (Thread Pool)', () => { }); }); + describe('draft Session capabilities', () => { + it('initializes and releases one compatible idle thread when reading close/delete capabilities', async () => { + const thread = createMockThread({ + agentCapabilities: { sessionCapabilities: { close: {}, delete: {} } }, + }); + const service = setupServiceWithMockFactory(jest.fn(() => thread)); + + await expect(service.getSessionCapabilities(mockAgentProcessConfig)).resolves.toEqual({ + close: true, + delete: true, + }); + + expect(thread.initialize).toHaveBeenCalledWith(expect.objectContaining(mockAgentProcessConfig)); + expect((service as any).reservedThreads.has(thread)).toBe(false); + expect((service as any).sessions.size).toBe(0); + }); + + it('routes standard Session deletion only through the active Session thread', async () => { + const { service, thread } = createService(); + (service as any).sessions.set('draft-session', thread); + + await service.deleteSession({ sessionId: 'draft-session' }); + + expect(thread.deleteSession).toHaveBeenCalledWith({ sessionId: 'draft-session' }); + await expect(service.deleteSession({ sessionId: 'missing-session' })).rejects.toThrow( + 'No active session for sessionId: missing-session', + ); + }); + }); + describe('warmUpAgentPool()', () => { it('initializes one standby thread without creating sessions', async () => { const threads: MockThread[] = []; @@ -260,15 +295,25 @@ describe('AcpAgentService (Thread Pool)', () => { expect(threads.every((thread) => thread.initialize.mock.calls.length === 1)).toBe(true); }); - it('starts at most one warmup process across concurrent different runtime configurations', async () => { + it('cancels the obsolete standby and follows the latest runtime configuration', async () => { const initializeGate = createDeferred(); - const thread = createMockThread(); - thread.initialize.mockImplementation(async () => { + const obsoleteThread = createMockThread({ threadId: 'obsolete-standby' }); + obsoleteThread.initialize.mockImplementation(async () => { await initializeGate.promise; - thread.initialized = true; + obsoleteThread.initialized = true; return { protocolVersion: 1, agentCapabilities: {} }; }); - const mockFactory = jest.fn().mockReturnValue(thread); + obsoleteThread.dispose.mockImplementation(async () => { + const cancellation = new Error('cancelled'); + cancellation.name = 'AcpThreadInitializationCancelledError'; + initializeGate.reject(cancellation); + }); + const latestThread = createMockThread({ threadId: 'latest-standby' }); + latestThread.initialize.mockImplementation(async () => { + latestThread.initialized = true; + return { protocolVersion: 1, agentCapabilities: {} }; + }); + const mockFactory = jest.fn().mockReturnValueOnce(obsoleteThread).mockReturnValueOnce(latestThread); const service = setupServiceWithMockFactory(mockFactory); const configA = { ...mockAgentProcessConfigWithSmallPool, cwd: '/workspace-a' }; const configB = { ...mockAgentProcessConfigWithSmallPool, cwd: '/workspace-b' }; @@ -277,14 +322,14 @@ describe('AcpAgentService (Thread Pool)', () => { const warmupB = service.warmUpAgentPool(configB); await flushAsyncWork(); - expect(mockFactory).toHaveBeenCalledTimes(1); - expect(thread.initialize).toHaveBeenCalledTimes(1); - expect((service as any).threadPool).toEqual([thread]); + expect(obsoleteThread.dispose).toHaveBeenCalled(); + expect(mockFactory).toHaveBeenCalledTimes(2); + expect(latestThread.initialize).toHaveBeenCalledTimes(1); + expect((service as any).threadPool).toEqual([latestThread]); - initializeGate.resolve({ protocolVersion: 1, agentCapabilities: {} }); await Promise.all([warmupA, warmupB]); - expect(mockFactory).toHaveBeenCalledTimes(1); + expect((service as any).threadPool).toEqual([latestThread]); }); it('lets a session claim a warming thread without replenishing another standby', async () => { @@ -320,6 +365,85 @@ describe('AcpAgentService (Thread Pool)', () => { expect(thread.initialize).toHaveBeenCalledTimes(1); expect(thread.newSession).toHaveBeenCalledTimes(1); expect((service as any).threadFactory).toHaveBeenCalledTimes(1); + expect(mockLogger.log).toHaveBeenCalledWith(expect.stringContaining('standby-warmup-claim')); + }); + + it('asynchronously replenishes a compatible standby after a session claims it', async () => { + const standby = createMockThread({ threadId: 'claimed-standby' }); + standby.initialize.mockImplementation(async () => { + standby.initialized = true; + return { protocolVersion: 1, agentCapabilities: {} }; + }); + standby.newSession.mockImplementation(async () => { + standby._fireEvent({ + type: 'session_notification', + notification: { + sessionId: 'claimed-session', + update: { sessionUpdate: 'available_commands_update', availableCommands: [] }, + }, + }); + return { sessionId: 'claimed-session' }; + }); + const replacement = createMockThread({ threadId: 'replacement-standby' }); + replacement.initialize.mockImplementation(async () => { + replacement.initialized = true; + return { protocolVersion: 1, agentCapabilities: {} }; + }); + const mockFactory = jest.fn().mockReturnValueOnce(standby).mockReturnValueOnce(replacement); + const service = setupServiceWithMockFactory(mockFactory); + const config = { ...mockAgentProcessConfig, threadPoolSize: 2 }; + + await service.setStandbyTarget(config); + const result = await service.createSession(config); + await flushAsyncWork(); + + expect(result.sessionId).toBe('claimed-session'); + expect(mockFactory).toHaveBeenCalledTimes(2); + expect(replacement.initialize).toHaveBeenCalledTimes(1); + expect((service as any).threadPool).toEqual([standby, replacement]); + expect((service as any).sessions.get('claimed-session')).toBe(standby); + expect(mockLogger.log).toHaveBeenCalledWith(expect.stringContaining('standby-hit')); + }); + + it('reclaims an incompatible process after its active session releases capacity', async () => { + const activeThread = createMockThread({ + threadId: 'active-a', + getStatus: jest.fn().mockReturnValue('awaiting_prompt'), + }); + activeThread.initialize.mockImplementation(async () => { + activeThread.initialized = true; + return { protocolVersion: 1, agentCapabilities: {} }; + }); + activeThread.newSession.mockImplementation(async () => { + activeThread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: 'session-a', + update: { sessionUpdate: 'available_commands_update', availableCommands: [] }, + }, + }); + return { sessionId: 'session-a' }; + }); + const standbyB = createMockThread({ threadId: 'standby-b' }); + standbyB.initialize.mockImplementation(async () => { + standbyB.initialized = true; + return { protocolVersion: 1, agentCapabilities: {} }; + }); + const mockFactory = jest.fn().mockReturnValueOnce(activeThread).mockReturnValueOnce(standbyB); + const service = setupServiceWithMockFactory(mockFactory); + const configA = { ...mockAgentProcessConfig, cwd: '/workspace-a', threadPoolSize: 1 }; + const configB = { ...mockAgentProcessConfig, cwd: '/workspace-b', threadPoolSize: 1 }; + + const session = await service.createSession(configA); + await service.setStandbyTarget(configB); + expect(activeThread.dispose).not.toHaveBeenCalled(); + + await service.disposeSession(session.sessionId); + await flushAsyncWork(); + + expect(activeThread.dispose).toHaveBeenCalled(); + expect(standbyB.initialize).toHaveBeenCalledTimes(1); + expect((service as any).threadPool).toEqual([standbyB]); }); it('waits for and replaces an incompatible warming thread before creating a session', async () => { @@ -428,6 +552,40 @@ describe('AcpAgentService (Thread Pool)', () => { expect((service as any).threadPool).toEqual([]); expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('failed to initialize thread')); }); + + it('backs off repeated standby startup failures at one, five, then thirty seconds', async () => { + jest.useFakeTimers(); + let attempt = 0; + const mockFactory = jest.fn(() => { + attempt += 1; + return createMockThread({ + threadId: `failed-standby-${attempt}`, + initialize: jest.fn().mockRejectedValue(new Error(`warmup failed ${attempt}`)), + }); + }); + const service = setupServiceWithMockFactory(mockFactory); + + await service.setStandbyTarget(mockAgentProcessConfigWithSmallPool); + expect(mockFactory).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(999); + expect(mockFactory).toHaveBeenCalledTimes(1); + await jest.advanceTimersByTimeAsync(1); + expect(mockFactory).toHaveBeenCalledTimes(2); + + await jest.advanceTimersByTimeAsync(4999); + expect(mockFactory).toHaveBeenCalledTimes(2); + await jest.advanceTimersByTimeAsync(1); + expect(mockFactory).toHaveBeenCalledTimes(3); + + await jest.advanceTimersByTimeAsync(29999); + expect(mockFactory).toHaveBeenCalledTimes(3); + await jest.advanceTimersByTimeAsync(1); + expect(mockFactory).toHaveBeenCalledTimes(4); + + await service.stopAgent(); + jest.useRealTimers(); + }); }); describe('getSessionMcpServers()', () => { @@ -595,6 +753,8 @@ describe('AcpAgentService (Thread Pool)', () => { expect(result.availableCommands[0].name).toBe('ReadFile'); expect(thread.initialize).toHaveBeenCalled(); expect(thread.newSession).toHaveBeenCalled(); + expect(mockLogger.log).toHaveBeenCalledWith(expect.stringContaining('timings={"threadAcquireMs":')); + expect(mockLogger.log).toHaveBeenCalledWith(expect.stringContaining('"newSessionRpcMs":')); }); it('should create a session with empty commands when available_commands_update times out', async () => { @@ -611,6 +771,37 @@ describe('AcpAgentService (Thread Pool)', () => { expect(thread.dispose).not.toHaveBeenCalled(); }); + it('should use the latest complete available command update during discovery', async () => { + const { service, thread } = createServiceWithAutoEvents(); + + setTimeout(() => { + thread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: 'session-1', + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [{ name: 'old-skill', description: 'Removed skill' }], + }, + }, + }); + thread._fireEvent({ + type: 'session_notification', + notification: { + sessionId: 'session-1', + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [{ name: 'new-skill', description: 'Installed skill' }], + }, + }, + }); + }, 10); + + const result = await service.createSession(mockAgentProcessConfig); + + expect(result.availableCommands).toEqual([{ name: 'new-skill', description: 'Installed skill' }]); + }); + it('should preserve working sessions and report diagnostics when the pool is saturated', async () => { const { service } = createServiceWithAutoEvents(); @@ -743,6 +934,46 @@ describe('AcpAgentService (Thread Pool)', () => { expect((service as any).sessions.get('loaded-session')).toBe(loadingThread); }); + it('cancels an in-flight session creation before it can bind a session', async () => { + const initializeGate = createDeferred(); + const cancellationError = new Error('session creation cancelled'); + cancellationError.name = 'ACP_SESSION_CREATION_CANCELLED'; + const thread = createMockThread({ + initialize: jest.fn().mockReturnValue(initializeGate.promise), + dispose: jest.fn(async () => initializeGate.reject(cancellationError)), + }); + const service = setupServiceWithMockFactory(jest.fn().mockReturnValue(thread)); + + const creation = service.createSession(mockAgentProcessConfig, 'launch-1'); + await flushAsyncWork(); + await service.cancelSessionCreation('launch-1'); + + await expect(creation).rejects.toMatchObject({ name: 'ACP_SESSION_CREATION_CANCELLED' }); + expect(thread.dispose).toHaveBeenCalledTimes(1); + expect(thread.newSession).not.toHaveBeenCalled(); + expect((service as any).threadPool).toEqual([]); + expect((service as any).sessions.size).toBe(0); + }); + + it('cancels promptly after newSession while command discovery is still pending', async () => { + const thread = createMockThread({ + initialized: true, + newSession: jest.fn().mockResolvedValue({ sessionId: 'temporary-session' }), + }); + const service = setupServiceWithMockFactory(jest.fn().mockReturnValue(thread)); + + const creation = service.createSession(mockAgentProcessConfig, 'launch-after-session'); + await flushAsyncWork(); + expect(thread.newSession).toHaveBeenCalledTimes(1); + + await service.cancelSessionCreation('launch-after-session'); + await expect(creation).rejects.toMatchObject({ name: 'ACP_SESSION_CREATION_CANCELLED' }); + + expect(mockTerminalHandler.releaseSessionTerminals).toHaveBeenCalledWith('temporary-session'); + expect((service as any).sessions.has('temporary-session')).toBe(false); + expect((service as any).threadPool).toEqual([]); + }); + it('should clean up on error when thread was newly created', async () => { const thread = createMockThread({ onEvent: jest.fn(() => ({ dispose: jest.fn() })), @@ -1020,7 +1251,17 @@ describe('AcpAgentService (Thread Pool)', () => { expect(result.historyUpdates).toEqual(nativeHistory); }); - it('does not expose local prompt, response, tool, thought, or permission sentinels in metadata-only session restore results', async () => { + it('restores retained user prompts without exposing other local entry sentinels', async () => { + const retainedUserHistory = [ + { + sessionId: 'existing-session-id', + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'BDD_SENSITIVE_PROMPT' }, + messageId: 'msg-1', + }, + }, + ]; const thread = createMockThread({ initialized: true, getStatus: jest.fn().mockReturnValue('idle'), @@ -1046,7 +1287,7 @@ describe('AcpAgentService (Thread Pool)', () => { data: { id: 'msg-5', content: 'BDD_PERMISSION_ALLOWED', timestamp: 5 }, }, ]), - getSessionNotifications: jest.fn().mockReturnValue([]), + getSessionNotifications: jest.fn().mockReturnValue(retainedUserHistory), onEvent: jest.fn(() => ({ dispose: jest.fn() })), }); const mockFactory = jest.fn().mockReturnValue(thread); @@ -1054,15 +1295,11 @@ describe('AcpAgentService (Thread Pool)', () => { const result = await service.loadSession('existing-session-id', mockAgentProcessConfig); - expect(result.historyUpdates).toEqual([]); + expect(result.historyUpdates).toEqual(retainedUserHistory); const serialized = JSON.stringify(result); - [ - 'BDD_SENSITIVE_PROMPT', - 'BDD_ASSISTANT_PART', - 'BDD_THOUGHT_STEP', - 'BDD_TOOL_RESULT', - 'BDD_PERMISSION_ALLOWED', - ].forEach((sentinel) => expect(serialized).not.toContain(sentinel)); + ['BDD_ASSISTANT_PART', 'BDD_THOUGHT_STEP', 'BDD_TOOL_RESULT', 'BDD_PERMISSION_ALLOWED'].forEach((sentinel) => + expect(serialized).not.toContain(sentinel), + ); }); it('should apply default session options after loading a session', async () => { @@ -2060,6 +2297,12 @@ describe('AcpAgentService (Thread Pool)', () => { ]; thread.getSessionNotifications.mockReturnValue(historyUpdates); thread.getStatus.mockReturnValue('working'); + thread.getSessionState.mockReturnValue({ + notifications: [], + entries: [], + modes: [], + availableCommands: [{ name: 'installed-skill', description: 'Installed skill' }], + }); setTimeout(() => { thread._fireEvent({ @@ -2096,6 +2339,7 @@ describe('AcpAgentService (Thread Pool)', () => { sessionId: createResult.sessionId, historyUpdates, threadStatus: 'working', + availableCommands: [{ name: 'installed-skill', description: 'Installed skill' }], }), }), ); @@ -2305,7 +2549,7 @@ describe('AcpAgentService (Thread Pool)', () => { expect((service as any).threadPool).toHaveLength(0); }); - it('waits for warming initialization to settle before disposing the thread', async () => { + it('cancels an unclaimed standby warmup before draining shutdown', async () => { const initializeGate = createDeferred(); const thread = createMockThread(); thread.initialize.mockImplementation(async () => { @@ -2324,12 +2568,12 @@ describe('AcpAgentService (Thread Pool)', () => { await flushAsyncWork(); expect(stopSettled).toBe(false); - expect(thread.dispose).not.toHaveBeenCalled(); + expect(thread.dispose).toHaveBeenCalledTimes(1); initializeGate.resolve({ protocolVersion: 1, agentCapabilities: {} }); await Promise.all([warmup, stop]); - expect(thread.dispose).toHaveBeenCalledTimes(1); + expect(thread.dispose).toHaveBeenCalled(); expect((service as any).threadPool).toHaveLength(0); }); @@ -2498,6 +2742,70 @@ describe('AcpAgentService (Thread Pool)', () => { // ----------------------------------------------------------------------- describe('listSessions()', () => { + it('returns an empty successful result when a compatible thread lists no sessions', async () => { + const { service, thread } = createService(); + (service as any).sessions.set('active-session', thread); + + await expect(service.listSessions()).resolves.toEqual({ sessions: [], nextCursor: undefined }); + expect(thread.listSessions).toHaveBeenCalledTimes(1); + }); + + it('merges successful session lists when another compatible thread fails', async () => { + const failingThread = createMockThread({ + threadId: 'failing-thread', + listSessions: jest.fn().mockRejectedValue(new Error('first list failed')), + }); + const successfulThread = createMockThread({ + threadId: 'successful-thread', + listSessions: jest.fn().mockResolvedValue({ + sessions: [{ sessionId: 'history-session', cwd: mockAgentProcessConfig.cwd, title: 'History Session' }], + }), + }); + const service = setupServiceWithMockFactory(jest.fn()); + (service as any).sessions.set('failed-session', failingThread); + (service as any).sessions.set('successful-session', successfulThread); + + await expect(service.listSessions()).resolves.toEqual({ + sessions: [{ sessionId: 'history-session', cwd: mockAgentProcessConfig.cwd, title: 'History Session' }], + nextCursor: undefined, + }); + }); + + it('throws a normalized error when every compatible thread fails to list sessions', async () => { + const agentError = { message: 'session service unavailable', code: -32001, data: { service: 'session' } }; + const failingThread = createMockThread({ + listSessions: jest.fn().mockRejectedValue(agentError), + }); + const service = setupServiceWithMockFactory(jest.fn()); + (service as any).sessions.set('failed-session', failingThread); + + let error: unknown; + try { + await service.listSessions(); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(Error); + expect(error).toMatchObject({ + message: 'session service unavailable', + code: -32001, + data: { service: 'session' }, + cause: agentError, + }); + }); + + it('releases an idle-thread reservation when its only list attempt fails', async () => { + const { service, thread } = createService(); + thread.listSessions.mockRejectedValue(new Error('list failed')); + + await expect(service.listSessions({ cwd: mockAgentProcessConfig.cwd }, mockAgentProcessConfig)).rejects.toThrow( + 'list failed', + ); + expect((service as any).reservedThreads.has(thread)).toBe(false); + expect((service as any).sessions.size).toBe(0); + }); + it('should return all active sessions', async () => { const { service } = createServiceWithAutoEvents(); diff --git a/packages/ai-native/__test__/node/acp-cli-back.test.ts b/packages/ai-native/__test__/node/acp-cli-back.test.ts index cc07729db2..c59cb0c50a 100644 --- a/packages/ai-native/__test__/node/acp-cli-back.test.ts +++ b/packages/ai-native/__test__/node/acp-cli-back.test.ts @@ -52,6 +52,7 @@ describe('AcpCliBackService', () => { mockAgentService = { createSession: jest.fn(), + cancelSessionCreation: jest.fn(), initializeAgent: jest.fn(), sendMessage: jest.fn(), attachSession: jest.fn(), @@ -63,6 +64,8 @@ describe('AcpCliBackService', () => { loadSession: jest.fn(), loadSessionOrNew: jest.fn(), listSessions: jest.fn(), + getSessionCapabilities: jest.fn(), + deleteSession: jest.fn(), setSessionMode: jest.fn(), stopAgent: jest.fn(), getAvailableModes: jest.fn(), @@ -225,6 +228,16 @@ describe('AcpCliBackService', () => { expect(result).toEqual(expected); expect(mockAgentService.createSession).toHaveBeenCalledWith(mockAgentSessionConfig); }); + + it('forwards cancellable session creation operation ids', async () => { + mockAgentService.createSession.mockResolvedValue({ sessionId: 'new-session', availableCommands: [] }); + + await service.createSession(mockAgentSessionConfig, 'launch-1'); + await service.cancelSessionCreation('launch-1'); + + expect(mockAgentService.createSession).toHaveBeenCalledWith(mockAgentSessionConfig, 'launch-1'); + expect(mockAgentService.cancelSessionCreation).toHaveBeenCalledWith('launch-1'); + }); }); describe('loadSessionOrNew()', () => { @@ -262,6 +275,7 @@ describe('AcpCliBackService', () => { status: 'running', historyUpdates: [], threadStatus: 'working', + availableCommands: [{ name: 'new-skill', description: 'Run the new skill' }], }, }); agentStream.emitData({ @@ -280,6 +294,7 @@ describe('AcpCliBackService', () => { sessionId: 'sess-1', threadStatus: 'working', historyUpdates: [], + availableCommands: [{ name: 'new-skill', description: 'Run the new skill' }], }), ); expect(progress).toContainEqual({ kind: 'content', content: 'continued output' }); @@ -575,6 +590,25 @@ describe('AcpCliBackService', () => { }); }); + it('should convert native session_info_update to a session_state update', () => { + expect( + toAgentUpdate({ + sessionId: 'sess-1', + update: { + sessionUpdate: 'session_info_update', + title: 'Renamed session', + updatedAt: '2026-08-20T01:00:00.000Z', + }, + } as any), + ).toEqual({ + type: 'session_state', + content: '', + sessionId: 'sess-1', + title: 'Renamed session', + updatedAt: '2026-08-20T01:00:00.000Z', + }); + }); + it('should convert native top-level plan entries to plan update content', () => { expect( toAgentUpdate({ @@ -649,6 +683,8 @@ describe('AcpCliBackService', () => { currentModeId: 'code', currentModelId: 'qwen3.6-plus', configOptions, + title: 'Renamed session', + updatedAt: '2026-08-20T01:00:00.000Z', }); agentStream.emitData({ type: 'done', content: '' }); @@ -659,6 +695,8 @@ describe('AcpCliBackService', () => { currentModeId: 'code', currentModelId: 'qwen3.6-plus', configOptions, + title: 'Renamed session', + updatedAt: '2026-08-20T01:00:00.000Z', }, ]); }); @@ -890,19 +928,25 @@ describe('AcpCliBackService', () => { }); describe('disposeSession()', () => { - it('should cancel request then dispose session', async () => { + it('should release the session without cancelling running Agent work', async () => { await service.disposeSession('sess-1'); - expect(mockAgentService.cancelRequest).toHaveBeenCalledWith('sess-1'); + expect(mockAgentService.cancelRequest).not.toHaveBeenCalled(); expect(mockAgentService.disposeSession).toHaveBeenCalledWith('sess-1'); }); + it('should force dispose the session when requested by E2E cleanup', async () => { + await service.disposeSession('sess-1', true); + + expect(mockAgentService.disposeSession).toHaveBeenCalledWith('sess-1', true); + }); + it('should still complete even if disposeSession fails', async () => { mockAgentService.disposeSession.mockRejectedValue(new Error('dispose failed')); await service.disposeSession('sess-1'); - expect(mockAgentService.cancelRequest).toHaveBeenCalledWith('sess-1'); + expect(mockAgentService.cancelRequest).not.toHaveBeenCalled(); expect(mockLogger.error).toHaveBeenCalled(); }); }); @@ -934,11 +978,15 @@ describe('AcpCliBackService', () => { }); describe('listSessions()', () => { - it('should list sessions via agentService', async () => { - mockAgentService.listSessions.mockResolvedValue({ - sessions: [{ sessionId: 's1', cwd: '/test', title: 'Session 1' } as any], - nextCursor: 'cursor-2', - }); + it('should list all paginated sessions via agentService', async () => { + mockAgentService.listSessions + .mockResolvedValueOnce({ + sessions: [{ sessionId: 's1', cwd: '/test', title: 'Session 1' } as any], + nextCursor: 'cursor-2', + }) + .mockResolvedValueOnce({ + sessions: [{ sessionId: 's2', cwd: '/test', title: 'Session 2' } as any], + }); const result = await service.listSessions(mockAgentSessionConfig); @@ -948,8 +996,15 @@ describe('AcpCliBackService', () => { }, mockAgentSessionConfig, ); - expect(result.sessions).toHaveLength(1); - expect(result.nextCursor).toBe('cursor-2'); + expect(mockAgentService.listSessions).toHaveBeenLastCalledWith( + { + cwd: mockAgentSessionConfig.cwd, + cursor: 'cursor-2', + }, + mockAgentSessionConfig, + ); + expect(result.sessions).toHaveLength(2); + expect(result.nextCursor).toBeUndefined(); }); it('should re-throw error from listSessions', async () => { @@ -959,6 +1014,21 @@ describe('AcpCliBackService', () => { }); }); + describe('draft Session capabilities', () => { + it('proxies negotiated capabilities and standard deletion to AcpAgentService', async () => { + mockAgentService.getSessionCapabilities.mockResolvedValue({ close: true, delete: false }); + + await expect(service.getSessionCapabilities(mockAgentSessionConfig)).resolves.toEqual({ + close: true, + delete: false, + }); + await service.deleteSession('draft-session'); + + expect(mockAgentService.getSessionCapabilities).toHaveBeenCalledWith(mockAgentSessionConfig); + expect(mockAgentService.deleteSession).toHaveBeenCalledWith({ sessionId: 'draft-session' }); + }); + }); + describe('dispose()', () => { it('should detach without disposing the container-scoped agent service', async () => { await service.dispose(); diff --git a/packages/ai-native/__test__/node/acp-error.test.ts b/packages/ai-native/__test__/node/acp-error.test.ts new file mode 100644 index 0000000000..713b2b8312 --- /dev/null +++ b/packages/ai-native/__test__/node/acp-error.test.ts @@ -0,0 +1,47 @@ +import { getAcpErrorMessage, normalizeAcpError } from '../../src/node/acp/acp-error'; + +describe('ACP error normalization', () => { + it('turns an OpenCode service failure into an actionable message while preserving diagnostics', () => { + const originalError = { + code: -32603, + message: 'Internal error: OpenCode service failure', + data: { service: 'session', errorName: 'DatabaseError' }, + }; + + const error = normalizeAcpError(originalError) as Error & { + code?: number; + data?: unknown; + originalMessage?: string; + }; + + expect(error.message).toBe( + "OpenCode couldn't complete the request because its session service failed. Retry the request. If it keeps failing, start a new session. (service: session, error: DatabaseError)", + ); + expect(error.code).toBe(-32603); + expect(error.data).toEqual(originalError.data); + expect(error.originalMessage).toBe(originalError.message); + expect(error.cause).toBe(originalError); + }); + + it('explains how to recover when the selected model is unavailable', () => { + const originalError = { + code: -32602, + message: 'Invalid params: model not found: cfuse/GLM-5.2', + data: { providerId: 'cfuse', modelId: 'cfuse/GLM-5.2' }, + }; + + const error = normalizeAcpError(originalError); + + expect(error.message).toBe( + 'The selected model "cfuse/GLM-5.2" is unavailable. Choose another model and try again.', + ); + expect((error as Error & { originalMessage?: string }).originalMessage).toBe(originalError.message); + }); + + it('keeps unknown Error instances unchanged', () => { + const originalError = new Error('Agent connection lost'); + + expect(normalizeAcpError(originalError)).toBe(originalError); + expect(getAcpErrorMessage(originalError)).toBe('Agent connection lost'); + }); +}); diff --git a/packages/ai-native/__test__/node/acp/acp-thread.test.ts b/packages/ai-native/__test__/node/acp/acp-thread.test.ts index 9bff1122fc..2dfd09586b 100644 --- a/packages/ai-native/__test__/node/acp/acp-thread.test.ts +++ b/packages/ai-native/__test__/node/acp/acp-thread.test.ts @@ -33,13 +33,20 @@ jest.mock('stream/web', () => ({ const mockClientSideConnection = jest.fn().mockImplementation(() => ({ initialize: jest.fn().mockResolvedValue({ protocolVersion: 1, - agentCapabilities: { fs: { readTextFile: true, writeTextFile: true }, terminal: true }, + agentCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + terminal: true, + loadSession: true, + sessionCapabilities: { list: {} }, + }, }), newSession: jest.fn().mockResolvedValue({ sessionId: 'new-session-1' }), loadSession: jest.fn().mockResolvedValue({ sessionId: 'loaded-session-1' }), prompt: jest.fn().mockResolvedValue({ stopReason: 'end_turn' }), cancel: jest.fn().mockResolvedValue(undefined), listSessions: jest.fn().mockResolvedValue({ sessions: [] }), + closeSession: jest.fn().mockResolvedValue({}), + deleteSession: jest.fn().mockResolvedValue({}), })); jest.mock('@agentclientprotocol/sdk', () => ({ @@ -139,6 +146,16 @@ function createTestConfig(): AgentProcessConfig { }; } +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + /** Helper: extract UserMessageEntry from AgentThreadEntry */ function getUserData(entry: AgentThreadEntry) { return entry.type === 'user_message' ? entry.data : null; @@ -243,6 +260,7 @@ describe('AcpThread', () => { expect(thread.status).toBe('awaiting_prompt'); expect(thread.sessionId).toBe('s1'); + expect(mockLogger.log).toHaveBeenCalledWith(expect.stringContaining('rpcDurationMs=')); }); it('should transition to working during prompt', async () => { @@ -270,6 +288,75 @@ describe('AcpThread', () => { expect(thread.status).toBe('awaiting_prompt'); }); + it('should transition from working through stopping to awaiting_prompt when cancellation completes', async () => { + const prompt = createDeferred(); + const cancel = jest.fn().mockResolvedValue(undefined); + (thread as any)._connected = true; + (thread as any)._connection = { + prompt: jest.fn(() => prompt.promise), + cancel, + }; + (thread as any)._initialized = true; + + const promptPromise = thread.prompt({ sessionId: 's1' } as any); + await Promise.resolve(); + expect(thread.status).toBe('working'); + + await thread.cancel({ sessionId: 's1' } as any); + expect(thread.status).toBe('stopping'); + + prompt.resolve({ stopReason: 'cancelled' }); + await expect(promptPromise).resolves.toEqual({ stopReason: 'cancelled' }); + expect(thread.status).toBe('awaiting_prompt'); + }); + + it('should keep repeated cancellation idempotent while stopping', async () => { + const cancel = createDeferred(); + const cancelRequest = jest.fn(() => cancel.promise); + (thread as any)._connected = true; + (thread as any)._connection = { cancel: cancelRequest }; + (thread as any)._initialized = true; + thread.setStatus('working'); + + const firstCancel = thread.cancel({ sessionId: 's1' } as any); + expect(thread.status).toBe('stopping'); + await expect(thread.cancel({ sessionId: 's1' } as any)).resolves.toBeUndefined(); + expect(cancelRequest).toHaveBeenCalledTimes(1); + + cancel.resolve(); + await firstCancel; + expect(thread.status).toBe('stopping'); + }); + + it('should restore the previous pending status when sending cancellation fails', async () => { + (thread as any)._connected = true; + (thread as any)._connection = { + cancel: jest.fn().mockRejectedValue(new Error('cancel transport failed')), + }; + (thread as any)._initialized = true; + thread.setStatus('working'); + + await expect(thread.cancel({ sessionId: 's1' } as any)).rejects.toThrow('cancel transport failed'); + expect(thread.status).toBe('working'); + }); + + it('should recover to awaiting_prompt when a stopping prompt rejects', async () => { + const prompt = createDeferred(); + (thread as any)._connected = true; + (thread as any)._connection = { + prompt: jest.fn(() => prompt.promise), + cancel: jest.fn().mockResolvedValue(undefined), + }; + (thread as any)._initialized = true; + + const promptPromise = thread.prompt({ sessionId: 's1' } as any); + await thread.cancel({ sessionId: 's1' } as any); + prompt.reject(new Error('cancelled prompt rejected')); + + await expect(promptPromise).rejects.toThrow('cancelled prompt rejected'); + expect(thread.status).toBe('awaiting_prompt'); + }); + it('should recover to awaiting_prompt when prompt fails while working', async () => { (thread as any)._connected = true; (thread as any)._connection = { @@ -636,6 +723,63 @@ describe('AcpThread', () => { expect(mockInitialize).toHaveBeenCalled(); expect(thread.initialized).toBe(true); }); + + it('does not revive a thread when disposal wins the process-start race', async () => { + jest.useFakeTimers(); + + const initialization = thread.initialize(createTestConfig()); + const initializationCancelled = expect(initialization).rejects.toMatchObject({ + name: 'AcpThreadInitializationCancelledError', + }); + await Promise.resolve(); + + const disposal = thread.dispose(); + await jest.runAllTimersAsync(); + await disposal; + + await initializationCancelled; + expect(thread.initialized).toBe(false); + expect(thread.isProcessRunning).toBe(false); + expect(thread.isConnected).toBe(false); + }); + + it('force-kills a starting process that does not exit after SIGTERM', async () => { + jest.useFakeTimers(); + const initialization = thread.initialize(createTestConfig()); + const initializationCancelled = expect(initialization).rejects.toMatchObject({ + name: 'AcpThreadInitializationCancelledError', + }); + await Promise.resolve(); + + const disposal = thread.dispose(); + await jest.advanceTimersByTimeAsync(5000); + await disposal; + await initializationCancelled; + + expect(process.kill).toHaveBeenCalledWith(-mockChildProcess.pid, 'SIGTERM'); + expect(process.kill).toHaveBeenCalledWith(-mockChildProcess.pid, 'SIGKILL'); + expect(thread.isProcessRunning).toBe(false); + }); + + it('ignores an ACP initialize response that arrives after disposal', async () => { + const initializeResult = createDeferred(); + (thread as any)._connected = true; + (thread as any)._connection = { + initialize: jest.fn(() => initializeResult.promise), + }; + + const initialization = thread.initialize(createTestConfig()); + const initializationCancelled = expect(initialization).rejects.toMatchObject({ + name: 'AcpThreadInitializationCancelledError', + }); + await Promise.resolve(); + await thread.dispose(); + initializeResult.resolve({ protocolVersion: 1, agentCapabilities: {} }); + + await initializationCancelled; + expect(thread.initialized).toBe(false); + expect(thread.isConnected).toBe(false); + }); }); // =================================================================== @@ -755,6 +899,68 @@ describe('AcpThread', () => { const entry = thread.addUserMessage('Test'); expect(entry.timestamp).toBeGreaterThan(0); }); + + it('should retain submitted user messages in native session history', () => { + (thread as any)._sessionId = 's1'; + + const entry = thread.addUserMessage('Hello, AI!'); + + expect(thread.getSessionNotifications()).toEqual([ + { + sessionId: 's1', + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'Hello, AI!' }, + messageId: entry.id, + }, + }, + ]); + }); + + it('should ignore an Agent echo of the submitted user message', async () => { + (thread as any)._sessionId = 's1'; + const client = (thread as any).createClientImpl(); + + thread.addUserMessage('Hello, AI!'); + await client.sessionUpdate({ + sessionId: 's1', + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'Hello, ' }, + }, + }); + await client.sessionUpdate({ + sessionId: 's1', + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'AI!' }, + }, + }); + await client.sessionUpdate({ + sessionId: 's1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Hi!' }, + }, + }); + + expect(thread.getSessionNotifications()).toEqual([ + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'Hello, AI!' }, + }), + }), + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Hi!' }, + }), + }), + ]); + expect(thread.entries).toHaveLength(2); + expect(getUserData(thread.entries[0])?.content).toBe('Hello, AI!'); + }); }); describe('markAssistantComplete()', () => { @@ -994,6 +1200,7 @@ describe('AcpThread', () => { }), }; (thread as any)._initialized = true; + (thread as any)._agentCapabilities = { loadSession: true }; (thread as any)._connection = connection; await thread.loadSession({ sessionId: 's1' } as any); @@ -1207,6 +1414,68 @@ describe('AcpThread', () => { await expect(thread.listSessions()).rejects.toThrow('AcpThread not initialized'); }); + + it('does not send session/load when the agent did not advertise loadSession', async () => { + const connection = { loadSession: jest.fn() }; + (thread as any)._initialized = true; + (thread as any)._agentCapabilities = {}; + (thread as any)._connection = connection; + + await expect(thread.loadSession({ sessionId: 's1' } as any)).rejects.toThrow( + 'Agent does not support ACP session/load.', + ); + expect(connection.loadSession).not.toHaveBeenCalled(); + }); + + it('does not send session/list when the agent did not advertise sessionCapabilities.list', async () => { + const connection = { listSessions: jest.fn() }; + (thread as any)._initialized = true; + (thread as any)._agentCapabilities = { loadSession: true, sessionCapabilities: {} }; + (thread as any)._connection = connection; + + await expect(thread.listSessions()).rejects.toThrow('Agent does not support ACP session/list.'); + expect(connection.listSessions).not.toHaveBeenCalled(); + }); + + it('does not send session/close when the agent did not advertise sessionCapabilities.close', async () => { + const connection = { closeSession: jest.fn() }; + (thread as any)._initialized = true; + (thread as any)._agentCapabilities = { sessionCapabilities: {} }; + (thread as any)._connection = connection; + + await expect(thread.closeSession({ sessionId: 's1' } as any)).rejects.toThrow( + 'Agent does not support ACP session/close.', + ); + expect(connection.closeSession).not.toHaveBeenCalled(); + }); + + it('does not send session/delete when the agent did not advertise sessionCapabilities.delete', async () => { + const connection = { deleteSession: jest.fn() }; + (thread as any)._initialized = true; + (thread as any)._agentCapabilities = { sessionCapabilities: {} }; + (thread as any)._connection = connection; + + await expect(thread.deleteSession({ sessionId: 's1' })).rejects.toThrow( + 'Agent does not support ACP session/delete.', + ); + expect(connection.deleteSession).not.toHaveBeenCalled(); + }); + + it('uses the standard close and delete methods after capability negotiation', async () => { + const connection = { + closeSession: jest.fn().mockResolvedValue({}), + deleteSession: jest.fn().mockResolvedValue({}), + }; + (thread as any)._initialized = true; + (thread as any)._agentCapabilities = { sessionCapabilities: { close: {}, delete: {} } }; + (thread as any)._connection = connection; + + await thread.closeSession({ sessionId: 's1' } as any); + await thread.deleteSession({ sessionId: 's1' }); + + expect(connection.closeSession).toHaveBeenCalledWith({ sessionId: 's1' }); + expect(connection.deleteSession).toHaveBeenCalledWith({ sessionId: 's1' }); + }); }); // =================================================================== diff --git a/packages/ai-native/docs/adr/0001-cache-task-conversation-view-models.md b/packages/ai-native/docs/adr/0001-cache-task-conversation-view-models.md new file mode 100644 index 0000000000..0b34fc700a --- /dev/null +++ b/packages/ai-native/docs/adr/0001-cache-task-conversation-view-models.md @@ -0,0 +1,9 @@ +# Cache Task Conversation View Models + +Agentic Layout will cache a data-only message view model per Task Conversation instead of retaining every conversation's mounted React tree or pre-created React nodes. A warm Task selection can therefore replace the active view model atomically without replaying retained history, while cold conversations are transformed once and committed as one collection; the virtualized item renderer creates `MessageBox` and rich content components only for visible rows. + +## Consequences + +Message identities must remain stable across activation, live progress updates must update the owning conversation's cached view model, and an evicted conversation may pay the one-time reconstruction cost again. + +The cache is a message-weighted LRU: the Active and Pending Task Conversations are protected, no more than five recent conversation view models are retained, and the cache also stops at 5,000 visible message entries. Eviction removes only reconstructable presentation data; canonical Task Conversation history and lightweight per-conversation reading anchors remain available. diff --git a/packages/ai-native/docs/adr/0002-maintain-bounded-acp-standby-capacity.md b/packages/ai-native/docs/adr/0002-maintain-bounded-acp-standby-capacity.md new file mode 100644 index 0000000000..62fb104e90 --- /dev/null +++ b/packages/ai-native/docs/adr/0002-maintain-bounded-acp-standby-capacity.md @@ -0,0 +1,15 @@ +# Maintain bounded ACP standby capacity in the Node service + +Agentic Layout declares the current ACP Standby Target from the browser, while the Node-side ACP Agent service continuously reconciles one compatible Standby ACP Process within the existing shared process-capacity limit. Keeping reconciliation beside the authoritative process pool avoids browser lifecycle and RPC ordering races; foreground Task Launch always takes priority, active Task Conversations are never evicted for warming, and an unsatisfied standby target remains best-effort rather than expanding resources beyond the configured limit. + +The browser debounces target changes and owns user-facing recovery for capacity exhaustion. The Node service owns standby claiming and replenishment, superseded-target reclamation, cancellable warmup, capacity-change retries, failure backoff, deduplication, and shutdown cleanup. A Task Launch that finds every process non-reclaimable fails without queuing, preserving its Task Draft and unsent Prompt for explicit retry. + +Foreground Task Launch remains cancellable while the Agent is being prepared. Cancellation preserves the Task Draft, invalidates late asynchronous results, and cleans up any Session created by the cancelled attempt so that it cannot replace the active conversation or become a Durable Agent Task. + +A Task Launch commits only when ACP has accepted the first Prompt and established its request stream. Session creation before that point is temporary: failure or cancellation releases it and preserves the Draft, while errors after acceptance belong to the now-durable Agent Task. This prevents failed retries from creating orphan Sessions, duplicate Tasks, or duplicate first Prompts. + +While launch is in progress, Agentic Layout freezes the submitted Draft configuration and Prompt and presents a single user-facing task-starting state without foreground actions. It avoids exposing warmup or process-initialization phases; internal cancellation still cleans up an abandoned launch without changing the committed submission snapshot. + +Project Addition alone does not change the standby target or start an ACP process. Task Draft Agent or Workspace changes are debounced and replace one target rather than accumulating warm processes; foreground submission flushes that debounce and immediately uses the latest Draft configuration. + +Historical Task Selection is a foreground Session load and does not change the standby target. Overlapping selections remain latest-wins at the UI boundary, and every superseded load releases its local Session reference and attachment when it settles; this cleanup must not cancel Agent work already running. Processes retained for genuinely running historical Tasks count as real active capacity, while browsing or rapidly switching ready history must not leak pool capacity. diff --git a/packages/ai-native/docs/adr/0002-virtualize-task-conversations-without-changing-message-presentation.md b/packages/ai-native/docs/adr/0002-virtualize-task-conversations-without-changing-message-presentation.md new file mode 100644 index 0000000000..8e7e939c02 --- /dev/null +++ b/packages/ai-native/docs/adr/0002-virtualize-task-conversations-without-changing-message-presentation.md @@ -0,0 +1,13 @@ +# Virtualize Task Conversations Without Changing Message Presentation + +Agentic Chat will preserve the community `MessageBox` presentation contract while replacing the community `MessageList` container with OpenSumi-owned virtualized scrolling. This keeps existing message appearance, themes, and rich content rendering stable while allowing long Task Conversations to mount only the visible message range; upgrading `react-chat-elements` alone is insufficient because its latest stable `MessageList` still renders the complete data source. + +The initial migration is limited to Agentic Layout. Classic ACP Chat retains its current list behavior until Agentic visual parity, scrolling, accessibility, and long-history behavior have been proven independently. + +## Consequences + +The replacement must preserve the existing message CSS contract and explicitly own bottom following, upward-reading stability, dynamic-height remeasurement, per-conversation scroll restoration, accessibility, and screenshot parity. Scroll restoration records either bottom affinity or a stable Message ID plus viewport offset; raw `scrollTop` is not a durable reading position for variable-height content. + +Verification will include deterministic switching between two Task Conversations with 1,000 visible messages each. The fixture will mix alternating user and assistant messages with periodic Markdown, reasoning, plan, and tool-call content so that virtualization is exercised against variable-height rows rather than uniform placeholders. + +Pull-request CI will enforce deterministic structural limits rather than a fixed wall-clock threshold: the previous conversation remains visible while selection is pending, mounted message rows remain bounded, warm selection does not replay retained history, message identity and ordering remain stable, and each conversation restores its reading anchor. Click-to-visible timing will be recorded as diagnostic evidence until enough CI samples exist to define a reliable percentile-based performance budget. diff --git a/packages/ai-native/docs/adr/0003-refresh-acp-slash-command-catalog-on-demand.md b/packages/ai-native/docs/adr/0003-refresh-acp-slash-command-catalog-on-demand.md new file mode 100644 index 0000000000..a7de6cabb3 --- /dev/null +++ b/packages/ai-native/docs/adr/0003-refresh-acp-slash-command-catalog-on-demand.md @@ -0,0 +1,8 @@ +# Refresh the ACP Slash Command Catalog through ACP updates + +ACP v1 defines Agent-to-Client `available_commands_update` notifications, but no Client-to-Agent command-catalog read or version-check request. ACP Agents therefore send a complete replacement catalog after skill installation, removal, enablement, or disablement and when a Task Conversation becomes live after restoration; Browser replaces only that Task Conversation's cached catalog. Opening `/` reads this cached catalog without waiting, while an Agent update may immediately replace an open menu. + +## Considered Options + +- Add a Client-to-Agent catalog refresh request: ACP v1 does not define one; a custom extension would reduce portability. +- Recreate the Task Conversation: this is unnecessarily disruptive to active work. diff --git a/packages/ai-native/docs/adr/0004-create-draft-bound-acp-sessions-for-initial-skill-catalogs.md b/packages/ai-native/docs/adr/0004-create-draft-bound-acp-sessions-for-initial-skill-catalogs.md new file mode 100644 index 0000000000..9b99b5b6e3 --- /dev/null +++ b/packages/ai-native/docs/adr/0004-create-draft-bound-acp-sessions-for-initial-skill-catalogs.md @@ -0,0 +1,9 @@ +# Create Draft-bound ACP Sessions for initial Skill catalogs + +ACP v1 advertises available slash commands only through session-scoped updates after `session/new`; it has no draft-catalog request. Agentic Layout therefore creates an ordinary ACP Session for a resolved Draft only when the Agent advertises Session closing, and uses that Session's catalog for the initial Skill UI. This is a role of an existing ACP Session, not a new local entity or durable Task: the current Draft owns the Session through a latest-intent generation, closes it when discarded or superseded, and deletes its unprompted history only when the Agent advertises standard Session deletion. A superseded Session load likewise releases only its own replay and attachment, and cannot replace the active Session. + +## Considered Options + +- Add a pre-session catalog protocol method: ACP v1 does not define one, and an extension would reduce interoperability. +- Wait for the first Prompt: preserves the prior lifecycle but leaves the initial Skill UI empty. +- Keep a client-side catalog cache: it is not authoritative for the selected Agent and Workspace Target. diff --git a/packages/ai-native/docs/adr/0004-use-agent-owned-session-history.md b/packages/ai-native/docs/adr/0004-use-agent-owned-session-history.md new file mode 100644 index 0000000000..a8f8eec36e --- /dev/null +++ b/packages/ai-native/docs/adr/0004-use-agent-owned-session-history.md @@ -0,0 +1,25 @@ +# Use Agent-owned ACP Sessions as Agentic Layout history + +## Context + +Agentic Layout previously used locally persisted Durable Task records to populate its history list and route restoration. Those records can survive even when an Agent no longer exposes the Session, and they do not contain the authoritative transcript needed to recover a conversation. This could leave users selecting stale rows and seeing a generic session-service failure instead of Agent-returned history. + +ACP v1 defines `session/list` as the discovery surface for Sessions known to an Agent and `session/load` as the optional restoration operation. Agents send restored conversation content through `session/update` while `session/load` is in progress. + +## Decision + +- The originating ACP Agent is authoritative for Session existence, metadata, and conversation content. +- Agentic Layout builds one atomic Session Browser snapshot by serially querying every available Agent for every available Known Workspace Target with `session/list`. +- A failed target query discards that Agent's entire result for the refresh. The UI remains silent and diagnostics contain only bounded Agent, Project, and error-type identifiers. +- Only Agent-returned Sessions whose `cwd` matches an available authorized project are shown. Agent-provided titles and `updatedAt` values are used directly. +- A listed Session creates only a metadata model and a page-local route from `sessionId` to `{ agentId, cwd }`. +- Agentic Layout may retain a separate local archive marker keyed by `{ agentId, cwd, sessionId }`. This marker changes only whether an Agent-returned Session appears in the active or archived browser section; it never substitutes for Agent discovery or mutates the Agent-owned Session. +- The active ACP Thread records each client-submitted User Prompt as a replay-shaped `user_message_chunk`. This fills the page-reload gap when an Agent omits user-message echoes from `session/load`; matching Agent echoes are suppressed so the restored turn appears once. These retained updates live only with the Thread and do not become durable local transcript authority. +- Selection calls the originating Agent's `session/load`, combines its load-time updates with any retained client-submitted User Prompt updates from the active Thread, and replaces the active conversation only after the load succeeds. Live attachment is a separate step and cannot remove a restored transcript when it fails. +- The local Registry remains authoritative for the Workspace Catalog and Agent Session Archive Markers. Existing legacy Task, archive, unread, attention, status, pending-activation, and remembered-active records remain stored for compatibility but are no longer read or written by Agentic Layout runtime behavior. + +## Consequences + +Users see only Sessions that an Agent currently reports and recover the transcript returned by that Agent, plus client-submitted User Prompts still retained by an active ACP Thread. A browser reload therefore preserves the user side of a live conversation even when the Agent replays only its own output. Releasing the backing Thread also releases this non-durable Prompt retention. Locally archived Sessions remain discoverable in a separate collapsed section without being closed or deleted. A list or load failure never falls back to Durable Task or prompt-derived history. Refresh is lifecycle-triggered rather than polled. Removing a Project changes future discovery scope but does not delete Agent Sessions or legacy local records. + +The current implementation assumes raw ACP `sessionId` values are globally unique across configured Agents. Protocol calls retain the raw value, while browser models continue to use the `acp:` prefix. diff --git a/packages/ai-native/docs/design/agentic-task-conversation-switching.md b/packages/ai-native/docs/design/agentic-task-conversation-switching.md new file mode 100644 index 0000000000..d2a48d9277 --- /dev/null +++ b/packages/ai-native/docs/design/agentic-task-conversation-switching.md @@ -0,0 +1,62 @@ +# Agentic Task Conversation Switching + +## Outcome + +Agentic Layout switches Task Conversations without replacing the readable conversation with a page-level loading state. The current Active Task remains visible while the latest requested Task is prepared, then the Task Row, transcript, input context, and reading position change atomically. + +The complete experience is delivered in one release. The Agentic path is not enabled until atomic selection, transcript/live readiness separation, bounded view-model caching, virtualized rendering, scroll restoration, and long-history coverage are all present. + +## Selection contract + +- The requested Task Row represents a Pending Task Selection with a local progress indicator. +- The current Active Task and Agentic Chat View remain visible and interactive while selection is pending. +- The Task List remains usable; a later selection supersedes an earlier one. +- Selecting the Active Task is a no-op, and selecting the same Pending Task does not duplicate work. +- A failed or superseded selection never becomes active and never clears unread state. +- Transcript Ready commits the visible Task Conversation atomically. +- Live Ready is independent. Before it is reached, the transcript and draft are available, the editor remains editable, and Send is disabled with a lightweight connection status. + +## Conversation presentation model + +- Each Task Conversation owns a stable, data-only message view model. +- View models contain message identity and presentation descriptors, not React nodes or mounted components. +- Cold history is converted once and committed as one collection; it is never replayed through one dispatch per retained message. +- Live updates incrementally update the owning conversation's view model, including background conversations. +- The Active and Pending conversations are protected in a message-weighted LRU cache. +- The cache retains at most five recent conversations and at most 5,000 visible message entries. +- Eviction removes only derived presentation data. Canonical history and lightweight reading anchors remain available. + +## Virtualized list + +- The change is limited to Agentic Layout; Classic ACP Chat retains its current list. +- The community `MessageBox` remains the message presentation primitive so existing appearance and theme behavior remain unchanged. +- OpenSumi owns the virtualized list and scrolling behavior because the latest stable `react-chat-elements` release still renders its complete data source. +- Stable Message IDs are used as item keys. +- Only the visible range and bounded overscan are mounted. +- Dynamic-height Markdown, reasoning, plans, tool calls, and expanded content are remeasured without losing the reading anchor. +- A conversation stores either bottom affinity or the top visible Message ID plus viewport offset. Raw `scrollTop` is not used as the durable cross-session position. +- Background output does not move a conversation whose user was reading above the bottom. + +## Verification + +The deterministic ACP fixture accepts a history message-count parameter. The long-history scenario creates two sessions with 1,000 visible messages each, alternating user and assistant content and periodically including Markdown, reasoning, plan, and tool-call rows. + +Pull-request CI enforces: + +- no page-level `Loading chat…` replacement during Task selection; +- the previous transcript remains visible until the target is Transcript Ready; +- only the latest overlapping selection can commit; +- warm selection does not reload or replay retained history; +- message count, ordering, stable identity, and session isolation are preserved; +- mounted message rows remain bounded, with an initial ceiling of 80; +- switching away and back restores bottom affinity or the Message ID reading anchor; +- streaming, expansion, collapse, and dynamic-height changes do not steal the reading position; +- the input is editable but cannot submit before Live Ready; +- Agentic message screenshots remain visually equivalent to the current `MessageBox` presentation; +- Classic ACP Chat behavior remains unchanged. + +Click-to-visible timings are recorded as diagnostic evidence. A hard percentile-based timing budget is deferred until representative CI samples are available. + +## Delivery + +Implementation may be organized internally into reviewable commits, but the user-visible Agentic path changes only once, after the complete contract and verification matrix pass together. diff --git a/packages/ai-native/docs/research/acp-v1-slash-command-refresh.md b/packages/ai-native/docs/research/acp-v1-slash-command-refresh.md new file mode 100644 index 0000000000..70f79b9b76 --- /dev/null +++ b/packages/ai-native/docs/research/acp-v1-slash-command-refresh.md @@ -0,0 +1,52 @@ +# ACP v1 Slash Command Refresh: Protocol Findings + +**Question.** Can an ACP v1 Client fetch or version-check an Agent's slash-command catalog after a session is created, so the UI can refresh it when a user opens the `/` menu? + +**Answer.** Not through a standard ACP v1 request. ACP v1 defines an Agent-to-Client `session/update` notification whose `sessionUpdate` is `available_commands_update`; it supports dynamic, full-list updates at any point in a session. The v1 schema and slash-command documentation define neither a Client-to-Agent command-catalog request nor a catalog revision/version field. + +## What ACP v1 provides + +1. After session creation, an Agent **may** send an `available_commands_update` in a `session/update` notification. Its required payload is `availableCommands`. The official docs explicitly allow another notification at any time in the session to add, remove, or change commands. +2. The notification is a complete available-command list for that session. It contains command `name`, `description`, and optional input hint; the defined payload has no `catalogVersion`, ETag, or `notModified` result. +3. The standard Client-to-Agent session methods in the v1 schema include lifecycle, mode/configuration, and prompt methods, but no command discovery or refresh method. + +**Implication for dynamic installation.** An ACP-v1-conformant Agent can make a newly installed skill visible immediately by emitting another `available_commands_update` after its command catalog changes. The Browser must treat each notification as replacement state for that session, rather than an incremental patch. + +## What this means for the `/`-open design + +The earlier proposal of a standard `refresh/getAvailableCommands(sessionId, knownVersion)` call is **not ACP v1**. With ACP v1 alone, opening `/` can show the cached list without a network round trip, but cannot guarantee a fresh list if the Browser missed an Agent notification (for example while disconnected). + +There are two valid designs: + +| Design | `/`-open behavior | Freshness guarantee | +| --- | --- | --- | +| Standard ACP v1 only | Show cached commands immediately. The Agent publishes a replacement update after install/uninstall/enable/disable and after session creation. | Fresh while the session receives Agent updates; no pull-based recovery for a missed update. | +| ACP v1 extension | Show cache immediately, then call an Agent-defined request such as `_opensumi/refresh_available_commands` once per menu-open lifecycle. The response may carry a revision and either `notModified` or the replacement list. | The requested on-open validation guarantee, for Agents that advertise and implement the extension. | + +The extension is ACP-compatible, but it is not a portable ACP v1 feature: custom methods **must** begin with `_`, and support should be advertised through the `_meta` field of `agentCapabilities` in the `initialize` response. A Client should fall back to the standard push-only behavior when that capability is absent. It should continue to accept ordinary `available_commands_update` notifications as the fast path. + +## Capability negotiation + +`initialize` happens before session creation. The Client sends its supported protocol version and client capabilities; the Agent replies with its selected version and agent capabilities. Missing capabilities must be treated as unsupported. ACP permits custom capabilities inside `agentCapabilities._meta`, which is the appropriate place to declare a proprietary pull-refresh method and its response contract. + +Example (illustrative, not ACP-standard): + +```json +{ + "agentCapabilities": { + "_meta": { + "opensumi": { + "refreshAvailableCommands": true + } + } + } +} +``` + +## Sources + +- [ACP v1 Slash Commands — Advertising commands and Dynamic updates](https://agentclientprotocol.com/protocol/v1/slash-commands) — states that the Agent may send `available_commands_update` after creating a session and may send it again at any time. +- [ACP v1 schema — `AvailableCommandsUpdate`](https://github.com/agentclientprotocol/agent-client-protocol/releases/latest/download/schema.json) — defines required `availableCommands`; no catalog-version field. Retrieved 2026-08-11. +- [ACP v1 schema — declared methods](https://github.com/agentclientprotocol/agent-client-protocol/releases/latest/download/schema.json) — no standard command-list/refresh request. Retrieved 2026-08-11. +- [ACP v1 Initialization](https://agentclientprotocol.com/protocol/v1/initialization) — defines version/capability negotiation and omitted-capability semantics. +- [ACP v1 Extensibility](https://agentclientprotocol.com/protocol/v1/extensibility) — reserves underscore-prefixed custom methods and specifies `_meta` capability advertisement. diff --git a/packages/ai-native/docs/research/react-chat-elements-virtual-list.md b/packages/ai-native/docs/research/react-chat-elements-virtual-list.md new file mode 100644 index 0000000000..8256021f06 --- /dev/null +++ b/packages/ai-native/docs/research/react-chat-elements-virtual-list.md @@ -0,0 +1,98 @@ +# react-chat-elements 虚拟列表支持调查 + +调查时间:2026-07-31 + +## 结论 + +截至调查时,`react-chat-elements` 最新 npm 版本 `12.0.18` 的 `MessageList` **不支持虚拟列表(virtualization/windowing)**。它仍然对整个 `dataSource` 执行 `.map()`,为每条数据创建一个 `MessageBox`。因此,单纯把 OpenSumi 的依赖升级到最新版,不能解决长会话一次挂载全部消息 DOM 的性能问题。 + +上游仓库曾在 `optimized-list-renders` 分支做过一次基于 `react-virtuoso` 的实验,但该提交没有合入 `master`,也没有进入任何 npm 发布版本。它可以作为实现参考,不能当作社区组件已支持该能力。 + +建议:继续保留“使用虚拟列表优化长会话”的方案,不把依赖升级视为替代方案。实现上可评估 OpenSumi 已有 `VirtualList`,或参考上游实验分支直接使用 `react-virtuoso`,同时补齐动态高度、流式更新、底部跟随和会话级滚动锚点测试。 + +## 当前项目实际使用的版本 + +- `packages/ai-native/package.json` 声明的是 `react-chat-elements: ^12.0.10`,不是严格固定在 `12.0.10`:[`package.json`](../../package.json#L59)。 +- 当前 `yarn.lock` 实际解析为 `12.0.14`:[`yarn.lock`](../../../../yarn.lock#L21498-L21500)。 +- npm registry 显示最新版本为 `12.0.18`,发布于 2025-03-18;`12.0.10` 发布于 2023-06-05,`12.0.14` 发布于 2024-02-20:[npm registry 完整元数据](https://registry.npmjs.org/react-chat-elements)。 + +因此,本调查比较的是:项目声明下限 `12.0.10`、当前锁定版本 `12.0.14`、社区最新版本 `12.0.18`。 + +## 最新发布版本为何不是真正的虚拟列表 + +### 1. 最新版仍创建全部消息组件 + +官方 `master` 当前提交为 [`f487e7e`](https://github.com/Detaysoft/react-chat-elements/commit/f487e7e83320bee8b74ac758d17f1ad418343173),其 `package.json` 版本为 [`12.0.18`](https://github.com/Detaysoft/react-chat-elements/blob/f487e7e83320bee8b74ac758d17f1ad418343173/package.json#L1-L4)。 + +`MessageList` 的主体仍是: + +```tsx +{ + props.dataSource.map((x, i: number) => ); +} +``` + +完整源码见官方仓库 [`MessageList.tsx`](https://github.com/Detaysoft/react-chat-elements/blob/f487e7e83320bee8b74ac758d17f1ad418343173/src/MessageList/MessageList.tsx#L137-L175)。这会为全部消息创建 React element,并且使用数组下标作为 key;源码中没有根据 viewport 只渲染可见区及 overscan 的逻辑。 + +最新版本的依赖列表也没有 `react-virtuoso`、`react-window` 或 `react-virtualized`:[官方 `package.json`](https://github.com/Detaysoft/react-chat-elements/blob/f487e7e83320bee8b74ac758d17f1ad418343173/package.json#L87-L94)、[npm `12.0.18` 元数据](https://registry.npmjs.org/react-chat-elements/12.0.18)。 + +### 2. 滚动控制不等于虚拟化 + +组件支持 `lockable`、`toBottomHeight`、`downButton` 和 `onScroll`,但这些逻辑只读取或设置 `scrollTop`、`scrollHeight`,用于保持距底位置、自动滚到底部和展示回到底部按钮:[滚动位置维护](https://github.com/Detaysoft/react-chat-elements/blob/f487e7e83320bee8b74ac758d17f1ad418343173/src/MessageList/MessageList.tsx#L24-L50)、[滚动事件和回到底部](https://github.com/Detaysoft/react-chat-elements/blob/f487e7e83320bee8b74ac758d17f1ad418343173/src/MessageList/MessageList.tsx#L101-L127)。 + +真正的 virtualization/windowing 应只挂载可视区域附近的少量 item,并用占位高度维持整体滚动空间。当前实现不会减少已挂载的 `MessageBox` 数量,所以它只是“可滚动的完整列表”,不是“虚拟列表”。 + +## 从 12.0.10 / 12.0.14 升级到 12.0.18 是否有相关变化 + +对 npm 官方 tarball 中的 `src/MessageList/MessageList.tsx` 做比较: + +- [`12.0.10 tarball`](https://registry.npmjs.org/react-chat-elements/-/react-chat-elements-12.0.10.tgz) 与 [`12.0.18 tarball`](https://registry.npmjs.org/react-chat-elements/-/react-chat-elements-12.0.18.tgz) 的消息渲染结构相同,都是全量 `dataSource.map()`。 +- 两版在该文件的实质差异只是“回到底部”按钮图标从 `react-icons` 的 `FaChevronDown` 换成 Hugeicons;对应官方提交为 [`3ac4b02`](https://github.com/Detaysoft/react-chat-elements/commit/3ac4b02aa2cfc076f77293eb638a4ec842d995f7)。 +- 官方针对 `MessageList.tsx` 的提交历史显示,2022-11-17 之后直到 2025-03-18,唯一变更就是上述图标替换:[文件提交历史](https://github.com/Detaysoft/react-chat-elements/commits/master/src/MessageList/MessageList.tsx)。 + +所以,从当前锁定的 `12.0.14` 升级到 `12.0.18` 不会带来消息窗口化,也不会改变长列表全量 DOM 的基本性能特征。 + +## 上游实验分支 + +官方仓库存在 `optimized-list-renders` 分支,其中单个提交 [`d5179f5`](https://github.com/Detaysoft/react-chat-elements/commit/d5179f598046cd03575b085f8c8f0ab4235d45ef) 引入了 `react-virtuoso`: + +- 导入 `Virtuoso`:[`MessageList.tsx`](https://github.com/Detaysoft/react-chat-elements/blob/d5179f598046cd03575b085f8c8f0ab4235d45ef/src/MessageList/MessageList.tsx#L8-L12)。 +- 使用 `totalCount` 和 `itemContent` 按需渲染:[`MessageList.tsx`](https://github.com/Detaysoft/react-chat-elements/blob/d5179f598046cd03575b085f8c8f0ab4235d45ef/src/MessageList/MessageList.tsx#L108-L130)。 + +这是真正的虚拟化实现,但 [`master...optimized-list-renders` 比较页](https://github.com/Detaysoft/react-chat-elements/compare/master...optimized-list-renders) 显示该分支只领先 1 个实验提交、同时已落后主分支多个提交。GitHub 的公开 PR 搜索未发现该提交对应的合并请求,它也不在 `master` 或 npm `12.0.18` 中。 + +这说明社区维护者探索过该方向,但目前不能通过稳定版 API 使用;若移植,需要由 OpenSumi 自己承担适配和维护。 + +## Release、Issue 和 PR 信号 + +- npm 的 `latest` 是 `12.0.18`:[npm package metadata](https://registry.npmjs.org/react-chat-elements)。 +- GitHub Releases 页面记录的最新 release 条目仍是 [`v12.0.8`](https://github.com/Detaysoft/react-chat-elements/releases/tag/v12.0.8),后续 npm 版本没有对应 GitHub release notes。 +- 官方 [`CHANGELOG.md`](https://github.com/Detaysoft/react-chat-elements/blob/f487e7e83320bee8b74ac758d17f1ad418343173/CHANGELOG.md) 只记录到 `12.0.4`,没有宣称虚拟列表支持。 +- 通过 GitHub Issues/PR 搜索 `virtualization`、`virtualized`、`"virtual list"`、`react-virtualized`、`virtuoso`,未发现已合并或正在推进的正式方案。搜索结果只能证明公开仓库当前可检索内容,不排除维护者在仓库外的计划;代码和已发布 tarball 才是本结论的主要依据。 + +## 对当前体验优化决策的影响 + +社区升级路线不能消除以下成本: + +1. 切换长会话时,全量历史仍会生成并挂载全部 `MessageBox`。 +2. Markdown、推理过程和工具调用等动态高度内容仍会全部参与 React reconciliation、布局和绘制。 +3. 数组下标 key 也不适合作为 OpenSumi 会话消息的稳定身份。 + +因此建议将决策拆开: + +- 可以另行评估是否升级 `12.0.14 -> 12.0.18`,但理由应是常规依赖维护或其他修复,不能把它列为长会话性能方案。 +- 长会话性能仍需 OpenSumi 自己实现虚拟化,并使用稳定 message ID 作为 item key。 +- 测试至少覆盖:大量动态高度消息之间切换、切回后恢复滚动锚点、底部流式追加、用户向上阅读时不抢滚动位置、展开/折叠工具调用后锚点稳定。 + +## 复核方法 + +本调查使用 npm registry 元数据和两个官方 npm tarball 作为发布事实来源,并以固定 SHA 的 GitHub 源码确认主分支实现。核心比较可复现为: + +```bash +npm view react-chat-elements version dist-tags time repository --json +npm pack react-chat-elements@12.0.10 +npm pack react-chat-elements@12.0.18 +diff -u \ + v12.0.10/package/src/MessageList/MessageList.tsx \ + v12.0.18/package/src/MessageList/MessageList.tsx +``` diff --git a/packages/ai-native/package.json b/packages/ai-native/package.json index f194b055b7..5fb429d930 100644 --- a/packages/ai-native/package.json +++ b/packages/ai-native/package.json @@ -19,7 +19,7 @@ "url": "git@github.com:opensumi/core.git" }, "dependencies": { - "@agentclientprotocol/sdk": "^0.16.1", + "@agentclientprotocol/sdk": "^1.3.0", "@ai-sdk/anthropic": "^1.1.9", "@ai-sdk/deepseek": "^0.1.11", "@ai-sdk/openai": "^1.1.9", @@ -58,6 +58,7 @@ "rc-collapse": "^4.0.0", "react-chat-elements": "^12.0.10", "react-highlight": "^0.15.0", + "react-virtuoso": "^4.18.11", "tiktoken": "1.0.12", "web-tree-sitter": "0.22.6", "zod": "^3.25.0 || ^4.0.0", diff --git a/packages/ai-native/src/browser/acp/agentic-task-registry.service.ts b/packages/ai-native/src/browser/acp/agentic-task-registry.service.ts index b5f94a091d..c04c7b0137 100644 --- a/packages/ai-native/src/browser/acp/agentic-task-registry.service.ts +++ b/packages/ai-native/src/browser/acp/agentic-task-registry.service.ts @@ -5,6 +5,7 @@ const TASK_REGISTRY_STORAGE_KEY = 'agentic.task-registry.v2'; const PENDING_TASK_ACTIVATION_STORAGE_KEY = 'agentic.pending-task-activation.v2'; const PENDING_TASK_LAUNCH_STORAGE_KEY = 'agentic.pending-task-launch.v2'; const ACTIVE_TASK_SESSION_STORAGE_KEY = 'agentic.active-task-session.v1'; +const ARCHIVED_AGENT_SESSIONS_STORAGE_KEY = 'agentic.archived-agent-sessions.v1'; const ARCHIVABLE_STATUSES = new Set(['ready', 'stopped', 'error']); @@ -31,13 +32,23 @@ export interface AgenticTaskRecord { attention?: 'permission' | 'input'; } -export type AgenticTaskStatus = 'ready' | 'running' | 'stopped' | 'error'; +export type AgenticTaskStatus = 'ready' | 'running' | 'stopping' | 'stopped' | 'error'; export interface AgenticTaskGroup { project: AgenticProjectRecord; tasks: AgenticTaskRecord[]; } +export interface AgenticArchivedSessionIdentity { + sessionId: string; + agentId: string; + cwd: string; +} + +export interface AgenticArchivedSessionRecord extends AgenticArchivedSessionIdentity { + archivedAt: number; +} + export interface AgenticTaskRegistryState { version: 3; projects: AgenticProjectRecord[]; @@ -70,6 +81,7 @@ export class AgenticTaskRegistryService { private storage: IStorage | undefined; private state: AgenticTaskRegistryState | undefined; + private archivedAgentSessions: AgenticArchivedSessionRecord[] = []; private initialization: Promise | undefined; private readonly onDidChangeEmitter = new Emitter(); readonly onDidChange: Event = this.onDidChangeEmitter.event; @@ -127,6 +139,22 @@ export class AgenticTaskRegistryService { return true; } + /** + * Remove a Project from the Agent session catalog without deleting legacy + * Task records. Legacy records are intentionally retained but are no longer + * a runtime source for Agentic Layout. + */ + async removeManagedSessionProject(projectId: string): Promise { + await this.ensureInitialized(); + const project = this.findProject(projectId); + if (!project?.managed) { + return false; + } + this.currentState.projects = this.currentState.projects.filter((candidate) => candidate.id !== projectId); + await this.persist(); + return true; + } + async registerFirstPrompt(options: RegisterFirstPromptOptions): Promise { await this.ensureInitialized(); const project = await this.registerProject(options.project); @@ -194,6 +222,38 @@ export class AgenticTaskRegistryService { return this.listGroups(true, query); } + async listArchivedAgentSessions(): Promise { + await this.ensureInitialized(); + return [...this.archivedAgentSessions] + .sort((a, b) => b.archivedAt - a.archivedAt) + .map((session) => ({ ...session })); + } + + async archiveAgentSession(session: AgenticArchivedSessionIdentity): Promise { + await this.ensureInitialized(); + const normalized = this.normalizeArchivedAgentSession({ ...session, archivedAt: Date.now() }); + if ( + !normalized || + this.archivedAgentSessions.some((candidate) => this.matchesArchivedSession(candidate, session)) + ) { + return false; + } + this.archivedAgentSessions.push(normalized); + await this.persistArchivedAgentSessions(); + return true; + } + + async unarchiveAgentSession(session: AgenticArchivedSessionIdentity): Promise { + await this.ensureInitialized(); + const next = this.archivedAgentSessions.filter((candidate) => !this.matchesArchivedSession(candidate, session)); + if (next.length === this.archivedAgentSessions.length) { + return false; + } + this.archivedAgentSessions = next; + await this.persistArchivedAgentSessions(); + return true; + } + async markUnread(sessionId: string, unread = true): Promise { return this.updateTask(sessionId, (task) => { task.unread = unread; @@ -343,6 +403,10 @@ export class AgenticTaskRegistryService { }); } + clearPendingLaunch(): void { + this.removeSessionValue(PENDING_TASK_LAUNCH_STORAGE_KEY); + } + consumePendingLaunch(): AgenticPendingTaskLaunch | undefined { const value = this.consumeSessionValue(PENDING_TASK_LAUNCH_STORAGE_KEY); if (!this.isRecord(value) || typeof value.projectId !== 'string' || typeof value.agentId !== 'string') { @@ -408,6 +472,9 @@ export class AgenticTaskRegistryService { private async initialize(): Promise { this.storage = await this.storageProvider(STORAGE_NAMESPACE.GLOBAL_RECENT_DATA); this.state = this.normalizeState(this.storage.get(TASK_REGISTRY_STORAGE_KEY)); + this.archivedAgentSessions = this.normalizeArchivedAgentSessions( + this.storage.get(ARCHIVED_AGENT_SESSIONS_STORAGE_KEY), + ); } private get currentState(): AgenticTaskRegistryState { @@ -422,6 +489,63 @@ export class AgenticTaskRegistryService { this.onDidChangeEmitter.fire(); } + private async persistArchivedAgentSessions(): Promise { + await this.storage?.set(ARCHIVED_AGENT_SESSIONS_STORAGE_KEY, JSON.stringify(this.archivedAgentSessions)); + this.onDidChangeEmitter.fire(); + } + + private normalizeArchivedAgentSessions(value: unknown): AgenticArchivedSessionRecord[] { + const source = typeof value === 'string' ? this.parseJSON(value) : value; + if (!Array.isArray(source)) { + return []; + } + const keys = new Set(); + const sessions: AgenticArchivedSessionRecord[] = []; + for (const value of source) { + const session = this.normalizeArchivedAgentSession(value); + if (!session) { + continue; + } + const key = this.archivedSessionKey(session); + if (!keys.has(key)) { + keys.add(key); + sessions.push(session); + } + } + return sessions; + } + + private normalizeArchivedAgentSession(value: unknown): AgenticArchivedSessionRecord | undefined { + if ( + !this.isRecord(value) || + typeof value.sessionId !== 'string' || + typeof value.agentId !== 'string' || + typeof value.cwd !== 'string' || + typeof value.archivedAt !== 'number' || + !Number.isFinite(value.archivedAt) + ) { + return undefined; + } + const sessionId = value.sessionId.trim(); + const agentId = value.agentId.trim(); + const cwd = value.cwd.trim(); + if (!sessionId || !agentId || !cwd) { + return undefined; + } + return { sessionId, agentId, cwd, archivedAt: value.archivedAt }; + } + + private matchesArchivedSession( + candidate: AgenticArchivedSessionIdentity, + session: AgenticArchivedSessionIdentity, + ): boolean { + return this.archivedSessionKey(candidate) === this.archivedSessionKey(session); + } + + private archivedSessionKey(session: AgenticArchivedSessionIdentity): string { + return JSON.stringify([session.agentId, session.cwd, session.sessionId]); + } + private normalizeState(value: unknown): AgenticTaskRegistryState { const source = typeof value === 'string' ? this.parseJSON(value) : value; if ( @@ -456,10 +580,9 @@ export class AgenticTaskRegistryService { } }); - const retainedProjectIds = new Set(tasks.map((task) => task.projectId)); return { version: 3, - projects: projects.filter((project) => project.managed || retainedProjectIds.has(project.id)), + projects, tasks, }; } @@ -590,7 +713,7 @@ export class AgenticTaskRegistryService { } private isAgenticTaskStatus(value: unknown): value is AgenticTaskStatus { - return value === 'ready' || value === 'running' || value === 'stopped' || value === 'error'; + return value === 'ready' || value === 'running' || value === 'stopping' || value === 'stopped' || value === 'error'; } private isRecord(value: unknown): value is Record { diff --git a/packages/ai-native/src/browser/acp/agentic-workspace-switch.service.ts b/packages/ai-native/src/browser/acp/agentic-workspace-switch.service.ts index c2d2593e7d..888a9e5840 100644 --- a/packages/ai-native/src/browser/acp/agentic-workspace-switch.service.ts +++ b/packages/ai-native/src/browser/acp/agentic-workspace-switch.service.ts @@ -254,13 +254,6 @@ export class AgenticWorkspaceSwitchService { } } - if (registeredProject) { - await this.registry.rememberProjectAgent(targetProject.id, agentId); - if (!shouldApply()) { - return false; - } - this.registry.preparePendingLaunch({ projectId: targetProject.id, agentId }); - } this.aiChatService.enterAgenticTaskDraft({ agentId, cwd: targetProject.workspacePath }); return true; } @@ -271,15 +264,17 @@ export class AgenticWorkspaceSwitchService { const persistedCurrentProject = currentWorkspaceProject ? await this.registry.getProject(currentWorkspaceProject.id) : undefined; - const activeTask = sessionModel?.sessionId ? await this.registry.getTask(sessionModel.sessionId) : undefined; - const activeTaskProject = activeTask?.projectId ? await this.registry.getProject(activeTask.projectId) : undefined; - const project = activeTaskProject || persistedCurrentProject || currentWorkspaceProject; + const activeTarget = this.aiChatService.getActiveAgenticTaskTarget(sessionModel?.sessionId); + const projects = activeTarget ? await this.registry.listProjects() : []; + const activeSessionProject = activeTarget + ? projects.find((candidate) => candidate.workspacePath === activeTarget.cwd) + : undefined; + const project = activeSessionProject || persistedCurrentProject || currentWorkspaceProject; const agentIds = Object.keys(getConfiguredAgentConfigs(this.preferenceService)); const latestRequestAgentId = sessionModel?.requests?.at(-1)?.message.agentId; const activeDraftAgentId = this.aiChatService.getActiveAgenticTaskAgentId(sessionModel?.sessionId); const preferredAgentId = [ - project?.lastAgentId, - activeTask?.agentId, + activeTarget?.agentId, latestRequestAgentId, activeDraftAgentId, getDefaultAgentType(this.preferenceService), @@ -289,8 +284,8 @@ export class AgenticWorkspaceSwitchService { project, preferredAgentId: preferredAgentId || agentIds[0], executionContext: - activeTaskProject && activeTaskProject.workspacePath !== currentWorkspaceProject?.workspacePath - ? activeTaskProject + activeSessionProject && activeSessionProject.workspacePath !== currentWorkspaceProject?.workspacePath + ? activeSessionProject : undefined, }; } @@ -323,36 +318,8 @@ export class AgenticWorkspaceSwitchService { } async restorePendingWork(): Promise { - const actionGeneration = ++this.taskActionGeneration; - const shouldApply = () => actionGeneration === this.taskActionGeneration; - const activation = this.registry.consumePendingActivation(); - if (activation) { - const task = await this.registry.getTask(activation.sessionId); - if (task && shouldApply()) { - await this.activateTask(task); - } - return; - } - - const launch = this.registry.consumePendingLaunch(); - if (launch) { - const project = await this.registry.getProject(launch.projectId); - if (!shouldApply() || !project || project.availability === 'unavailable') { - return; - } - - this.aiChatService.enterAgenticTaskDraft({ agentId: launch.agentId, cwd: project.workspacePath }); - return; - } - - const activeTask = this.registry.getRememberedActiveTaskSession(); - if (!activeTask) { - return; - } - const task = await this.registry.getTask(activeTask.sessionId); - if (task && shouldApply()) { - await this.activateTask(task); - } + // Agent Session selection and drafts are page-local. Legacy Task records + // remain retained for compatibility but are not read or mutated here. } async refreshProjectAvailability(project: AgenticProjectRecord): Promise { diff --git a/packages/ai-native/src/browser/acp/components/AcpChatHistory.tsx b/packages/ai-native/src/browser/acp/components/AcpChatHistory.tsx index 98b318a19e..591707480b 100644 --- a/packages/ai-native/src/browser/acp/components/AcpChatHistory.tsx +++ b/packages/ai-native/src/browser/acp/components/AcpChatHistory.tsx @@ -13,6 +13,7 @@ import styles from '../../components/acp/chat-history.module.less'; const threadStatusIcon: Record = { idle: 'disconnect', working: 'loading', + stopping: 'debug-pause', awaiting_prompt: 'disconnect', auth_required: 'disconnect', errored: 'error', diff --git a/packages/ai-native/src/browser/acp/components/AcpChatInput.tsx b/packages/ai-native/src/browser/acp/components/AcpChatInput.tsx index 61073b7422..8d77f256a3 100644 --- a/packages/ai-native/src/browser/acp/components/AcpChatInput.tsx +++ b/packages/ai-native/src/browser/acp/components/AcpChatInput.tsx @@ -238,6 +238,7 @@ export const AcpChatInput = React.forwardRef((props: IAcpChatInputProps, ref) => propsRef.current.setCommand(draft.command || ''); }, focus: () => textareaRef.current?.focus(), + isFocused: () => document.activeElement === textareaRef.current, setExpanded: (expanded: boolean) => { setIsExpand(expanded); propsRef.current.onExpand?.(expanded); diff --git a/packages/ai-native/src/browser/acp/components/AcpChatViewHeader.tsx b/packages/ai-native/src/browser/acp/components/AcpChatViewHeader.tsx index 003ef795f1..2d35ac27d3 100644 --- a/packages/ai-native/src/browser/acp/components/AcpChatViewHeader.tsx +++ b/packages/ai-native/src/browser/acp/components/AcpChatViewHeader.tsx @@ -26,7 +26,7 @@ import { AgenticWorkspaceSwitchService } from '../agentic-workspace-switch.servi import { AcpPermissionBridgeService } from '../permission-bridge.service'; import AcpChatHistory, { IChatHistoryItem } from './AcpChatHistory'; -import { AgenticTaskList } from './AgenticTaskList'; +import { AgenticSessionList } from './AgenticSessionList'; const MAX_TITLE_LENGTH = 100; @@ -292,7 +292,7 @@ export function AcpChatViewHeader({ handleCloseChatView }: { handleClear: () =>
{isAgenticLayout ? (
- +
) : ( <> diff --git a/packages/ai-native/src/browser/acp/components/AcpFooterButtons.tsx b/packages/ai-native/src/browser/acp/components/AcpFooterButtons.tsx index ed86e5192d..1dc52cbfe7 100644 --- a/packages/ai-native/src/browser/acp/components/AcpFooterButtons.tsx +++ b/packages/ai-native/src/browser/acp/components/AcpFooterButtons.tsx @@ -1,21 +1,46 @@ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useInjectable } from '@opensumi/ide-core-browser'; -import { ChatFeatureRegistryToken } from '@opensumi/ide-core-common'; +import { ChatFeatureRegistryToken, localize } from '@opensumi/ide-core-common'; +import { IChatInternalService } from '../../../common'; import { ChatFeatureRegistry } from '../../chat/chat.feature.registry'; +import { AcpChatInternalService, type AcpSkillCatalogState } from '../../chat/chat.internal.service.acp'; import styles from '../../components/components.module.less'; export function AcpSlashCommandFooter() { const chatFeatureRegistry = useInjectable(ChatFeatureRegistryToken); + const aiChatService = useInjectable(IChatInternalService); const slashCommands = useMemo(() => chatFeatureRegistry.getAllSlashCommand(), [chatFeatureRegistry]); + const [skillCatalogState, setSkillCatalogState] = useState(aiChatService.getSkillCatalogState()); + + useEffect(() => { + const disposable = aiChatService.onSkillCatalogStateChange(setSkillCatalogState); + return () => disposable.dispose(); + }, [aiChatService]); const handleTriggerClick = () => { window.dispatchEvent(new CustomEvent('opensumi-chat-input-open-slash-panel')); }; - if (slashCommands.length === 0) { + if (skillCatalogState === 'pending') { + return ( +
+ {localize('aiNative.chat.acp.skills.loading', 'Skills loading…')} +
+ ); + } + + if (skillCatalogState === 'empty') { + return ( +
+ {localize('aiNative.chat.acp.skills.empty', 'No Skills')} +
+ ); + } + + if (slashCommands.length === 0 && aiChatService.getAvailableCommands().length === 0) { return null; } diff --git a/packages/ai-native/src/browser/acp/components/AcpTurnEditor.tsx b/packages/ai-native/src/browser/acp/components/AcpTurnEditor.tsx index 2d78558a47..3007931a4e 100644 --- a/packages/ai-native/src/browser/acp/components/AcpTurnEditor.tsx +++ b/packages/ai-native/src/browser/acp/components/AcpTurnEditor.tsx @@ -71,6 +71,7 @@ export interface IChatMentionInputProps { placeholder?: string; enableOptions?: boolean; disabled?: boolean; + submitDisabled?: boolean; loading?: boolean; sendBtnClassName?: string; defaultHeight?: number; @@ -166,6 +167,7 @@ export const AcpTurnEditor = React.forwardRef mentionInputRef.current?.focus(), + isFocused: () => mentionInputRef.current?.isFocused() ?? false, setExpanded: (expanded) => { setIsExpanded((current) => { if (current !== expanded) { @@ -965,6 +969,8 @@ export const AcpTurnEditor = React.forwardRef(`[id^="${chatStyles.ai_chat_view}"]`) || undefined; +} + +function getStoredListWidth(): number | undefined { + try { + const width = Number.parseFloat(window.sessionStorage.getItem(LIST_WIDTH_STORAGE_KEY) || ''); + return Number.isFinite(width) ? width : undefined; + } catch { + return undefined; + } +} + +function storeListWidth(width: number): void { + try { + window.sessionStorage.setItem(LIST_WIDTH_STORAGE_KEY, String(width)); + } catch { + // Keep the in-memory width when tab storage is unavailable. + } +} + +function formatUpdatedAt(value: string | undefined): string { + if (!value) { + return ''; + } + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? new Date(timestamp).toLocaleString() : ''; +} + +function getArchivedSessionIdentity(session: AcpAgentSessionDescriptor): AgenticArchivedSessionIdentity { + return { sessionId: session.sessionId, agentId: session.agentId, cwd: session.cwd }; +} + +function getArchivedSessionKey(session: AgenticArchivedSessionIdentity): string { + return JSON.stringify([session.agentId, session.cwd, session.sessionId]); +} + +function SessionRow({ + active, + archived, + failed, + pending, + session, + onActivate, + onArchive, + onUnarchive, +}: { + active: boolean; + archived: boolean; + failed: boolean; + pending: boolean; + session: AcpAgentSessionDescriptor; + onActivate: (session: AcpAgentSessionDescriptor) => void; + onArchive?: (session: AcpAgentSessionDescriptor) => void; + onUnarchive?: (session: AcpAgentSessionDescriptor) => void; +}) { + const title = session.title?.trim() || localize('aiNative.agentic.session.untitled', 'Untitled session'); + const updatedAt = formatUpdatedAt(session.updatedAt); + const tooltip = [title, session.agentId, updatedAt].filter(Boolean).join('\n'); + + return ( +
+ + {archived ? ( + + ) : ( + + )} +
+ ); +} + +function SessionProjectGroup({ + activeSessionId, + collapsed, + failedSessionIds, + group, + hasAgentSessions, + pendingSessionId, + preferredAgentId, + projectLabel, + onActivate, + onArchive, + onRemove, + onRename, + onToggle, +}: { + activeSessionId?: string; + collapsed: boolean; + failedSessionIds: ReadonlySet; + group: AgentSessionGroup; + hasAgentSessions: boolean; + pendingSessionId?: string; + preferredAgentId?: string; + projectLabel: string; + onActivate: (session: AcpAgentSessionDescriptor) => void; + onArchive: (session: AcpAgentSessionDescriptor) => void; + onRemove: (project: AgenticProjectRecord) => void; + onRename: (project: AgenticProjectRecord) => void; + onToggle: () => void; +}) { + const [managementOpen, setManagementOpen] = React.useState(false); + const hasSessions = group.sessions.length > 0; + + return ( +
+
+ + + + {managementOpen && ( +
+ + +
+ )} +
+ {!collapsed && + group.sessions.map((session) => ( + + ))} +
+ ); +} + +function ArchivedSessionGroups({ + groups, + onActivate, + onUnarchive, + projectLabels, +}: { + groups: AgentSessionGroup[]; + onActivate: (session: AcpAgentSessionDescriptor) => void; + onUnarchive: (session: AcpAgentSessionDescriptor) => void; + projectLabels: ReadonlyMap; +}) { + const [expanded, setExpanded] = React.useState(false); + + return ( +
+ + {expanded && + groups.map((group) => ( +
+
+ + {projectLabels.get(group.project.id) || getAgenticProjectDisplayLabel(group.project)} + + {group.sessions.length} +
+ {group.sessions.map((session) => ( + + ))} +
+ ))} +
+ ); +} + +export function AgenticSessionList() { + const registry = useInjectable(AgenticTaskRegistryService); + const workspaceSwitch = useInjectable(AgenticWorkspaceSwitchService); + const aiChatService = useInjectable(IChatInternalService); + const preferenceService = useInjectable(PreferenceService); + const windowDialogService = useInjectable(IWindowDialogService); + const messageService = useInjectable(IMessageService); + const listRef = React.useRef(null); + const activationVersionRef = React.useRef(0); + const projectRefreshVersionRef = React.useRef(0); + const [projects, setProjects] = React.useState([]); + const [sessions, setSessions] = React.useState(() => aiChatService.getAgentSessions()); + const [archivedSessions, setArchivedSessions] = React.useState([]); + const [query, setQuery] = React.useState(''); + const [activeSessionId, setActiveSessionId] = React.useState( + aiChatService.sessionModel?.sessionId, + ); + const [pendingSessionId, setPendingSessionId] = React.useState(); + const [failedSessionIds, setFailedSessionIds] = React.useState>(() => new Set()); + const [collapsedProjectIds, setCollapsedProjectIds] = React.useState>(() => new Set()); + const [renameProject, setRenameProject] = React.useState(); + const [maximumListWidth, setMaximumListWidth] = React.useState(MAX_LIST_WIDTH); + + const refreshMaximumWidth = React.useCallback(() => { + const maximum = getListMaximumWidth(getAgenticChatView(listRef.current)?.clientWidth || 0); + setMaximumListWidth(maximum); + return maximum; + }, []); + const getConfiguredWidth = React.useCallback((maximum: number) => { + const chatView = getAgenticChatView(listRef.current); + const configured = Number.parseFloat(chatView?.style.getPropertyValue('--agentic-task-list-width') || ''); + return clampListWidth( + Number.isFinite(configured) ? configured : getStoredListWidth() || DEFAULT_LIST_WIDTH, + maximum, + ); + }, []); + const resize = React.useCallback((width: number) => { + getAgenticChatView(listRef.current)?.style.setProperty('--agentic-task-list-width', `${width}px`); + storeListWidth(width); + }, []); + + React.useEffect(() => { + refreshMaximumWidth(); + const chatView = getAgenticChatView(listRef.current); + if (!chatView || typeof ResizeObserver === 'undefined') { + return; + } + const observer = new ResizeObserver(refreshMaximumWidth); + observer.observe(chatView); + return () => observer.disconnect(); + }, [refreshMaximumWidth]); + + const refreshProjects = React.useCallback(async () => { + const refreshVersion = ++projectRefreshVersionRef.current; + await workspaceSwitch.seedProjectCatalog(); + const current = await registry.listProjects(); + await Promise.all(current.map((project) => workspaceSwitch.refreshProjectAvailability(project))); + const refreshed = await registry.listProjects(); + if (refreshVersion === projectRefreshVersionRef.current) { + setProjects(refreshed); + } + return refreshed; + }, [registry, workspaceSwitch]); + + const refreshArchivedSessions = React.useCallback(async () => { + const archived = await registry.listArchivedAgentSessions(); + setArchivedSessions(archived); + return archived; + }, [registry]); + + const refresh = React.useCallback(async () => { + try { + await Promise.all([refreshProjects(), refreshArchivedSessions()]); + setSessions(await aiChatService.refreshAgentSessions()); + } catch { + // Discovery failures are intentionally silent in Agentic Layout. + } + }, [aiChatService, refreshArchivedSessions, refreshProjects]); + + React.useEffect(() => { + void refresh(); + const catalogDisposable = aiChatService.onDidChangeAgentSessions((catalog) => { + setSessions(catalog.map((session) => ({ ...session }))); + }); + const projectDisposable = registry.onDidChange(() => void refresh()); + const sessionDisposable = aiChatService.onChangeSession((sessionId) => setActiveSessionId(sessionId || undefined)); + return () => { + projectRefreshVersionRef.current += 1; + catalogDisposable.dispose(); + projectDisposable.dispose(); + sessionDisposable?.dispose(); + }; + }, [aiChatService, refresh, registry]); + + const projectLabels = React.useMemo(() => getAgenticProjectDisplayLabels(projects), [projects]); + const normalizedQuery = query.trim().toLocaleLowerCase(); + const archivedSessionKeys = React.useMemo( + () => new Set(archivedSessions.map(getArchivedSessionKey)), + [archivedSessions], + ); + const createGroups = React.useCallback( + (archived: boolean): AgentSessionGroup[] => { + const sessionsByPath = new Map(); + for (const session of sessions) { + if (archivedSessionKeys.has(getArchivedSessionKey(session)) !== archived) { + continue; + } + if (normalizedQuery && !(session.title || '').toLocaleLowerCase().includes(normalizedQuery)) { + continue; + } + const bucket = sessionsByPath.get(session.cwd) || []; + bucket.push(session); + sessionsByPath.set(session.cwd, bucket); + } + return projects + .filter((project) => project.availability === 'available') + .map((project) => ({ + project, + sessions: (sessionsByPath.get(project.workspacePath) || []).sort( + (a, b) => (Date.parse(b.updatedAt || '') || 0) - (Date.parse(a.updatedAt || '') || 0), + ), + })) + .filter((group) => (archived ? group.sessions.length > 0 : !normalizedQuery || group.sessions.length > 0)); + }, + [archivedSessionKeys, normalizedQuery, projects, sessions], + ); + const groups = React.useMemo(() => createGroups(false), [createGroups]); + const archivedGroups = React.useMemo(() => createGroups(true), [createGroups]); + + const activeTarget = aiChatService.getActiveAgenticTaskTarget(activeSessionId); + const preferredAgentId = activeTarget?.agentId || getDefaultAgentType(preferenceService); + + const activate = React.useCallback( + async (session: AcpAgentSessionDescriptor) => { + if (session.sessionId === activeSessionId && !failedSessionIds.has(session.sessionId)) { + return; + } + const version = ++activationVersionRef.current; + const hasUnsentDraft = + aiChatService.isActiveAgenticTaskDraft() && hasAcpChatSendPayload(aiChatService.getInputDraft() || {}); + if (hasUnsentDraft) { + const discardAndSwitch = localize('aiNative.agentic.session.discardDraftAndSwitch', 'Discard Draft and Switch'); + const selected = await messageService.warning( + localize( + 'aiNative.agentic.session.switchDiscardDraft', + 'Discard the unsent draft and switch sessions? Your draft-bound Agent session will be closed.', + ), + [discardAndSwitch], + true, + ); + if (version !== activationVersionRef.current || selected !== discardAndSwitch) { + return; + } + } + setPendingSessionId(session.sessionId); + const result = await aiChatService.activateAgentSession(session, () => version === activationVersionRef.current); + if (version !== activationVersionRef.current) { + return; + } + setPendingSessionId(undefined); + if (result.status === 'activated') { + if (hasUnsentDraft) { + await aiChatService.discardAgenticTaskDraft(); + } + setActiveSessionId(session.sessionId); + setFailedSessionIds((current) => { + const next = new Set(current); + next.delete(session.sessionId); + return next; + }); + } else if (result.status !== 'superseded') { + setFailedSessionIds((current) => new Set(current).add(session.sessionId)); + } + }, + [activeSessionId, aiChatService, failedSessionIds, messageService], + ); + + const archive = React.useCallback( + async (session: AcpAgentSessionDescriptor) => { + if (await registry.archiveAgentSession(getArchivedSessionIdentity(session))) { + await refreshArchivedSessions(); + } + }, + [refreshArchivedSessions, registry], + ); + + const unarchive = React.useCallback( + async (session: AcpAgentSessionDescriptor) => { + if (await registry.unarchiveAgentSession(getArchivedSessionIdentity(session))) { + await refreshArchivedSessions(); + } + }, + [refreshArchivedSessions, registry], + ); + + const addProject = React.useCallback(async () => { + const directories = await windowDialogService.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + title: localize('aiNative.agentic.project.add', 'Add Project'), + }); + if (directories?.[0] && (await workspaceSwitch.addProject(directories[0]))) { + await refresh(); + } + }, [refresh, windowDialogService, workspaceSwitch]); + + const removeProject = React.useCallback( + async (project: AgenticProjectRecord) => { + if (await registry.removeManagedSessionProject(project.id)) { + await refresh(); + return; + } + messageService.info( + localize('aiNative.agentic.project.removeOnlyManaged', 'Only added Projects can be removed.'), + ); + }, + [messageService, refresh, registry], + ); + + const rename = React.useCallback( + async (project: AgenticProjectRecord, label: string) => { + await registry.renameProject(project.id, label); + await refreshProjects(); + }, + [refreshProjects, registry], + ); + + return ( + + ); +} diff --git a/packages/ai-native/src/browser/acp/components/AgenticTaskLaunchMenu.tsx b/packages/ai-native/src/browser/acp/components/AgenticTaskLaunchMenu.tsx index b17acbf82d..5591ba7647 100644 --- a/packages/ai-native/src/browser/acp/components/AgenticTaskLaunchMenu.tsx +++ b/packages/ai-native/src/browser/acp/components/AgenticTaskLaunchMenu.tsx @@ -4,15 +4,17 @@ import { COMMON_COMMANDS, CommandService, PreferenceService, + fastdom, getIcon, localize, useInjectable, } from '@opensumi/ide-core-browser'; -import { PreferenceScope } from '@opensumi/ide-core-common'; +import { ChatInputRegistryToken, PreferenceScope } from '@opensumi/ide-core-common'; import { AINativeSettingSectionsId } from '@opensumi/ide-core-common/lib/settings/ai-native'; import { strings } from '@opensumi/ide-utils'; import { AI_CHAT_NEW_TASK } from '../../chat/acp-new-draft.commands'; +import { ChatInputRegistry } from '../../chat/chat.input.registry'; import chatStyles from '../../chat/chat.module.less'; import { getAvailableAgentConfigs, @@ -55,6 +57,7 @@ export function AgenticTaskLaunchMenu({ const workspaceSwitch = useInjectable(AgenticWorkspaceSwitchService); const preferenceService = useInjectable(PreferenceService); const commandService = useInjectable(CommandService); + const chatInputRegistry = useInjectable(ChatInputRegistryToken); const [agentMenuOpen, setAgentMenuOpen] = React.useState(false); const [launching, setLaunching] = React.useState(() => workspaceSwitch.isTaskLaunchPending); const isChatHeader = variant === 'chat-header'; @@ -88,10 +91,21 @@ export function AgenticTaskLaunchMenu({ const projectAvailable = !!project && project.availability === 'available'; const available = projectAvailable && agentOptions.length > 0; const preferredAvailableAgentId = - [project?.lastAgentId, preferredAgentId, getDefaultAgentType(preferenceService)].find( + [preferredAgentId, getDefaultAgentType(preferenceService)].find( (agentId): agentId is string => !!agentId && agentOptions.some((agent) => agent.id === agentId), ) ?? agentOptions[0]?.id; + const focusTaskDraftInput = React.useCallback(() => { + const focus = (attempt = 0) => { + chatInputRegistry.focusActiveInput(); + if (chatInputRegistry.isActiveInputFocused() !== false || attempt >= 60) { + return; + } + fastdom.measureAtNextFrame(() => focus(attempt + 1)); + }; + focus(); + }, [chatInputRegistry]); + const launch = React.useCallback( async (agentId = preferredAvailableAgentId) => { if (launching || !project || !agentId || project.availability === 'unavailable') { @@ -105,9 +119,10 @@ export function AgenticTaskLaunchMenu({ const launched = await workspaceSwitch.launchTask(project, agentId); if (launched) { setAgentMenuOpen(false); + focusTaskDraftInput(); } }, - [commandService, isChatHeader, launching, preferredAvailableAgentId, project, workspaceSwitch], + [commandService, focusTaskDraftInput, isChatHeader, launching, preferredAvailableAgentId, project, workspaceSwitch], ); const openAgentConfigurations = React.useCallback(async () => { @@ -124,16 +139,23 @@ export function AgenticTaskLaunchMenu({ }, [commandService, preferenceService]); if (!isChatHeader) { - const targetLabel = projectLabel || project?.label || project?.workspacePath || 'Project'; + const targetLabel = + projectLabel || + project?.label || + project?.workspacePath || + localize('aiNative.agentic.project.fallbackName', 'Project'); + const launchTitle = strings.format( + localize('aiNative.agentic.project.newTask', 'New session for {0}'), + targetLabel, + ); return (
{archiveEligible && ( - {group.project.managed && group.tasks.length === 0 && ( - + + {removalBlockedReason && ( +
+ {removalBlockedReason} +
)}
)} @@ -579,6 +638,7 @@ function ProjectGroup({ group.tasks.map((task) => (