feat(web): add subagent conversation transport
This commit is contained in:
@@ -8,7 +8,9 @@
|
||||
* explicit act of widening what features may do to the sessions domain.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { RpcResult, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
RpcResult, SessionId, SubagentAddress,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionSearchResultItem } from '../sessions/manager.ts'
|
||||
import type {
|
||||
@@ -34,6 +36,12 @@ export interface ISessions {
|
||||
* @param id - session id (must exist in the list; unknown ids fail loud).
|
||||
*/
|
||||
open(id: SessionId): void
|
||||
/**
|
||||
* Resolve an already discovered direct-parent address without opening it.
|
||||
* @param id - possible addressed child id.
|
||||
* @returns the retained address, when present.
|
||||
*/
|
||||
subagentAddress(id: SessionId): SubagentAddress | undefined
|
||||
/** Clear the current selection into the no-session view state. */
|
||||
clear(): void
|
||||
/**
|
||||
|
||||
@@ -31,7 +31,8 @@ export type { IWorkspaces } from './contract/workspaces.ts'
|
||||
export type {
|
||||
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
|
||||
} from './sessions/service.ts'
|
||||
export type { SessionListPhase, SessionSearchResultItem } from './sessions/manager.ts'
|
||||
export type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './sessions/manager.ts'
|
||||
export type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client'
|
||||
export type { WorkspaceListPhase } from './workspaces/manager.ts'
|
||||
export type { WorkspaceListState } from './workspaces/service.ts'
|
||||
export type {
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
InboxItemId, RpcError, SessionId, ToolCallView, ToolResultView,
|
||||
InboxItemId, RpcError, SessionId, SubagentAddress, ToolCallView, ToolResultView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { PendingInteraction } from './pending.ts'
|
||||
export type { TodoItem }
|
||||
@@ -335,6 +335,11 @@ export interface ConversationSnapshot {
|
||||
/** Authoritative transient inbox snapshot, replaced after every host-side change. */
|
||||
queue: readonly QueuedMessage[]
|
||||
running: boolean
|
||||
/**
|
||||
* Catalog-discovered continuation address. Its parent availability controls
|
||||
* human input; null means ordinary session transport.
|
||||
*/
|
||||
subagent: { address: SubagentAddress; parentAvailable: boolean } | null
|
||||
/** Input-area shape (see {@link ComposerPhase}); derived here, switched on by consumers. */
|
||||
composerPhase: ComposerPhase
|
||||
/** Set after host/session-removed; the UI grays out and disables input. */
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import type {
|
||||
IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId,
|
||||
SessionSummary, WorkspaceId,
|
||||
SessionSummary, SubagentAddress, SubagentCatalog, WorkspaceId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
@@ -45,6 +45,14 @@ export interface SessionListSnapshot {
|
||||
/** Arrival lifecycle (see {@link SessionListPhase}); `state` stays the pull-activity axis. */
|
||||
phase: SessionListPhase
|
||||
error: RpcError | null
|
||||
subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
|
||||
currentAddress: SubagentAddress | undefined
|
||||
}
|
||||
|
||||
/** One parent-addressed durable catalog projected through the sessions snapshot. */
|
||||
export interface SubagentCatalogSnapshot extends SubagentCatalog {
|
||||
state: 'loading' | 'ready' | 'error'
|
||||
error: RpcError | null
|
||||
}
|
||||
|
||||
type SessionListMutation =
|
||||
@@ -84,6 +92,11 @@ export class SessionManager {
|
||||
private listInflight: Promise<void> | null = null
|
||||
/** Mutations arriving after a list request starts are replayed over its response. */
|
||||
private listMutations: SessionListMutation[] | null = null
|
||||
private readonly addresses = new Map<SessionId, SubagentAddress>()
|
||||
private readonly catalogs = new Map<SessionId, SubagentCatalogSnapshot>()
|
||||
private readonly catalogInflight = new Map<SessionId, Promise<void>>()
|
||||
private readonly openCatalogs = new Set<SessionId>()
|
||||
private readonly catalogDebounce = new Map<SessionId, ReturnType<typeof setTimeout>>()
|
||||
|
||||
private selected: SessionId | undefined
|
||||
|
||||
@@ -104,8 +117,10 @@ export class SessionManager {
|
||||
constructor(
|
||||
private readonly api: IApiClient,
|
||||
restoredSelection?: SessionId,
|
||||
restoredAddress?: SubagentAddress,
|
||||
) {
|
||||
this.selected = restoredSelection
|
||||
if (restoredAddress !== undefined) this.addresses.set(restoredAddress.childSessionId, restoredAddress)
|
||||
this.listSnapshotCache = this.buildListSnapshot()
|
||||
}
|
||||
|
||||
@@ -119,7 +134,27 @@ export class SessionManager {
|
||||
if (!this.summaries.some(summary => summary.sessionId === sessionId)) {
|
||||
throw new Error(`sessions.select: unknown session ${sessionId}`)
|
||||
}
|
||||
this.addresses.delete(sessionId)
|
||||
this.sessions.get(sessionId)?.configureSubagent(undefined)
|
||||
this.selected = sessionId
|
||||
void this.refreshSubagents(sessionId)
|
||||
this.notifier.notifyNow()
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a healthy child through its durable direct-parent address.
|
||||
* @param address - catalog-derived parent and child ids.
|
||||
*/
|
||||
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') {
|
||||
throw new Error(`sessions.selectSubagent: ${address.childSessionId} is not a healthy catalog child`)
|
||||
}
|
||||
this.addresses.set(address.childSessionId, address)
|
||||
this.sessions.get(address.childSessionId)?.configureSubagent(address, catalog?.parentAvailable ?? false)
|
||||
this.selected = address.childSessionId
|
||||
void this.refreshSubagents(address.childSessionId)
|
||||
this.notifier.notifyNow()
|
||||
}
|
||||
|
||||
@@ -129,6 +164,15 @@ export class SessionManager {
|
||||
this.notifier.notifyNow()
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the durable catalog address retained for one child.
|
||||
* @param sessionId - possible addressed child id.
|
||||
* @returns The direct-parent address, when navigation discovered one.
|
||||
*/
|
||||
subagentAddress(sessionId: SessionId): SubagentAddress | undefined {
|
||||
return this.addresses.get(sessionId)
|
||||
}
|
||||
|
||||
// ---- Instance management ----
|
||||
|
||||
/**
|
||||
@@ -168,13 +212,23 @@ export class SessionManager {
|
||||
if (summary !== undefined) {
|
||||
session.handleBlank(summary.blank)
|
||||
session.handleRunning(summary.running)
|
||||
} else {
|
||||
const address = this.addresses.get(sessionId)
|
||||
const child = address === undefined ? undefined : this.catalogs.get(address.parentSessionId)?.entries
|
||||
.find(entry => entry.kind === 'child' && entry.id === sessionId)
|
||||
if (child?.kind === 'child') session.handleRunning(child.activity === 'running')
|
||||
}
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
private createSession(sessionId: SessionId): Session {
|
||||
const address = this.addresses.get(sessionId)
|
||||
return new Session(sessionId, this.api, {
|
||||
...(address === undefined ? {} : {
|
||||
address,
|
||||
parentAvailable: this.catalogs.get(address.parentSessionId)?.parentAvailable ?? false,
|
||||
}),
|
||||
// The sender's local first-send flip mirrors into the list row so the
|
||||
// session surfaces (lists filter on blank) before any host frame lands.
|
||||
onEngaged: (engaged) => {
|
||||
@@ -197,6 +251,78 @@ export class SessionManager {
|
||||
return store
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh one direct-child catalog, reusing its in-flight request.
|
||||
* @param parentSessionId - catalog owner.
|
||||
*/
|
||||
refreshSubagents(parentSessionId: SessionId): Promise<void> {
|
||||
const existing = this.catalogInflight.get(parentSessionId)
|
||||
if (existing !== undefined) return existing
|
||||
const previous = this.catalogs.get(parentSessionId)
|
||||
this.catalogs.set(parentSessionId, {
|
||||
entries: previous?.entries ?? [],
|
||||
parentAvailable: previous?.parentAvailable ?? false,
|
||||
state: 'loading',
|
||||
error: null,
|
||||
})
|
||||
this.notifier.markDirty()
|
||||
const operation = (async () => {
|
||||
try {
|
||||
const { result } = await this.api.subagents.list({ parentSessionId })
|
||||
if (result.ok) {
|
||||
this.catalogs.set(parentSessionId, {
|
||||
...result.value,
|
||||
state: 'ready',
|
||||
error: null,
|
||||
})
|
||||
for (const [childId, address] of this.addresses) {
|
||||
if (address.parentSessionId !== parentSessionId) continue
|
||||
this.sessions.get(childId)?.handleSubagentParentAvailable(result.value.parentAvailable)
|
||||
}
|
||||
} else {
|
||||
this.catalogs.set(parentSessionId, {
|
||||
entries: previous?.entries ?? [],
|
||||
parentAvailable: previous?.parentAvailable ?? false,
|
||||
state: 'error',
|
||||
error: result.error,
|
||||
})
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const folded = transportError<never>(error)
|
||||
this.catalogs.set(parentSessionId, {
|
||||
entries: previous?.entries ?? [],
|
||||
parentAvailable: previous?.parentAvailable ?? false,
|
||||
state: 'error',
|
||||
error: folded.ok ? null : folded.error,
|
||||
})
|
||||
} finally {
|
||||
this.catalogInflight.delete(parentSessionId)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
})()
|
||||
this.catalogInflight.set(parentSessionId, operation)
|
||||
return operation
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark whether a catalog menu is consuming live membership updates.
|
||||
* @param parentSessionId - catalog owner.
|
||||
* @param open - current menu state.
|
||||
*/
|
||||
setSubagentCatalogOpen(parentSessionId: SessionId, open: boolean): void {
|
||||
if (open) {
|
||||
this.openCatalogs.add(parentSessionId)
|
||||
void this.refreshSubagents(parentSessionId)
|
||||
} else {
|
||||
this.openCatalogs.delete(parentSessionId)
|
||||
const timer = this.catalogDebounce.get(parentSessionId)
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer)
|
||||
this.catalogDebounce.delete(parentSessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- List surface ----
|
||||
|
||||
/** Full refresh via session.list (single-flight: an in-flight call is reused). */
|
||||
@@ -484,11 +610,23 @@ export class SessionManager {
|
||||
...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}),
|
||||
})
|
||||
this.sessions.get(frame.sessionId)?.handleBlank(frame.blank)
|
||||
if (frame.parentSessionId !== undefined
|
||||
&& (this.selected === frame.parentSessionId || this.openCatalogs.has(frame.parentSessionId))) {
|
||||
this.scheduleCatalogRefresh(frame.parentSessionId)
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'host/session-removed': {
|
||||
this.recordMutation({ kind: 'remove', sessionId: frame.sessionId })
|
||||
this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot
|
||||
if (this.addresses.has(frame.sessionId)) {
|
||||
// A continuable 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)
|
||||
this.updateCatalogActivity(frame.sessionId, false)
|
||||
} else {
|
||||
this.sessions.get(frame.sessionId)?.handleRemoved()
|
||||
}
|
||||
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
|
||||
this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone
|
||||
this.projectionStores.delete(frame.sessionId) // removed sessions drop their projection rows with the instance
|
||||
@@ -497,6 +635,7 @@ export class SessionManager {
|
||||
case 'host/session-status': {
|
||||
this.recordMutation({ kind: 'status', sessionId: frame.sessionId, running: frame.running })
|
||||
this.sessions.get(frame.sessionId)?.handleRunning(frame.running)
|
||||
this.updateCatalogActivity(frame.sessionId, frame.running)
|
||||
return
|
||||
}
|
||||
case 'host/agent-error': {
|
||||
@@ -535,9 +674,40 @@ export class SessionManager {
|
||||
/** After each connection generation: refresh the session baseline and rebuild opened windows. */
|
||||
handleConnected(): void {
|
||||
void this.refreshList()
|
||||
const selectedAddress = this.selected === undefined ? undefined : this.addresses.get(this.selected)
|
||||
if (selectedAddress !== undefined) void this.refreshSubagents(selectedAddress.parentSessionId)
|
||||
if (this.selected !== undefined) void this.refreshSubagents(this.selected)
|
||||
for (const parentSessionId of this.openCatalogs) void this.refreshSubagents(parentSessionId)
|
||||
for (const session of this.sessions.values()) void session.resync()
|
||||
}
|
||||
|
||||
/** Debounce membership refetches while one parent catalog is open. */
|
||||
private scheduleCatalogRefresh(parentSessionId: SessionId): void {
|
||||
if (this.catalogDebounce.has(parentSessionId)) return
|
||||
const timer = setTimeout(() => {
|
||||
this.catalogDebounce.delete(parentSessionId)
|
||||
void this.refreshSubagents(parentSessionId)
|
||||
}, 50)
|
||||
this.catalogDebounce.set(parentSessionId, timer)
|
||||
}
|
||||
|
||||
/** Flip a listed child's coarse activity in place from the shared Host frame. */
|
||||
private updateCatalogActivity(childSessionId: SessionId, running: boolean): void {
|
||||
let changed = false
|
||||
for (const [parentSessionId, catalog] of this.catalogs) {
|
||||
const activity = running ? 'running' as const : 'inactive' as const
|
||||
if (!catalog.entries.some(entry =>
|
||||
entry.kind === 'child' && entry.id === childSessionId && entry.activity !== activity)) continue
|
||||
const entries = catalog.entries.map((entry) => {
|
||||
if (entry.kind !== 'child' || entry.id !== childSessionId) return entry
|
||||
return { ...entry, activity }
|
||||
})
|
||||
changed = true
|
||||
this.catalogs.set(parentSessionId, { ...catalog, entries })
|
||||
}
|
||||
if (changed) this.notifier.markDirty()
|
||||
}
|
||||
|
||||
private buildListSnapshot(): SessionListSnapshot {
|
||||
const merged: TitledSessionSummary[] = this.summaries.map((summary) => {
|
||||
// List rows read the generic 'title' projection key (host-computed unit
|
||||
@@ -566,7 +736,8 @@ export class SessionManager {
|
||||
const sameOrder = items.length === this.itemsCache.length && items.every((e, i) => e === this.itemsCache[i])
|
||||
if (!sameOrder) this.itemsCache = items
|
||||
const selected = this.selected
|
||||
const current = selected !== undefined && items.some(item => item.sessionId === selected)
|
||||
const current = selected !== undefined
|
||||
&& (items.some(item => item.sessionId === selected) || this.addresses.has(selected))
|
||||
? selected
|
||||
: undefined
|
||||
return {
|
||||
@@ -575,6 +746,8 @@ export class SessionManager {
|
||||
state: this.listState,
|
||||
phase: this.listPhase,
|
||||
error: this.listError,
|
||||
subagentsByParent: Object.fromEntries(this.catalogs),
|
||||
currentAddress: current === undefined ? undefined : this.addresses.get(current),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type {
|
||||
IApiClient, RpcError, RpcResult, SessionId, WorkspaceId,
|
||||
IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, WorkspaceId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
@@ -31,7 +31,7 @@ import type { SessionFace } from '../contract/session.ts'
|
||||
import type { ISessions } from '../contract/sessions.ts'
|
||||
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
|
||||
import { SessionManager } from './manager.ts'
|
||||
import type { SessionListPhase, SessionSearchResultItem } from './manager.ts'
|
||||
import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts'
|
||||
import { SessionProvideChannel } from './provide.ts'
|
||||
import type { Session } from './session.ts'
|
||||
|
||||
@@ -68,6 +68,16 @@ export interface SessionListState {
|
||||
current: SessionId | undefined
|
||||
/** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */
|
||||
phase: SessionListPhase
|
||||
/** Direct durable catalogs keyed by their selected parent address. */
|
||||
subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
|
||||
/** Current session's catalog-derived address, absent on ordinary navigation. */
|
||||
currentAddress: SubagentAddress | undefined
|
||||
}
|
||||
|
||||
/** Persisted navigation cell: address survives refresh for correct history routing. */
|
||||
interface SessionSelection {
|
||||
sessionId?: SessionId
|
||||
subagentAddress?: SubagentAddress
|
||||
}
|
||||
|
||||
/** Structured session-create failure. */
|
||||
@@ -221,7 +231,7 @@ export class SessionsService implements ISessions {
|
||||
* selection survives transient list states (reconnect re-pull) and
|
||||
* resurfaces when its session returns.
|
||||
*/
|
||||
private readonly selection: SnapshotStore<{ sessionId?: SessionId }>
|
||||
private readonly selection: SnapshotStore<SessionSelection>
|
||||
|
||||
private readonly scopes = new Map<SessionId, ScopeRecord>()
|
||||
/** The provide channel (roster, materialization rules, current projection) — shared with the test runtime's double. */
|
||||
@@ -244,12 +254,14 @@ export class SessionsService implements ISessions {
|
||||
private readonly rootCtx: Context,
|
||||
api: IApiClient,
|
||||
) {
|
||||
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
|
||||
this.selection = createSnapshotStore<SessionSelection>(
|
||||
{},
|
||||
{ persist: { name: 'dsh.sessions.current' } })
|
||||
this.manager = new SessionManager(api, this.selection.getSnapshot().sessionId)
|
||||
const restored = this.selection.getSnapshot()
|
||||
this.manager = new SessionManager(api, restored.sessionId, restored.subagentAddress)
|
||||
this.list = createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, phase: 'pending',
|
||||
subagentsByParent: {}, currentAddress: undefined,
|
||||
})
|
||||
// The manager owns wire truth; the store is its projection. Manager
|
||||
// notifications are already microtask-batched.
|
||||
@@ -303,6 +315,41 @@ export class SessionsService implements ISessions {
|
||||
this.manager.select(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a healthy catalog child through its direct-parent address.
|
||||
* @param address - catalog-derived parent and child ids.
|
||||
*/
|
||||
openSubagent(address: SubagentAddress): void {
|
||||
this.manager.selectSubagent(address)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an already discovered direct-parent address without opening it.
|
||||
* Feature plugins use this to avoid Agent-bound RPCs in persisted child views.
|
||||
* @param id - possible addressed child id.
|
||||
* @returns The retained address, when present.
|
||||
*/
|
||||
subagentAddress(id: SessionId): SubagentAddress | undefined {
|
||||
return this.manager.subagentAddress(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inform the runtime whether a catalog menu is consuming membership updates.
|
||||
* @param parentSessionId - selected parent.
|
||||
* @param open - menu state.
|
||||
*/
|
||||
setSubagentCatalogOpen(parentSessionId: SessionId, open: boolean): void {
|
||||
this.manager.setSubagentCatalogOpen(parentSessionId, open)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh one direct-child catalog.
|
||||
* @param parentSessionId - catalog owner.
|
||||
*/
|
||||
refreshSubagents(parentSessionId: SessionId): Promise<void> {
|
||||
return this.manager.refreshSubagents(parentSessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current selection so the layout shows the no-session empty
|
||||
* state (new-session affordance and the workspace preselection flow).
|
||||
@@ -509,6 +556,7 @@ export class SessionsService implements ISessions {
|
||||
* cannot miss; kept so a future current writer cannot crash the notify. */
|
||||
if (record !== undefined) {
|
||||
void record.session.open()
|
||||
void this.manager.refreshSubagents(current)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,7 +614,9 @@ export class SessionsService implements ISessions {
|
||||
|
||||
/** Project the manager's list snapshot into the store (title derivation is display-only). */
|
||||
private projectList(): void {
|
||||
const { items, current, phase } = this.manager.getListSnapshot()
|
||||
const {
|
||||
items, current, phase, subagentsByParent, currentAddress,
|
||||
} = this.manager.getListSnapshot()
|
||||
const ids: SessionId[] = []
|
||||
const byId: Record<SessionId, SessionSummary> = {}
|
||||
for (const entry of items) {
|
||||
@@ -583,15 +633,36 @@ export class SessionsService implements ISessions {
|
||||
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
|
||||
}
|
||||
}
|
||||
if (current !== undefined && currentAddress !== undefined && byId[current] === undefined) {
|
||||
const child = subagentsByParent[currentAddress.parentSessionId]?.entries
|
||||
.find(entry => entry.kind === 'child' && entry.id === current)
|
||||
if (child?.kind === 'child') {
|
||||
byId[current] = {
|
||||
id: current,
|
||||
displayTitle: child.label,
|
||||
parentId: currentAddress.parentSessionId,
|
||||
running: child.activity === 'running',
|
||||
waitingApproval: false,
|
||||
blank: false,
|
||||
updatedAt: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
const persisted = this.selection.getSnapshot().sessionId
|
||||
// No current (cleared, or masked gap) wipes the persisted cell — a reload
|
||||
// stays on empty; the in-memory selection still resurfaces a masked id.
|
||||
if (current === undefined) {
|
||||
if (persisted !== undefined) this.selection.set({})
|
||||
} else if (byId[current] !== undefined && persisted !== current) {
|
||||
this.selection.set({ sessionId: current })
|
||||
} else if (byId[current] !== undefined
|
||||
&& (persisted !== current
|
||||
|| this.selection.getSnapshot().subagentAddress?.childSessionId !== currentAddress?.childSessionId
|
||||
|| this.selection.getSnapshot().subagentAddress?.parentSessionId !== currentAddress?.parentSessionId)) {
|
||||
this.selection.set({
|
||||
sessionId: current,
|
||||
...(currentAddress === undefined ? {} : { subagentAddress: currentAddress }),
|
||||
})
|
||||
}
|
||||
this.list.set({ ids, byId, current, phase })
|
||||
this.list.set({ ids, byId, current, phase, subagentsByParent, currentAddress })
|
||||
this.pruneScopes(byId)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
HistoryEntry, IApiClient, InboxItemId, MuxFrame, QueueAction, RpcError,
|
||||
RpcId, RpcResult, SessionId, ToolEventView,
|
||||
RpcId, RpcResponse, RpcResult, SessionId, SubagentAddress, ToolEventView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
@@ -34,6 +34,10 @@ const MAX_RETRY_DELAY_MS = 2_147_483_647
|
||||
|
||||
/** Manager-owned observers of a Session object's local state edges. */
|
||||
export interface SessionOptions {
|
||||
/** Catalog-discovered address selecting non-activating subagent transport. */
|
||||
address?: SubagentAddress
|
||||
/** Whether the exact direct parent Agent was live at the latest catalog read. */
|
||||
parentAvailable?: boolean
|
||||
/**
|
||||
* First ACCEPTED prompt on a blank session (fires at most once, on the
|
||||
* prompt RPC's success response): the manager mirrors the blank→false flip
|
||||
@@ -119,6 +123,8 @@ export class Session implements SessionFace {
|
||||
private dispatchesRev = 0
|
||||
private dispatchesCache: { rev: number; value: ReadonlyMap<string, readonly CodeSubCall[]> } | null = null
|
||||
private running = false
|
||||
private address: SubagentAddress | undefined
|
||||
private parentAvailable = false
|
||||
/**
|
||||
* Sticky send marker, private input of the composerPhase derivation: set
|
||||
* synchronously before prompt()'s first await, never reset — the blank →
|
||||
@@ -174,6 +180,8 @@ export class Session implements SessionFace {
|
||||
private readonly options: SessionOptions = {},
|
||||
) {
|
||||
this.projections = options.projections ?? new ProjectionValueStore()
|
||||
this.address = options.address
|
||||
this.parentAvailable = options.parentAvailable ?? false
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
}
|
||||
|
||||
@@ -213,7 +221,12 @@ export class Session implements SessionFace {
|
||||
this.notifier.markDirty()
|
||||
let result: RpcResult<{ accepted: true }>
|
||||
try {
|
||||
result = (await this.api.sessions.prompt({ sessionId: this.sessionId, mode, content })).result
|
||||
if (this.address === undefined) {
|
||||
result = (await this.api.sessions.prompt({ sessionId: this.sessionId, mode, content })).result
|
||||
} else {
|
||||
const routed = (await this.api.subagents.prompt({ ...this.address, content })).result
|
||||
result = routed.ok ? { ok: true, value: { accepted: true } } : routed
|
||||
}
|
||||
} catch (error) {
|
||||
result = transportError(error)
|
||||
}
|
||||
@@ -253,6 +266,19 @@ export class Session implements SessionFace {
|
||||
* @returns the cancel result.
|
||||
*/
|
||||
async cancel(): Promise<RpcResult<{ accepted: true }>> {
|
||||
if (this.address !== undefined) {
|
||||
const result: RpcResult<{ accepted: true }> = {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'subagent-not-delivered',
|
||||
message: 'subagent activation cancellation is unavailable',
|
||||
details: { childSessionId: this.address.childSessionId },
|
||||
},
|
||||
}
|
||||
this.promptError = { op: 'stop', error: result.error }
|
||||
this.notifier.markDirty()
|
||||
return result
|
||||
}
|
||||
let result: RpcResult<{ accepted: true }>
|
||||
try {
|
||||
result = (await this.api.sessions.cancel({ sessionId: this.sessionId })).result
|
||||
@@ -318,9 +344,7 @@ export class Session implements SessionFace {
|
||||
this.loadingOlder = true
|
||||
this.notifier.markDirty()
|
||||
try {
|
||||
const { result } = await this.api.sessions.history({
|
||||
sessionId: this.sessionId, beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES,
|
||||
})
|
||||
const { result } = await this.history({ beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES })
|
||||
if (!result.ok) return // keep the window as-is; do not overwrite openError (open already succeeded)
|
||||
const older = result.value.events
|
||||
if (older.length === 0) {
|
||||
@@ -479,6 +503,31 @@ export class Session implements SessionFace {
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Install or clear the catalog-discovered transport address. A changed
|
||||
* address rebuilds an already-open window through its new history route.
|
||||
* @param address - direct parent/child address, or undefined for ordinary transport.
|
||||
* @param parentAvailable - latest exact-parent availability hint.
|
||||
*/
|
||||
configureSubagent(address: SubagentAddress | undefined, parentAvailable = false): void {
|
||||
const same = this.address?.parentSessionId === address?.parentSessionId
|
||||
&& this.address?.childSessionId === address?.childSessionId
|
||||
this.address = address
|
||||
this.parentAvailable = parentAvailable
|
||||
if (!same && this.openState !== 'cold') void this.resync()
|
||||
else this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update only the parent availability hint from a catalog refresh.
|
||||
* @param available - whether the exact direct parent is live.
|
||||
*/
|
||||
handleSubagentParentAvailable(available: boolean): void {
|
||||
if (this.parentAvailable === available) return
|
||||
this.parentAvailable = available
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Blank-bit relay from the authoritative summary source (list baseline and
|
||||
* the session-added frame). Monotone: once any signal (local first send,
|
||||
@@ -533,7 +582,7 @@ export class Session implements SessionFace {
|
||||
this.openError = null
|
||||
this.notifier.markDirty()
|
||||
try {
|
||||
let { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
|
||||
let { result } = await this.history({ maxMessages: PAGE_MESSAGES })
|
||||
if (generation !== this.openGeneration) return
|
||||
if (!result.ok) {
|
||||
this.openState = 'error'
|
||||
@@ -544,7 +593,7 @@ export class Session implements SessionFace {
|
||||
// Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more.
|
||||
const tailSeq = this.windowTailSeq()
|
||||
if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) {
|
||||
result = (await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })).result
|
||||
result = (await this.history({ maxMessages: PAGE_MESSAGES })).result
|
||||
if (generation !== this.openGeneration) return
|
||||
if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
|
||||
}
|
||||
@@ -621,7 +670,7 @@ export class Session implements SessionFace {
|
||||
this.stitching = true
|
||||
const generation = this.openGeneration
|
||||
try {
|
||||
const { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
|
||||
const { result } = await this.history({ maxMessages: PAGE_MESSAGES })
|
||||
// Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself.
|
||||
if (result.ok && generation === this.openGeneration && this.openState === 'open') {
|
||||
this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
|
||||
@@ -888,6 +937,9 @@ export class Session implements SessionFace {
|
||||
codeDispatches: this.dispatchesCache.value,
|
||||
queue: this.queueCache.value,
|
||||
running: this.running,
|
||||
subagent: this.address === undefined
|
||||
? null
|
||||
: { address: this.address, parentAvailable: this.parentAvailable },
|
||||
composerPhase: derivePhase(
|
||||
// Command lifecycle nodes are not conversation: running /permission
|
||||
// or /plan on a fresh session keeps the hero (the client mirror of
|
||||
@@ -905,6 +957,17 @@ export class Session implements SessionFace {
|
||||
lastAgentError: this.lastAgentError,
|
||||
}
|
||||
}
|
||||
|
||||
/** Select ordinary or addressed history transport from the stored browser fact. */
|
||||
private history(payload: { beforeSeq?: number; maxMessages?: number }): Promise<RpcResponse<{
|
||||
events: HistoryEntry[]
|
||||
hasMore: boolean
|
||||
projections?: ProjectionsBaseline
|
||||
}>> {
|
||||
return this.address === undefined
|
||||
? this.api.sessions.history({ sessionId: this.sessionId, ...payload })
|
||||
: this.api.subagents.history({ ...this.address, ...payload })
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate the plugin-owned payload at the session-event wire boundary. */
|
||||
|
||||
Reference in New Issue
Block a user