From 8a518e353b8f7206279367040d729b2039ecd17a Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 23:33:07 +0800 Subject: [PATCH] feat(web): rewrite subagent conversations for FIFO activation --- ...07-27-web-subagent-conversations.i18n.yaml | 4 +- .../2026-07-27-web-subagent-conversations.md | 111 ++++--- ...026-07-27-web-subagent-conversations.zh.md | 111 ++++--- apps/cli/composition.md | 3 + .../subagent-conversation/tree.expected.md | 12 +- .../subagent-conversation/ui.expected.md | 12 + apps/web/tests/subagent-conversation.e2e.ts | 97 +++++- .../client/connection/src/client/fixture.ts | 2 +- .../runtime/src/client/sessions/manager.ts | 4 +- .../runtime/src/client/sessions/service.ts | 5 +- .../runtime/src/client/sessions/session.ts | 12 +- packages/client/runtime/tests/manager.spec.ts | 29 +- packages/client/runtime/tests/session.spec.ts | 28 +- .../client/ui-command/tests/service.spec.ts | 2 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/contract/slots.ts | 4 +- .../src/client/skeleton/ConversationRoot.tsx | 3 +- .../tests/chat-stats-bash-sample.spec.tsx | 2 + .../tests/coverage-tails.spec.tsx | 2 + packages/client/ui-model/README.i18n.yaml | 4 +- packages/client/ui-model/README.md | 6 +- packages/client/ui-model/README.zh.md | 10 +- .../ui-model/src/client/ModelSelect.tsx | 9 +- .../client/ui-model/src/client/directory.ts | 11 + packages/client/ui-model/src/client/index.ts | 28 +- .../client/ui-model/src/client/service.ts | 6 +- packages/client/ui-model/src/client/slots.ts | 2 + .../ui-model/tests/browser-plugin.spec.ts | 35 ++- .../ui-model/tests/model-select.spec.tsx | 17 ++ .../tests/plan-review-panel.spec.tsx | 1 + .../tests/question-composer.spec.tsx | 2 +- .../ui-skill/tests/browser-plugin.spec.ts | 2 +- packages/client/ui-slash/src/types.ts | 9 +- packages/client/ui-subagent/README.i18n.yaml | 4 +- packages/client/ui-subagent/README.md | 10 +- packages/client/ui-subagent/README.zh.md | 10 +- .../src/client/SubagentCatalogAction.tsx | 42 +-- .../src/client/SubagentReadOnlyComposer.tsx | 24 +- .../client/ui-subagent/src/client/index.ts | 21 +- .../tests/conversation-ui.spec.tsx | 45 ++- packages/host/apiproxy/src/api-proxy.ts | 275 ++++++++++-------- packages/host/apiproxy/src/api/rpc.schema.ts | 2 +- packages/host/apiproxy/src/api/rpc.ts | 2 +- .../host/apiproxy/src/api/subagents.schema.ts | 21 +- packages/host/apiproxy/src/api/subagents.ts | 53 +++- .../tests/api-proxy-subagents.spec.ts | 63 ++-- .../host/apiproxy/tests/fetch-carrier.spec.ts | 58 +++- .../host/apiproxy/tests/rpc-schemas.spec.ts | 23 +- packages/subagent/subagent/src/child-agent.ts | 2 + .../subagent/tests/continuation.spec.ts | 1 + 52 files changed, 829 insertions(+), 420 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml index 24c362151f..85df974af0 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md -2026-07-27-web-subagent-conversations.md: c84e9df6f9d25f99d798651bac4af15249af2bb7 -2026-07-27-web-subagent-conversations.zh.md: 95ba12138b5db595bf1fd844e13741ca2471444d +2026-07-27-web-subagent-conversations.md: ffe248af94c08ea5dba7e47cdd1f70c56122d234 +2026-07-27-web-subagent-conversations.zh.md: 8d152cb954324c65c5662b0e2a960caf291409d1 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md index c84e9df6f9..ffe248af94 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md @@ -6,116 +6,105 @@ English | [中文](2026-07-27-web-subagent-conversations.zh.md) ## Problem -Continuable background subagents have durable identities, persisted transcripts, inbox-driven Activations, and a direct-child catalog. The model can discover and continue them through `list_agents` and `send_message`, but the Web client has no equivalent product path. Its session tree knows only lineage, so it cannot distinguish a continuable subagent from an ordinary fork, and opening an inactive session through the ordinary history path resumes an Agent merely to display it. +Session-backed subagents have durable identities, persisted transcripts, and a direct-child catalog, but the Web client otherwise sees only ordinary session lineage. It cannot distinguish a subagent from a fork, discover descriptor mode, or view a cold child without using the ordinary history path that resumes an Agent. -Treating a child as an ordinary Web session would violate the [continuable subagent contract](../../implemented/feature/2026-07-28-continuable-subagent-conversations.md). Ordinary `session.history` and `session.prompt` address an Agent directly; a continuable child must display from persistence without materializing an Activation and accept human input through `SubagentService.followup()` so the Agent inbox owns ordering while the continuation manager owns authorization, cold resume, durability, and teardown. +The browser must preserve the [continuable subagent contract](../../implemented/feature/2026-07-28-continuable-subagent-conversations.md): a continuable child has at most one process-local Activation, accepts later work only through the exact live direct parent, and uses the Agent inbox as its sole FIFO. Viewing history must not create an Activation. Once an inbox message is accepted, the HTTP caller neither owns its execution nor gains a cancellation handle. -The UI also needs to preserve the [durable catalog](../../implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md) semantics. `running` and `inactive` are live-preferred session-activity snapshots, not success outcomes or delivery promises; corrupt, unsupported, and unavailable children remain explicit diagnostics; and only direct continuable children belong in one catalog response. +The UI must also preserve the [durable catalog](../../implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md). The catalog contains both one-shot and continuable children, retains per-child diagnostics, and reports only the live-preferred activity snapshot `running` or `inactive`. Activity is not a durable outcome or a promise that continuation will succeed. ## Decision -The Web product will expose direct continuable children from the selected session's title header and let a user open their persisted conversations. A child conversation will reuse the existing event fold, message rendering, streaming path, title, and input chrome, but its history and prompt operations will use a dedicated subagent address `{ parentSessionId, childSessionId }` rather than the ordinary session RPCs. +The Web product exposes the selected session's direct session-backed subagents from a header action. Users can lazily expand descendant catalogs and open either mode in the existing conversation region. A one-shot child is permanently read-only. A continuable child accepts human follow-ups only while its exact direct-parent Agent is live; otherwise its persisted transcript remains readable with a recovery explanation. -Human input will call `ctx.subagents.followup(parent, childSessionId, content, { source: { kind: 'user', rpcId }, signal })`. A resident Activation admits the message into its Agent inbox; an absent Activation cold-resumes the same durable Session before inbox admission. The host will never resume the parent merely to enable interaction: the exact direct-parent Agent must already be live to authorize delivery. When it is absent, the child remains a read-only transcript. +Every opened child carries a catalog-derived address `{ parentSessionId, childSessionId, mode }`. The mode-bearing address, not lineage or the coarse origin marker, selects dedicated history and prompt transports. History reads the persisted session without activation. A continuable prompt calls `ctx.subagents.followup()` and succeeds at inbox acceptance with `{ messageId }`; it does not steer an open turn, expose an Activation, wait for completion, or return an outcome. -This proposal covers Web discovery, transcript viewing, and human continuation. It does not turn a subagent into a user-owned conversation that survives independently of its parent; that product belongs to [interactive side sessions](../../proposed/feature/2026-07-08-interactive-side-sessions.md). +The ordinary Stop action is absent from addressed child conversations. `SubagentService.followup()` owns admission only until inbox acceptance and intentionally exposes no public child cancellation operation. A later cancellation design needs an explicit authority and lifecycle contract rather than falling through to `session.cancel`. + +This decision covers Web discovery, transcript viewing, and parent-authorized human continuation. It does not make a subagent independently user-owned; that product remains [interactive side sessions](../../proposed/feature/2026-07-08-interactive-side-sessions.md). ## Design context -The Figma [subagent list](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=383-14602&p=f), [hierarchical expansion](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=383-15917&p=f), and [child conversation](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=388-18584&p=f) frames are the interaction and visual references for this proposal. They are non-normative presentation context: this Agent Note owns lifecycle, wire, failure, and acceptance contracts, and a later design-file edit does not change those contracts without a corresponding note update. +The Figma [subagent list](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=383-14602&p=f), [hierarchical expansion](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=383-15917&p=f), and [child conversation](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=388-18584&p=f) frames are non-normative interaction and visual references. This note owns lifecycle, wire, and failure semantics. -| Design intent | Contract in this proposal | +| Design intent | Shipped contract | | --- | --- | -| The session header shows a subagent count and opens a compact list. | The header action shows the direct durable catalog, including healthy entries and explicit diagnostic rows. | -| Selecting a row opens a child with ordinary conversation chrome, title, transcript, and composer. | The child reuses the conversation UI, but history and input route through the addressed subagent RPCs. The composer sends human follow-ups only while the exact parent Agent is live and otherwise explains the read-only state. | -| Nested agents can be explored progressively. | Expanding a row loads only that child's direct catalog and inserts it as the next tree level. The client never materializes an eager recursive catalog. | -| Rows show labels, activity dots, and relative time, while the sidebar omits duplicate subagent rows. | Labels and coarse `running` or `inactive` activity come from the catalog. An optional log-backed title and relative last-activity time come from the ordinary session summary; they are not Activation outcome or duration. A durable coarse `SessionHeader.origin` classification removes duplicate subagent rows without hiding ordinary forks. | +| The session header opens a compact child list. | The action shows every direct catalog entry in service order, including disabled diagnostics. | +| Selecting a row reuses the conversation UI. | Addressed history never activates the child; only a continuable row with a live parent retains the ordinary composer. | +| Nested agents expand progressively. | Each disclosure loads only that row's direct catalog and retains its own parent address. | +| Rows show labels, state, and relative time without duplicating sidebar rows. | Mode and `running`/`inactive` activity are textual as well as visual; optional title and time come from summaries. `SessionHeader.origin` removes duplicate navigation rows but grants no capability. | ## Product contract -Every selected session may show a header action labeled ` subagents`, where `N` counts healthy `kind: 'child'` entries returned by `listChildren()` and excludes diagnostics. The action is absent when the complete response has neither a child nor a diagnostic. Opening it shows the direct children in service order, with the durable creation label, an activity indicator, and a disabled diagnostic row for every corrupt, unsupported, or unavailable candidate. +The header action count includes healthy `kind: 'child'` entries and excludes diagnostics. It is absent only after a complete empty response. The tree presents continuable and one-shot rows, falling back to the session id when an optional one-shot label is absent. Corrupt, unsupported, and unavailable candidates remain visible as disabled diagnostic rows. -`running` means the child Session is live in the host's logical session corpus. `inactive` means the child exists only in persistence and may be resumed by a later message. The UI renders them as `正在处理` and `已完成`, but the latter is only presentation for inactivity, not a succeeded, failed, or cancelled outcome. A row may show the ordinary session summary's relative `updatedAt` as a last-activity hint, but it does not present that value as Activation elapsed time. A listed child's `running`/`inactive` value updates live: the catalog consumer flips it in place from the same `host/session-status` frame that drives ordinary session `running`, so a child settling `running → inactive` needs no navigation or refetch. That frame carries only liveness, so a child's `label`, diagnostic reason, health transition, or membership change is not in it and still resolves through a `subagent.list` refetch; cross-process settlement and the window before a refetch lands remain snapshot-stale, and `subagent.prompt`'s result, not the activity label, is delivery-time authority. +`running` means the logical child record is live in the session corpus; `inactive` means it exists only in persistence. The UI does not translate either value into success, failure, cancellation, completeness, or resumability. `host/session-status` updates known activity in place. Membership, labels, mode, and diagnostics still require a debounced `subagent.list` refresh while the affected branch is open. A prompt response remains delivery-time authority. -Selecting a healthy row opens the child in the existing conversation region, whose resident header shows the child title. The header dropdown is an ARIA tree: expanding a row loads that child's direct catalog lazily, and further expansion repeats the same operation at any depth. Every visible branch retains its own direct-parent address and catalog lifecycle; collapsing a branch or closing the tree stops membership consumption for that branch and its expanded descendants. +Selecting a row records its exact address before opening the resident client `Session`. History pagination, event folding, tool render intents, titles, breadcrumbs, and live mux reconciliation reuse the ordinary conversation machinery. The catalog is an ARIA tree with lazy ArrowRight/ArrowLeft disclosure, linear ArrowUp/ArrowDown navigation, Home/End, Escape, and focus restoration. -The composer is enabled only when the catalog adapter reports that the exact parent Agent is live. Submitting clears the draft optimistically and restores it on an explicit not-delivered response, matching the ordinary composer failure behavior. A successful response carries the accepted inbox `messageId`; it does not expose whether an Activation was resident or cold-resumed, or promise that the resulting turn completed successfully. +A one-shot row always replaces the composer with copy explaining that the execution record is read-only. A continuable row does so only while `parentAvailable` is false. When enabled, its Send action admits another FIFO turn even if the child is currently running; it never becomes Stop. Prompt failures retain the draft through the ordinary error behavior. -When the parent is not live, the transcript stays readable and the composer presents a read-only explanation. The adapter does not auto-resume the parent, because a replacement Agent is not the retained owner of an existing Activation. Visiting the parent through its ordinary session path may make a parent Agent live for a later new Activation, but child navigation itself has no such side effect. - -The ordinary Stop action is hidden for subagent conversations in this version. `session.cancel` would bypass continuation-manager ownership and child-first teardown, while the subagent service exposes no per-message or per-Activation cancellation operation after inbox acceptance. A correct cancellation control requires a separately designed Activation observation and authority surface. +Agent-bound auxiliary controls are unavailable in addressed child views. In particular, the model selector and `/model` contribution do not call ordinary `session.models` or `session.selectModel`, because either route would activate persisted child history outside the direct-parent continuation seam. ## Host adapter and wire contract -`@deepseek-ai/dsh-host-apiproxy` will own a browser-safe `subagents` domain alongside `sessions`, with zod-validated unary methods registered through the existing `RpcMethodMap` and fetch carrier: +`@deepseek-ai/dsh-host-apiproxy` owns a browser-safe `subagents` domain: -- `subagent.list` takes `parentSessionId`, calls `ctx.subagents.listChildren(parentSessionId, signal)`, and returns the complete ordered entry array plus whether `ctx.agents.get(parentSessionId)` currently resolves the required live parent. Parent availability is a UI hint; `subagent.prompt` remains the delivery-time authority. -- `subagent.history` takes the direct parent id, child id, and the ordinary history page arguments. It first verifies that the child is a healthy entry in that parent's durable catalog, then reads the child through `ctx.sessionQuery` without publishing or resuming an Agent. It returns the same raw-event-plus-render-intent shape and message-aligned pagination contract used by ordinary history so the browser uses one fold. -- `subagent.prompt` takes the direct parent id, child id, and `ContentBlock[]`. It requires the exact live parent from `ctx.agents`, calls `ctx.subagents.followup()` with human attribution, the request's rpcId, and the operation signal, and returns `{ messageId }`. The adapter never bypasses the service through direct `agent.followup()`, `agent.steer()`, or generic `ctx.agents.resume()` calls. +- `subagent.list` takes `parentSessionId`, calls `ctx.subagents.listChildren(parentSessionId, signal)`, returns the complete ordered entries, and includes whether the exact parent currently resolves from `ctx.agents`. +- `subagent.history` takes the full mode-bearing address plus ordinary page arguments. It verifies the child and mode against the direct catalog, reads through `ctx.sessionQuery.readSession()`, rechecks direct lineage, and returns the ordinary raw-event, render-intent, pagination, and host-computed session-projection baseline without publishing an Agent. +- `subagent.prompt` accepts only a `mode: 'continuable'` address and `ContentBlock[]`. It requires the exact live parent, revalidates the catalog address, calls `ctx.subagents.followup(parent, childId, content, { source, signal })`, and returns the accepted `MessageId`. -The gateway maps missing-parent, catalog diagnostic, not-resumable, unauthorized, ownership-conflict, cancelled, and not-delivered failures to typed RPC errors without leaking the model-hidden descriptor. A race after `subagent.list` may still make `subagent.prompt` fail; the prompt result, not the earlier availability bit or `running` activity, is authoritative. +The gateway maps missing parent, missing or diagnostic catalog entries, not-resumable and unauthorized children, request cancellation, and temporarily unavailable continuation admission to typed RPC errors. It does not expose descriptor or provider details. A list/prompt race is normal: the prompt result, not the earlier availability or activity snapshot, is authoritative. -The mux remains the live event path. A persisted child contributes no live subscription merely because its history is viewed. When `subagent.prompt` starts a cold-resume Activation, publication makes the existing mux subscribe to that child and the browser reconciles subsequent events by sequence. Reconnect rebuilds an addressed child from `subagent.history`, not ordinary `session.history`. +Viewing persisted history creates no mux subscription by itself. When a follow-up materializes a cold child Activation, the existing Host and mux streams publish its lifecycle and events. Reconnect rebuilds the addressed window through `subagent.history`. -The adapter belongs in `dsh-host-apiproxy`, which owns the channel-independent contract and host implementation. `dsh-host-webserver` remains only the HTTP/SSE carrier and gains no subagent behavior. The browser imports the protocol through the existing connection client and never reaches host `ctx` directly, preserving the [GUI RPC layering](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md). +The adapter stays in `dsh-host-apiproxy`; `dsh-host-webserver` remains a carrier. Browser code imports the contract through the existing connection package and never reaches host `ctx`, preserving the [GUI RPC layering](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md). ## Client object layer and presentation -The React-free client runtime will own durable catalog snapshots, in-flight refreshes, subagent addresses, and prompt/history routing. Opening a catalog child records its `{ parentSessionId, childSessionId }` address before opening the resident `Session`; that Session uses `subagent.history` and `subagent.prompt`, while ordinary sessions keep their existing transport. Re-selecting the same child through an ordinary selection path retains the known address so navigation cannot silently switch transports. A child address discovered from the catalog is the only browser fact that selects this route; `parentId` or `origin` alone is insufficient because ordinary forks share the lineage field and origin is only a presentation classifier. +The React-free runtime owns catalogs, single-flight refreshes, retained addresses, availability hints, and transport selection. Re-selecting a known child retains its address so navigation cannot silently switch to ordinary session APIs. Restored navigation persists the full mode-bearing address. -Catalog data is projected through the existing sessions snapshot consumed by `useSessions`, rather than placed in a component store or exposed through a feature-defined hook. The tree reads ordinary session summaries from the same snapshot for optional title and last-activity presentation. While the root or an expanded descendant catalog is open, its consumer attaches to the existing host-frame fan-out, as the workspaces manager already does: a `host/session-status` frame naming a listed child flips that child's `running`/`inactive` activity in place through the same optimistic mutation path as ordinary session `running`, with no `subagent.list` refetch. A `host/session-added` frame whose parent matches an open branch triggers one debounced, single-flight `subagent.list` refetch to admit the new member and its label and descriptor. Component-local state owns dropdown visibility, expanded branch ids, and keyboard focus. +Catalogs ride the standard `useSessions` snapshot. Component-local state owns menu visibility, expanded branches, and focus. `ui-conversation` declares the generic header-action list slot and dispatches the current conversation snapshot through its composer chain; it contains no subagent-specific takeover flag. `@deepseek-ai/dsh-client-ui-subagent` registers the catalog action and elects a reason-specific read-only composer from ordinary owner props. Components receive derived props and callbacks, never `ctx`. -`ui-conversation` will declare and render a session-scoped `conversation.session.header.actions` list slot beside the title. The existing `@deepseek-ai/dsh-client-ui-subagent` plugin will register the catalog trigger and dropdown there, and will provide the subagent-specific read-only composer presentation through the conversation's existing composition points. Extending that package keeps one Web subagent feature owner; its current `@label` reference source remains plain-text model input and does not acquire continuation semantics in this proposal. +Every in-process subagent child stamps `SessionHeader.origin: 'subagent'` before publication. Session list summaries and incremental Host frames project it so grouped and flat sidebars omit duplicate child rows while preserving ordinary forks. Descriptor mode and catalog verification remain the authority for navigation, continuation, and authorization. -The presentation follows existing styling and accessibility rules: Chinese product copy, token-only light/dark colors, tree and treeitem semantics, ArrowRight/ArrowLeft branch disclosure, linear ArrowUp/ArrowDown navigation, focus returned to the trigger on close, activity communicated by text as well as color, and disabled diagnostic rows that remain readable. The component receives only derived props and injected callbacks; it never receives `ctx` or a host service. +The package's existing `@label` source remains separate plain-text model input. It does not resolve labels to addresses or acquire continuation semantics. ## Default Web assembly -The `dsh web` composition will mount the SQLite session-query provider beside JSONL persistence, with its database under the configured session persistence root. Both in-process spawn and fork delegation tools will select `backgroundMode: continuable`; that route uses the continuation manager and Agent inbox rather than Tasks. The host catalog projection excludes one-shot entries, while remote ACP runs remain outside it because they publish no local child Session. - -The default Web composition will mount the model-facing `send_message` and `list_agents` adapters for coordinator parity, but the GUI will not call those tools; it will call the shared `SubagentService` through the host RPC adapter. Their model-visible schemas and snapshots will be verified independently from the GUI transcript. +The shipped Web composition mounts SQLite session query beside JSONL persistence and configures spawn and fork background delegation as continuable. It also mounts the model-facing `send_message` and `list_agents` adapters for coordinator parity, but the GUI calls the shared `SubagentService` through the host RPC domain rather than invoking model tools. One-shot children remain catalog-visible and read-only. ## Alternatives considered -**Reuse ordinary `session.history` and `session.prompt`.** Rejected because both paths resume or drive the child Agent directly and bypass continuation-manager authority and inbox admission. Display must be non-activating, and human input must share the same follow-up or cold-resume operation as parent input. +**Reuse ordinary session APIs.** Rejected because ordinary history may resume the child and ordinary prompt drives it without direct-parent continuation authority. -**Put the adapter in `dsh-host-webserver`.** Rejected because subagent listing and continuation are channel-independent client capabilities. The webserver carries validated RPC calls and SSE frames; it does not own harness services or business routing. +**Put the adapter in the webserver.** Rejected because catalog and continuation are channel-independent client capabilities; the webserver only carries validated messages. -**Create another UI package.** Rejected because `ui-subagent` already owns Web subagent references and is the natural feature boundary for catalog, navigation, and human continuation. The conversation package owns only the header slot and generic conversation chrome. +**Create a new UI package.** Rejected because `ui-subagent` already owns Web subagent references and is the coherent owner for catalog and addressed-child presentation. -**Auto-resume an absent parent when the user submits.** Rejected because human continuation is authorized through the exact live parent Agent. Silent parent activation also turns a child-page gesture into an unexpected parent lifecycle mutation. +**Auto-resume an absent parent.** Rejected because continuation requires the exact live direct parent. Child navigation must not mutate the parent lifecycle. -**Expose ordinary cancel immediately.** Rejected because Agent cancellation bypasses continuation-manager ownership and child-first teardown. A correct cancel control needs the current Activation identity plus an owner-authorized operation that can define whether it cancels one inbox message, one turn, or the residency epoch, including Activations the GUI did not start. +**Expose ordinary cancellation.** Rejected because the accepted inbox turn outlives its admission request and the continuation seam exposes no authority-safe cancellation handle. -**Add Activation outcome and timing fields to the durable catalog.** Deferred because the catalog intentionally describes durable child identity and coarse live presence. A durable Activation record is a separate backend contract and should not be inferred from session presence or the last `turn/end`. +**Show only continuable children.** Rejected because the durable catalog deliberately describes both session-backed modes. One-shot transcripts remain useful even though they never accept follow-ups. -**Build an eager recursive tree.** Rejected because `listChildren()` is direct and can scan every candidate log. The presentation composes a recursive tree from lazy direct-child queries, preserving each catalog's ordering and diagnostic semantics without multiplying work across an unseen hierarchy. +**Infer mode or sidebar filtering from lineage.** Rejected because ordinary forks share `parentSession`. The descriptor-backed catalog owns mode; the separate `origin` marker is only a cheap navigation classifier. -**Infer sidebar filtering from lineage or a global catalog scan.** Rejected because ordinary forks share `parentSession`, while a global catalog scan is parent-addressed and too expensive for a navigation classifier. Every in-process subagent-backed session instead stamps `SessionHeader.origin: 'subagent'` before publication; `session.list` and `host/session-added` project it to the client, and the shared sidebar filter omits only those rows. The header catalog remains the navigation entry point and descriptor authority; `origin` never proves lifecycle mode, resumability, or authorization. +**Build an eager recursive tree or dedicated catalog stream.** Rejected for the current scale. Lazy direct-child reads preserve ordering and diagnostics; existing Host frames update activity and trigger bounded membership refreshes. -**Push catalog changes as a dedicated server stream.** Deferred in favor of reusing the existing `host/session-status` and `host/session-added` fan-out. A `subagent.catalog` delta frame would make membership and diagnostics fully live without any refetch, but it is a new host wire contract and a real-time projection over the durable catalog — exactly the derived index the [durable catalog](../../implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md) leaves to measured scale. The first version flips activity from the existing liveness frame and refetches only on membership change. - -**Let the child remain independently interactive after the parent disappears.** Rejected as a reinterpretation of continuable background work. Independent lifetime, user ownership, and merge-back semantics belong to interactive side sessions. +**Let a child remain independently interactive after its parent disappears.** Rejected because independent lifetime and user ownership require side-session semantics. ## Testing -- The default Web composition creates continuable spawn and fork children and mounts the session-query and continuation surfaces required for durable listing and human follow-up. -- A selected parent displays its complete direct-child catalog in stable service order, including healthy labels/activities and explicit disabled diagnostics; an empty healthy catalog contributes no header action. Expanding a child fetches its direct catalog, displays the next level with tree semantics and direct-parent addresses, and recursively closes descendant consumers when its branch closes. -- Opening a persisted child renders its event transcript and title header without publishing or resuming either the child or its parent. -- A child with a live parent submits through `SubagentService.followup()` with human attribution; the UI receives the accepted inbox `messageId` and receives the resulting child events through the existing mux and fold. -- A listed `running` child settling to `inactive` updates its activity in place from the live frame stream without a `subagent.list` refetch; a newly created direct child appears after one debounced refetch. -- A child whose parent is absent remains readable and rejects input without auto-resuming the parent. No child history, prompt, or stop gesture calls the ordinary Agent APIs. -- Refresh and reconnect rebuild an addressed child through the subagent history path without duplicating events or losing events emitted across cold-resume publication. -- Both grouped and flat sidebars omit `origin: 'subagent'` rows, including the current child, while ordinary fork rows remain visible; the same-child ordinary selection path retains the catalog-derived address and therefore keeps subagent history/prompt routing. -- Host protocol tests pin schemas, id echoing, direct-parent validation, non-activating history, live-parent enforcement, error mapping, and inbox-message acknowledgement. Client object tests pin catalog/address state and transport selection; jsdom tests pin the header tree, lazy nested disclosure, diagnostics, enabled/read-only composer states, keyboard behavior, and draft restoration. -- A keyless assembled Web snapshot demonstrates a settled continuable child plus a descriptor-bearing persisted grandchild, progressive catalog expansion without Activation, opening from persistence, and accepting one human follow-up into a cold-resumed Activation's inbox. +- Host protocol tests pin schemas, id echoing, mode verification, non-activating history, exact-parent enforcement, FIFO admission receipts, cancellation, and sanitized failure mapping. +- Client object tests pin retained and restored addresses, one-shot read-only rejection, history routing, continuable prompt routing, no addressed cancellation, suppression of Agent-bound model controls, live activity flips, and membership refresh. +- jsdom tests pin mixed-mode rows, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons. +- The keyless assembled Web snapshot contains an inactive continuable child, an inactive one-shot sibling, and a persisted grandchild; it expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. +- Sidebar tests pin `origin: 'subagent'` filtering without hiding ordinary forks. ## Consequences -- The feature is built on the continuation and durable-catalog contracts; changes to their stacked implementation may require the host adapter and fixtures to move with them before this proposal can ship. -- `listChildren()` may rescan persistence and child logs (O(D×C + ΣLᵢ) per the durable catalog). Activity changes are therefore applied live from `host/session-status` without a refetch; only membership changes trigger a debounced, single-flight `subagent.list` reload, so the scan does not run on every render or status frame. -- Parent availability and child activity are process-local snapshots. Publication, Activation disposal, another sender, or another process may win after listing; explicit prompt failure remains normal behavior rather than an invariant violation. -- A child Activation may publish between history fetch and mux subscription. The existing sequence reconciliation must be proven against this cold-to-live, subagent-specific open path. -- Switching the default Web delegation tools to continuable background mode changes the model-visible acknowledgement and durability requirements for `run_in_background`; snapshot coverage must land with the assembly change. -- Persisted subagent origin adds one coarse product-classification field to every local child header and its list/increment projections. It is intentionally weaker than the descriptor and addressed continuation contracts, so navigation de-duplication cannot become an authorization shortcut. -- The feature has no correct cancellation button, durable outcome, Activation duration, deletion, pagination for the catalog, or independently interactive offline child. The UI must not imply those capabilities; its relative time is only the session summary's last-activity hint. +- Catalog reads may rescan persisted lineage and descriptor logs, so activity uses existing live frames while membership refresh stays debounced and single-flight. +- Parent availability and child activity are process-local snapshots. Publication, disposal, another sender, or another process may win after listing; typed prompt failure remains expected. +- A child may publish between history fetch and mux subscription, so the existing sequence reconciliation also covers the cold-to-live addressed path. +- Persisted origin adds one deliberately weak product-classification field to child headers and list projections; it cannot become an authorization shortcut. +- The UI has no child cancellation, durable outcome, activation duration, deletion, or independently interactive offline mode, and its text must not imply those capabilities. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md index 95ba12138b..8d152cb954 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md @@ -6,116 +6,105 @@ Status: implemented ## 问题 -可继续的后台 subagent 具有持久化身份、持久化 transcript(文本记录)、由 inbox 驱动的 Activation 以及直接 child 目录。模型可以通过 `list_agents` 与 `send_message` 发现并继续这些 subagent,但 Web 客户端没有对应的产品路径。它的会话树只知道谱系,因此无法区分可继续的 subagent 与普通 fork;通过普通历史路径打开 inactive 会话时,还会仅为展示而恢复 Agent。 +由会话支撑的 subagent 具有持久化身份、持久化 transcript(文本记录)与直接 child 目录,但 Web 客户端除此之外只能看到普通会话谱系。它无法区分 subagent 与 fork、获知描述符 mode,或在不使用会恢复 agent(智能体)的普通历史路径的情况下查看冷态 child。 -将 child 当作普通 Web 会话会违反[可继续 subagent 契约](../../implemented/feature/2026-07-28-continuable-subagent-conversations.md)。普通 `session.history` 与 `session.prompt` 会直接寻址 Agent;可继续的 child 必须从持久化存储中展示且不物化 Activation,并通过 `SubagentService.followup()` 接收用户输入,使 Agent inbox 负责排序,继续执行 manager 负责授权、cold resume、持久性与清理。 +浏览器必须遵守[可继续 subagent 契约](../../implemented/feature/2026-07-28-continuable-subagent-conversations.md):一个可继续 child 在进程内最多只能有一项 Activation,只能通过确切的存活直接 parent 接受后续工作,并将 agent inbox 用作唯一的 FIFO。查看历史不得创建 Activation。inbox 消息一经接受,HTTP 调用方既不拥有其执行过程,也不会获得取消句柄。 -UI 还需要保留[持久化目录](../../implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)的语义。`running` 与 `inactive` 是采用实时优先规则的会话活动快照,不代表成功结果或投递承诺;损坏、不受支持及不可用的 child 仍以显式 diagnostic 呈现;一次目录响应只包含直接可继续 child。 +UI 还必须遵守[持久化目录](../../implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)。目录同时包含 one-shot 与可继续 child,保留每个 child 的 diagnostic,并且只报告采用实时优先规则的 `running` 或 `inactive` 活动快照。活动状态既不是持久化结果,也不承诺继续执行会成功。 ## 决策 -Web 产品将在选中会话的标题栏公开直接可继续 child,并允许用户打开其持久化对话。child 对话会复用现有的事件 fold、消息渲染、流式输出路径、标题与输入区 chrome,但历史和提示词操作将使用专用的 subagent 地址 `{ parentSessionId, childSessionId }`,而非普通会话 RPC。 +Web 产品通过页头操作公开选中会话中由会话支撑的直接 subagent。用户可以懒加载展开后代目录,并在现有对话区域中打开任一 mode。one-shot child 永久只读。可继续 child 只有在其确切直接 parent agent 存活时才接受用户后续消息;否则,其持久化 transcript 仍然可读,并附带恢复说明。 -用户输入将调用 `ctx.subagents.followup(parent, childSessionId, content, { source: { kind: 'user', rpcId }, signal })`。驻留的 Activation 会把消息准入其 Agent inbox;没有 Activation 时,则先对同一个持久化 Session 执行 cold resume,再完成 inbox 准入。宿主绝不会仅为启用交互而恢复 parent:确切的直接 parent Agent 必须已存活才能授权投递。如果它不存在,child 会保持只读 transcript。 +每个打开的 child 都携带目录派生地址 `{ parentSessionId, childSessionId, mode }`。选择专用历史与提示词传输的是包含 mode 的地址,而不是谱系或粗粒度 origin 标记。历史操作会从持久化存储读取会话,而不触发激活。可继续提示词操作会调用 `ctx.subagents.followup()`,并在 inbox 接受消息时以 `{ messageId }` 成功返回;它不会 steer 打开的轮次、公开 Activation、等待完成或返回结果。 -本提案涵盖 Web 端发现、transcript 查看与用户继续交互。它不会把 subagent 变成脱离 parent 后仍可独立存续的用户自有对话;这类产品属于[交互式 side session](../../proposed/feature/2026-07-08-interactive-side-sessions.md)。 +已寻址 child 对话不提供普通 Stop 操作。`SubagentService.followup()` 只负责消息被 inbox 接受前的准入,并有意不公开任何 child 取消操作。后续取消设计需要显式的授权与生命周期契约,而不能回退到 `session.cancel`。 + +本决策涵盖 Web 端发现、transcript 查看与经 parent 授权的用户继续交互。它不会让 subagent 成为用户独立所有的对象;这类产品仍然属于[交互式 side session](../../proposed/feature/2026-07-08-interactive-side-sessions.md)。 ## 设计上下文 -Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=383-14602&p=f)、[层级展开](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=383-15917&p=f)与 [child 对话](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=388-18584&p=f)画框是本提案的交互与视觉参考。它们只是非规范性的呈现上下文:生命周期、协议、失败与验收契约以本记录为准;后续只修改设计文件而不相应更新本记录,不会改变这些契约。 +Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=383-14602&p=f)、[层级展开](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=383-15917&p=f)与 [child 对话](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=388-18584&p=f)画框是非规范性的交互与视觉参考。本记录负责生命周期、协议与失败语义。 -| 设计意图 | 本提案中的契约 | +| 设计意图 | 已交付契约 | | --- | --- | -| 会话页头显示 subagent 数量,并可打开紧凑列表。 | 页头操作显示直接持久化目录,其中包括健康条目和显式 diagnostic 行。 | -| 选择条目后,系统会使用普通对话 chrome、标题、transcript 与输入框打开 child。 | child 复用对话 UI,但历史和输入通过已寻址的 subagent RPC 路由。只有确切的 parent Agent 存活时,输入框才发送用户后续消息;否则会说明当前为只读状态。 | -| 用户可以逐层浏览嵌套 agent。 | 展开某一行时,只加载该 child 的直接目录,并将其插入为下一层树节点。客户端绝不会物化预先加载的递归目录。 | -| 条目显示 label、活动状态点与相对时间,侧边栏则省略重复的 subagent 行。 | label 与粗粒度的 `running` 或 `inactive` 活动状态来自目录。由日志支撑的可选 title 与相对最近活动时间来自普通会话摘要;它们不是 Activation 结果或耗时。持久化的粗粒度 `SessionHeader.origin` 分类会移除重复的 subagent 行,同时不会隐藏普通 fork。 | +| 会话页头可打开紧凑的 child 列表。 | 该操作按服务顺序显示每个直接目录条目,包括已禁用的 diagnostic。 | +| 选择一行会复用对话 UI。 | 已寻址历史绝不激活 child;只有 parent 存活的可继续行才保留普通输入框。 | +| 嵌套 agent 会逐层展开。 | 每次展开只加载该行的直接目录,并保留其自身的 parent 地址。 | +| 条目显示 label、状态与相对时间,同时避免侧边栏条目重复。 | mode 与 `running`/`inactive` 活动状态会同时以文字和视觉呈现;可选 title 与时间来自摘要。`SessionHeader.origin` 会移除重复的导航条目,但不授予任何功能权限。 | ## 产品契约 -每个选中会话都可以显示标为 ` subagents` 的页头操作,其中 `N` 只计算健康的 `kind: 'child'` 条目,这些条目由 `listChildren()` 返回,且计数不包含 diagnostic。完整响应既无 child 也无 diagnostic 时,不显示该操作。打开后,界面按服务顺序显示直接 child,每行包含持久化创建 label 与活动指示;每个损坏、不受支持或不可用的候选则显示为禁用的 diagnostic 行。 +页头操作的计数包含健康的 `kind: 'child'` 条目,不包含 diagnostic。只有在完整响应为空后,才不显示该操作。树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。 -`running` 表示 child Session 存活于宿主的逻辑会话语料库中。`inactive` 表示 child 仅存在于持久化存储中,后续消息可以将其恢复。UI 分别将二者呈现为「正在处理」与「已完成」,但后者只是在呈现 inactive,而非成功、失败或已取消结果。条目可以把普通会话摘要中的 `updatedAt` 显示为相对时间,用作最近活动提示,但不会把该值呈现为 Activation 耗时。已列出 child 的 `running`/`inactive` 值会实时更新:目录消费方从驱动普通会话 `running` 的同一条 `host/session-status` 帧就地翻转它,因此 child 从 `running` 结算为 `inactive` 无需导航或重新拉取。该帧只携带存活状态,因此 child 的 `label`、diagnostic 原因、健康状态迁移或成员变化都不在其中,仍需通过 `subagent.list` 重新拉取解析;跨进程结算以及重新拉取落地前的窗口仍为快照陈旧状态,投递时权威依据是 `subagent.prompt` 的结果,而非活动文案。 +`running` 表示逻辑 child 记录存活于会话语料库中;`inactive` 表示它只存在于持久化存储中。UI 不会把任一值解释为成功、失败、取消、完成状态或可恢复性。`host/session-status` 会就地更新已知活动状态。受影响分支打开期间,成员、label、mode 与 diagnostic 仍需要通过去抖动的 `subagent.list` 刷新来更新。消息投递时仍以提示词响应为权威依据。 -选择健康条目后,现有对话区域会打开对应 child,常驻标题栏显示该 child 的标题。页头下拉菜单是一棵 ARIA 树:展开某一行会懒加载该 child 的直接目录,继续展开则可在任意深度重复同一操作。每个可见分支都保留自身的直接 parent 地址和目录生命周期;折叠分支或关闭树时,会停止消费该分支及其已展开后代的成员关系。 +选择一行后,系统会先记录其确切地址,再打开常驻客户端 `Session`。历史分页、事件 fold、工具渲染意图、title、面包屑导航与实时 mux 归并都会复用普通对话机制。目录是一棵 ARIA 树,支持懒加载式 ArrowRight/ArrowLeft 展开与折叠、线性 ArrowUp/ArrowDown 导航、Home/End、Escape 以及焦点恢复。 -只有在目录适配器报告确切的 parent Agent 已存活时,输入框才会启用。提交时,界面先乐观清空草稿;收到明确的未送达响应后再恢复草稿,与普通输入框的失败行为一致。成功响应携带已接纳的 inbox `messageId`;它不暴露 Activation 原本驻留还是经 cold resume 物化,也不承诺相应轮次已成功完成。 +one-shot 行始终会用文案替代输入框,说明执行记录为只读。可继续行仅在 `parentAvailable` 为 false 时如此。启用后,即使 child 正在运行,其 Send 操作也会准入另一个 FIFO 轮次,绝不会变成 Stop。提示词失败会通过普通错误行为保留草稿。 -parent 不存活时,transcript 仍可阅读,输入框则显示只读说明。适配器不会自动恢复 parent,因为替代 Agent 并不是现有 Activation 保留的 owner。通过普通会话路径访问 parent,可能使一个 parent Agent 存活,以便后续创建新 Activation;但 child 导航本身不会产生这种副作用。 - -这一版本会在 subagent 对话中隐藏普通 Stop 操作。`session.cancel` 会绕过继续执行 manager 的所有权与 child-first 清理,而 subagent 服务在 inbox 接受后不公开逐消息或逐 Activation 取消操作。正确的取消控件需要另行设计 Activation 观测与授权表层。 +已寻址 child 视图不提供绑定到 agent 的辅助控件。具体而言,模型选择器与 `/model` contribution 不会调用普通 `session.models` 或 `session.selectModel`,因为任一路径都会在直接 parent 继续执行 seam 之外激活持久化 child 历史。 ## 宿主适配器与协议契约 -`@deepseek-ai/dsh-host-apiproxy` 将拥有浏览器安全的 `subagents` 域,与 `sessions` 并列,并通过现有 `RpcMethodMap` 与 fetch 载体注册由 zod 校验的一元方法: +`@deepseek-ai/dsh-host-apiproxy` 拥有浏览器安全的 `subagents` 域: -- `subagent.list` 接受 `parentSessionId`,调用 `ctx.subagents.listChildren(parentSessionId, signal)`,返回完整有序的条目数组,并说明 `ctx.agents.get(parentSessionId)` 当前能否解析出所需的存活 parent。parent 可用性只是 UI 提示;消息投递时仍以 `subagent.prompt` 为准。 -- `subagent.history` 接受直接 parent id、child id 与普通历史页参数。它首先确认 child 是该 parent 持久化目录中的健康条目,再通过 `ctx.sessionQuery` 读取 child,且不发布或恢复 agent。它返回与普通历史相同的原始事件加渲染意图形态,以及按消息对齐的分页契约,使浏览器只使用一套 fold。 -- `subagent.prompt` 接受直接 parent id、child id 与 `ContentBlock[]`。它要求 `ctx.agents` 中存在确切的存活 parent,使用用户来源信息、请求的 rpcId 与操作 signal 调用 `ctx.subagents.followup()`,并返回 `{ messageId }`。适配器绝不会绕过该服务而直接调用 `agent.followup()`、`agent.steer()` 或通用 `ctx.agents.resume()`。 +- `subagent.list` 接受 `parentSessionId`,调用 `ctx.subagents.listChildren(parentSessionId, signal)`,返回完整有序的条目,并说明当前能否从 `ctx.agents` 解析出确切 parent。 +- `subagent.history` 接受包含 mode 的完整地址与普通页参数。它对照直接目录校验 child 与 mode,通过 `ctx.sessionQuery.readSession()` 读取,再次检查直接谱系,并在不发布 agent 的情况下返回普通原始事件、渲染意图、分页与由 Host 计算的会话投影基线。 +- `subagent.prompt` 只接受 `mode: 'continuable'` 地址与 `ContentBlock[]`。它要求确切的存活 parent,重新校验目录地址,调用 `ctx.subagents.followup(parent, childId, content, { source, signal })`,并返回已接受的 `MessageId`。 -网关会将 parent 缺失、目录 diagnostic、不可恢复、未授权、所有权冲突、已取消及未送达等失败映射为类型化 RPC 错误,且不会泄露对模型隐藏的描述符。`subagent.list` 之后的竞态仍可能导致 `subagent.prompt` 失败;权威依据是提示词操作的结果,而非更早的可用性位或 `running` 活动状态。 +网关会将 parent 缺失、目录条目缺失或为 diagnostic、child 不可恢复或未授权、请求取消以及继续执行准入暂时不可用等失败映射为类型化 RPC 错误。它不会公开描述符或提供方细节。list/prompt 竞态属于正常情况:权威依据是提示词操作的结果,而不是更早的可用性或活动快照。 -mux 仍然是实时事件路径。仅查看持久化 child 的历史不会产生实时订阅。`subagent.prompt` 启动 cold resume Activation 时,发布操作会让现有 mux 订阅该 child,浏览器则按序号归并后续事件。重新连接时,系统通过 `subagent.history` 重建已寻址的 child,而不是调用普通 `session.history`。 +查看持久化历史本身不会创建 mux 订阅。当后续消息物化冷态 child Activation 时,现有 Host 与 mux 流会发布其生命周期与事件。重新连接时,系统通过 `subagent.history` 重建已寻址窗口。 -适配器属于 `dsh-host-apiproxy`,由它负责通道无关的契约与宿主实现。`dsh-host-webserver` 仍然只作为 HTTP/SSE 载体,不增加任何 subagent 行为。浏览器通过现有连接客户端导入协议,绝不直接访问宿主 `ctx`,从而保持 [GUI RPC 分层](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)。 +适配器仍位于 `dsh-host-apiproxy`;`dsh-host-webserver` 仍作为载体。浏览器代码通过现有连接包(package)导入契约,绝不直接访问宿主 `ctx`,从而保持 [GUI RPC 分层](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)。 ## 客户端对象层与呈现 -不依赖 React 的客户端运行时将负责持久化目录快照、进行中的刷新、subagent 地址及提示词/历史路由。打开目录中的 child 时,系统会先记录其 `{ parentSessionId, childSessionId }` 地址,再打开常驻的 `Session`;该 Session 使用 `subagent.history` 与 `subagent.prompt`,普通会话则保持现有传输路径。通过普通选择路径再次选择同一 child 时,会保留已知地址,避免导航操作静默切换传输。只有从目录中发现的 child 地址,才能作为浏览器选择这条路由的事实;单凭 `parentId` 或 `origin` 都不足以判断,因为普通 fork 也使用同一个谱系字段,而 origin 只是呈现分类器。 +不依赖 React 的运行时负责目录、单次并发刷新、保留的地址、可用性提示与传输选择。再次选择已知 child 时会保留其地址,避免导航静默切换到普通会话 API。恢复的导航会持久化包含 mode 的完整地址。 -目录数据通过 `useSessions` 消费的现有会话快照投影,而不会放入组件 store,也不会通过功能自定义钩子公开。树还从同一份快照读取普通会话摘要,用于显示可选 title 与最近活动时间。根目录或某个已展开后代的目录打开期间,其消费方会像 workspaces manager 那样挂到现有的宿主帧分发上:命中某个已列出 child 的 `host/session-status` 帧会通过与普通会话 `running` 相同的乐观 mutation 路径就地翻转该 child 的 `running`/`inactive` 活动状态,且不重新拉取 `subagent.list`。parent 与某个已打开分支匹配的 `host/session-added` 帧会触发一次去抖动、单次并发(single-flight)的 `subagent.list` 重新拉取,以纳入新成员及其 label 与描述符。组件局部状态负责下拉菜单可见性、已展开分支 id 与键盘焦点。 +目录通过标准 `useSessions` 快照传递。组件局部状态负责菜单可见性、已展开分支与焦点。`ui-conversation` 声明通用页头操作列表 slot,并通过其编辑器链分发当前对话快照;其中没有 subagent 专用的接管标记。`@deepseek-ai/dsh-client-ui-subagent` 注册目录操作,并根据普通 owner props 选择按原因区分的只读编辑器。组件只接收派生 props 与回调,绝不接收 `ctx`。 -`ui-conversation` 会在标题旁声明并渲染会话作用域的 `conversation.session.header.actions` 列表 slot。现有 `@deepseek-ai/dsh-client-ui-subagent` 插件会在该处注册目录触发器与下拉菜单,并通过对话现有的组合点提供 subagent 专用的只读输入框呈现。扩展该包可以让 Web subagent 功能只有一个 owner;当前的 `@label` 引用 source 仍然只是面向模型的纯文本输入,本提案不会赋予它继续执行语义。 +每个进程内 subagent child 都会在发布前写入 `SessionHeader.origin: 'subagent'`。会话列表摘要与增量 Host 帧会投影该字段,使分组和扁平侧边栏省略重复的 child 行,同时保留普通 fork。描述符 mode 与目录校验仍然是导航、继续执行和授权的权威依据。 -界面呈现遵循现有样式和无障碍规则:产品文案使用中文、亮色与暗色只使用 token、采用 tree 与 treeitem 语义、ArrowRight/ArrowLeft 控制分支展开、ArrowUp/ArrowDown 进行线性导航、关闭后焦点返回触发器、活动状态同时通过文字和颜色表达、禁用的 diagnostic 行仍然可读。组件只接收派生 props 与注入的回调;绝不接收 `ctx` 或宿主服务。 +该包现有的 `@label` source 仍然是独立的面向模型纯文本输入。它不会将 label 解析为地址,也不会获得继续执行语义。 ## 默认 Web 组合 -`dsh web` 组合会在 JSONL 持久化旁挂载 SQLite 会话查询提供方,其数据库位于已配置的会话持久化根目录下。进程内 spawn 与 fork 委派工具都会选择 `backgroundMode: continuable`;该路由使用继续执行 manager 与 Agent inbox,而非 Task。宿主目录投影会排除 one-shot 条目,远程 ACP 运行则因为不发布本地 child Session 而不进入目录。 - -默认 Web 组合会挂载面向模型的 `send_message` 与 `list_agents` 适配器,以保持 coordinator 对等性,但 GUI 不会调用这些工具;它会通过宿主 RPC 适配器调用共享的 `SubagentService`。这些工具面向模型的 schema 与快照将独立于 GUI transcript 进行验证。 +已交付的 Web 组合会在 JSONL 持久化旁挂载 SQLite 会话查询,并将 spawn 与 fork 后台委派配置为可继续模式。它还会挂载面向模型的 `send_message` 与 `list_agents` 适配器,以保持 coordinator 对等性,但 GUI 会通过宿主 RPC 域调用共享的 `SubagentService`,而不是调用模型工具。one-shot child 仍在目录中可见且只读。 ## 备选方案 -**复用普通 `session.history` 与 `session.prompt`。** 不予采纳,因为这两条路径都会直接恢复或驱动 child Agent,绕过继续执行 manager 的授权与 inbox 准入。展示不得触发 Activation,用户输入必须与 parent 输入共用同一项 follow-up 或 cold resume 操作。 +**复用普通会话 API。** 不予采纳,因为普通历史可能恢复 child,而普通提示词会在缺少直接 parent 继续执行授权的情况下驱动它。 -**将适配器放入 `dsh-host-webserver`。** 不予采纳,因为 subagent 列表与继续交互是通道无关的客户端功能。webserver 只承载已校验的 RPC 调用与 SSE 帧,不负责 harness 服务或业务路由。 +**将适配器放入 webserver。** 不予采纳,因为目录与继续执行是通道无关的客户端功能;webserver 只承载已校验的消息。 -**新建另一个 UI 包(package)。** 不予采纳,因为 `ui-subagent` 已经负责 Web subagent 引用,也是目录、导航与用户继续交互的自然功能边界。对话包只负责页头 slot 与通用对话 chrome。 +**新建 UI 包。** 不予采纳,因为 `ui-subagent` 已经负责 Web subagent 引用,也是目录与已寻址 child 呈现的统一 owner。 -**用户提交时自动恢复缺失的 parent。** 不予采纳,因为用户继续交互必须通过确切的存活 parent Agent 完成授权。静默激活 parent 还会让 child 页面上的操作意外改变 parent 生命周期。 +**自动恢复缺失的 parent。** 不予采纳,因为继续执行要求确切的存活直接 parent。child 导航不得改变 parent 生命周期。 -**立即公开普通取消操作。** 不予采纳,因为取消 Agent 会绕过继续执行 manager 的所有权与 child-first 清理。正确的取消控件需要当前 Activation 身份,以及能够界定取消一条 inbox 消息、一个轮次还是整个驻留 epoch 的 owner 授权操作,其中也包括 GUI 未启动的 Activation。 +**公开普通取消操作。** 不予采纳,因为已获 inbox 接受的轮次会比其准入请求存续更久,而继续执行 seam 不会公开具备安全授权的取消句柄。 -**向持久化目录添加 Activation 结果与时间字段。** 暂缓,因为目录有意只描述持久化 child 身份与粗粒度的存活状态。持久化 Activation 记录属于独立的后端契约,不应根据会话存在状态或最后一次 `turn/end` 推断。 +**只显示可继续 child。** 不予采纳,因为持久化目录有意描述由会话支撑的两种 mode。one-shot transcript 即使绝不接受后续消息,仍然有用。 -**构建预先加载的递归树。** 不予采纳,因为 `listChildren()` 只查询直接 child,而且可能扫描每份候选日志。界面通过懒加载的直接 child 查询组合出递归树,既保留每份目录的排序与 diagnostic 语义,又不会在用户看不到的层级中成倍增加工作量。 +**根据谱系推断 mode 或侧边栏过滤。** 不予采纳,因为普通 fork 共享 `parentSession`。由描述符支撑的目录负责提供 mode;单独的 `origin` 标记只是低成本的导航分类器。 -**从谱系推断侧边栏过滤,或扫描全局目录。** 不予采纳,因为普通 fork 共享 `parentSession`,而全局目录扫描按 parent 寻址,用作导航分类器成本过高。每个由进程内 subagent 支撑的会话会在发布前写入 `SessionHeader.origin: 'subagent'`;`session.list` 与 `host/session-added` 将其投影到客户端,共享侧边栏过滤器只省略这些行。页头目录仍然是导航入口与描述符权威来源;`origin` 绝不证明生命周期 mode、可恢复性或授权。 +**构建预先加载的递归树或专用目录流。** 就当前规模而言不予采纳。懒加载式直接 child 读取会保留排序与 diagnostic;现有 Host 帧会更新活动状态,并触发有界的成员刷新。 -**将目录变化作为专用服务端流推送。** 暂缓,转而复用现有的 `host/session-status` 与 `host/session-added` 分发。`subagent.catalog` 增量帧能让成员与 diagnostic 完全实时而无需任何重新拉取,但它是一项新的宿主协议契约,也是在持久化目录之上的实时投影——恰恰是[持久化目录](../../implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)留待规模验证的派生索引。第一版从现有存活帧翻转活动状态,只在成员变化时重新拉取。 - -**让 child 在 parent 消失后仍能独立交互。** 不予采纳,因为这会重新解释可继续后台工作的含义。独立生命周期、用户所有权与回并语义属于交互式 side session。 +**让 child 在 parent 消失后仍能独立交互。** 不予采纳,因为独立生命周期与用户所有权需要 side session 语义。 ## 测试 -- 默认 Web 组合会创建可继续的 spawn 与 fork child,并挂载持久化枚举及用户 follow-up 所需的会话查询与继续执行接口。 -- 选中的 parent 会按稳定服务顺序显示完整的直接 child 目录,其中包含健康条目的 label/活动状态与明确禁用的 diagnostic;目录完全为空时,不提供页头操作。展开 child 时会获取其直接目录,以树语义和直接 parent 地址显示下一层,并在分支关闭时递归关闭后代消费方。 -- 打开持久化 child 后,系统会渲染其事件 transcript 与标题栏,且不发布或恢复 child 与 parent。 -- parent 存活时,child 会通过 `SubagentService.followup()` 提交带用户来源信息的输入;UI 会收到已接纳的 inbox `messageId`,并通过现有 mux 与 fold 接收由此产生的 child 事件。 -- 已列出的 `running` child 结算为 `inactive` 时,其活动状态会从实时帧流就地更新,而不重新拉取 `subagent.list`;新创建的直接 child 会在一次去抖动的重新拉取后出现。 -- parent 缺失时,child 仍然可读并拒绝输入,且不会自动恢复 parent。任何 child 历史、提示词或停止操作都不会调用普通 agent API。 -- 刷新和重新连接会通过 subagent 历史路径重建已寻址的 child,不重复事件,也不会丢失 cold resume 发布前后产生的事件。 -- 分组与扁平侧边栏都会省略 `origin: 'subagent'` 行,包括当前 child;普通 fork 行仍然可见。同一 child 的普通选择路径会保留目录派生地址,因此继续使用 subagent 历史/提示词路由。 -- 宿主协议测试固定 schema、id 回显、直接 parent 校验、非激活式历史、存活 parent 强制要求、错误映射以及 inbox 消息确认。客户端对象测试固定目录/地址状态与传输选择;jsdom 测试固定页头树、懒加载式嵌套展开、diagnostic、启用/只读输入框状态、键盘行为与草稿恢复。 -- 一项无密钥的组装 Web 快照展示已结算的可继续 child 与带描述符的已持久化 grandchild、在不物化 Activation 的情况下逐层展开目录、从持久化存储打开,以及让 cold-resume Activation 的 inbox 接受一次用户后续消息。 +- 宿主协议测试固定 schema、id 回显、mode 校验、非激活式历史、确切 parent 强制要求、FIFO 准入回执、取消与脱敏后的失败映射。 +- 客户端对象测试固定已保留与已恢复的地址、one-shot 只读拒绝、历史路由、可继续提示词路由、已寻址对话不提供取消、屏蔽绑定到 agent 的模型控件、实时活动状态翻转与成员刷新。 +- jsdom 测试固定混合 mode 行、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。 +- 无密钥的组装 Web 快照包含一个 inactive 的可继续 child、一个 inactive 的 one-shot sibling 和一个持久化 grandchild;它会在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。 +- 侧边栏测试固定 `origin: 'subagent'` 过滤,同时不隐藏普通 fork。 ## 后果 -- 该功能建立在继续执行与持久化目录契约之上;在本提案可以交付前,其堆叠实现的变更可能要求宿主适配器与 fixture(测试前置数据)一同调整。 -- `listChildren()` 可能重新扫描持久化存储与 child 日志(按持久化目录为 O(D×C + ΣLᵢ))。因此活动状态变化通过 `host/session-status` 实时应用而不重新拉取;只有成员变化才触发一次去抖动、单次并发的 `subagent.list` 重新加载,所以该扫描不会在每次渲染或每个状态帧上运行。 -- parent 可用性与 child 活动状态都是进程局部快照。列出之后,发布、Activation dispose、其他发送方或其他进程都可能抢先改变状态;明确的提示词失败属于正常行为,不是违反不变量。 -- child Activation 可能在历史获取与 mux 订阅之间发布。现有序号归并必须针对这条从冷态转为存活的 subagent 专用打开路径得到验证。 -- 将默认 Web 委派工具切换为可继续后台模式,会改变 `run_in_background` 面向模型的确认消息与持久性要求;快照覆盖必须与组合变更一同落地。 -- 持久化 subagent origin 会给每个本地 child header 及其列表/增量投影增加一个粗粒度产品分类字段。它刻意弱于描述符与已寻址继续执行契约,因此导航去重不能变成授权捷径。 -- 该功能没有正确的取消按钮、持久化结果、Activation 耗时、删除、目录分页或可独立交互的离线 child。UI 不得暗示这些功能已经存在;其中的相对时间仅表示会话摘要给出的最近活动提示。 +- 目录读取可能重新扫描持久化谱系与描述符日志,因此活动状态使用现有实时帧,而成员刷新保持去抖动和单次并发。 +- parent 可用性与 child 活动状态都是进程局部快照。列出之后,发布、dispose、其他发送方或其他进程都可能抢先改变状态;类型化提示词失败仍属预期行为。 +- child 可能在历史获取与 mux 订阅之间发布,因此现有序号归并也涵盖从冷态转为存活的已寻址路径。 +- 持久化 origin 会为 child header 与列表投影添加一个有意保持弱约束的产品分类字段;它不能变成授权捷径。 +- UI 不提供 child 取消、持久化结果、激活耗时、删除或可独立交互的离线 mode,其文案不得暗示这些功能已经存在。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 189a8af496..8e32ea3eb2 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -96,6 +96,8 @@ flowchart LR cfg --> plugin_tui_subagent_fork plugin_tui_tool_subagent_control["tool-subagent-control
@deepseek-ai/dsh-tool-subagent-control"] cfg --> plugin_tui_tool_subagent_control + plugin_tui_tool_subagent_list_agents["tool-subagent-list-agents
@deepseek-ai/dsh-tool-subagent-control/list-agents"] + cfg --> plugin_tui_tool_subagent_list_agents plugin_tui_tool_subagent["tool-subagent
@deepseek-ai/dsh-tool-subagent"] cfg --> plugin_tui_tool_subagent plugin_tui_tool_subagent_fork["tool-subagent-fork
@deepseek-ai/dsh-tool-subagent"] @@ -188,6 +190,7 @@ flowchart LR | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | | `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | | `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` | +| `tool-subagent-list-agents` | `@deepseek-ai/dsh-tool-subagent-control/list-agents` | | `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | | `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | | `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | diff --git a/apps/web/tests/snapshots/subagent-conversation/tree.expected.md b/apps/web/tests/snapshots/subagent-conversation/tree.expected.md index 0b3767dcbe..4db5fab3fd 100644 --- a/apps/web/tests/snapshots/subagent-conversation/tree.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/tree.expected.md @@ -1,10 +1,14 @@ - tree "子代理会话": - - treeitem "event-sourcing researcher 已完成" [expanded] [level=1]: + - treeitem "event-sourcing researcher 可继续 · 当前未运行" [expanded] [level=1]: - button "收起 event-sourcing researcher 的下级子代理": - img - - text: event-sourcing researcher 已完成 + - text: event-sourcing researcher 可继续 · 当前未运行 - group: - - treeitem "example editor 已完成" [level=2]: + - treeitem "example editor 可继续 · 当前未运行" [level=2]: - button "展开 example editor 的下级子代理": - img - - text: example editor 已完成 + - text: example editor 可继续 · 当前未运行 + - treeitem "event-sourcing reviewer 一次性 · 当前未运行" [level=1]: + - button "展开 event-sourcing reviewer 的下级子代理": + - img + - text: event-sourcing reviewer 一次性 · 当前未运行 diff --git a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md index 38eb3a6921..dd7313bcbe 100644 --- a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md @@ -12,11 +12,23 @@ - tab "Trajectory" - tab "Waterfall" - text: Explain event sourcing in one sentence. +- button "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - img - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": - img - text: Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls. - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - text: Now give the same explanation to a human reader. +- button "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - img - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": - img - text: Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls. diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index f4dedd098c..9dc7db7b12 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -22,6 +22,7 @@ const TREE_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/t const SIDEBAR_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/sidebar.expected.md', import.meta.url)) const MODE = webSnapshotMode() const LABEL = 'event-sourcing researcher' +const ONE_SHOT_LABEL = 'event-sourcing reviewer' const NESTED_LABEL = 'example editor' const PARENT_PROMPT = 'Ask a research subagent to explain event sourcing.' const INITIAL_PROMPT = 'Explain event sourcing in one sentence.' @@ -41,12 +42,21 @@ function childFixture(source: string, fixtureId: string, withContinuation: boole return [childHeader, ...eventLines, ...continued, ''].join('\n') } +async function waitForAgentToSettle(scaffold: WebScaffold, id: SessionId): Promise { + const deadline = Date.now() + 30_000 + while (scaffold.ctx.agents.get(id) !== undefined) { + if (Date.now() >= deadline) throw new Error(`subagent ${id} did not settle`) + await new Promise(resolve => setTimeout(resolve, 10)) + } +} + describe('web e2e: persisted subagent conversation and human continuation', () => { let scaffold: WebScaffold let browser: Browser let page: Page let sidecarRoot: string let childId: SessionId + let oneShotId: SessionId let grandchildId: SessionId let tripwire: ReturnType const apiCalls: string[] = [] @@ -70,7 +80,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - await connectFreshWorkspace(page) + await connectFreshWorkspace(page, scaffold.workspaceCwd) const parent = scaffold.ctx.agents.roots()[0] if (parent === undefined) throw new Error('fresh workspace did not publish its parent Agent') @@ -90,10 +100,50 @@ describe('web e2e: persisted subagent conversation and human continuation', () = }, }) childId = started.childId - await expect.poll( - () => scaffold.ctx.agents.get(childId), - { timeout: 30_000 }, - ).toBeUndefined() + await waitForAgentToSettle(scaffold, childId) + oneShotId = sessionId('recorded-one-shot') + const oneShotAt = Date.now() + await scaffold.ctx.sessionPersistence.create({ + version: SESSION_FORMAT_VERSION, + id: oneShotId, + createdAt: oneShotAt, + cwd: scaffold.workspaceCwd, + parentSession: parent.id, + origin: 'subagent', + delegationDepth: 1, + }) + await scaffold.ctx.sessionPersistence.append(oneShotId, [ + { + type: 'turn/start', + seq: 0, + time: oneShotAt, + data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + }, + { + type: 'user/message', + seq: 1, + time: oneShotAt + 1, + data: { + content: [{ type: 'text', text: 'Review the event sourcing explanation.' }], + source: { kind: 'user' }, + }, + surfaceOp: 'append', + }, + { + type: 'subagent/descriptor', + seq: 2, + time: oneShotAt + 2, + data: snapshotSubagentDescriptor({ + mode: 'one-shot', provider: 'spawn', label: ONE_SHOT_LABEL, + }), + }, + { + type: 'turn/end', + seq: 3, + time: oneShotAt + 3, + data: { turn: 1, reason: { kind: 'completed' } }, + }, + ] as SessionEvent[]) grandchildId = sessionId('recorded-grandchild') const authoredAt = Date.now() await scaffold.ctx.sessionPersistence.create({ @@ -102,6 +152,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = createdAt: authoredAt, cwd: scaffold.workspaceCwd, parentSession: childId, + origin: 'subagent', delegationDepth: 2, }) await scaffold.ctx.sessionPersistence.append(grandchildId, [ @@ -125,7 +176,9 @@ describe('web e2e: persisted subagent conversation and human continuation', () = type: 'subagent/descriptor', seq: 2, time: authoredAt + 2, - data: snapshotSubagentDescriptor({ provider: 'spawn', label: NESTED_LABEL }), + data: snapshotSubagentDescriptor({ + mode: 'continuable', provider: 'spawn', label: NESTED_LABEL, + }), }, { type: 'turn/end', @@ -135,14 +188,22 @@ describe('web e2e: persisted subagent conversation and human continuation', () = }, ] as SessionEvent[]) expect(scaffold.ctx.agents.get(childId)).toBeUndefined() + expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined() expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined() await expect(scaffold.ctx.subagents.listChildren(parent.id)).resolves.toMatchObject([ - { kind: 'child', id: childId, label: LABEL, activity: 'inactive' }, + { kind: 'child', id: childId, mode: 'continuable', label: LABEL, activity: 'inactive' }, + { + kind: 'child', id: oneShotId, mode: 'one-shot', + label: ONE_SHOT_LABEL, activity: 'inactive', + }, ]) await expect(scaffold.ctx.subagents.listChildren(childId)).resolves.toMatchObject([ - { kind: 'child', id: grandchildId, label: NESTED_LABEL, activity: 'inactive' }, + { + kind: 'child', id: grandchildId, mode: 'continuable', + label: NESTED_LABEL, activity: 'inactive', + }, ]) - await page.getByRole('button', { name: '1 个子代理' }).waitFor({ timeout: 15_000 }) + await page.getByRole('button', { name: '2 个子代理' }).waitFor({ timeout: 15_000 }) }, 120_000) afterAll(async () => { @@ -159,7 +220,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = it('expands a persisted grandchild progressively without activating either level', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-tree')) - await page.getByRole('button', { name: '1 个子代理' }).click() + await page.getByRole('button', { name: '2 个子代理' }).click() await page.getByRole('button', { name: `展开 ${LABEL} 的下级子代理` }).click() await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).waitFor({ timeout: 15_000 }) expect(scaffold.ctx.agents.get(childId)).toBeUndefined() @@ -175,7 +236,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = it('opens the completed child from persistence without activating it', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-open')) - await page.getByRole('button', { name: '1 个子代理' }).click() + await page.getByRole('button', { name: '2 个子代理' }).click() await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() await expect.poll( () => page.getByText(INITIAL_PROMPT, { exact: true }).count(), @@ -193,7 +254,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE) }) - it('continues through a cold-resumed Activation and receives the child mux events', async () => { + it('continues through FIFO follow-up admission and receives the child mux events', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-followup')) const ended = new Promise((resolveEnded, reject) => { const timer = setTimeout(() => { @@ -223,4 +284,16 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }) + + it('opens a one-shot child as permanently read-only history', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-one-shot')) + const parentSession = page.getByRole('tree', { name: 'Sessions' }) + .getByRole('treeitem') + .last() + await parentSession.click() + await page.getByRole('button', { name: '2 个子代理' }).click() + await page.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) }).click() + await page.getByText('一次性任务不支持后续消息,可在这里查看完整执行记录。').waitFor() + expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined() + }) }) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 4ccd38735d..0dbb9be2c7 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2435,7 +2435,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'session.cancel': return this.api.sessions.cancel(request) case 'subagent.list': return this.api.subagents.list(request) case 'subagent.history': return this.api.subagents.history(request) - case 'subagent.prompt': return this.api.subagents.prompt(request) + case 'subagent.prompt': return this.api.subagents.prompt(request, signal) case 'host.describe': return this.api.host.describe(request) case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal) case 'host.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index bc6e751c7a..03087915ea 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -153,7 +153,7 @@ export class SessionManager { selectSubagent(address: SubagentAddress): void { const catalog = this.catalogs.get(address.parentSessionId) const entry = catalog?.entries.find(candidate => candidate.id === address.childSessionId) - if (entry === undefined || entry.kind !== 'child') { + if (entry === undefined || entry.kind !== 'child' || entry.mode !== address.mode) { throw new Error(`sessions.selectSubagent: ${address.childSessionId} is not a healthy catalog child`) } this.addresses.set(address.childSessionId, address) @@ -625,7 +625,7 @@ export class SessionManager { case 'host/session-removed': { this.recordMutation({ kind: 'remove', sessionId: frame.sessionId }) if (this.addresses.has(frame.sessionId)) { - // A continuable activation detaching is not durable child deletion: + // An Activation detaching is not durable child deletion: // keep the addressed conversation usable and return its catalog row // to the inactive state. this.sessions.get(frame.sessionId)?.handleRunning(false) diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index d5eb160441..6f360eb2c6 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -642,7 +642,7 @@ export class SessionsService implements ISessions { if (child?.kind === 'child') { byId[current] = { id: current, - displayTitle: child.label, + displayTitle: child.label ?? current, parentId: currentAddress.parentSessionId, origin: 'subagent', running: child.activity === 'running', @@ -660,7 +660,8 @@ export class SessionsService implements ISessions { } else if (byId[current] !== undefined && (persisted !== current || this.selection.getSnapshot().subagentAddress?.childSessionId !== currentAddress?.childSessionId - || this.selection.getSnapshot().subagentAddress?.parentSessionId !== currentAddress?.parentSessionId)) { + || this.selection.getSnapshot().subagentAddress?.parentSessionId !== currentAddress?.parentSessionId + || this.selection.getSnapshot().subagentAddress?.mode !== currentAddress?.mode)) { this.selection.set({ sessionId: current, ...(currentAddress === undefined ? {} : { subagentAddress: currentAddress }), diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 0a85609750..2bcb2ac5fc 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -223,6 +223,15 @@ export class Session implements SessionFace { try { if (this.address === undefined) { result = (await this.api.sessions.prompt({ sessionId: this.sessionId, mode, content })).result + } else if (this.address.mode === 'one-shot') { + result = { + ok: false, + error: { + code: 'subagent-not-resumable', + message: 'one-shot subagent conversations are read-only', + details: { childSessionId: this.address.childSessionId }, + }, + } } else { const routed = (await this.api.subagents.prompt({ ...this.address, content })).result result = routed.ok ? { ok: true, value: { accepted: true } } : routed @@ -270,7 +279,7 @@ export class Session implements SessionFace { const result: RpcResult<{ accepted: true }> = { ok: false, error: { - code: 'subagent-not-delivered', + code: 'subagent-delivery-unavailable', message: 'subagent activation cancellation is unavailable', details: { childSessionId: this.address.childSessionId }, }, @@ -512,6 +521,7 @@ export class Session implements SessionFace { configureSubagent(address: SubagentAddress | undefined, parentAvailable = false): void { const same = this.address?.parentSessionId === address?.parentSessionId && this.address?.childSessionId === address?.childSessionId + && this.address?.mode === address?.mode this.address = address this.parentAvailable = parentAvailable if (!same && this.openState !== 'cold') void this.resync() diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 4e39668244..9f6d3a5fa7 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -286,38 +286,43 @@ describe('subagent catalogs', () => { summary(S2, { parentSessionId: S1, origin: 'subagent' }), ] as never[] })) api.onSubagentList = () => Promise.resolve(ok({ - entries: [{ kind: 'child', id: S2, label: 'worker', activity: 'running' }] as never[], + entries: [{ + kind: 'child', id: S2, mode: 'continuable', label: 'worker', activity: 'running', + }] as never[], parentAvailable: true, })) const manager = new SessionManager(api) await manager.refreshList() await manager.refreshSubagents(S1) - manager.selectSubagent({ parentSessionId: S1, childSessionId: S2 }) + manager.selectSubagent({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' }) expect(manager.getListSnapshot().currentAddress).toEqual({ - parentSessionId: S1, childSessionId: S2, + parentSessionId: S1, childSessionId: S2, mode: 'continuable', }) expect(manager.get(S2).getSnapshot().subagent).toEqual({ - address: { parentSessionId: S1, childSessionId: S2 }, + address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' }, parentAvailable: true, }) // Clicking the same child through an ordinary list-selection path must not // erase the catalog-derived address and fall back to session.* transport. manager.select(S2) expect(manager.getListSnapshot().currentAddress).toEqual({ - parentSessionId: S1, childSessionId: S2, + parentSessionId: S1, childSessionId: S2, mode: 'continuable', }) expect(manager.get(S2).getSnapshot().subagent).toEqual({ - address: { parentSessionId: S1, childSessionId: S2 }, + address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' }, parentAvailable: true, }) await manager.get(S2).open() await manager.get(S2).prompt([{ type: 'text', text: 'continue' }], 'queue') expect(api.callsOf('subagent.history')).toEqual([ - { parentSessionId: S1, childSessionId: S2, maxMessages: 50 }, + { parentSessionId: S1, childSessionId: S2, mode: 'continuable', maxMessages: 50 }, ]) expect(api.callsOf('subagent.prompt')).toEqual([ - { parentSessionId: S1, childSessionId: S2, content: [{ type: 'text', text: 'continue' }] }, + { + parentSessionId: S1, childSessionId: S2, mode: 'continuable', + content: [{ type: 'text', text: 'continue' }], + }, ]) expect(api.callsOf('session.history')).toEqual([]) expect(api.callsOf('session.prompt')).toEqual([]) @@ -337,7 +342,9 @@ describe('subagent catalogs', () => { }) expect(manager.get(S2).getSnapshot()).toMatchObject({ removed: false, - subagent: { address: { parentSessionId: S1, childSessionId: S2 } }, + subagent: { + address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' }, + }, }) }) @@ -554,7 +561,9 @@ describe('connected generation', () => { it('reloads the durable parent address for a restored child selection', async () => { const api = new FakeApiClient() - const address = { parentSessionId: S1, childSessionId: S2 } + const address = { + parentSessionId: S1, childSessionId: S2, mode: 'continuable' as const, + } const manager = new SessionManager(api, S2, address) manager.handleConnected() diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 1fbee80853..7f5f793948 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -584,7 +584,7 @@ describe('prompt and cancel errors', () => { it('routes an addressed child through non-activating history and continuation prompt only', async () => { const api = new FakeApiClient() const session = new Session(SID, api, { - address: { parentSessionId: PARENT, childSessionId: SID }, + address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, parentAvailable: true, }) await session.open() @@ -592,22 +592,40 @@ describe('prompt and cancel errors', () => { const cancelled = await session.cancel() expect(prompted).toEqual({ ok: true, value: { accepted: true } }) - expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-not-delivered' } }) + expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-delivery-unavailable' } }) expect(api.callsOf('subagent.history')).toEqual([ - { parentSessionId: PARENT, childSessionId: SID, maxMessages: 50 }, + { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable', maxMessages: 50 }, ]) expect(api.callsOf('subagent.prompt')).toEqual([ - { parentSessionId: PARENT, childSessionId: SID, content: [{ type: 'text', text: '继续' }] }, + { + parentSessionId: PARENT, childSessionId: SID, mode: 'continuable', + content: [{ type: 'text', text: '继续' }], + }, ]) expect(api.callsOf('session.history')).toEqual([]) expect(api.callsOf('session.prompt')).toEqual([]) expect(api.callsOf('session.cancel')).toEqual([]) expect(session.getSnapshot().subagent).toEqual({ - address: { parentSessionId: PARENT, childSessionId: SID }, + address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, parentAvailable: true, }) }) + it('keeps one-shot history readable without exposing prompt or cancel transport', async () => { + const api = new FakeApiClient() + const session = new Session(SID, api, { + address: { parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot' }, + }) + await session.open() + const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue') + + expect(prompted).toMatchObject({ ok: false, error: { code: 'subagent-not-resumable' } }) + expect(api.callsOf('subagent.history')).toEqual([ + { parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot', maxMessages: 50 }, + ]) + expect(api.callsOf('subagent.prompt')).toEqual([]) + }) + it('sends content through session.prompt; composerPhase steps blank → engaging synchronously at send entry', async () => { const { api, session } = makeSession() // The blank → engaging edge fires before the RPC settles: the first-send diff --git a/packages/client/ui-command/tests/service.spec.ts b/packages/client/ui-command/tests/service.spec.ts index c023c84771..08fda13a6f 100644 --- a/packages/client/ui-command/tests/service.spec.ts +++ b/packages/client/ui-command/tests/service.spec.ts @@ -74,7 +74,7 @@ async function bench(opts: BenchOptions = {}) { scope: (id: SessionId) => scopes.get(id)?.ctx, scopeOf: (c: Context) => scopeOf(c), subagentAddress: (id: SessionId) => id === opts.addressed - ? { parentSessionId: sid('parent'), childSessionId: id } + ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const } : undefined, }) ctx.provide('connection', { api }) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 5fdf0cf08d..aec4bc46bc 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: fef922ab42e71813faaa826cc2580bdec72baa74 -README.zh.md: 4c1e7670bbfceaed73328108d3b5a3e646ee86bf +README.md: 65e4542e622713e2cd120906926fc0601ef280bd +README.zh.md: 4828ad214bb5ec0a9921307dd01dcc282910b330 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index fef922ab42..65e4542e62 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -12,7 +12,7 @@ The view ring IS a slot: the conversation registration declares the `'conversati Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission ` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing. -The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes `subagentReadOnly`; ui-subagent claims that state to explain the unavailable-parent condition, while the ordinary InputBar hides Stop for every addressed continuable subagent conversation because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership. +The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership. Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap, shows inline JSON for both `content` and `source`, and synthesizes no tool state, summary, or keyed toolview dispatch ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 4c1e7670bb..4828ad214b 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -10,7 +10,7 @@ 视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: `);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包(package)自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。 -会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含 `subagentReadOnly`;ui-subagent 会接管该状态并说明 parent 不可用,而普通 InputBar 会在所有已寻址的可继续 subagent 对话中隐藏 Stop,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。 +会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。 已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,并以内联 JSON 展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。 diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index d006ff5eb3..f1ba8c8ef1 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -334,8 +334,8 @@ export type ComposerBarProps = */ export interface ComposerChainProps { interactions: readonly PendingInteraction[] - /** A catalog-addressed child whose exact parent Agent is unavailable. */ - subagentReadOnly: boolean + /** Current conversation facts for feature-owned takeover selectors. */ + session: ConversationSnapshot | undefined } /** diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 5423cff184..b8926c1c47 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -19,7 +19,6 @@ export function ConversationRoot({ const openState = useSession(s => s.openState) const composerPhase = useSession(s => s.composerPhase) const pending = useSession(s => s.pending) ?? [] - const subagentReadOnly = useSession(s => s?.subagent?.parentAvailable === false) ?? false const session = useSession(s => s) const inputState = useInput(s => s) const cwd = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.cwd) @@ -154,7 +153,7 @@ export function ConversationRoot({ const phase = settling ? 'settling' : hero ? 'hero' : 'active' const composer = renderSlotChain( 'conversation.composer', - { interactions: pending, subagentReadOnly }, + { interactions: pending, session }, { fallback: composerBar, overlay: true }, ) diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx index 03b58acd5f..a02f8ccb6f 100644 --- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx @@ -248,6 +248,8 @@ describe('bash sample row', () => { }, current: undefined, phase: 'ready', + subagentsByParent: {}, + currentAddress: undefined, }) } diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx index 24c900deeb..445b875109 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx @@ -96,6 +96,8 @@ describe('tails', () => { byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 } }, current: undefined, phase: 'ready', + subagentsByParent: {}, + currentAddress: undefined, }) const props = (block: RunningToolCall | ToolResultNode) => ({ callId: 'c1', toolName: 'bash', block, openFile: vi.fn(), diff --git a/packages/client/ui-model/README.i18n.yaml b/packages/client/ui-model/README.i18n.yaml index 9620b15513..1a4e421a7f 100644 --- a/packages/client/ui-model/README.i18n.yaml +++ b/packages/client/ui-model/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-model/README.md -README.md: 267717c78434f7a73b1c1eebca0cc0f9d65c3642 -README.zh.md: 6d6f433315336812a51b5110ceeac3eecbd9bbd4 +README.md: 27fb7b936b796b956f7348fa776856180350bb56 +README.zh.md: 9cc6b04ef2e7ba24fb8fc3f6d5456bf88f0652fe diff --git a/packages/client/ui-model/README.md b/packages/client/ui-model/README.md index 267717c784..27fb7b936b 100644 --- a/packages/client/ui-model/README.md +++ b/packages/client/ui-model/README.md @@ -2,13 +2,13 @@ English | [中文](README.zh.md) -Model selection plugin, browser half: TWO entries over ONE per-session directory owned by `ModelService` (`ctx.models`). The `/model` popupSelect contribution (registered through `ctx.command`) and the composer's named `conversation.input.model` seat both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance. The compact composer trigger opens a two-level Model/Effort menu: models stay provider-grouped, while the selected exact model supplies its adapter-owned effort names, descriptions, and default. The Host-reported provider/model/reasoning target is the single fact both entries echo; `/model` applies the selected model's default effort, and the composer can then choose any advertised effort. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored target before display. Provider-local metadata failures list inline while usable groups stay selectable, and selection failures retain the prior target and directory. Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope. +Model selection plugin, browser half: TWO entries over ONE per-session directory owned by `ModelService` (`ctx.models`). For ordinary sessions, the `/model` popupSelect contribution (registered through `ctx.command`) and the composer's named `conversation.input.model` seat both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance. The compact composer trigger opens a two-level Model/Effort menu: models stay provider-grouped, while the selected exact model supplies its adapter-owned effort names, descriptions, and default. The Host-reported provider/model/reasoning target is the single fact both entries echo; `/model` applies the selected model's default effort, and the composer can then choose any advertised effort. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored target before display. Provider-local metadata failures list inline while usable groups stay selectable, and selection failures retain the prior target and directory. Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope. Addressed subagent sessions expose neither entry, and their directory rejects loads, selections, and reconnect refreshes, because ordinary Agent-bound model RPCs would activate persisted child history outside the direct-parent continuation seam. The `/client` export surface is the plugin body (`apply`/`inject`), `ModelService`, `ModelDirectory` with its state shape, and the seat's injected face type. ## Model Experience -Indirectly, through the `session.selectModel` RPC both entries submit: the Host snapshots the selected provider/model/reasoning target at the next prompt-assembly boundary, so the following request uses the chosen route and effort while a running step keeps its assembled target. The selection becomes durable only when the existing request header records a request that consumes it; menu interaction adds no prompt content. +Indirectly, through the `session.selectModel` RPC available to ordinary sessions, both entries submit the provider/model/reasoning target that the Host snapshots at the next prompt-assembly boundary, so the following request uses the chosen route and effort while a running step keeps its assembled target; the selection becomes durable only when the existing request header records a request that consumes it, and menu interaction adds no prompt content. #### KV Cache effect @@ -16,6 +16,6 @@ Switching the route can reduce or invalidate provider-side cache reuse for subse ## Known Limitations and Deferred Work -- **No create-time selection** — both entries address an existing session's agent; there is no draft-phase model choice to fold into session creation (the seed order at the host's `targetFor` documents where such a tier would go). +- **No create-time or addressed-subagent selection** — both entries require an existing ordinary session's Agent; there is no draft-phase model choice to fold into session creation, and subagent continuation deliberately exposes no independent model-retargeting contract. - **Directory names are presentation-only** — selection and persistence use provider/model/effort ids; a provider whose catalog or exact-model metadata lookup fails lists as an unselectable failure row until reload. - **No arbitrary effort input** — the composer offers only the exact model's adapter-advertised levels; an adapter without reasoning metadata leaves the Effort row absent. diff --git a/packages/client/ui-model/README.zh.md b/packages/client/ui-model/README.zh.md index 6d6f433315..9cc6b04ef2 100644 --- a/packages/client/ui-model/README.zh.md +++ b/packages/client/ui-model/README.zh.md @@ -2,13 +2,13 @@ [English](README.md) | 中文 -模型选择插件(浏览器侧):**两个入口共用一份会话级目录**,由 `ModelService`(`ctx.models`)持有。`/model` popupSelect 贡献项(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` 坑位都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单:模型仍按提供方分组,所选具体模型则提供由其适配器持有的推理强度名称、说明和默认值。Host 报告的提供方/模型/推理(reasoning)目标是两个入口共同回显的唯一事实;`/model` 应用所选模型的默认推理强度,composer 随后可以选择任一已公布的推理强度。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。各提供方的元数据获取失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话作用域一并释放。 +模型选择插件(浏览器半侧):**两个入口共用一份 per-session 目录**,由 `ModelService`(`ctx.models`)持有。对于普通会话,`/model` popupSelect contribution(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` 坑位都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单:模型仍按提供方分组,所选确切模型则提供由其适配器持有的推理强度名称、说明和默认值。Host 报告的提供方/模型/推理(reasoning)目标是两个入口共同回显的唯一事实;`/model` 应用所选模型的默认推理强度,composer 随后可以选择任一已公布的推理强度。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。逐提供方元数据失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话 scope 一并释放。已寻址 subagent 会话不公开任一入口,其目录会拒绝加载、选择与重新连接刷新,因为绑定到 agent(智能体)的普通模型 RPC 会在直接 parent 继续执行 seam 之外激活持久化 child 历史。 `/client` 导出面为插件本体(`apply`/`inject`)、`ModelService`、`ModelDirectory` 及其状态形状、坑位注入面类型。 ## 模型体验 -间接影响,经两个入口共同提交的 `session.selectModel` RPC:Host 在下一次提示词组装边界快照所选提供方/模型/推理强度目标,因此下一次请求采用所选路由和推理强度,而运行中的步骤保留已组装目标。只有当现有请求头记录一次实际采用该选择的请求后,选择才会持久化;菜单交互不会添加提示词内容。 +间接影响,经仅普通会话可用的 `session.selectModel` RPC,两个入口都会提交提供方/模型/推理强度目标,Host 会在下一次提示词组装边界对该目标进行快照,因此后续请求采用所选路由和推理强度,而运行中的步骤保留已组装目标;只有当现有请求头记录一次实际采用该选择的请求后,选择才会持久化,且菜单交互不会添加提示词内容。 #### KV Cache 影响 @@ -16,6 +16,6 @@ ## 已知限制与暂缓事项 -- **无创建期选择**——两个入口都面向既有会话的 agent(智能体);没有将草稿阶段的模型选择纳入会话创建的通道(host 的 `targetFor` 中的种子顺序说明了该层未来的落点)。 -- **目录名仅供呈现**——选择与持久化使用提供方/模型/推理强度 id;目录查询或具体模型元数据查询失败的提供方以不可选失败行列出,重新加载前保持原样。 -- **不能任意输入推理强度**——composer 仅提供具体模型由适配器公布的推理强度;适配器没有推理元数据时不显示 Effort 行。 +- **无创建期或已寻址 subagent 选择**——两个入口都要求既有普通会话的 agent;没有可折入会话创建的 Draft 期模型选择,subagent 继续执行也有意不公开独立更改模型目标的契约。 +- **目录名仅供呈现**——选择与持久化使用提供方/模型/推理强度 id;目录查询或确切模型元数据查询失败的提供方以不可选失败行列出,重新加载前保持原样。 +- **不能任意输入推理强度**——composer 仅提供确切模型由适配器公布的推理强度;适配器没有推理元数据时不显示 Effort 行。 diff --git a/packages/client/ui-model/src/client/ModelSelect.tsx b/packages/client/ui-model/src/client/ModelSelect.tsx index 4cc2687832..2ad7159044 100644 --- a/packages/client/ui-model/src/client/ModelSelect.tsx +++ b/packages/client/ui-model/src/client/ModelSelect.tsx @@ -40,7 +40,8 @@ interface EffortChoice { * @returns the trigger and, while open, the two-level menu. */ export function ModelSelect( - { locked, directory, load, select, t }: ModelSelectInjected & { locked: boolean } & PropsLocale<'model'>, + { locked, available, directory, load, select, t }: + ModelSelectInjected & { locked: boolean } & PropsLocale<'model'>, ) { const state = useSyncExternalStore( fn => directory.subscribe(fn), @@ -92,7 +93,9 @@ export function ModelSelect( const busy = state.status === 'selecting' // Mount-time load resolves the trigger label; every open refreshes. - useEffect(() => { load() }, [load]) + useEffect(() => { + if (available) load() + }, [available, load]) useEffect(() => { if (!open) return @@ -103,6 +106,8 @@ export function ModelSelect( return () => { document.removeEventListener('mousedown', closeOutside) } }, [open]) + if (!available) return null + const show = (): void => { setPane('root') setOpen(true) diff --git a/packages/client/ui-model/src/client/directory.ts b/packages/client/ui-model/src/client/directory.ts index 827502d258..c98549bae8 100644 --- a/packages/client/ui-model/src/client/directory.ts +++ b/packages/client/ui-model/src/client/directory.ts @@ -39,10 +39,12 @@ export class ModelDirectory { /** * @param sessions - the session wire face (captured from the plugin's root connection). * @param sessionId - the owning session. + * @param available - whether this session may use Agent-bound model RPCs. */ constructor( private readonly sessions: Pick, private readonly sessionId: SessionId, + private readonly available: () => boolean, ) {} /** @@ -51,6 +53,7 @@ export class ModelDirectory { * @returns the fresh directory value. */ async load(): Promise { + this.assertAvailable() const generation = ++this.generation this.store.update((s) => { s.status = 'loading'; s.error = null }) const { result } = await this.sessions.models({ sessionId: this.sessionId }) @@ -80,6 +83,7 @@ export class ModelDirectory { * @param target - provider, provider-owned model id, and optional adapter-owned effort. */ async select(target: ModelTarget): Promise { + this.assertAvailable() const generation = ++this.generation this.store.update((s) => { s.status = 'selecting'; s.error = null }) const { result } = await this.sessions.selectModel({ @@ -116,6 +120,7 @@ export class ModelDirectory { s.status = 'idle' s.error = null }) + if (!this.available()) return void this.load().catch(() => { /* the next menu open remains the explicit retry surface */ }) } @@ -123,4 +128,10 @@ export class ModelDirectory { dispose(): void { this.disposed = true } + + private assertAvailable(): void { + if (!this.available()) { + throw new Error('model selection is unavailable for addressed subagent sessions') + } + } } diff --git a/packages/client/ui-model/src/client/index.ts b/packages/client/ui-model/src/client/index.ts index a672c6935a..86091f4ede 100644 --- a/packages/client/ui-model/src/client/index.ts +++ b/packages/client/ui-model/src/client/index.ts @@ -7,7 +7,9 @@ * so the host-reported current target is the single fact both surfaces echo * — a switch made in either entry is what the other shows next. Failures * ride each entry's own retry surface (popup shell error/retry; seat menu - * inline error) without forking the state. + * inline error) without forking the state. Addressed subagent sessions expose + * neither entry because those Agent-bound RPCs would activate persisted + * history outside the direct-parent continuation seam. */ import type { ModelTarget, SessionModels } from '@deepseek-ai/dsh-client-connection/client' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' @@ -119,14 +121,23 @@ export function apply(ctx: ClientContext): void { ctx.inject(['command', 'models'], (scope: ClientContext) => { const command = scope.get('command') as CommandServiceContract const models = scope.models + const sessions = scope.sessions scope.effect(() => command.register({ name: 'model', description: t('command.description'), - available: () => true, + available: session => sessions.subagentAddress(session.sessionId) === undefined, ui: { kind: 'popupSelect', - options: async session => optionsOf(await models.directoryFor(session.sessionId).load(), t), + options: async (session) => { + if (sessions.subagentAddress(session.sessionId) !== undefined) { + throw new Error('model selection is unavailable for addressed subagent sessions') + } + return optionsOf(await models.directoryFor(session.sessionId).load(), t) + }, onSelect: async (option, session) => { + if (sessions.subagentAddress(session.sessionId) !== undefined) { + throw new Error('model selection is unavailable for addressed subagent sessions') + } const directory = models.directoryFor(session.sessionId) const target = targetOf(directory.store.getSnapshot(), option.id) if (target === undefined) { @@ -143,15 +154,22 @@ export function apply(ctx: ClientContext): void { // conversation service's presence is the registration-safe signal. ctx.inject(['slots', 'conversation', 'models'], (scope: ClientContext) => { const models = scope.models + const sessions = scope.sessions scope.effect(() => scope.slots.register({ name: 'conversation.input.model', locale: NS, inject: (sessionId): ModelSelectInjected => { const directory = models.directoryFor(sessionId) + const available = sessions.subagentAddress(sessionId) === undefined return { + available, directory: directory.store, - load: () => { directory.load().catch(() => { /* surfaced on the store */ }) }, - select: (target: ModelTarget) => directory.select(target).then(() => true, () => false), + load: () => { + if (available) directory.load().catch(() => { /* surfaced on the store */ }) + }, + select: (target: ModelTarget) => available + ? directory.select(target).then(() => true, () => false) + : Promise.resolve(false), } }, }, ModelSelect), 'ui-model: composer model seat registration') diff --git a/packages/client/ui-model/src/client/service.ts b/packages/client/ui-model/src/client/service.ts index cbe8c75a57..5506bf13a5 100644 --- a/packages/client/ui-model/src/client/service.ts +++ b/packages/client/ui-model/src/client/service.ts @@ -68,7 +68,11 @@ export class ModelService extends Service { const actx = sessions.scope(sessionId) if (actx === undefined) throw new Error(`ui-model: session "${String(sessionId)}" resolved no scope`) const connection = this.ctx.get('connection') as ConnectionHandle - const directory = new ModelDirectory(connection.api.sessions, sessionId) + const directory = new ModelDirectory( + connection.api.sessions, + sessionId, + () => sessions.subagentAddress(sessionId) === undefined, + ) live.directories.set(sessionId, directory) actx.effect(() => () => { directory.dispose() diff --git a/packages/client/ui-model/src/client/slots.ts b/packages/client/ui-model/src/client/slots.ts index d508026f78..3d408de232 100644 --- a/packages/client/ui-model/src/client/slots.ts +++ b/packages/client/ui-model/src/client/slots.ts @@ -10,6 +10,8 @@ import type { ModelDirectoryState } from './directory.ts' /** Injected business face of the composer model seat. */ export interface ModelSelectInjected { + /** Whether this session supports Agent-bound model inspection and selection. */ + available: boolean /** The session's shared directory store (same instance the /model popup reads). */ directory: SnapshotStore /** Refresh the advisory directory (fire-and-forget; errors land on the store). */ diff --git a/packages/client/ui-model/tests/browser-plugin.spec.ts b/packages/client/ui-model/tests/browser-plugin.spec.ts index b1c3defb9d..9a9e58ce00 100644 --- a/packages/client/ui-model/tests/browser-plugin.spec.ts +++ b/packages/client/ui-model/tests/browser-plugin.spec.ts @@ -93,7 +93,13 @@ async function bench() { ctx.provide('conversation', {}) ctx.provide('locale', new LocaleService(ctx)) const scopes = new Map() - ctx.provide('sessions', { scope: (id: SessionId) => scopes.get(id) }) + const addressed = new Set() + ctx.provide('sessions', { + scope: (id: SessionId) => scopes.get(id), + subagentAddress: (id: SessionId) => addressed.has(id) + ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const } + : undefined, + }) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() await ctx.plugin(function probe() {}).await() @@ -108,6 +114,7 @@ async function bench() { seat: () => seats.get('conversation.input.model')!, hostCurrent: () => current, setHostCurrent: (target: ModelTarget) => { current = target }, + address: (id: SessionId) => { addressed.add(id) }, } } @@ -214,4 +221,30 @@ describe('ui-model dual entry', () => { const b = await bench() expect(() => b.seat().inject!(sid('ghost'))).toThrow(/resolved no scope/) }) + + it('withholds both model entries from addressed subagent sessions without Agent-bound RPCs', async () => { + const b = await bench() + b.mint('child') + b.address(sid('child')) + + expect(b.contribution().available(projection('child'))).toBe(false) + await expect(b.contribution().ui.options( + projection('child'), + new AbortController().signal, + )).rejects.toThrow(/unavailable for addressed subagent/) + + const face = b.seat().inject!(sid('child')) + expect(face.available).toBe(false) + face.load() + await expect(face.select({ provider: 'deepseek', model: 'deepseek-v4-pro' })).resolves.toBe(false) + await expect(b.ctx.models.directoryFor(sid('child')).load()) + .rejects.toThrow(/unavailable for addressed subagent/) + await expect(b.ctx.models.directoryFor(sid('child')).select({ + provider: 'deepseek', + model: 'deepseek-v4-pro', + })).rejects.toThrow(/unavailable for addressed subagent/) + b.ctx.emit('connection/reset') + await Promise.resolve() + expect(b.calls).toEqual({ models: 0, select: 0 }) + }) }) diff --git a/packages/client/ui-model/tests/model-select.spec.tsx b/packages/client/ui-model/tests/model-select.spec.tsx index d1dc4f7d1b..61a8f6edd0 100644 --- a/packages/client/ui-model/tests/model-select.spec.tsx +++ b/packages/client/ui-model/tests/model-select.spec.tsx @@ -55,6 +55,7 @@ describe('ModelSelect reasoning effort', () => { }) render( { })) render( { expect(screen.getAllByRole('menuitemradio').map(item => item.textContent)) .toEqual(['Default', 'Standard']) }) + + it('renders no Agent-bound control for an addressed subagent session', () => { + const load = vi.fn() + render() + + expect(screen.queryByRole('button')).toBeNull() + expect(load).not.toHaveBeenCalled() + }) }) diff --git a/packages/client/ui-question/tests/plan-review-panel.spec.tsx b/packages/client/ui-question/tests/plan-review-panel.spec.tsx index 069445415f..be22597a04 100644 --- a/packages/client/ui-question/tests/plan-review-panel.spec.tsx +++ b/packages/client/ui-question/tests/plan-review-panel.spec.tsx @@ -29,6 +29,7 @@ const seatOver = (dict: Record, common: Record): /** Framework standard-kit stubs: the panel consumes only the locale seat. */ const kit = { sessionId: SID, + session: undefined, useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook, useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook, useWorkspaces: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook, diff --git a/packages/client/ui-question/tests/question-composer.spec.tsx b/packages/client/ui-question/tests/question-composer.spec.tsx index 78c047ff2f..e31d756a9f 100644 --- a/packages/client/ui-question/tests/question-composer.spec.tsx +++ b/packages/client/ui-question/tests/question-composer.spec.tsx @@ -26,7 +26,7 @@ const seatOver = (dict: Record, common: Record): * the composed props type mandates delivery of the rest (framework hooks are * plain stubs per the client testing discipline). */ const kit = { - subagentReadOnly: false, + session: undefined, sessionId: SID, useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook, useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook, diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts index 420be78d2b..72a7d6f7a6 100644 --- a/packages/client/ui-skill/tests/browser-plugin.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.spec.ts @@ -31,7 +31,7 @@ async function bench(list: ListFn, addressed?: SessionId) { ctx.provide('connection', { api: { skills: { list } } }) ctx.provide('sessions', { subagentAddress: (id: SessionId) => id === addressed - ? { parentSessionId: sid('parent'), childSessionId: id } + ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const } : undefined, }) await ctx.plugin({ inject: [...inject], apply }).await() diff --git a/packages/client/ui-slash/src/types.ts b/packages/client/ui-slash/src/types.ts index 2b63381efb..c885984b73 100644 --- a/packages/client/ui-slash/src/types.ts +++ b/packages/client/ui-slash/src/types.ts @@ -11,11 +11,10 @@ import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' /** - * The provider-facing projection of one client session. Client sessions are - * always agent-backed — the host births Session+Agent+cwd together and the - * client only creates scopes for materialized sessions — so the projection - * carries the stable session identity alone: sources address every RPC by - * `sessionId` with no capability discrimination. + * The provider-facing projection of one client session. It carries stable + * identity alone; a source that calls Agent-bound RPCs must consult its own + * service's capability state because an addressed persisted subagent may + * have a client scope without a live Host Agent. */ export interface ClientSessionContext { readonly sessionId: SessionId diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index 36cb547883..e1fefc3e74 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-subagent/README.md -README.md: c69211bd6f84b6e09760ff47d5a319b1ef2ce28c -README.zh.md: 43776fbc32a5bd369e837779787cdc82ceb0f749 +README.md: 7399681265a9905f12fa0cbaf621528fb86562a3 +README.zh.md: f9a507336058920835745c3882fc3b9fe83c5ab0 diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index c69211bd6f..7399681265 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -2,11 +2,11 @@ English | [中文](README.zh.md) -Web subagent feature owner: contributes the lazily expandable catalog tree to `conversation.session.header.actions`, the unavailable-parent replacement to the conversation composer chain, and the existing `@` reference source to `ctx.slash`. +Web subagent feature owner: contributes the lazily expandable catalog tree to `conversation.session.header.actions`, reason-specific read-only replacements to the conversation composer chain, and the existing `@` reference source to `ctx.slash`. -The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty catalog arrives it shows the healthy direct-child count and a compact tree in service order. Each healthy row combines its durable label, `running`/`inactive` activity (rendered as `正在处理`/`已完成`), optional log-backed title, and session-summary activity time; corrupt, unsupported, or unavailable rows remain readable but disabled. Expanding a row lazily opens that child's direct catalog and reports every visible branch to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId}` address. Component-local state owns tree visibility, expanded branches, and keyboard focus. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only. +The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty catalog arrives it shows the healthy direct-child count and a compact tree in service order. Continuable and one-shot rows display mode plus `running`/`inactive` activity, an optional log-backed title, and session-summary activity time; an unlabeled one-shot row falls back to its session id. Corrupt, unsupported, or unavailable rows remain readable but disabled. Expanding a row lazily opens that child's direct catalog and reports every visible branch to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, and keyboard focus. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only. -An addressed child with no exact live parent elects the read-only composer entry and explains the recovery path. A child with a live parent keeps the ordinary input chrome, whose Session routes through `subagent.prompt`; this package never receives host context or calls a model-facing tool. The catalog and composer behavior are specified by the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md). +A one-shot child always elects a read-only composer that identifies the transcript as a completed execution record. A continuable child does so only when its exact parent is unavailable, with copy explaining the recovery path. A continuable child with a live parent keeps the ordinary input chrome, whose Session routes through `subagent.prompt`; running input remains Send because every follow-up joins the child's FIFO inbox, and addressed sessions never expose Stop. This package never receives host context or calls a model-facing tool. The catalog and composer behavior are specified by the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md). Subagent-origin Session rows are omitted from the ordinary sidebar, so the parent header catalog is their navigation entry point. Ordinary forks remain in the sidebar. @@ -18,7 +18,7 @@ The `@` source remains deliberately separate and inert. Candidates are zero-RPC #### What the model sees -Only the legacy `@` reference source affects model input: a picked candidate reaches the ordinary user message as literal `@label`, without a dedicated block or host-side resolution. Catalog browsing, child navigation, persisted transcript viewing, and human continuation UI add no prompt section; continuation content becomes a normal user-role event through the host subagent adapter. +Only the legacy `@` reference source affects model input: a picked candidate reaches the ordinary user message as literal `@label`, without a dedicated block or host-side resolution. Catalog browsing, child navigation, and persisted transcript viewing add no prompt section; accepted continuation content becomes a normal FIFO user message through the host subagent adapter. #### Token effect @@ -30,5 +30,5 @@ Append-only. This package never edits earlier request tokens. ## Known Limitations and Deferred Work -- **The catalog has coarse liveness only** — it cannot show durable outcome, elapsed time, exact Activation state, or a correct cancel button. +- **The catalog has coarse activity only** — it cannot show durable outcome, elapsed time, Activation identity, or an authority-safe cancel button. - **`@` references remain display-title text** — duplicate or renamed labels are ambiguous, so they intentionally do not acquire continuation semantics. diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index 43776fbc32..f9a5073360 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -Web subagent 功能 owner:向 `conversation.session.header.actions` 贡献可懒加载展开的目录树,向会话编辑器链贡献 parent 不可用时的替代呈现,并保留注册到 `ctx.slash` 的既有 `@` 引用 source。 +Web subagent 功能 owner:向 `conversation.session.header.actions` 贡献可懒加载展开的目录树,向会话编辑器链贡献按原因区分的只读替代呈现,并保留注册到 `ctx.slash` 的既有 `@` 引用 source。 -页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空目录到达后,它会显示健康的直接 child 数量,并按服务顺序显示一棵紧凑树。每个健康行都组合其持久化 label、`running`/`inactive` 活动状态(分别呈现为「正在处理」/「已完成」)、由日志支撑的可选 title 与会话摘要中的活动时间;损坏、不受支持或不可用的行仍保持可读但禁用。展开某一行时,会懒加载该 child 的直接目录,并向运行时报告每个可见分支,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支与键盘焦点。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。 +页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空目录到达后,它会显示健康的直接 child 数量,并按服务顺序显示一棵紧凑树。可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态、由日志支撑的可选 title 与会话摘要中的活动时间;没有 label 的 one-shot 行会回退到其会话 id。损坏、不受支持或不可用的行仍保持可读但禁用。展开某一行时,会懒加载该 child 的直接目录,并向运行时报告每个可见分支,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支与键盘焦点。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。 -已寻址 child 没有确切的存活 parent 时,会选中只读编辑器配置项并说明恢复路径。parent 存活时,child 保留普通输入 chrome,其 Session 会通过 `subagent.prompt` 路由;本包绝不接收宿主 context,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)规定。 +one-shot child 始终选用只读编辑器,并将 transcript(文本记录)说明为已完成的执行记录。可继续 child 仅在其确切 parent 不可用时选用只读编辑器,并以文案说明恢复路径。确切 parent 存活时,可继续 child 保留普通输入 chrome,其 Session 会通过 `subagent.prompt` 路由;child 运行期间,输入操作仍为 Send,因为每条后续消息都会进入 child 的 FIFO inbox,且已寻址会话绝不公开 Stop。本包绝不接收宿主 context,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)规定。 普通侧边栏会省略带 subagent origin 的 Session 行,因此 parent 页头目录是它们的导航入口。普通 fork 仍保留在侧边栏中。 @@ -18,7 +18,7 @@ Web subagent 功能 owner:向 `conversation.session.header.actions` 贡献可 #### 模型看到的内容 -只有旧有 `@` 引用 source 会影响模型输入:pick 的候选以字面文本 `@label` 进入普通用户消息,没有专用内容块或宿主侧解析。浏览目录、导航 child、查看持久化 transcript 与用户继续交互 UI 都不会添加提示词 section;继续交互内容会经宿主 subagent 适配器成为普通 user-role 事件。 +只有旧有 `@` 引用 source 会影响模型输入:pick 的候选以字面文本 `@label` 进入普通用户消息,没有专用内容块或宿主侧解析。浏览目录、导航 child 与查看持久化 transcript 都不会添加提示词 section;获准进入的继续交互内容会经宿主 subagent 适配器成为普通 FIFO 用户消息。 #### Token 影响 @@ -30,5 +30,5 @@ Web subagent 功能 owner:向 `conversation.session.header.actions` 贡献可 ## 已知限制与暂缓事项 -- **目录只有粗粒度存活状态**:它不能显示持久化结果、耗时、确切的 Activation 状态或正确的取消按钮。 +- **目录只有粗粒度活动状态**:它不能显示持久化结果、耗时、Activation 身份或具备安全授权的取消按钮。 - **`@` 引用仍是显示标题文本**:重复或改名后的 label 会有歧义,因此它们刻意不获得继续执行语义。 diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx index 047122171f..70ef38e4ea 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx @@ -16,9 +16,9 @@ type Catalogs = SessionListState['subagentsByParent'] /** Business actions supplied by the slot registration. */ export interface SubagentCatalogInjected { - openChild(address: SubagentAddress): void - refresh(parentSessionId: SessionId): void - setCatalogOpen(parentSessionId: SessionId, open: boolean): void + openChild: (address: SubagentAddress) => void + refresh: (parentSessionId: SessionId) => void + setCatalogOpen: (parentSessionId: SessionId, open: boolean) => void } /** Full props for the session-header catalog action. */ @@ -33,16 +33,16 @@ interface CatalogRowsProps { expanded: ReadonlySet level: number now: number - openChild(address: SubagentAddress): void - refresh(parentSessionId: SessionId): void - toggleBranch(childSessionId: SessionId): void - closeCatalog(): void + openChild: (address: SubagentAddress) => void + refresh: (parentSessionId: SessionId) => void + toggleBranch: (childSessionId: SessionId) => void + closeCatalog: () => void } function diagnosticReason(entry: Extract): string { switch (entry.reason) { case 'corrupt': return '会话记录损坏' - case 'unsupported': return '不是可继续的子代理' + case 'unsupported': return '子代理记录版本不受支持' case 'unavailable': return '会话记录暂不可用' } } @@ -119,11 +119,16 @@ function CatalogRows({ const isExpanded = expanded.has(entry.id) const knownLeaf = childCatalog?.state === 'ready' && childCatalog.entries.length === 0 const summary = summaries[entry.id] - const secondary = summary?.title ?? (entry.activity === 'running' ? '正在处理' : '已完成') + const label = entry.label ?? entry.id + const mode = entry.mode === 'one-shot' ? '一次性' : '可继续' + const activity = entry.activity === 'running' ? '正在运行' : '当前未运行' + const secondary = [summary?.title, mode, activity] + .filter(value => value !== undefined) + .join(' · ') const time = relativeTime(summary?.updatedAt, now) const open = (): void => { - openChild({ parentSessionId, childSessionId: entry.id }) + openChild({ parentSessionId, childSessionId: entry.id, mode: entry.mode }) closeCatalog() } const handleKey = (event: KeyboardEvent): void => { @@ -131,11 +136,10 @@ function CatalogRows({ event.preventDefault() event.stopPropagation() open() - } else if (event.key === 'ArrowRight' && !knownLeaf && !isExpanded) { - event.preventDefault() - event.stopPropagation() - toggleBranch(entry.id) - } else if (event.key === 'ArrowLeft' && isExpanded) { + } else if ( + (event.key === 'ArrowRight' && !knownLeaf && !isExpanded) + || (event.key === 'ArrowLeft' && isExpanded) + ) { event.preventDefault() event.stopPropagation() toggleBranch(entry.id) @@ -153,7 +157,7 @@ function CatalogRows({ role="treeitem" tabIndex={0} aria-level={level} - aria-label={[entry.label, secondary, time].filter(value => value !== undefined).join(' ')} + aria-label={[label, secondary, time].filter(value => value !== undefined).join(' ')} {...knownLeaf ? {} : { 'aria-expanded': isExpanded }} className={css.row} onClick={open} @@ -166,7 +170,7 @@ function CatalogRows({ type="button" tabIndex={-1} className={`${css.disclosure} ${isExpanded ? css.disclosureOpen : ''}`} - aria-label={`${isExpanded ? '收起' : '展开'} ${entry.label} 的下级子代理`} + aria-label={`${isExpanded ? '收起' : '展开'} ${label} 的下级子代理`} onClick={toggle} > @@ -174,7 +178,7 @@ function CatalogRows({ )} - {entry.label} + {label} {secondary} {time !== undefined && {time}} @@ -341,7 +345,7 @@ export function SubagentCatalogAction({ {healthy.length} 个子代理 - {open && catalog !== undefined && ( + {open && (
& { matched: ComposerChainProps } + PropsRuntime<'conversation.composer'> & { matched: SubagentReadOnlyMatch } /** - * Explain why the normal composer is unavailable for a parentless child. + * Explain why the normal composer is unavailable for an addressed child. + * @param props - selector-owned read-only reason plus standard slot props. * @returns A read-only composer replacement. */ -export function SubagentReadOnlyComposer() { +export function SubagentReadOnlyComposer({ + matched, +}: Pick) { + const oneShot = matched.reason === 'one-shot' return (
- 此子代理暂时只读 - 父会话当前不在线,重新打开父会话后即可继续发送消息。 + {oneShot ? '一次性子代理记录' : '此子代理暂时只读'} + + {oneShot + ? '一次性任务不支持后续消息,可在这里查看完整执行记录。' + : '父会话当前不在线,重新打开父会话后即可继续发送消息。'} +
) } diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index 4f7b66fb38..239ffd4335 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -15,19 +15,26 @@ import type { import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ClientSessionContext, SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' import { SubagentCatalogAction, type SubagentCatalogInjected } from './SubagentCatalogAction.tsx' -import { SubagentReadOnlyComposer } from './SubagentReadOnlyComposer.tsx' +import { + SubagentReadOnlyComposer, type SubagentReadOnlyMatch, +} from './SubagentReadOnlyComposer.tsx' export type { SubagentCatalogActionProps, SubagentCatalogInjected, } from './SubagentCatalogAction.tsx' -export type { SubagentReadOnlyComposerProps } from './SubagentReadOnlyComposer.tsx' +export type { + SubagentReadOnlyComposerProps, SubagentReadOnlyMatch, +} from './SubagentReadOnlyComposer.tsx' /** Required services for references, conversation slots, and session navigation. */ export const inject = ['slash', 'sessions', 'conversation', 'slots'] -/** Claim the composer only when an addressed child has no live continuation owner. */ -function selectReadOnlySubagent(owner: ComposerChainProps): ComposerChainProps | null { - return owner.subagentReadOnly ? owner : null +/** Claim the composer for one-shot history or an unavailable continuation owner. */ +function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatch | null { + const subagent = owner.session?.subagent + if (subagent === undefined || subagent === null) return null + if (subagent.address.mode === 'one-shot') return { reason: 'one-shot' } + return subagent.parentAvailable ? null : { reason: 'parent-unavailable' } } /** @@ -98,9 +105,9 @@ export function apply(ctx: ClientContext): void { ctx.effect( () => ctx.slots.register({ name: 'conversation.composer', - priority: 10, + priority: -10, select: selectReadOnlySubagent, }, SubagentReadOnlyComposer), - 'ui-subagent: unavailable-parent composer', + 'ui-subagent: read-only addressed composer', ) } diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index b6ce39040b..878ceb0bbe 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx @@ -18,8 +18,13 @@ const GRANDCHILD = 'grandchild' as SessionId function catalog(over: Partial = {}): SubagentCatalogSnapshot { return { entries: [ - { kind: 'child', id: CHILD, label: 'worker', activity: 'running' }, - { kind: 'child', id: 'child-2' as SessionId, label: 'reviewer', activity: 'inactive' }, + { + kind: 'child', id: CHILD, mode: 'continuable', label: 'worker', activity: 'running', + }, + { + kind: 'child', id: 'child-2' as SessionId, mode: 'one-shot', + label: 'reviewer', activity: 'inactive', + }, { kind: 'diagnostic', id: 'bad' as SessionId, reason: 'corrupt' }, ], parentAvailable: true, @@ -42,6 +47,7 @@ function props( displayTitle: 'worker', running: true, blank: false, + waitingApproval: false, updatedAt: Date.now(), }, }, @@ -49,9 +55,12 @@ function props( subagentsByParent: value === undefined ? nested : { [PARENT]: value, ...nested }, currentAddress: undefined, } satisfies SessionListState + function useSessions(select: (snapshot: SessionListState) => T): T { + return select(state) + } return { sessionId: PARENT, - useSessions: ((select: (snapshot: SessionListState) => T) => select(state)), + useSessions, openChild: vi.fn(), refresh: vi.fn(), setCatalogOpen: vi.fn(), @@ -67,14 +76,14 @@ describe('SubagentCatalogAction', () => { expect(input.setCatalogOpen).toHaveBeenCalledWith(PARENT, true) expect(screen.getAllByRole('treeitem')).toHaveLength(3) - expect(screen.getByText('正在扫描项目文件')).toBeTruthy() - expect(screen.getByText('已完成')).toBeTruthy() + expect(screen.getByText('正在扫描项目文件 · 可继续 · 正在运行')).toBeTruthy() + expect(screen.getByText('一次性 · 当前未运行')).toBeTruthy() const diagnostic = screen.getByRole('treeitem', { name: /会话记录损坏/ }) expect(diagnostic.getAttribute('aria-disabled')).toBe('true') fireEvent.click(screen.getByRole('treeitem', { name: /worker/ })) expect(input.openChild).toHaveBeenCalledWith({ - parentSessionId: PARENT, childSessionId: CHILD, + parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', }) expect(input.setCatalogOpen).toHaveBeenLastCalledWith(PARENT, false) }) @@ -102,7 +111,10 @@ describe('SubagentCatalogAction', () => { it('lazily expands and collapses descendant catalogs with direct-parent navigation', () => { const childCatalog = catalog({ entries: [ - { kind: 'child', id: GRANDCHILD, label: 'indexer', activity: 'inactive' }, + { + kind: 'child', id: GRANDCHILD, mode: 'continuable', + label: 'indexer', activity: 'inactive', + }, ], }) const grandchildCatalog = catalog({ entries: [] }) @@ -120,7 +132,7 @@ describe('SubagentCatalogAction', () => { fireEvent.click(nested) expect(input.openChild).toHaveBeenCalledWith({ - parentSessionId: CHILD, childSessionId: GRANDCHILD, + parentSessionId: CHILD, childSessionId: GRANDCHILD, mode: 'continuable', }) expect(input.setCatalogOpen).toHaveBeenCalledWith(PARENT, false) expect(input.setCatalogOpen).toHaveBeenCalledWith(CHILD, false) @@ -129,7 +141,10 @@ describe('SubagentCatalogAction', () => { it('uses ArrowRight and ArrowLeft for branch disclosure', async () => { const input = props(catalog(), { [CHILD]: catalog({ - entries: [{ kind: 'child', id: GRANDCHILD, label: 'indexer', activity: 'running' }], + entries: [{ + kind: 'child', id: GRANDCHILD, mode: 'continuable', + label: 'indexer', activity: 'running', + }], }), }) render() @@ -165,7 +180,10 @@ describe('SubagentCatalogAction', () => { it('closes every observed catalog when the root becomes empty', () => { const populated = props(catalog(), { [CHILD]: catalog({ - entries: [{ kind: 'child', id: GRANDCHILD, label: 'indexer', activity: 'inactive' }], + entries: [{ + kind: 'child', id: GRANDCHILD, mode: 'continuable', + label: 'indexer', activity: 'inactive', + }], }), }) const view = render() @@ -182,7 +200,12 @@ describe('SubagentCatalogAction', () => { describe('SubagentReadOnlyComposer', () => { it('explains the exact missing-parent recovery path', () => { - render() + render() expect(screen.getByRole('status').textContent).toContain('父会话当前不在线') }) + + it('explains that one-shot histories never accept follow-ups', () => { + render() + expect(screen.getByRole('status').textContent).toContain('一次性任务不支持后续消息') + }) }) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 32b0329fa4..c339d38c87 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -18,6 +18,8 @@ import { isAppendSurfaceEvent, lastActivityTime } from '@deepseek-ai/dsh-session import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query' +import { SubagentError } from '@deepseek-ai/dsh-subagent' +import type { SubagentListEntry as CatalogSubagentListEntry } from '@deepseek-ai/dsh-subagent' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' import { workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, @@ -28,7 +30,7 @@ import type {} from '@deepseek-ai/dsh-tools' import type { ApiProxy, CredentialView, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem, - SessionSummary, SettingsNamespaceView, SubagentListEntry as SubagentCatalogEntry, ToolEventView, + SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView, WorkspaceId, WorkspaceView, } from './api/index.ts' import { @@ -69,8 +71,6 @@ import type { import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction' import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' import { openNativePath } from './native-path-opener.ts' -import { SubagentError } from '@deepseek-ai/dsh-subagent' -import type { SubagentListEntry as CoreSubagentListEntry } from '@deepseek-ai/dsh-subagent' /** Page size when history is called without maxMessages. */ const DEFAULT_MAX_MESSAGES = 50 @@ -484,92 +484,6 @@ function historyPage( } } -/** Map a continuation failure without exposing descriptor or provider details. */ -function subagentPromptError( - request: RpcRequest<{ childSessionId: SessionId }>, - error: unknown, - signal?: AbortSignal, -): RpcResponse { - const childSessionId = request.payload.childSessionId - if (signal?.aborted) { - return err(request, { code: 'cancelled', message: 'subagent prompt was cancelled', details: {} }) - } - if (error instanceof SubagentError) { - switch (error.code) { - case 'NOT_RESUMABLE': - return err(request, { code: 'subagent-not-resumable', message: 'subagent cannot be resumed', details: { childSessionId } }) - case 'UNAUTHORIZED': - return err(request, { code: 'subagent-unauthorized', message: 'subagent does not belong to this parent', details: { childSessionId } }) - case 'ACTIVATION_CLOSING': - case 'DRAINING': - return err(request, { code: 'subagent-not-delivered', message: 'message was not delivered', details: { childSessionId } }) - default: - break - } - } - return err(request, { code: 'internal', message: 'subagent prompt failed', details: {} }) -} - -/** Verify one address against the complete durable direct-child catalog. */ -async function healthyCatalogChild( - ctx: Context, - parentSessionId: SessionId, - childSessionId: SessionId, - signal?: AbortSignal, -): Promise<{ error?: RpcError }> { - try { - const entries = await ctx.subagents.listChildren(parentSessionId, signal) - const entry = entries.find(candidate => candidate.id === childSessionId) - if (entry === undefined || (entry.kind === 'child' && entry.mode !== 'continuable')) { - return { - error: { - code: 'subagent-not-found', - message: `session "${childSessionId}" is not a continuable direct child of "${parentSessionId}"`, - details: { parentSessionId, childSessionId }, - }, - } - } - if (entry.kind === 'diagnostic') { - return { - error: { - code: 'subagent-catalog-diagnostic', - message: `subagent "${childSessionId}" is ${entry.reason}`, - details: { parentSessionId, childSessionId, reason: entry.reason }, - }, - } - } - return {} - } catch (error: unknown) { - if (error instanceof SubagentError && error.code === 'CANCELLED') { - return { error: { code: 'cancelled', message: 'subagent catalog read was cancelled', details: {} } } - } - if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { - return { - error: { - code: 'subagent-not-found', - message: `parent session "${parentSessionId}" is unavailable`, - details: { parentSessionId, childSessionId }, - }, - } - } - return { error: { code: 'internal', message: 'subagent catalog read failed', details: {} } } - } -} - -/** Project the durable catalog onto the continuable-only browser surface. */ -function continuableCatalog(entries: readonly CoreSubagentListEntry[]): SubagentCatalogEntry[] { - return entries.flatMap((entry): SubagentCatalogEntry[] => { - if (entry.kind === 'diagnostic') return [entry] - if (entry.mode !== 'continuable') return [] - return [{ - kind: 'child', - id: entry.id, - label: entry.label, - activity: entry.activity, - }] - }) -} - /** * The projection baseline for one history tail page: the registry's * watermark-cache snapshot — one fully synchronous read (no await between the @@ -607,6 +521,107 @@ function listProjectionsFor(ctx: Context, meta: SessionHeader, session: Session } } +/** Projection baseline for a detached history tail without Agent activation. */ +function detachedProjectionsFor( + ctx: Context, + events: readonly SessionEvent[], +): SessionProjectionsBlock | undefined { + const registry = ctx.get('sessionProjections') + if (registry === undefined) return undefined + return registry.restore({}, events, 0).snapshot +} + +/** Map continuation admission failures without exposing provider details. */ +function subagentPromptError( + request: RpcRequest<{ childSessionId: SessionId }>, + error: unknown, + signal: AbortSignal, +): RpcResponse { + const childSessionId = request.payload.childSessionId + if (signal.aborted) { + return err(request, { code: 'cancelled', message: 'subagent prompt was cancelled', details: {} }) + } + if (error instanceof SubagentError) { + switch (error.code) { + case 'NOT_RESUMABLE': + return err(request, { + code: 'subagent-not-resumable', + message: 'subagent cannot be resumed', + details: { childSessionId }, + }) + case 'UNAUTHORIZED': + return err(request, { + code: 'subagent-unauthorized', + message: 'subagent does not belong to this parent', + details: { childSessionId }, + }) + case 'DRAINING': + case 'ACTIVATION_CLOSING': + case 'CONTINUATION_UNAVAILABLE': + case 'PERSISTENCE_UNAVAILABLE': + return err(request, { + code: 'subagent-delivery-unavailable', + message: 'subagent follow-up is temporarily unavailable', + details: { childSessionId }, + }) + default: + break + } + } + return err(request, { code: 'internal', message: 'subagent prompt failed', details: {} }) +} + +/** Verify one address and mode against the complete direct-child catalog. */ +async function catalogChild( + ctx: Context, + address: SubagentAddress, + signal?: AbortSignal, +): Promise<{ + entry?: Extract + error?: RpcError +}> { + const { parentSessionId, childSessionId, mode } = address + try { + const entries = await ctx.subagents.listChildren(parentSessionId, signal) + const entry = entries.find(candidate => candidate.id === childSessionId) + if (entry === undefined || (entry.kind === 'child' && entry.mode !== mode)) { + return { + error: { + code: 'subagent-not-found', + message: `session "${childSessionId}" is not a ${mode} direct child of "${parentSessionId}"`, + details: { parentSessionId, childSessionId }, + }, + } + } + if (entry.kind === 'diagnostic') { + return { + error: { + code: 'subagent-catalog-diagnostic', + message: `subagent "${childSessionId}" is ${entry.reason}`, + details: { parentSessionId, childSessionId, reason: entry.reason }, + }, + } + } + return { entry } + } catch (error: unknown) { + if (signal?.aborted + || (error instanceof SubagentError && error.code === 'CANCELLED') + || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) { + return { error: { code: 'cancelled', message: 'subagent catalog read was cancelled', details: {} } } + } + if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { + return { + error: { + code: 'subagent-not-found', + message: `parent session "${parentSessionId}" was not found`, + details: { parentSessionId, childSessionId }, + }, + } + } + return { error: { code: 'internal', message: 'subagent catalog read failed', details: {} } } + } +} + /** * Thrown by the cold-resume path when the id names no servable session * (absent from the store, or a pre-project legacy log without a cwd). @@ -1698,20 +1713,34 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro try { const entries = await ctx.subagents.listChildren(request.payload.parentSessionId, signal) return ok(request, { - entries: continuableCatalog(entries), + entries, parentAvailable: ctx.agents.get(request.payload.parentSessionId) !== undefined, }) } catch (error: unknown) { - if (error instanceof SubagentError && error.code === 'CANCELLED') { - return err(request, { code: 'cancelled', message: 'subagent catalog read was cancelled', details: {} }) + if (signal?.aborted + || (error instanceof SubagentError && error.code === 'CANCELLED') + || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) { + return err(request, { + code: 'cancelled', + message: 'subagent catalog read was cancelled', + details: {}, + }) } - return err(request, { code: 'internal', message: 'subagent catalog read failed', details: {} }) + return err(request, { + code: 'internal', + message: 'subagent catalog read failed', + details: {}, + }) } }, async history(request, signal) { - const { parentSessionId, childSessionId, beforeSeq, maxMessages } = request.payload - const verified = await healthyCatalogChild(ctx, parentSessionId, childSessionId, signal) + const { + parentSessionId, childSessionId, mode, beforeSeq, maxMessages, + } = request.payload + const verified = await catalogChild(ctx, { + parentSessionId, childSessionId, mode, + }, signal) if (verified.error !== undefined) return err(request, verified.error) try { const snapshot = await ctx.sessionQuery.readSession(childSessionId) @@ -1723,24 +1752,33 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro details: { childSessionId }, }) } - return ok(request, historyPage(ctx, snapshot.events, beforeSeq, maxMessages)) + const page = historyPage(ctx, snapshot.events, beforeSeq, maxMessages) + const projections = beforeSeq === undefined + ? detachedProjectionsFor(ctx, snapshot.events) + : undefined + return ok(request, { ...page, ...projections === undefined ? {} : { projections } }) } catch (error: unknown) { - if (signal?.aborted) { - return err(request, { code: 'cancelled', message: 'subagent history read was cancelled', details: {} }) + if (signal?.aborted + || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) { + return err(request, { + code: 'cancelled', + message: 'subagent history read was cancelled', + details: {}, + }) } - if (error instanceof SessionQueryError) { - if (error.code === 'SESSION_QUERY_ABORTED') { - return err(request, { code: 'cancelled', message: 'subagent history read was cancelled', details: {} }) - } - if (error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { - return err(request, { - code: 'subagent-not-found', - message: 'subagent disappeared during history read', - details: { parentSessionId, childSessionId }, - }) - } + if (error instanceof SessionQueryError + && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { + return err(request, { + code: 'subagent-not-found', + message: 'subagent disappeared during history read', + details: { parentSessionId, childSessionId }, + }) } - return err(request, { code: 'internal', message: 'subagent history read failed', details: {} }) + return err(request, { + code: 'internal', + message: 'subagent history read failed', + details: {}, + }) } }, @@ -1754,19 +1792,18 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro details: { parentSessionId }, }) } - const verified = await healthyCatalogChild(ctx, parentSessionId, childSessionId, signal) + const verified = await catalogChild(ctx, { + parentSessionId, childSessionId, mode: 'continuable', + }, signal) if (verified.error !== undefined) return err(request, verified.error) - const operationSignal = signal ?? new AbortController().signal try { - const messageId = await ctx.subagents.followup( - parent, - childSessionId, - content, - { source: { kind: 'user', rpcId: request.rpcId }, signal: operationSignal }, - ) + const messageId = await ctx.subagents.followup(parent, childSessionId, content, { + source: { kind: 'user', rpcId: request.rpcId }, + signal, + }) return ok(request, { messageId }) } catch (error: unknown) { - return subagentPromptError(request, error, operationSignal) + return subagentPromptError(request, error, signal) } }, }, diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index de6f4b54b1..40289b91ef 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -65,7 +65,7 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', }) }), z.object({ code: z.literal('subagent-not-resumable'), message: z.string(), details: z.object({ childSessionId: z.string() }) }), z.object({ code: z.literal('subagent-unauthorized'), message: z.string(), details: z.object({ childSessionId: z.string() }) }), - z.object({ code: z.literal('subagent-not-delivered'), message: z.string(), details: z.object({ childSessionId: z.string() }) }), + z.object({ code: z.literal('subagent-delivery-unavailable'), message: z.string(), details: z.object({ childSessionId: z.string() }) }), z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }), ]) as unknown as z.ZodType diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index 54beecf64b..09cd1ce3f1 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -80,7 +80,7 @@ export interface RpcErrorDetailsMap { } 'subagent-not-resumable': { childSessionId: SessionId } 'subagent-unauthorized': { childSessionId: SessionId } - 'subagent-not-delivered': { childSessionId: SessionId } + 'subagent-delivery-unavailable': { childSessionId: SessionId } 'internal': {} } diff --git a/packages/host/apiproxy/src/api/subagents.schema.ts b/packages/host/apiproxy/src/api/subagents.schema.ts index d89df0d8eb..ae415d68cc 100644 --- a/packages/host/apiproxy/src/api/subagents.schema.ts +++ b/packages/host/apiproxy/src/api/subagents.schema.ts @@ -1,19 +1,29 @@ /** Zod schemas for the browser-safe subagent domain. */ import { z } from 'zod' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' -import { contentBlockSchema, historyEntrySchema, sessionIdSchema } from './sessions.schema.ts' -import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import { + contentBlockSchema, historyEntrySchema, sessionIdSchema, sessionProjectionsBlockSchema, +} from './sessions.schema.ts' import type { SubagentListEntry } from './subagents.ts' /** Healthy and diagnostic durable catalog rows. */ -export const subagentListEntrySchema = z.discriminatedUnion('kind', [ +export const subagentListEntrySchema = z.union([ z.object({ kind: z.literal('child'), id: sessionIdSchema, - label: z.string(), + mode: z.literal('one-shot'), activity: z.union([z.literal('running'), z.literal('inactive')]), + label: z.string().optional(), + }), + z.object({ + kind: z.literal('child'), + id: sessionIdSchema, + mode: z.literal('continuable'), + activity: z.union([z.literal('running'), z.literal('inactive')]), + label: z.string(), }), z.object({ kind: z.literal('diagnostic'), @@ -37,6 +47,7 @@ export const subagentListValueSchema = z.object({ export const subagentHistoryRequestSchema = z.object({ parentSessionId: sessionIdSchema, childSessionId: sessionIdSchema, + mode: z.union([z.literal('one-shot'), z.literal('continuable')]), beforeSeq: z.number().int().nonnegative().optional(), maxMessages: z.number().int().positive().optional(), }) satisfies z.ZodType>> @@ -45,12 +56,14 @@ export const subagentHistoryRequestSchema = z.object({ export const subagentHistoryValueSchema = z.object({ events: z.array(historyEntrySchema), hasMore: z.boolean(), + projections: sessionProjectionsBlockSchema.optional(), }) as unknown as z.ZodType>> /** subagent.prompt request payload. */ export const subagentPromptRequestSchema = z.object({ parentSessionId: sessionIdSchema, childSessionId: sessionIdSchema, + mode: z.literal('continuable'), content: z.array(contentBlockSchema), }) as unknown as z.ZodType> diff --git a/packages/host/apiproxy/src/api/subagents.ts b/packages/host/apiproxy/src/api/subagents.ts index 0e67b7832b..18abc0260f 100644 --- a/packages/host/apiproxy/src/api/subagents.ts +++ b/packages/host/apiproxy/src/api/subagents.ts @@ -1,23 +1,31 @@ /** * Browser-safe subagent domain contract. Persisted transcript reads never - * activate an Agent, while prompts route through the direct parent's - * Activation-backed continuation owner. + * activate an Agent, while continuable prompts route through the exact live + * direct parent into the child's Agent inbox. */ import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { RpcRequest, RpcResponse } from './rpc.ts' -import type { HistoryEntry } from './sessions.ts' +import type { HistoryEntry, SessionProjectionsBlock } from './sessions.ts' /** Complete durable direct-child catalog row. */ export type SubagentListEntry = | { kind: 'child' id: SessionId - label: string activity: 'running' | 'inactive' - } + } & ( + | { + mode: 'one-shot' + label?: string + } + | { + mode: 'continuable' + label: string + } + ) | { kind: 'diagnostic' id: SessionId @@ -30,10 +38,15 @@ export interface SubagentPromptReceipt { } /** Durable parent/child address that selects subagent transport in the client. */ -export interface SubagentAddress { - parentSessionId: SessionId - childSessionId: SessionId -} +export type SubagentAddress = + & { + parentSessionId: SessionId + childSessionId: SessionId + } + & ( + | { mode: 'one-shot' } + | { mode: 'continuable' } + ) /** Complete direct-child catalog plus the delivery-time parent availability hint. */ export interface SubagentCatalog { @@ -44,8 +57,9 @@ export interface SubagentCatalog { /** Subagent-domain unary methods. */ export interface SubagentsApi { /** - * Lists direct continuable children without loading either side. Parent - * availability is a hint; prompt performs the authoritative check. + * Lists direct session-backed children without loading either side. Parent + * availability is a hint; continuable prompt performs the authoritative + * check. */ list( request: RpcRequest<{ parentSessionId: SessionId }>, @@ -59,14 +73,21 @@ export interface SubagentsApi { history( request: RpcRequest, signal?: AbortSignal, - ): Promise> + ): Promise> /** - * Delivers human content through the exact live parent's continuation - * owner. Success identifies the accepted inbox message. + * Delivers human content to a continuable child through the exact live + * parent's continuation owner. Success identifies the message accepted by + * the child's FIFO inbox; later execution is independent of this request. */ prompt( - request: RpcRequest, - signal?: AbortSignal, + request: RpcRequest< + Extract & { content: ContentBlock[] } + >, + signal: AbortSignal, ): Promise> } diff --git a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts index 9cd7d4acdf..5c00a1946e 100644 --- a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts @@ -71,7 +71,14 @@ describe('subagent gateway', () => { expect(response.rpcId).toBe('subagent-rpc') expect(response.result).toMatchObject({ ok: true, - value: { parentAvailable: false, entries: [{ kind: 'child' }, { kind: 'diagnostic' }] }, + value: { + parentAvailable: false, + entries: [ + { kind: 'child', mode: 'continuable' }, + { kind: 'child', mode: 'one-shot' }, + { kind: 'diagnostic' }, + ], + }, }) expect(listChildren).toHaveBeenCalledWith(PARENT, undefined) }) @@ -79,7 +86,7 @@ describe('subagent gateway', () => { it('reads a healthy direct child without looking up or activating any Agent', async () => { const { api, getAgent, readSession } = bench() const response = await api.subagents.history(request({ - parentSessionId: PARENT, childSessionId: CHILD, maxMessages: 10, + parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', maxMessages: 10, })) expect(response.result).toMatchObject({ ok: true, @@ -89,12 +96,26 @@ describe('subagent gateway', () => { expect(getAgent).not.toHaveBeenCalled() }) + it('reads one-shot history and rejects an address with the wrong mode', async () => { + const oneShot = { + kind: 'child', id: CHILD, mode: 'one-shot', label: 'batch', activity: 'inactive', + } + const { api, readSession } = bench({ entries: [oneShot] }) + expect((await api.subagents.history(request({ + parentSessionId: PARENT, childSessionId: CHILD, mode: 'one-shot', + }))).result).toMatchObject({ ok: true }) + expect((await api.subagents.history(request({ + parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', + }))).result).toMatchObject({ ok: false, error: { code: 'subagent-not-found' } }) + expect(readSession).toHaveBeenCalledTimes(1) + }) + it('rejects a diagnostic address before reading history', async () => { const { api, readSession } = bench({ entries: [ { kind: 'diagnostic', id: CHILD, reason: 'unsupported' }, ] }) const response = await api.subagents.history(request({ - parentSessionId: PARENT, childSessionId: CHILD, + parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', })) expect(response.result).toMatchObject({ ok: false, @@ -109,30 +130,36 @@ describe('subagent gateway', () => { it('routes human content through the exact live parent with rpc attribution', async () => { const { api, parent, followup } = bench() const content = [{ type: 'text' as const, text: '继续' }] + const signal = new AbortController().signal const response = await api.subagents.prompt(request({ - parentSessionId: PARENT, childSessionId: CHILD, content, - })) + parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content, + }), signal) expect(response.result).toMatchObject({ ok: true, value: { messageId: 'message-1' }, }) - expect(followup).toHaveBeenCalledTimes(1) - const [actualParent, actualChild, actualContent, delivery] = followup.mock.calls[0]! - expect([actualParent, actualChild, actualContent]).toEqual([parent, CHILD, content]) - expect(delivery.source).toEqual({ kind: 'user', rpcId: RpcId('subagent-rpc') }) - expect(delivery.signal).toBeInstanceOf(AbortSignal) + expect(followup).toHaveBeenCalledWith( + parent, + CHILD, + content, + { source: { kind: 'user', rpcId: RpcId('subagent-rpc') }, signal }, + ) }) it('fails before delivery when the parent is absent and maps continuation failures', async () => { const absent = bench({ parentLive: false }) expect((await absent.api.subagents.prompt(request({ - parentSessionId: PARENT, childSessionId: CHILD, content: [], - }))).result).toMatchObject({ ok: false, error: { code: 'subagent-parent-unavailable' } }) + parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [], + }), new AbortController().signal)).result).toMatchObject({ + ok: false, error: { code: 'subagent-parent-unavailable' }, + }) expect(absent.listChildren).not.toHaveBeenCalled() - const failed = bench({ followupError: new SubagentError('not delivered', 'DRAINING') }) + const failed = bench({ followupError: new SubagentError('draining', 'DRAINING') }) expect((await failed.api.subagents.prompt(request({ - parentSessionId: PARENT, childSessionId: CHILD, content: [], - }))).result).toMatchObject({ ok: false, error: { code: 'subagent-not-delivered' } }) + parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [], + }), new AbortController().signal)).result).toMatchObject({ + ok: false, error: { code: 'subagent-delivery-unavailable' }, + }) }) it('maps history disappearance and hides unexpected backend details', async () => { @@ -140,7 +167,7 @@ describe('subagent gateway', () => { readError: new SessionQueryError('secret path', 'SESSION_QUERY_SESSION_NOT_FOUND'), }) expect((await disappeared.api.subagents.history(request({ - parentSessionId: PARENT, childSessionId: CHILD, + parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', }))).result).toMatchObject({ ok: false, error: { @@ -160,8 +187,8 @@ describe('subagent gateway', () => { const prompt = bench({ followupError: new Error('secret provider') }) expect((await prompt.api.subagents.prompt(request({ - parentSessionId: PARENT, childSessionId: CHILD, content: [], - }))).result).toMatchObject({ + parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [], + }), new AbortController().signal)).result).toMatchObject({ ok: false, error: { code: 'internal', message: 'subagent prompt failed' }, }) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 2ba5b94246..56af007a28 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -110,7 +110,18 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra async history(request) { return { rpcId: request.rpcId, result: { ok: true, value: { events: [], hasMore: false } } } }, - async prompt(request) { + async prompt(request, signal) { + if (request.payload.content.some(block => block.type === 'text' && block.text === 'hang')) { + if (!signal.aborted) { + await new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + return { + rpcId: request.rpcId, + result: { ok: false, error: { code: 'cancelled' as const, message: 'aborted', details: {} } }, + } + } return { rpcId: request.rpcId, result: { ok: true, value: { messageId: 'message-1' as never } }, @@ -400,6 +411,23 @@ describe('unary round trip (handler ⇄ client, no network)', () => { } }) + it('round-trips the subagent domain through the wire form', async () => { + const c = client() + expect((await c.subagents.list({ parentSessionId: 'parent' as never })).result) + .toEqual({ ok: true, value: { entries: [], parentAvailable: false } }) + expect((await c.subagents.history({ + parentSessionId: 'parent' as never, + childSessionId: 'child' as never, + mode: 'one-shot', + })).result).toEqual({ ok: true, value: { events: [], hasMore: false } }) + expect((await c.subagents.prompt({ + parentSessionId: 'parent' as never, + childSessionId: 'child' as never, + mode: 'continuable', + content: [], + })).result).toEqual({ ok: true, value: { messageId: 'message-1' } }) + }) + it('keeps caller and connection aborts on command.execute', async () => { const api = fakeApi() const started = Promise.withResolvers() @@ -465,6 +493,34 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect(parsed.result.error?.code).toBe('cancelled') }) + it('propagates the carrier Request signal into subagent.prompt', async () => { + const handler = toFetchHandler(fakeApi()) + const controller = new AbortController() + const body = JSON.stringify({ + type: 'client-request', + rpcId: 'r-subagent-sig', + method: 'subagent.prompt', + payload: { + parentSessionId: 'parent', + childSessionId: 'child', + mode: 'continuable', + content: [{ type: 'text', text: 'hang' }], + }, + }) + const pending = handler.fetch(new Request( + 'http://x/api/subagent.prompt', + { method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal }, + )) + controller.abort() + const response = await pending + const parsed = await response.json() as { + rpcId: string + result: { error?: { code: string } } + } + expect(parsed.rpcId).toBe('r-subagent-sig') + expect(parsed.result.error?.code).toBe('cancelled') + }) + it('propagates the carrier Request signal into host.pickDirectory', async () => { const api = fakeApi() api.host.pickDirectory = async (request, signal) => { diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 41c2be9620..ab402c6fff 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -85,7 +85,7 @@ describe('rpcErrorSchema', () => { expect(rpcErrorSchema.parse({ code: 'subagent-catalog-diagnostic', message: 'm', details: { parentSessionId: 'p', childSessionId: 'c', reason: 'corrupt' } }).code).toBe('subagent-catalog-diagnostic') expect(rpcErrorSchema.parse({ code: 'subagent-not-resumable', message: 'm', details: { childSessionId: 'c' } }).code).toBe('subagent-not-resumable') expect(rpcErrorSchema.parse({ code: 'subagent-unauthorized', message: 'm', details: { childSessionId: 'c' } }).code).toBe('subagent-unauthorized') - expect(rpcErrorSchema.parse({ code: 'subagent-not-delivered', message: 'm', details: { childSessionId: 'c' } }).code).toBe('subagent-not-delivered') + expect(rpcErrorSchema.parse({ code: 'subagent-delivery-unavailable', message: 'm', details: { childSessionId: 'c' } }).code).toBe('subagent-delivery-unavailable') expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal') }) @@ -291,29 +291,34 @@ describe('sessions domain schemas', () => { describe('subagent domain schemas', () => { it('validates the direct catalog and addressed history pair', () => { - const child = { kind: 'child', id: 'c', label: 'worker', activity: 'running' } + const child = { + kind: 'child', id: 'c', mode: 'continuable', label: 'worker', activity: 'running', + } + const oneShot = { kind: 'child', id: 'o', mode: 'one-shot', activity: 'inactive' } const diagnostic = { kind: 'diagnostic', id: 'bad', reason: 'unsupported' } expect(subagentListEntrySchema.parse(child)).toEqual(child) + expect(subagentListEntrySchema.parse(oneShot)).toEqual(oneShot) expect(subagentListEntrySchema.parse(diagnostic)).toEqual(diagnostic) expect(subagentListRequestSchema.parse({ parentSessionId: 'p' })).toEqual({ parentSessionId: 'p' }) expect(subagentListValueSchema.parse({ - entries: [child, diagnostic], parentAvailable: true, - }).entries).toHaveLength(2) + entries: [child, oneShot, diagnostic], parentAvailable: true, + }).entries).toHaveLength(3) expect(subagentHistoryRequestSchema.parse({ - parentSessionId: 'p', childSessionId: 'c', beforeSeq: 4, maxMessages: 2, + parentSessionId: 'p', childSessionId: 'c', mode: 'continuable', beforeSeq: 4, maxMessages: 2, }).beforeSeq).toBe(4) expect(() => subagentHistoryRequestSchema.parse({ - parentSessionId: 'p', childSessionId: 'c', maxMessages: 0, + parentSessionId: 'p', childSessionId: 'c', mode: 'continuable', maxMessages: 0, })).toThrow() expect(subagentHistoryValueSchema.parse({ events: [], hasMore: false }).hasMore).toBe(false) }) - it('validates prompt content and the accepted inbox identity', () => { + it('validates continuable prompt content and the accepted inbox identity', () => { expect(subagentPromptRequestSchema.parse({ - parentSessionId: 'p', childSessionId: 'c', content: [{ type: 'text', text: '继续' }], + parentSessionId: 'p', childSessionId: 'c', mode: 'continuable', + content: [{ type: 'text', text: '继续' }], }).childSessionId).toBe('c') expect(subagentPromptValueSchema.parse({ messageId: 'm1' }).messageId).toBe('m1') - expect(() => subagentPromptValueSchema.parse({ taskId: 't1' })).toThrow() + expect(() => subagentPromptValueSchema.parse({ route: 'started', taskId: 't2' })).toThrow() }) }) diff --git a/packages/subagent/subagent/src/child-agent.ts b/packages/subagent/subagent/src/child-agent.ts index 96e94493a9..c477954468 100644 --- a/packages/subagent/subagent/src/child-agent.ts +++ b/packages/subagent/subagent/src/child-agent.ts @@ -88,6 +88,8 @@ export function childSessionMeta( return { ...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {}, parentSession: parentHeader.id, + // Navigation classification only; the descriptor remains the authority + // for mode and continuation capability. origin: 'subagent', // Durable: the recursion budget must survive persistence and resume. delegationDepth: childDepth, diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index dc82fe38f2..ecfe40cec8 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -211,6 +211,7 @@ describe('SubagentService.startContinuable', () => { expect('surfaceOp' in descriptor).toBe(false) expect(loaded.meta.id).toBe(started.childId) expect(loaded.meta.parentSession).toBe(SessionId('parent')) + expect(loaded.meta.origin).toBe('subagent') }) it('rolls the child back completely when the caller signal aborts before acceptance', async () => {