Merge remote-tracking branch 'origin/master' into dshw/pr-deepseek-harness-deepseek-harness-2781
# Conflicts: # .agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml # .agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md # .agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md # docs/event-producer-consumer.i18n.yaml # docs/event-producer-consumer.md # docs/event-producer-consumer.zh.md # packages/api/session-controller/tests/session-cold.host.spec.ts # packages/bundle/web-app/package.json # packages/host/apiproxy/src/api-proxy.ts # packages/session/session-projection-cache/package.json
This commit is contained in:
commit
334dd53c92
3162 files changed
+119688
-47896
No files matched your search
@@ -0,0 +1,414 @@
|
||||
/** Agent activation, composition, and model-selection policy owned by API Session. */
|
||||
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { installModelSelection } from '@deepseek-ai/dsh-agent'
|
||||
import type {
|
||||
Agent, AgentOptions, AgentSetup, ModelSelection as AgentModelSelection, ModelSelectionRef,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-agent-default-model'
|
||||
import { resolveSessionPreset } from '@deepseek-ai/dsh-agent-presets'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type {} from '@deepseek-ai/dsh-typert-registry'
|
||||
import type { SessionError } from './types.ts'
|
||||
|
||||
/** Cold Session identity absent from persistence. */
|
||||
export class ApiSessionNotFound extends Error {}
|
||||
|
||||
/** Session identity whose lifecycle belongs to subagent routing. */
|
||||
export class ApiSessionSubagentOwnership extends Error {
|
||||
/** @param sessionId - identity reserved to subagent routing. */
|
||||
constructor(readonly sessionId: SessionId) {
|
||||
super(`session "${sessionId}" is a subagent session; use subagent delivery`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Explicit-id creation attempted to adopt a Session under another cwd. */
|
||||
export class ApiSessionCwdConflict extends Error {
|
||||
constructor(
|
||||
readonly sessionId: SessionId,
|
||||
readonly requestedCwd: string,
|
||||
readonly existingCwd: string | undefined,
|
||||
) {
|
||||
super(
|
||||
existingCwd === undefined
|
||||
? `session "${sessionId}" records no cwd and cannot be adopted for "${requestedCwd}"`
|
||||
: `session "${sessionId}" belongs to "${existingCwd}", not "${requestedCwd}"`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Explicit-id creation attempted to adopt a Session under another preset. */
|
||||
export class ApiSessionPresetConflict extends Error {
|
||||
constructor(
|
||||
readonly sessionId: SessionId,
|
||||
readonly requestedPreset: string,
|
||||
readonly existingPreset: string | undefined,
|
||||
) {
|
||||
super(
|
||||
existingPreset === undefined
|
||||
? `session "${sessionId}" records no agent preset and cannot be adopted under "${requestedPreset}"`
|
||||
: `session "${sessionId}" runs agent preset "${existingPreset}", not "${requestedPreset}"`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Failures produced while resolving one ordinary Session identity to its live Agent. */
|
||||
export type ApiSessionAgentError = Extract<
|
||||
SessionError,
|
||||
{ readonly code: 'session-not-found' | 'agent-busy' | 'internal' }
|
||||
>
|
||||
|
||||
/** Result of resolving one ordinary Session identity to its live Agent. */
|
||||
export type ApiSessionAgentResult =
|
||||
| { readonly agent: Agent }
|
||||
| { readonly error: ApiSessionAgentError }
|
||||
|
||||
type InstalledSelection = ModelSelectionRef & { current: AgentModelSelection }
|
||||
|
||||
/**
|
||||
* Test whether generic Session routing must leave an identity to subagent routing.
|
||||
* @param ctx - Host context carrying the Agent ownership registry.
|
||||
* @param session - attached or live Session whose ownership is tested.
|
||||
* @param agent - live Agent when one exists for the Session.
|
||||
* @returns whether subagent routing owns the Session identity.
|
||||
*/
|
||||
export function hasApiSessionSubagentOwner(
|
||||
ctx: Context,
|
||||
session: Pick<Session, 'header'>,
|
||||
agent: Agent | undefined,
|
||||
): boolean {
|
||||
if (session.header.origin === 'subagent') return true
|
||||
const parentId = session.header.parentSession
|
||||
if (parentId === undefined || agent === undefined) return false
|
||||
const parent = ctx.agents.get(parentId)
|
||||
return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the stable caller-facing subagent ownership rejection.
|
||||
* @param sessionId - Session identity owned by subagent routing.
|
||||
* @returns a stable Session-domain failure.
|
||||
*/
|
||||
export function apiSessionSubagentOwnershipError(sessionId: SessionId): ApiSessionAgentError {
|
||||
return {
|
||||
code: 'agent-busy',
|
||||
message: `session "${sessionId}" is owned by subagent routing`,
|
||||
details: { reason: 'use subagent delivery for this child session' },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect one cold Session without repairing, resuming, or publishing it.
|
||||
* @param ctx - Host context carrying Session persistence.
|
||||
* @param sessionId - durable Session identity.
|
||||
* @param signal - optional cancellation for persistence reads.
|
||||
* @returns the persisted header and complete event prefix.
|
||||
*/
|
||||
export async function inspectApiSession(
|
||||
ctx: Context,
|
||||
sessionId: SessionId,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
const persistence = ctx.get('sessionPersistence')
|
||||
if (persistence === undefined) {
|
||||
throw new Error('session persistence is not configured (load a dsh-session-persistence backend)')
|
||||
}
|
||||
const meta = (await persistence.list(signal)).find(candidate => candidate.id === sessionId)
|
||||
if (meta === undefined || meta.cwd === undefined) {
|
||||
throw new ApiSessionNotFound(`session "${sessionId}" not found`)
|
||||
}
|
||||
const inspected = await persistence.inspect(sessionId, signal)
|
||||
if (inspected.meta.cwd === undefined) {
|
||||
throw new ApiSessionNotFound(`session "${sessionId}" not found`)
|
||||
}
|
||||
return { meta: inspected.meta, events: [...inspected.events] }
|
||||
}
|
||||
|
||||
/** Owns every operation that may create, resume, or configure a Web Agent. */
|
||||
export class ApiSessionAgentController {
|
||||
private readonly resumes = new Map<SessionId, Promise<Agent>>()
|
||||
private readonly creations = new Map<SessionId, Promise<Agent>>()
|
||||
private readonly selections = new WeakMap<Agent, InstalledSelection>()
|
||||
private readonly imageAdmissionChains = new WeakMap<Agent, Promise<void>>()
|
||||
|
||||
/** @param ctx - Host context carrying Agent, model, persistence, and Typert services. */
|
||||
constructor(private readonly ctx: Context) {
|
||||
ctx.typert.lookups.configure('agent', async (sessionId: SessionId) => {
|
||||
const found = await this.resolveAgent(sessionId)
|
||||
if ('error' in found) throw new TypertLookupFailure(found.error)
|
||||
return found.agent
|
||||
})
|
||||
ctx.typert.lookups.configure('session', async (sessionId: SessionId) => {
|
||||
const found = await this.resolveAgent(sessionId)
|
||||
if ('error' in found) throw new TypertLookupFailure(found.error)
|
||||
return found.agent.session
|
||||
})
|
||||
ctx.typert.contexts.configureHost('agent', async (sessionId: SessionId) => {
|
||||
const found = await this.resolveAgent(sessionId)
|
||||
if ('error' in found) throw new TypertLookupFailure(found.error)
|
||||
return found.agent.ctx
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve or resume one ordinary Session, deduplicating concurrent resumes.
|
||||
* @param sessionId - ordinary Session identity.
|
||||
* @returns the live Agent or a stable Session-domain failure.
|
||||
*/
|
||||
async resolveAgent(sessionId: SessionId): Promise<ApiSessionAgentResult> {
|
||||
const live = this.liveAgent(sessionId)
|
||||
if (live !== undefined) return live
|
||||
const attached = this.ctx.sessions.get(sessionId)
|
||||
if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, undefined)) {
|
||||
return { error: apiSessionSubagentOwnershipError(sessionId) }
|
||||
}
|
||||
|
||||
let resume = this.resumes.get(sessionId)
|
||||
if (resume === undefined) {
|
||||
resume = this.resume(sessionId).finally(() => { this.resumes.delete(sessionId) })
|
||||
this.resumes.set(sessionId, resume)
|
||||
}
|
||||
try {
|
||||
return { agent: await resume }
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ApiSessionNotFound) {
|
||||
return {
|
||||
error: {
|
||||
code: 'session-not-found',
|
||||
message: error.message,
|
||||
details: { sessionId },
|
||||
},
|
||||
}
|
||||
}
|
||||
if (error instanceof ApiSessionSubagentOwnership) {
|
||||
return { error: apiSessionSubagentOwnershipError(error.sessionId) }
|
||||
}
|
||||
const raced = this.liveAgent(sessionId)
|
||||
if (raced !== undefined) return raced
|
||||
const racedSession = this.ctx.sessions.get(sessionId)
|
||||
if (racedSession !== undefined && hasApiSessionSubagentOwner(this.ctx, racedSession, undefined)) {
|
||||
return { error: apiSessionSubagentOwnershipError(sessionId) }
|
||||
}
|
||||
return {
|
||||
error: {
|
||||
code: 'internal',
|
||||
message: `resume failed for session "${sessionId}": ${String(error)}`,
|
||||
details: {},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one requested identity, creating or resuming it once.
|
||||
* @param sessionId - requested Session identity.
|
||||
* @param cwd - directory the Session must own.
|
||||
* @param checkPersistedIdentity - whether to inspect a cold identity before creation.
|
||||
* @param presetId - optional Agent preset the Session must own.
|
||||
* @returns the matching live ordinary Agent.
|
||||
*/
|
||||
async ensureSession(
|
||||
sessionId: SessionId,
|
||||
cwd: string,
|
||||
checkPersistedIdentity: boolean,
|
||||
presetId?: string,
|
||||
): Promise<Agent> {
|
||||
let creation = this.creations.get(sessionId)
|
||||
if (creation === undefined) {
|
||||
creation = this.createOrAdopt(sessionId, cwd, checkPersistedIdentity, presetId)
|
||||
.catch((error: unknown) => {
|
||||
const live = this.ctx.agents.get(sessionId)
|
||||
if (live !== undefined) {
|
||||
if (hasApiSessionSubagentOwner(this.ctx, live.session, live)) {
|
||||
throw new ApiSessionSubagentOwnership(sessionId)
|
||||
}
|
||||
return live
|
||||
}
|
||||
const attached = this.ctx.sessions.get(sessionId)
|
||||
if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, undefined)) {
|
||||
throw new ApiSessionSubagentOwnership(sessionId)
|
||||
}
|
||||
throw error
|
||||
})
|
||||
.finally(() => { this.creations.delete(sessionId) })
|
||||
this.creations.set(sessionId, creation)
|
||||
}
|
||||
const agent = await creation
|
||||
if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) {
|
||||
throw new ApiSessionSubagentOwnership(sessionId)
|
||||
}
|
||||
this.assertPresetUnchanged(sessionId, presetId, resolveSessionPreset(agent.session))
|
||||
if (agent.session.header.cwd !== cwd) {
|
||||
throw new ApiSessionCwdConflict(sessionId, cwd, agent.session.header.cwd)
|
||||
}
|
||||
return agent
|
||||
}
|
||||
|
||||
/**
|
||||
* Install or return the Session-local model selection used by prompt assembly.
|
||||
* @param agent - live Agent that owns the selection.
|
||||
* @returns the installed mutable selection reference.
|
||||
*/
|
||||
selectionFor(agent: Agent): InstalledSelection {
|
||||
const installed = this.selections.get(agent)
|
||||
if (installed !== undefined) return installed
|
||||
let picked: AgentModelSelection | undefined
|
||||
const defaultModel = this.ctx.agentDefaultModel
|
||||
const selection: InstalledSelection = {
|
||||
get current(): AgentModelSelection {
|
||||
if (picked !== undefined) return picked
|
||||
const logged = agent.session.requestHeader()?.config
|
||||
if (logged === undefined) return defaultModel.currentSelection()
|
||||
return {
|
||||
provider: logged.provider,
|
||||
model: logged.model,
|
||||
...(logged.reasoningEffort === undefined ? {} : { reasoningEffort: logged.reasoningEffort }),
|
||||
}
|
||||
},
|
||||
set current(next: AgentModelSelection) {
|
||||
picked = next
|
||||
},
|
||||
assembled: undefined,
|
||||
}
|
||||
installModelSelection(agent.ctx, selection)
|
||||
this.selections.set(agent, selection)
|
||||
return selection
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize image admission and model selection for one Agent.
|
||||
* @param agent - live Agent that owns the serialization chain.
|
||||
* @param operation - asynchronous operation admitted after prior work settles.
|
||||
* @returns the operation result or rejection.
|
||||
*/
|
||||
serializeImageAdmission<Value>(agent: Agent, operation: () => Promise<Value>): Promise<Value> {
|
||||
const result = (this.imageAdmissionChains.get(agent) ?? Promise.resolve()).then(operation)
|
||||
this.imageAdmissionChains.set(agent, result.then(() => undefined, () => undefined))
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the preset id and pre-publication Agent setup for a create or resume.
|
||||
* @param presetId - requested preset or the configured default when omitted.
|
||||
* @returns the resolved preset identity and Agent setup callback.
|
||||
*/
|
||||
async composeAgent(presetId: string | undefined): Promise<{
|
||||
readonly agentPreset?: string
|
||||
readonly setup: AgentSetup
|
||||
}> {
|
||||
const presets = this.ctx.get('agentPresets')
|
||||
if (presets === undefined) return { setup: (agentCtx) => { this.installSelection(agentCtx) } }
|
||||
const resolvedId = (await presets.resolve(presetId)).id
|
||||
return {
|
||||
agentPreset: resolvedId,
|
||||
setup: async (agentCtx) => {
|
||||
this.installSelection(agentCtx)
|
||||
await presets.mount(agentCtx, resolvedId)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private liveAgent(sessionId: SessionId): ApiSessionAgentResult | undefined {
|
||||
const agent = this.ctx.agents.get(sessionId)
|
||||
if (agent === undefined) return undefined
|
||||
return hasApiSessionSubagentOwner(this.ctx, agent.session, agent)
|
||||
? { error: apiSessionSubagentOwnershipError(sessionId) }
|
||||
: { agent }
|
||||
}
|
||||
|
||||
private async resume(sessionId: SessionId): Promise<Agent> {
|
||||
const inspected = await inspectApiSession(this.ctx, sessionId)
|
||||
if (hasApiSessionSubagentOwner(this.ctx, { header: inspected.meta }, undefined)) {
|
||||
throw new ApiSessionSubagentOwnership(sessionId)
|
||||
}
|
||||
const composition = await this.composeAgent(resolveSessionPreset({
|
||||
header: inspected.meta,
|
||||
events: inspected.events,
|
||||
}))
|
||||
const published = this.ctx.sessions.get(sessionId)
|
||||
const live = this.ctx.agents.get(sessionId)
|
||||
if (published !== undefined && hasApiSessionSubagentOwner(this.ctx, published, live)) {
|
||||
throw new ApiSessionSubagentOwnership(sessionId)
|
||||
}
|
||||
return (await this.ctx.agents.resume({
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: this.agentOptions(),
|
||||
setup: composition.setup,
|
||||
})).agent
|
||||
}
|
||||
|
||||
private async createOrAdopt(
|
||||
sessionId: SessionId,
|
||||
cwd: string,
|
||||
checkPersistedIdentity: boolean,
|
||||
presetId: string | undefined,
|
||||
): Promise<Agent> {
|
||||
const attached = this.ctx.sessions.get(sessionId)
|
||||
const live = this.ctx.agents.get(sessionId)
|
||||
if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, live)) {
|
||||
throw new ApiSessionSubagentOwnership(sessionId)
|
||||
}
|
||||
if (live !== undefined) return live
|
||||
|
||||
const persistence = checkPersistedIdentity ? this.ctx.get('sessionPersistence') : undefined
|
||||
const stored = persistence === undefined
|
||||
? undefined
|
||||
: (await persistence.list()).find(header => header.id === sessionId)
|
||||
if (persistence !== undefined && stored !== undefined) {
|
||||
const inspected = await persistence.inspect(sessionId)
|
||||
if (hasApiSessionSubagentOwner(this.ctx, { header: inspected.meta }, undefined)) {
|
||||
throw new ApiSessionSubagentOwnership(sessionId)
|
||||
}
|
||||
if (inspected.meta.cwd !== cwd) {
|
||||
throw new ApiSessionCwdConflict(sessionId, cwd, inspected.meta.cwd)
|
||||
}
|
||||
const storedPreset = resolveSessionPreset({ header: inspected.meta, events: inspected.events })
|
||||
this.assertPresetUnchanged(sessionId, presetId, storedPreset)
|
||||
const composition = await this.composeAgent(storedPreset)
|
||||
return (await this.ctx.agents.resume({
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: this.agentOptions(),
|
||||
setup: composition.setup,
|
||||
})).agent
|
||||
}
|
||||
|
||||
try {
|
||||
await mkdir(cwd, { recursive: true })
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error })
|
||||
}
|
||||
const composition = await this.composeAgent(presetId)
|
||||
return (await this.ctx.agents.create({
|
||||
sessionId,
|
||||
agentOptions: this.agentOptions(),
|
||||
meta: {
|
||||
cwd,
|
||||
...(composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset }),
|
||||
},
|
||||
setup: composition.setup,
|
||||
})).agent
|
||||
}
|
||||
|
||||
private agentOptions(): AgentOptions {
|
||||
const { provider, model } = this.ctx.agentDefaultModel.currentSelection()
|
||||
return { provider, model }
|
||||
}
|
||||
|
||||
private installSelection(agentCtx: Context): void {
|
||||
const agent = agentCtx.agent
|
||||
if (agent === undefined) throw new Error('api-session: Agent setup has no scoped Agent')
|
||||
this.selectionFor(agent)
|
||||
}
|
||||
|
||||
private assertPresetUnchanged(
|
||||
sessionId: SessionId,
|
||||
requested: string | undefined,
|
||||
existing: string | undefined,
|
||||
): void {
|
||||
if (requested === undefined || requested === existing) return
|
||||
throw new ApiSessionPresetConflict(sessionId, requested, existing)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/** Shared projection of the live LLM registry into the browser model catalog. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ModelCatalogFailure,
|
||||
ModelProviderGroup,
|
||||
ModelReasoning,
|
||||
} from './types.ts'
|
||||
|
||||
/**
|
||||
* Build the browser model catalog without requiring a Session.
|
||||
* @param ctx - Host context carrying the live LLM registry.
|
||||
* @returns successful non-empty provider groups and isolated provider failures.
|
||||
*/
|
||||
export async function buildModelCatalog(ctx: Context): Promise<{
|
||||
readonly groups: ModelProviderGroup[]
|
||||
readonly failures: ModelCatalogFailure[]
|
||||
}> {
|
||||
const catalog = await Promise.all(ctx.llm.listProviders().map(async (provider) => {
|
||||
try {
|
||||
const models = await ctx.llm.listModels(provider.id)
|
||||
const entries = await Promise.all(models.map(async (model) => {
|
||||
const resolved = await ctx.llm.resolveModelInfo(provider.id, model.id)
|
||||
const reasoning: ModelReasoning | undefined = resolved.reasoning === undefined
|
||||
? undefined
|
||||
: {
|
||||
efforts: resolved.reasoning.efforts.map(effort => ({
|
||||
id: effort.id,
|
||||
name: effort.name,
|
||||
...(effort.description === undefined ? {} : { description: effort.description }),
|
||||
})),
|
||||
...(resolved.reasoning.defaultEffort === undefined
|
||||
? {}
|
||||
: { defaultEffort: resolved.reasoning.defaultEffort }),
|
||||
}
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
...(model.description === undefined ? {} : { description: model.description }),
|
||||
...(reasoning === undefined ? {} : { reasoning }),
|
||||
}
|
||||
}))
|
||||
return {
|
||||
kind: 'group' as const,
|
||||
group: { id: provider.id, name: provider.name, models: entries },
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
kind: 'failure' as const,
|
||||
failure: {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
}
|
||||
}
|
||||
}))
|
||||
return {
|
||||
groups: catalog.flatMap(item => item.kind === 'group' ? [item.group] : [])
|
||||
.filter(group => group.models.length > 0),
|
||||
failures: catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : []),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/** Observable contiguous Session event window consumed by domain assemblers. */
|
||||
import { notifySubscribers, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { SessionEventEntry } from '../../types.ts'
|
||||
|
||||
interface EventWindowLeaf {
|
||||
readonly kind: 'leaf'
|
||||
readonly entries: readonly SessionEventEntry[]
|
||||
readonly length: number
|
||||
}
|
||||
|
||||
interface EventWindowConcat {
|
||||
readonly kind: 'concat'
|
||||
readonly left: EventWindowNode
|
||||
readonly right: EventWindowNode
|
||||
readonly length: number
|
||||
}
|
||||
|
||||
type EventWindowNode = EventWindowLeaf | EventWindowConcat
|
||||
|
||||
function leaf(entries: readonly SessionEventEntry[]): EventWindowLeaf {
|
||||
return { kind: 'leaf', entries, length: entries.length }
|
||||
}
|
||||
|
||||
function concat(left: EventWindowNode, right: EventWindowNode): EventWindowConcat {
|
||||
return { kind: 'concat', left, right, length: left.length + right.length }
|
||||
}
|
||||
|
||||
function materialize(node: EventWindowNode): readonly SessionEventEntry[] {
|
||||
if (node.kind === 'leaf') return node.entries
|
||||
const entries = new Array<SessionEventEntry>(node.length)
|
||||
const pending: EventWindowNode[] = [node]
|
||||
let index = 0
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop() as EventWindowNode
|
||||
if (current.kind === 'concat') {
|
||||
pending.push(current.right, current.left)
|
||||
continue
|
||||
}
|
||||
for (const entry of current.entries) {
|
||||
entries[index] = entry
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
function windowSnapshot(
|
||||
node: EventWindowNode,
|
||||
hasMore: boolean,
|
||||
revision: number,
|
||||
change: SessionEventChange,
|
||||
): SessionEventWindow {
|
||||
let entries: readonly SessionEventEntry[] | undefined
|
||||
return {
|
||||
get entries() {
|
||||
entries ??= materialize(node)
|
||||
return entries
|
||||
},
|
||||
hasMore,
|
||||
revision,
|
||||
change,
|
||||
}
|
||||
}
|
||||
|
||||
/** Exact delta that produced the latest event-window revision. */
|
||||
export type SessionEventChange =
|
||||
| { readonly kind: 'replace'; readonly entries: readonly SessionEventEntry[] }
|
||||
| { readonly kind: 'prepend'; readonly entries: readonly SessionEventEntry[] }
|
||||
| { readonly kind: 'append'; readonly entries: readonly SessionEventEntry[] }
|
||||
|
||||
/** Current contiguous event window and its latest synchronous delta. */
|
||||
export interface SessionEventWindow {
|
||||
readonly entries: readonly SessionEventEntry[]
|
||||
readonly hasMore: boolean
|
||||
readonly revision: number
|
||||
readonly change: SessionEventChange
|
||||
}
|
||||
|
||||
/** Conversation-facing event source exposed by one Session binding. */
|
||||
export type SessionEventSource = ObservableSnapshot<SessionEventWindow>
|
||||
|
||||
/** Session-owned event feed; every accepted window mutation publishes synchronously. */
|
||||
export class MutableSessionEventSource implements SessionEventSource {
|
||||
private readonly listeners = new Set<() => void>()
|
||||
private window: EventWindowNode = leaf([])
|
||||
private snapshot: SessionEventWindow = windowSnapshot(
|
||||
this.window,
|
||||
false,
|
||||
0,
|
||||
{ kind: 'replace', entries: [] },
|
||||
)
|
||||
|
||||
/** @returns the cached event-window snapshot. */
|
||||
getSnapshot(): SessionEventWindow { return this.snapshot }
|
||||
|
||||
/**
|
||||
* Subscribe to synchronous window publication.
|
||||
* @param listener - invalidation callback.
|
||||
* @returns unsubscribe function.
|
||||
*/
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener)
|
||||
return () => { this.listeners.delete(listener) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the complete contiguous window.
|
||||
* @param entries - complete window.
|
||||
* @param hasMore - whether older history remains.
|
||||
*/
|
||||
replace(entries: readonly SessionEventEntry[], hasMore: boolean): void {
|
||||
this.window = leaf(entries)
|
||||
this.publish(hasMore, { kind: 'replace', entries })
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend one older contiguous page.
|
||||
* @param entries - newly loaded older entries.
|
||||
* @param hasMore - whether still older history remains.
|
||||
*/
|
||||
prepend(entries: readonly SessionEventEntry[], hasMore: boolean): void {
|
||||
this.window = concat(leaf(entries), this.window)
|
||||
this.publish(hasMore, { kind: 'prepend', entries })
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one contiguous live entry.
|
||||
* @param entry - live tail entry.
|
||||
*/
|
||||
append(entry: SessionEventEntry): void {
|
||||
const entries = [entry]
|
||||
this.window = concat(this.window, leaf(entries))
|
||||
this.publish(this.snapshot.hasMore, {
|
||||
kind: 'append',
|
||||
entries,
|
||||
})
|
||||
}
|
||||
|
||||
private publish(
|
||||
hasMore: boolean,
|
||||
change: SessionEventChange,
|
||||
): void {
|
||||
this.snapshot = windowSnapshot(this.window, hasMore, this.snapshot.revision + 1, change)
|
||||
notifySubscribers(this.listeners, '[session-controller] event feed')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/** Client operation results spanning Session Remote calls and the legacy subagent carrier. */
|
||||
|
||||
import type { RpcError } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionError } from '../../types.ts'
|
||||
|
||||
/** Failure surfaced by the Client Session object layer. */
|
||||
export type ClientFailure = RpcError | SessionError
|
||||
|
||||
/** Success or failure returned by a Client Session operation. */
|
||||
export type ClientResult<T> =
|
||||
| { readonly ok: true; readonly value: T }
|
||||
| { readonly ok: false; readonly error: ClientFailure }
|
||||
|
||||
/**
|
||||
* Fold a rejected carrier operation into the Client Session failure vocabulary.
|
||||
* @param error - rejection from a legacy subagent or local carrier call.
|
||||
* @returns the failure branch of a Client Session result.
|
||||
*/
|
||||
export function transportResult<T>(error: unknown): ClientResult<T> {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'internal',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
details: {},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* The outward session face. Feature packages never see the concrete Session
|
||||
* class: components read lifecycle state through `useSession` (the
|
||||
* ObservableSnapshot half), and orchestration code calls the behavior verbs
|
||||
* below — nothing else. Widening this interface is the explicit act of
|
||||
* widening what features may do to a session (and what every test fixture
|
||||
* must stub); implementation-internal entry points (history staging, wire-frame
|
||||
* dispatch) stay on the class, invisible out here.
|
||||
*/
|
||||
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { PromptContentPart, QueueAction } from '../../types.ts'
|
||||
import type { ClientResult } from './result.ts'
|
||||
import type { SessionSnapshot } from './snapshot.ts'
|
||||
|
||||
/** Key-addressed projection read face (the useProjection resolution path; see ProjectionValueStore). */
|
||||
export interface ProjectionsFace {
|
||||
/**
|
||||
* The identity-stable bare observable for one projection key (absence is
|
||||
* an `undefined` snapshot, never a missing face).
|
||||
* @param key - projection key.
|
||||
* @returns the key's value face.
|
||||
*/
|
||||
faceOf(key: string): ObservableSnapshot<unknown>
|
||||
}
|
||||
|
||||
/** Identity plus the behavior verbs features may invoke on a session. */
|
||||
export interface ISession {
|
||||
/** The session's host identity (agent id — same axis). */
|
||||
readonly sessionId: SessionId
|
||||
/** Host-computed projection values by key (the useProjection seat). */
|
||||
readonly projections: ProjectionsFace
|
||||
/**
|
||||
* Send a prompt into the session.
|
||||
* @param content - text plus browser-owned temporary image uploads.
|
||||
* @param mode - 'queue' appends a turn; 'steer' interrupts the running one.
|
||||
* @returns acceptance, or the business error (also mirrored into snapshot.promptError).
|
||||
*/
|
||||
prompt(
|
||||
content: PromptContentPart[],
|
||||
mode: 'queue' | 'steer',
|
||||
signal?: AbortSignal,
|
||||
): Promise<ClientResult<{ accepted: true }>>
|
||||
/**
|
||||
* Resolve one durable image referenced by this session.
|
||||
* @param attachmentId - opaque id found in the folded session log.
|
||||
* @returns the authenticated reference and decoded bytes.
|
||||
*/
|
||||
readAttachment(
|
||||
attachmentId: AttachmentIdType,
|
||||
): Promise<ClientResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>>
|
||||
/**
|
||||
* Apply one edit, remove, or strict steer action to a still-pending queue occurrence.
|
||||
* @param itemId - agent-owned inbox occurrence identity.
|
||||
* @param action - requested queue operation.
|
||||
* @returns acceptance, or a business/transport error.
|
||||
*/
|
||||
updateQueue(itemId: MessageId, action: QueueAction): Promise<ClientResult<{ accepted: true }>>
|
||||
/**
|
||||
* Cancel the running turn. Pending queued work remains and resumes in FIFO
|
||||
* order after the Host reaches cancellation quiescence.
|
||||
* @returns acceptance, or the business error.
|
||||
*/
|
||||
cancel(): Promise<ClientResult<{ accepted: true }>>
|
||||
/**
|
||||
* Rename this session (explicit user title; pins it against automatic
|
||||
* regeneration).
|
||||
* @param title - raw title text (the host normalizes acceptance).
|
||||
* @returns the normalized accepted title and its event seq, or the business error.
|
||||
*/
|
||||
rename(title: string): Promise<ClientResult<{ title: string; seq: number }>>
|
||||
/**
|
||||
* Extend the history window backwards (older messages pagination).
|
||||
* @returns completion; failures land in snapshot.openState/loadingOlder.
|
||||
*/
|
||||
loadOlder(): Promise<void>
|
||||
/**
|
||||
* Execute one slash-command line against this session's agent — pure
|
||||
* admission semantics (the host executor durably logs the lifecycle).
|
||||
* @param line - the full command line, leading slash included.
|
||||
* @returns the admission result, or the Remote face's error branch.
|
||||
*/
|
||||
command(line: string): Promise<RemoteResult<{ matched: boolean }>>
|
||||
}
|
||||
|
||||
/**
|
||||
* The full outward face: behavior verbs plus the Session lifecycle read side
|
||||
* (the `useSession` hook source). This is the type carried by
|
||||
* `SessionBinding.session` and the provide channel.
|
||||
*/
|
||||
export type SessionFace = ISession & ObservableSnapshot<SessionSnapshot>
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* The outward sessions-service face — what `ctx.sessions` exposes to feature
|
||||
* packages. Transport entry points and implementation internals stay on
|
||||
* the concrete class. Widening this interface is the
|
||||
* explicit act of widening what features may do to the sessions domain.
|
||||
*/
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
|
||||
import type { AgentContext } from '../scope.ts'
|
||||
import type { SessionSearchResultItem } from '../sessions/manager.ts'
|
||||
import type { SessionBinding, SessionListState } from '../sessions/service.ts'
|
||||
import type { ClientResult } from './result.ts'
|
||||
import type { SessionFace } from './session.ts'
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
|
||||
export type { AgentContext } from '../scope.ts'
|
||||
|
||||
/** The sessions-service face injected as `ctx.sessions`. */
|
||||
export interface ISessions {
|
||||
/** The useSessions standard feed (list rows + current selection; read face — writes stay inside the domain). */
|
||||
readonly list: ObservableSnapshot<SessionListState>
|
||||
/**
|
||||
* The `session.search` result bound the wire schema fixes, exposed to
|
||||
* presentation as injected data. Not per-connection state: every transport
|
||||
* (fixture included) reports the same number.
|
||||
*/
|
||||
readonly searchResultLimit: number
|
||||
/**
|
||||
* Create or adopt a Session on the Host.
|
||||
* @param opts - target workspace, directory, and optional preallocated identity.
|
||||
* @returns the Session identity after its local binding is addressable.
|
||||
*/
|
||||
create(opts?: {
|
||||
workspaceId?: WorkspaceId
|
||||
cwd?: string
|
||||
sessionId?: SessionId
|
||||
}): Promise<SessionId>
|
||||
/**
|
||||
* Select a session as current.
|
||||
* @param id - session id (must exist in the list; unknown ids fail loud).
|
||||
*/
|
||||
open(id: SessionId): void
|
||||
/**
|
||||
* Open a healthy catalog child through its exact direct-parent address.
|
||||
* @param address - catalog-derived parent and child ids.
|
||||
*/
|
||||
openSubagent(address: SubagentAddress): 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
|
||||
/**
|
||||
* 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
|
||||
/**
|
||||
* Refresh one direct-child catalog.
|
||||
* @param parentSessionId - catalog owner.
|
||||
* @returns completion of the current or newly started refresh.
|
||||
*/
|
||||
refreshSubagents(parentSessionId: SessionId): Promise<void>
|
||||
|
||||
/**
|
||||
* Record the composition one session now runs. The agent-preset seat calls
|
||||
* this after a successful blank-session switch, so the header label moves
|
||||
* with the composition instead of waiting for the next full list refresh.
|
||||
* @param sessionId - the switched session.
|
||||
* @param agentPreset - the preset id the host confirmed.
|
||||
*/
|
||||
noteAgentPreset(sessionId: SessionId, agentPreset: string): void
|
||||
/** Clear the current selection into the no-session view state. */
|
||||
clear(): void
|
||||
/**
|
||||
* Refresh the Host-authoritative Session list.
|
||||
* @returns completion of the current or newly started Session-list refresh.
|
||||
*/
|
||||
refresh(): Promise<void>
|
||||
/**
|
||||
* Search the Host's visible message-content index. Results stay
|
||||
* request-local; the list snapshot remains the metadata authority.
|
||||
* @param query - non-blank literal phrase.
|
||||
* @param signal - cancellation for a superseded search.
|
||||
* @returns bounded results, or a business/transport error.
|
||||
*/
|
||||
search(
|
||||
query: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<ClientResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>>
|
||||
/**
|
||||
* Fork a session from a completed-turn prefix of the source; on resolution
|
||||
* the child is in the list store and `open()` can target it.
|
||||
* @param opts - source session id, the optional event seq anchoring the
|
||||
* cut (the boundary is the first turn/end at or after it; an in-log
|
||||
* anchor in an open turn is unavailable rather than clipped backward),
|
||||
* and whether to increment an inherited durable title before resolving.
|
||||
* @returns the child session id.
|
||||
* @throws when the fork fails, or when a requested child-title rename fails after creation.
|
||||
*/
|
||||
fork(opts: { sessionId: SessionId; atSeq?: number; increaseTitle?: boolean }): Promise<SessionId>
|
||||
/**
|
||||
* Resolve an Agent-scoped context view (use-and-discard).
|
||||
* @param id - session id.
|
||||
* @returns scoped ctx, or undefined for a session neither listed nor already scoped.
|
||||
*/
|
||||
scope(id: SessionId): AgentContext | undefined
|
||||
/**
|
||||
* Read the Agent scope tag off a context (service-method boundary: fetch
|
||||
* bundles must reach scope resolution through ctx.sessions).
|
||||
* @param ctx - any client context.
|
||||
* @returns the session id, or undefined on root contexts.
|
||||
*/
|
||||
scopeOf(ctx: Context): SessionId | undefined
|
||||
/**
|
||||
* Resolve the session face behind an Agent-scoped context.
|
||||
* @param ctx - an Agent-scoped context.
|
||||
* @returns the session face, or undefined when the ctx is untagged or its scope was pruned.
|
||||
*/
|
||||
sessionOf(ctx: Context): SessionFace | undefined
|
||||
/**
|
||||
* Resolve the stable session binding (scope-addressed assembly feed).
|
||||
* @param id - session id.
|
||||
* @returns binding, or undefined for a session neither listed nor already scoped.
|
||||
*/
|
||||
binding(id: SessionId): SessionBinding | undefined
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/** Session-owned observable state excluding Conversation target data. */
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ClientFailure } from './result.ts'
|
||||
|
||||
/** One transient inbox occurrence from the authoritative queue snapshot. */
|
||||
export interface QueuedMessage {
|
||||
readonly id: MessageId
|
||||
readonly messageId: MessageId
|
||||
readonly placement: 'queued' | 'steering' | 'context'
|
||||
readonly content: readonly ContentBlock[]
|
||||
readonly preview: string
|
||||
readonly text: string | null
|
||||
}
|
||||
|
||||
/** History-open lifecycle of a Session event window. */
|
||||
export type OpenState = 'cold' | 'loading' | 'open' | 'error'
|
||||
|
||||
/** Send/stop failure surfaced by Session consumers. */
|
||||
export interface PromptError {
|
||||
readonly op: 'send' | 'stop'
|
||||
readonly error: ClientFailure
|
||||
}
|
||||
|
||||
/** Immutable Session lifecycle and control snapshot. */
|
||||
export interface SessionSnapshot {
|
||||
readonly sessionId: SessionId
|
||||
readonly queue: readonly QueuedMessage[]
|
||||
readonly running: boolean
|
||||
readonly subagent: { readonly address: SubagentAddress; readonly parentAvailable: boolean } | null
|
||||
readonly removed: boolean
|
||||
readonly openState: OpenState
|
||||
readonly openError: ClientFailure | null
|
||||
readonly hasMore: boolean
|
||||
readonly loadingOlder: boolean
|
||||
readonly promptError: PromptError | null
|
||||
readonly blank: boolean
|
||||
readonly lastAgentError: string | null
|
||||
/** A prompt call has begun on this Client Session object. */
|
||||
readonly promptAttempted: boolean
|
||||
/** The first accepted prompt has not reached a durable `turn/start` event. */
|
||||
readonly awaitingFirstTurn: boolean
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/** Client Session object layer, Agent scopes, and Remote lifecycle wiring. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-agent/types'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { createSessionControlStream } from './transport.ts'
|
||||
import { ClientSessions } from './sessions/service.ts'
|
||||
import type { SessionRemotes } from './sessions/remotes.ts'
|
||||
import type {} from '../remote-events.ts'
|
||||
|
||||
export {
|
||||
createSessionControlStream,
|
||||
SessionEventStream,
|
||||
SESSION_SEARCH_RESULT_LIMIT,
|
||||
SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
|
||||
sessionStreamFailure,
|
||||
} from './transport.ts'
|
||||
export type {
|
||||
ClientSessionPageRequest,
|
||||
SessionControlStream,
|
||||
SessionControlStreamOptions,
|
||||
SessionEventStreamOptions,
|
||||
SessionJournalChange,
|
||||
SessionRemote,
|
||||
} from './transport.ts'
|
||||
export { createScope, scopeOf } from './scope.ts'
|
||||
export type { AgentContext, AgentScopeHandle } from './scope.ts'
|
||||
export { SessionCreateError, SessionForkError } from './sessions/service.ts'
|
||||
export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts'
|
||||
export type {
|
||||
SessionListPhase,
|
||||
SessionListSnapshot,
|
||||
SessionSearchResultItem,
|
||||
SubagentCatalogSnapshot,
|
||||
} from './sessions/manager.ts'
|
||||
export type { Session } from './sessions/session.ts'
|
||||
export type {
|
||||
ProjectionsBaseline,
|
||||
ProjectionValueStore,
|
||||
SessionProjectionMap,
|
||||
UseProjection,
|
||||
} from './sessions/projection-store.ts'
|
||||
export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
|
||||
export type { ISessions } from './contract/sessions.ts'
|
||||
export { MutableSessionEventSource } from './contract/events.ts'
|
||||
export type { SessionEventChange, SessionEventSource, SessionEventWindow } from './contract/events.ts'
|
||||
export type {
|
||||
OpenState,
|
||||
PromptError,
|
||||
QueuedMessage,
|
||||
SessionSnapshot,
|
||||
} from './contract/snapshot.ts'
|
||||
export type { ClientFailure, ClientResult } from './contract/result.ts'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** Client Session object layer and Agent scope owner. */
|
||||
sessions: import('./contract/sessions.ts').ISessions
|
||||
}
|
||||
}
|
||||
|
||||
/** Required wire, Remote, and Context projection services. */
|
||||
export const inject = [
|
||||
'connection',
|
||||
'typert',
|
||||
'remote',
|
||||
'remote.commands',
|
||||
'remote.session',
|
||||
]
|
||||
|
||||
/**
|
||||
* Install Client Session state and its reconnecting control stream.
|
||||
* @param ctx - Client Cordis context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const remotes = ctx.remote as unknown as SessionRemotes
|
||||
const sessions = new ClientSessions(ctx, connection.api, remotes)
|
||||
ctx.remote.$on('api-session/added', (summary) => { sessions.handleSessionAdded(summary) })
|
||||
ctx.remote.$on('api-session/removed', (sessionId) => { sessions.handleSessionRemoved(sessionId) })
|
||||
ctx.remote.$on('api-session/status', (sessionId, running) => {
|
||||
sessions.handleSessionStatus(sessionId, running)
|
||||
})
|
||||
ctx.remote.$on('api-session/activity', (sessionId, updatedAt) => {
|
||||
sessions.handleSessionActivity(sessionId, updatedAt)
|
||||
})
|
||||
ctx.remote.$on('api-session/error', (sessionId, message) => {
|
||||
sessions.handleSessionError(sessionId, message)
|
||||
})
|
||||
|
||||
const control = createSessionControlStream(remotes, {
|
||||
accept: (frame) => { sessions.handleControlFrame(frame) },
|
||||
failed: (error) => { console.error('[session-controller] control stream failed:', error) },
|
||||
})
|
||||
control.start()
|
||||
ctx.on('connection/reset', () => { sessions.handleConnected() })
|
||||
if (connection.hostDescription.getSnapshot() !== undefined) sessions.handleConnected()
|
||||
ctx.typert.contexts.registerClient('agent', {
|
||||
identity: candidate => sessions.scopeOf(candidate),
|
||||
resolve: sessionId => sessions.resolveAgentScope(sessionId),
|
||||
})
|
||||
ctx.effect(() => async () => { await control.dispose() }, 'session-controller.client.control')
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Merge an authoritative baseline without moving identities already visible to
|
||||
* the client. Baseline-only identities are inserted relative to the nearest
|
||||
* following known identity; identities absent from the baseline are removed.
|
||||
*
|
||||
* @param current - the established client order.
|
||||
* @param baseline - the latest authoritative rows.
|
||||
* @param keyOf - stable identity selector.
|
||||
* @returns baseline-valued rows with the established relative order retained.
|
||||
*/
|
||||
export function mergeOrderedBaseline<T>(
|
||||
current: readonly T[],
|
||||
baseline: readonly T[],
|
||||
keyOf: (value: T) => unknown,
|
||||
): T[] {
|
||||
const baselineByKey = new Map<unknown, T>()
|
||||
for (const value of baseline) baselineByKey.set(keyOf(value), value)
|
||||
|
||||
const merged = current
|
||||
.map(value => baselineByKey.get(keyOf(value)))
|
||||
.filter((value): value is T => value !== undefined)
|
||||
const mergedKeys = new Set(merged.map(keyOf))
|
||||
|
||||
for (let index = 0; index < baseline.length; index++) {
|
||||
const value = baseline[index]
|
||||
/* v8 ignore next -- dense-array guard: index is bounded by baseline.length. */
|
||||
if (value === undefined || mergedKeys.has(keyOf(value))) continue
|
||||
let insertion = merged.length
|
||||
for (let following = index + 1; following < baseline.length; following++) {
|
||||
const candidate = baseline[following]
|
||||
/* v8 ignore next -- dense-array guard: following is bounded by baseline.length. */
|
||||
if (candidate === undefined) continue
|
||||
const known = merged.findIndex(item => keyOf(item) === keyOf(candidate))
|
||||
if (known !== -1) {
|
||||
insertion = known
|
||||
break
|
||||
}
|
||||
}
|
||||
merged.splice(insertion, 0, value)
|
||||
mergedKeys.add(keyOf(value))
|
||||
}
|
||||
return merged
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Client Agent-scope primitive: mint a Cordis context tagged with the owning
|
||||
* Agent's identity. The mechanism mirrors the host `dsh-scope` architecture
|
||||
* (no-op plugin fiber + context tag + `Context.filter` routing predicate);
|
||||
* the shape deliberately diverges: the filter lives on the actx itself
|
||||
* instead of a separate carrier object, so scoped dispatch is plain cordis —
|
||||
* `actx.bail(actx, event, payload)` / `actx.emit(actx, ...)` — with no
|
||||
* wrapper. The host needs a detached carrier because its dispatch subject is
|
||||
* the business Agent object; client scope events carry only ids, so the
|
||||
* actx is the natural subject. The second divergence stands: the scope key
|
||||
* is the branded `SessionId` (value compared), not an object identity — the
|
||||
* agent and its session share one id (1:1, same axis; no separate AgentId
|
||||
* brand), and a client scope's identity IS that wire id. Third divergence,
|
||||
* deliberate: the client scopes the Agent IDENTITY, not a live Agent object
|
||||
* — a cold session's host Agent is already disposed while its client actx
|
||||
* stays alive for history viewing.
|
||||
*/
|
||||
import { Context as CordisContext } from '@deepseek-ai/cordis'
|
||||
import type { Context, Fiber } from '@deepseek-ai/cordis'
|
||||
import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { TypertRemoteScopeApi } from '@deepseek-ai/dsh-typert-protocol'
|
||||
|
||||
/** Client Cordis Context carrying one Agent identity and its scoped Remote namespaces. */
|
||||
export type AgentContext = Omit<Context, 'remote'> & {
|
||||
readonly remote: ClientRemote & TypertRemoteScopeApi<'agent'>
|
||||
}
|
||||
|
||||
/** Context tag written by {@link createScope}. */
|
||||
const kScope = Symbol('dsh.client.scope')
|
||||
|
||||
/** A minted Agent scope and its disposal boundary. */
|
||||
export interface AgentScopeHandle {
|
||||
/**
|
||||
* Tagged context: scope-owned registrations and scoped dispatch both go
|
||||
* through it (passing it as the dispatch subject routes to this agent's
|
||||
* tagged listeners plus every untagged one).
|
||||
*/
|
||||
ctx: AgentContext
|
||||
/** Backing fiber (dispose tears down every scope-owned registration). */
|
||||
fiber: Fiber
|
||||
}
|
||||
|
||||
/** Shared no-op plugin backing each Agent scope fiber. */
|
||||
function agentScope(): void {}
|
||||
|
||||
/**
|
||||
* Mint an Agent scope under `ctx`: a no-op plugin fiber whose context
|
||||
* carries the agent tag and the dispatch filter — untagged listeners are
|
||||
* admitted globally, tagged listeners only for a matching agent.
|
||||
* Registrations through the returned ctx dispose with the fiber.
|
||||
* @param ctx - client root context the scope fiber mounts under.
|
||||
* @param key - owning agent identity (the routing tag; agent id === session id).
|
||||
* @returns the tagged context and its backing fiber.
|
||||
*/
|
||||
export function createScope(ctx: Context, key: SessionId): AgentScopeHandle {
|
||||
const fiber = ctx.plugin(agentScope)
|
||||
const scoped = fiber.ctx.extend({
|
||||
[kScope]: key,
|
||||
[CordisContext.filter](listenerCtx: Context): boolean {
|
||||
const tag = scopeOf(listenerCtx)
|
||||
return tag === undefined || tag === key
|
||||
},
|
||||
}) as AgentContext
|
||||
return {
|
||||
fiber,
|
||||
ctx: scoped,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the nearest agent tag inherited by a context.
|
||||
* @param ctx - any client context.
|
||||
* @returns its agent identity (the session id), or undefined for root contexts.
|
||||
*/
|
||||
export function scopeOf(ctx: Context): SessionId | undefined {
|
||||
return (ctx as Context & { [kScope]?: SessionId })[kScope]
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// flattenLineage: summaries -> flat list with lineage indentation (pure function).
|
||||
// The input order is authoritative; lineage only makes each child adjacent to its parent.
|
||||
// Orphaned lineage degrades to root level; cycles fail soft and emit as roots.
|
||||
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
import type { SessionSummary } from '../../types.ts'
|
||||
|
||||
/** Host list summary enriched with the latest Session Controller title projection. */
|
||||
export interface TitledSessionSummary extends SessionSummary {
|
||||
title?: string
|
||||
/** Current host-computed projection values for list consumers. */
|
||||
projectionValues?: Readonly<Partial<SessionProjectionMap>>
|
||||
}
|
||||
|
||||
/** One flattened session-list row with lineage depth. */
|
||||
export interface SessionListEntry {
|
||||
sessionId: SessionId
|
||||
title?: string
|
||||
updatedAt: number
|
||||
running: boolean
|
||||
/** Empty-log bit mirrored from the summary; lists hide blank sessions (filtering stays with the consumer). */
|
||||
blank: boolean
|
||||
parentSessionId?: SessionId
|
||||
/** Coarse durable origin for navigation filtering; not a continuation capability. */
|
||||
origin?: 'subagent'
|
||||
cwd?: string
|
||||
/** Agent preset the session's agent was composed from (summary passthrough). */
|
||||
agentPreset?: string
|
||||
/** Current host-computed projection values for list consumers. */
|
||||
projectionValues?: Readonly<Partial<SessionProjectionMap>>
|
||||
/** Finished running while not selected and not yet opened — the sidebar's green "done" reminder (clears on select or the next run). */
|
||||
completed: boolean
|
||||
/** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */
|
||||
depth: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Summaries -> flat list with lineage indentation. Root and sibling order
|
||||
* follows the established input order; this projection never re-sorts a
|
||||
* hydrated list from mutable timestamps.
|
||||
* @param summaries - the host's session.list items.
|
||||
* @param completed - sessions with a pending completion reminder (manager-owned live fact; absent = false).
|
||||
* @returns display rows in render order.
|
||||
*/
|
||||
export function flattenLineage(
|
||||
summaries: readonly TitledSessionSummary[],
|
||||
completed?: ReadonlySet<SessionId>,
|
||||
): SessionListEntry[] {
|
||||
const byId = new Map<SessionId, TitledSessionSummary>()
|
||||
for (const s of summaries) byId.set(s.sessionId, s)
|
||||
|
||||
const children = new Map<SessionId, TitledSessionSummary[]>()
|
||||
const roots: TitledSessionSummary[] = []
|
||||
for (const s of summaries) {
|
||||
if (s.parentSessionId !== undefined && byId.has(s.parentSessionId)) {
|
||||
const list = children.get(s.parentSessionId) ?? []
|
||||
list.push(s)
|
||||
children.set(s.parentSessionId, list)
|
||||
} else {
|
||||
roots.push(s) // root, or an orphan whose parent is absent from summaries (degrade to root, never drop)
|
||||
}
|
||||
}
|
||||
|
||||
const out: SessionListEntry[] = []
|
||||
const visited = new Set<SessionId>()
|
||||
const walk = (s: TitledSessionSummary, depth: number): void => {
|
||||
if (visited.has(s.sessionId)) {
|
||||
console.warn(`[session-controller] lineage cycle at ${s.sessionId}; emitting as root`)
|
||||
return
|
||||
}
|
||||
visited.add(s.sessionId)
|
||||
out.push({
|
||||
...s,
|
||||
completed: completed?.has(s.sessionId) ?? false,
|
||||
depth,
|
||||
})
|
||||
const kids = children.get(s.sessionId)
|
||||
if (kids === undefined) return
|
||||
for (const kid of kids) walk(kid, depth + 1)
|
||||
}
|
||||
for (const root of roots) walk(root, 0)
|
||||
// Cycle members (unreachable from any root): emit as roots so no entry is lost.
|
||||
for (const s of summaries) {
|
||||
if (!visited.has(s.sessionId)) walk(s, 0)
|
||||
}
|
||||
return out
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,97 @@
|
||||
import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
|
||||
|
||||
/**
|
||||
* Batches structural updates in microtasks and stream updates by animation
|
||||
* frame. Reads may rebuild a dirty snapshot without consuming the pending
|
||||
* subscriber notification.
|
||||
*/
|
||||
export class Notifier {
|
||||
private listeners = new Set<() => void>()
|
||||
private dirty = false
|
||||
private notifyPending = false
|
||||
private scheduled: 'none' | 'microtask' | 'frame' = 'none'
|
||||
private scheduleGeneration = 0
|
||||
|
||||
/** @param rebuild - snapshot rebuild function injected by the owner (writes the owner's snapshotCache). */
|
||||
constructor(private readonly rebuild: () => void) {}
|
||||
|
||||
/**
|
||||
* uSES subscription entry.
|
||||
* @param listener - change callback.
|
||||
* @returns the unsubscribe function.
|
||||
*/
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener)
|
||||
return () => {
|
||||
this.listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
/** Mark the snapshot dirty and notify in a microtask. */
|
||||
markDirty(): void {
|
||||
this.dirty = true
|
||||
this.notifyPending = true
|
||||
if (this.scheduled === 'microtask') return
|
||||
this.schedule('microtask')
|
||||
}
|
||||
|
||||
/** Mark the snapshot dirty and publish cumulative state at most once per frame. */
|
||||
markFrameDirty(): void {
|
||||
this.dirty = true
|
||||
this.notifyPending = true
|
||||
if (this.scheduled !== 'none') return
|
||||
this.schedule(typeof globalThis.requestAnimationFrame === 'function' ? 'frame' : 'microtask')
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous flush: controlled-input writes must notify in the same tick as
|
||||
* onChange, or React rolls the DOM back to the stale value and the caret jumps to the end.
|
||||
*/
|
||||
notifyNow(): void {
|
||||
this.dirty = true
|
||||
this.notifyPending = true
|
||||
this.invalidateSchedule()
|
||||
this.flush()
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-getSnapshot check: rebuild synchronously when dirty (read path
|
||||
* before first subscribe / while unobserved). Notification stays pending.
|
||||
*/
|
||||
ensureFresh(): void {
|
||||
if (!this.dirty) return
|
||||
this.dirty = false
|
||||
this.rebuild()
|
||||
}
|
||||
|
||||
private schedule(kind: 'microtask' | 'frame'): void {
|
||||
const generation = ++this.scheduleGeneration
|
||||
this.scheduled = kind
|
||||
const publish = () => {
|
||||
if (generation !== this.scheduleGeneration) return
|
||||
this.scheduled = 'none'
|
||||
this.flush()
|
||||
}
|
||||
if (kind === 'frame') {
|
||||
globalThis.requestAnimationFrame(publish)
|
||||
} else {
|
||||
queueMicrotask(publish)
|
||||
}
|
||||
}
|
||||
|
||||
private invalidateSchedule(): void {
|
||||
this.scheduleGeneration++
|
||||
this.scheduled = 'none'
|
||||
}
|
||||
|
||||
private flush(): void {
|
||||
if (!this.notifyPending) return
|
||||
if (this.listeners.size === 0) return // lazy: dirty (if still set) rebuilds on next getSnapshot
|
||||
this.notifyPending = false
|
||||
if (this.dirty) {
|
||||
this.dirty = false
|
||||
this.rebuild()
|
||||
}
|
||||
notifySubscribers(this.listeners, '[session-controller]')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Generic per-session projection value store (push model; see the
|
||||
* session-projection subsystem page, docs/subsystems/session-projection.md):
|
||||
* the host is the only computation site; the client holds finished
|
||||
* whole values per key — `key → { value, seq }` — seeded by a Session page's
|
||||
* projections block and updated by Session Controller `projection` frames,
|
||||
* under the single rule **higher seq wins**. No client-side domain folding
|
||||
* exists: a domain ships projection support with zero client code. Per-key
|
||||
* bare observable faces feed `useProjection` (ui-renderer binds them).
|
||||
*/
|
||||
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import { Notifier } from './notifier.ts'
|
||||
|
||||
// The single projection type table, typed end to end (host unit, wire block,
|
||||
// client store, React hook) — the Service Definition package's pure-type outlet
|
||||
// (`/types`, zero imports), never the package root: the root's dsh-agent →
|
||||
// dsh-session chain would drag the host `Context.sessions` merge into the
|
||||
// client program (one program must not hold both sides). No second
|
||||
// client-side "views" table (rejected in the Alternatives of
|
||||
// .agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md).
|
||||
export type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
|
||||
/**
|
||||
* The fifth framework hook seat (see the session-projection subsystem page,
|
||||
* docs/subsystems/session-projection.md): key-addressed
|
||||
* projection reader delivered through the standard kit. `undefined` uniformly
|
||||
* means capability absent — host unit unmounted, or no baseline/frame has
|
||||
* carried the key yet. The selector overload mirrors useSession (per-key uSES
|
||||
* binding; reference stability holds because a key's value reference changes
|
||||
* only when a frame or baseline lands).
|
||||
*/
|
||||
export type UseProjection = {
|
||||
<K extends Extract<keyof SessionProjectionMap, string>>(key: K): SessionProjectionMap[K] | undefined
|
||||
<K extends Extract<keyof SessionProjectionMap, string>, S>(
|
||||
key: K,
|
||||
selector: (value: SessionProjectionMap[K] | undefined) => S,
|
||||
eq?: (a: S, b: S) => boolean,
|
||||
): S
|
||||
}
|
||||
|
||||
/**
|
||||
* Tail-page projections baseline — structurally identical to Session
|
||||
* Controller's `SessionProjectionsBlock`, restated here so the
|
||||
* React-free store depends only on the type table, not the wire package's
|
||||
* response vocabulary.
|
||||
*/
|
||||
export interface ProjectionsBaseline {
|
||||
/** The consistent-cut seq (equals the window tail seq by construction). */
|
||||
asOfSeq: number
|
||||
/** Whole current values by key; a registered key absent here means the capability is absent. */
|
||||
values: Readonly<Record<string, unknown>>
|
||||
}
|
||||
|
||||
/** One key's row: the latest finished value and the seq it is consistent with. */
|
||||
interface Row {
|
||||
value: unknown
|
||||
seq: number
|
||||
}
|
||||
|
||||
/** Per-key notification channel: the bare face plus its batching notifier. */
|
||||
interface Channel {
|
||||
face: ObservableSnapshot<unknown>
|
||||
notifier: Notifier
|
||||
}
|
||||
|
||||
/**
|
||||
* One session's projection values. Framework semantics, uniform across every
|
||||
* key: a baseline seeds rows at its cut, a push frame updates one row, and in
|
||||
* both paths a lower-or-equal seq loses — a replayed frame cannot regress a
|
||||
* value, a stale baseline cannot overwrite a newer frame. A key the store has
|
||||
* never seen reads `undefined` (capability absent). Faces are identity-stable
|
||||
* per key (create-on-demand, cached) so the React side binds each exactly
|
||||
* once; the store-level channel (`subscribeAny`) serves coarse consumers (the
|
||||
* manager's list projection reads the `title` key).
|
||||
*/
|
||||
export class ProjectionValueStore {
|
||||
private readonly rows = new Map<string, Row>()
|
||||
private readonly channels = new Map<string, Channel>()
|
||||
private valuesCache: Readonly<Partial<SessionProjectionMap>> | undefined
|
||||
/** Coarse any-key channel (no snapshot cache to rebuild: reads hit rows directly). */
|
||||
private readonly anyNotifier = new Notifier(() => {})
|
||||
|
||||
/**
|
||||
* Key-addressed bare observable face (the useProjection resolution path).
|
||||
* Always defined — absence is an `undefined` snapshot, never a missing
|
||||
* face, so a component may subscribe before the key ever carries a value.
|
||||
* @param key - projection key.
|
||||
* @returns the identity-stable face for this key.
|
||||
*/
|
||||
faceOf(key: string): ObservableSnapshot<unknown> {
|
||||
return this.channel(key).face
|
||||
}
|
||||
|
||||
/**
|
||||
* Current whole value for a key (erased framework read; typed reads go
|
||||
* through `useProjection`'s map lookup).
|
||||
* @param key - projection key.
|
||||
* @returns the value, or undefined while the key is absent.
|
||||
*/
|
||||
get(key: string): unknown {
|
||||
return this.rows.get(key)?.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every current projection value as one reference-stable snapshot.
|
||||
* @returns The same frozen value map until a row changes.
|
||||
*/
|
||||
values(): Readonly<Partial<SessionProjectionMap>> {
|
||||
if (this.valuesCache === undefined) {
|
||||
this.valuesCache = Object.freeze(Object.fromEntries(
|
||||
[...this.rows].map(([key, row]) => [key, row.value]),
|
||||
))
|
||||
}
|
||||
return this.valuesCache
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to any-key changes (microtask-batched) — the manager's list
|
||||
* rebuild channel.
|
||||
* @param listener - change callback.
|
||||
* @returns the unsubscribe function.
|
||||
*/
|
||||
subscribeAny(listener: () => void): () => void {
|
||||
return this.anyNotifier.subscribe(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one finished value from the Session control stream.
|
||||
* @param key - projection key.
|
||||
* @param value - whole value computed by the host unit.
|
||||
* @param seq - the unit's watermark at emission.
|
||||
*/
|
||||
apply(key: string, value: unknown, seq: number): void {
|
||||
const row = this.rows.get(key)
|
||||
if (row !== undefined && seq <= row.seq) return // higher seq wins; replays and stale frames drop
|
||||
this.rows.set(key, { value, seq })
|
||||
this.changed(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed from a history tail page's projections block: every carried key
|
||||
* lands under the same seq rule as frames; a key the block omits is
|
||||
* capability-absent as of the cut — its row clears unless a newer frame
|
||||
* already superseded the cut (a stale baseline can neither overwrite nor
|
||||
* clear newer values).
|
||||
* @param baseline - the response's projections block.
|
||||
*/
|
||||
seed(baseline: ProjectionsBaseline): void {
|
||||
// Erased walk: the framework crosses the open key space; per-key typing
|
||||
// is re-established at the consumer (useProjection's map lookup).
|
||||
const values = baseline.values as Record<string, unknown>
|
||||
for (const key of Object.keys(values)) this.apply(key, values[key], baseline.asOfSeq)
|
||||
for (const [key, row] of this.rows) {
|
||||
if (Object.hasOwn(values, key)) continue
|
||||
if (row.seq > baseline.asOfSeq) continue
|
||||
this.rows.delete(key)
|
||||
this.changed(key)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop rows beyond a replacement control baseline. Such rows describe
|
||||
* process state the Host lost before persisting it and would otherwise
|
||||
* outrank recomputed lower-seq values forever. The caller seeds the new
|
||||
* baseline immediately afterward.
|
||||
* @param lastSeq - highest durable sequence reflected by the baseline.
|
||||
*/
|
||||
truncate(lastSeq: number): void {
|
||||
for (const [key, row] of this.rows) {
|
||||
if (row.seq <= lastSeq) continue
|
||||
this.rows.delete(key)
|
||||
this.changed(key)
|
||||
}
|
||||
}
|
||||
|
||||
private changed(key: string): void {
|
||||
this.valuesCache = undefined
|
||||
this.channels.get(key)?.notifier.markDirty()
|
||||
this.anyNotifier.markDirty()
|
||||
}
|
||||
|
||||
private channel(key: string): Channel {
|
||||
let channel = this.channels.get(key)
|
||||
if (channel === undefined) {
|
||||
// The notifier only batches (no snapshot cache to rebuild: faces read rows directly).
|
||||
const notifier = new Notifier(() => {})
|
||||
channel = {
|
||||
notifier,
|
||||
face: {
|
||||
getSnapshot: () => this.rows.get(key)?.value,
|
||||
subscribe: listener => notifier.subscribe(listener),
|
||||
},
|
||||
}
|
||||
this.channels.set(key, channel)
|
||||
}
|
||||
return channel
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionQueuedItem } from '../../types.ts'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { QueuedMessage } from '../contract/snapshot.ts'
|
||||
|
||||
const QUEUE_PREVIEW_CHARS = 200
|
||||
|
||||
function previewOf(content: readonly ContentBlock[]): string {
|
||||
const flat = content
|
||||
.map(block => (block.type === 'text' ? block.text : `[${block.type}]`))
|
||||
.join(' ').replace(/\s+/g, ' ').trim()
|
||||
const chars = Array.from(flat)
|
||||
return chars.length > QUEUE_PREVIEW_CHARS ? `${chars.slice(0, QUEUE_PREVIEW_CHARS).join('')}…` : flat
|
||||
}
|
||||
|
||||
function textOf(content: readonly ContentBlock[]): string | null {
|
||||
if (!content.every(block => block.type === 'text')) return null
|
||||
return content.map(block => block.text).join('')
|
||||
}
|
||||
|
||||
type QueueItems = readonly SessionQueuedItem[]
|
||||
|
||||
/** Authoritative transient queue projection and durable steering handoff. */
|
||||
export class SessionQueueMirror {
|
||||
private current: readonly QueuedMessage[] = []
|
||||
|
||||
/**
|
||||
* Return the current immutable queue projection.
|
||||
* @returns current queue rows.
|
||||
*/
|
||||
snapshot(): readonly QueuedMessage[] {
|
||||
return this.current
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace from one authoritative stream queue frame.
|
||||
* @param items - complete host queue snapshot.
|
||||
*/
|
||||
replace(items: QueueItems): void {
|
||||
this.current = items.map((item) => {
|
||||
const content = item.message.content as unknown as readonly ContentBlock[]
|
||||
return {
|
||||
id: item.id,
|
||||
messageId: item.message.id,
|
||||
placement: item.placement,
|
||||
content,
|
||||
preview: previewOf(content),
|
||||
text: textOf(content),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire a transient steering row once its durable message enters the log.
|
||||
* @param event - newly contiguous durable Session event.
|
||||
* @returns whether the projection changed.
|
||||
*/
|
||||
acceptDurable(event: SessionEvent): boolean {
|
||||
if (event.type !== 'user/message') return false
|
||||
const messageId = event.data.id
|
||||
const index = this.current.findIndex(item =>
|
||||
item.placement === 'steering' && item.messageId === messageId)
|
||||
if (index < 0) return false
|
||||
this.current = this.current.filter((_item, candidate) => candidate !== index)
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Remote namespaces the Session cluster calls. One parameter for one concept:
|
||||
* the generated surface a Session and its manager reach the Host through.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-api-session-controller/client/sessions/remotes
|
||||
*/
|
||||
|
||||
import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types'
|
||||
import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { SessionRemote } from '../transport.ts'
|
||||
|
||||
/** Narrow Commands namespace consumed by a Client Session. */
|
||||
export interface SessionCommandsRemote {
|
||||
execute(
|
||||
agentId: SessionId,
|
||||
line: string,
|
||||
images: readonly EncodedImageAttachment[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<RemoteResult<object | undefined>>
|
||||
}
|
||||
|
||||
/** Generated Remote namespaces consumed by the Client Session object layer. */
|
||||
export interface SessionRemotes {
|
||||
readonly $stream: ClientRemote['$stream']
|
||||
readonly commands: SessionCommandsRemote
|
||||
readonly session: SessionRemote
|
||||
}
|
||||
@@ -0,0 +1,735 @@
|
||||
/**
|
||||
* ClientSessions: root sessions service — list snapshot store (manager
|
||||
* projection; carries `current`, the persisted selection every
|
||||
* session-scoped surface keys off), Agent scope tree (mintScope pattern: no-op plugin
|
||||
* Fiber + ctx.extend scope tag; one scope per session, agent id === session
|
||||
* id), stable SessionBinding cache, breadcrumb-route projection.
|
||||
*
|
||||
* Scope lifecycle is stage-driven: a scope is minted lazily on first
|
||||
* resolution (pure — resolution has no side effects and is render-safe);
|
||||
* the event window and deferred teardown key off the STAGED session, which
|
||||
* follows `list.current` exactly. Staging is the open signal: the window
|
||||
* opens ⟺ the session is on stage (the stage is `current`; the staged
|
||||
* state can widen to a multi-pane list later). A session leaving the list
|
||||
* tears its scope down immediately unless it is the staged one, whose scope
|
||||
* survives frozen (read-only view) until the stage moves on.
|
||||
*/
|
||||
import type { Context, Fiber } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
IApiClient, SubagentAddress,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
|
||||
import { SESSION_SEARCH_RESULT_LIMIT } from '../../types.ts'
|
||||
import type { SessionJob as JobView } from '../../types.ts'
|
||||
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
import {
|
||||
createSnapshotStore, type SnapshotStore,
|
||||
} from '@deepseek-ai/dsh-client-store'
|
||||
import type { ClientFailure, ClientResult } from '../contract/result.ts'
|
||||
import type { SessionEventSource } from '../contract/events.ts'
|
||||
import type { SessionFace } from '../contract/session.ts'
|
||||
import type { AgentContext, ISessions } from '../contract/sessions.ts'
|
||||
import { createScope, scopeOf as scopeTagOf } from '../scope.ts'
|
||||
import { SessionManager } from './manager.ts'
|
||||
import type { SessionRemotes } from './remotes.ts'
|
||||
import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts'
|
||||
import type { Session } from './session.ts'
|
||||
|
||||
/** Session list row projected from the host list RPC plus live stream increments. */
|
||||
export interface SessionSummary {
|
||||
id: SessionId
|
||||
/** Latest durable log-backed title, absent until the host projects one. */
|
||||
title?: string
|
||||
/** Human-facing label: durable title, project basename, then session id. */
|
||||
displayTitle: string
|
||||
cwd?: string
|
||||
/**
|
||||
* Agent preset this session's agent was composed from; absent when the
|
||||
* deployment composes no presets. The session header labels what the
|
||||
* session actually runs rather than the deployment's current default.
|
||||
*/
|
||||
agentPreset?: string
|
||||
parentId?: SessionId
|
||||
/** Coarse durable origin for navigation filtering; not a continuation capability. */
|
||||
origin?: 'subagent'
|
||||
running: boolean
|
||||
/** Finished while not selected and not yet opened — the sidebar's green "done" reminder. Absent = false. */
|
||||
completed?: boolean
|
||||
/**
|
||||
* Empty-log bit (host summary derivation mirror). New Session reuses a blank
|
||||
* one targeting the same workspace. Filtering stays with the consumer: the
|
||||
* store carries every row, while the Workspace browser shows only the
|
||||
* selected blank entry.
|
||||
*/
|
||||
blank: boolean
|
||||
updatedAt: number
|
||||
/** Current host-computed projection values retained by the object layer. */
|
||||
projectionValues?: Readonly<Partial<SessionProjectionMap>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Session list store shape. `current` rides the same snapshot (arbitrated:
|
||||
* the single useSessions standard hook reads list and selection together —
|
||||
* sidebar highlighting and current-session consumers share one fact source).
|
||||
*/
|
||||
export interface SessionListState {
|
||||
/** Host-list order; addressed breadcrumb-only rows are excluded. */
|
||||
ids: SessionId[]
|
||||
/** Host rows plus the current addressed subagent route used by navigation. */
|
||||
byId: Record<SessionId, SessionSummary>
|
||||
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>>
|
||||
/**
|
||||
* Background jobs each session can see, mirrored last-wins from Session
|
||||
* Controller's control baseline and `jobs` frames. A missing key is an empty
|
||||
* set, so consumers read absence rather than a sentinel.
|
||||
*/
|
||||
jobsBySession: Readonly<Record<SessionId, readonly JobView[]>>
|
||||
/** 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. */
|
||||
export class SessionCreateError extends Error {
|
||||
override readonly name = 'SessionCreateError'
|
||||
|
||||
/**
|
||||
* @param rpcError - Host business or folded transport error.
|
||||
* @param requestedSessionId - caller-preallocated id used for later stream/list reconciliation.
|
||||
*/
|
||||
constructor(
|
||||
readonly rpcError: ClientFailure,
|
||||
readonly requestedSessionId: SessionId | undefined,
|
||||
) {
|
||||
super(`session create failed: ${rpcError.code}: ${rpcError.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Structured session-fork failure. */
|
||||
export class SessionForkError extends Error {
|
||||
override readonly name = 'SessionForkError'
|
||||
|
||||
/**
|
||||
* @param rpcError - Host business or folded transport error.
|
||||
* @param sourceSessionId - the session the fork was cut from.
|
||||
*/
|
||||
constructor(
|
||||
readonly rpcError: ClientFailure,
|
||||
readonly sourceSessionId: SessionId,
|
||||
) {
|
||||
super(`session fork failed: ${rpcError.code}: ${rpcError.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Identity-stable logical binding for one materialized Client Session. */
|
||||
export interface SessionBinding {
|
||||
readonly sessionId: SessionId
|
||||
/** The outward session face only — feature code never sees the concrete class. */
|
||||
readonly session: SessionFace
|
||||
/** Contiguous event window reserved for Conversation assembly. */
|
||||
readonly eventSource: SessionEventSource
|
||||
readonly ctx: AgentContext
|
||||
}
|
||||
|
||||
// Scope primitives live in ../scope.ts (the client mirror of host
|
||||
// dsh-scope, keyed by Agent identity); re-exported here so existing
|
||||
// consumers keep their import site.
|
||||
export { scopeOf } from '../scope.ts'
|
||||
|
||||
/**
|
||||
* Display title projection: durable title, project directory basename, then
|
||||
* the raw id.
|
||||
*/
|
||||
function displayTitleOf(title: string | undefined, cwd: string | undefined, id: SessionId): string {
|
||||
if (title !== undefined) return title
|
||||
if (cwd !== undefined && cwd !== '') {
|
||||
const base = workspaceTitleOf(cwd)
|
||||
if (base !== '') return base
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment a trailing fork number while preserving its half-width or
|
||||
* full-width parentheses; an unnumbered title starts with ` (1)`.
|
||||
* @param title - source session's durable title.
|
||||
* @returns the title assigned to the fork child.
|
||||
*/
|
||||
function increasedForkTitle(title: string): string {
|
||||
const ascii = /^(.*?)\((\d+)\)$/u.exec(title)
|
||||
if (ascii?.[1] !== undefined && ascii[2] !== undefined) {
|
||||
return `${ascii[1]}(${BigInt(ascii[2]) + 1n})`
|
||||
}
|
||||
const fullWidth = /^(.*?)((\d+))$/u.exec(title)
|
||||
if (fullWidth?.[1] !== undefined && fullWidth[2] !== undefined) {
|
||||
return `${fullWidth[1]}(${BigInt(fullWidth[2]) + 1n})`
|
||||
}
|
||||
return `${title} (1)`
|
||||
}
|
||||
|
||||
interface ScopeRecord {
|
||||
fiber: Fiber
|
||||
ctx: AgentContext
|
||||
binding: SessionBinding
|
||||
/** The concrete Session for runtime-internal entry points (staging open()); the binding carries only the outward face. */
|
||||
session: Session
|
||||
}
|
||||
|
||||
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, and breadcrumb routes. */
|
||||
export class ClientSessions implements ISessions {
|
||||
/**
|
||||
* The wire schema's own result bound, re-exposed for presentation plugins as
|
||||
* injected data. Not per-connection state: the `session.search` response
|
||||
* schema caps `items` at this constant, so every transport (fixture included)
|
||||
* reports the same number.
|
||||
*/
|
||||
readonly searchResultLimit = SESSION_SEARCH_RESULT_LIMIT
|
||||
/** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */
|
||||
readonly list: SnapshotStore<SessionListState>
|
||||
/** The object-layer instance cluster and frame dispatch entry. */
|
||||
private readonly manager: SessionManager
|
||||
/**
|
||||
* Persisted selection cell (the durable half of `list.current`). Private on
|
||||
* purpose: reads go through the list snapshot; writes through {@link
|
||||
* ClientSessions.open} / {@link ClientSessions.clear}. Projection
|
||||
* validates it against the live list instead of destructively pruning, so a
|
||||
* selection survives transient list states (reconnect re-pull) and
|
||||
* resurfaces when its session returns.
|
||||
*/
|
||||
private readonly selection: SnapshotStore<SessionSelection>
|
||||
|
||||
private readonly scopes = new Map<SessionId, ScopeRecord>()
|
||||
/** In-flight scope drops remain here after records leave `scopes`, so root disposal can await quiescence. */
|
||||
private readonly scopeDrops = new Set<Promise<void>>()
|
||||
/**
|
||||
* The staged session id — follows `list.current` exactly, holding its last
|
||||
* defined value across masked gaps (a transiently absent selection blanks
|
||||
* `current` without moving the stage, so reconnect re-pulls and removals
|
||||
* keep the staged scope's frozen view alive until the stage moves on).
|
||||
*/
|
||||
private watched: SessionId | undefined
|
||||
/** Removed-while-staged sessions whose teardown waits for the stage to move away. */
|
||||
private readonly deferredRemovals = new Set<SessionId>()
|
||||
|
||||
/**
|
||||
* @param ctx - client root context (scope fibers mount under it).
|
||||
* @param api - wire client shared with every Session.
|
||||
* @param remote - generated Remote namespaces shared with every Session.
|
||||
*/
|
||||
constructor(
|
||||
private readonly rootCtx: Context,
|
||||
api: IApiClient,
|
||||
remote: SessionRemotes,
|
||||
) {
|
||||
this.selection = createSnapshotStore<SessionSelection>(
|
||||
{},
|
||||
{ persist: { name: 'dsh.sessions.current' } })
|
||||
const restored = this.selection.getSnapshot()
|
||||
this.manager = new SessionManager(
|
||||
api,
|
||||
remote,
|
||||
restored.sessionId,
|
||||
restored.subagentAddress,
|
||||
)
|
||||
this.list = createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, phase: 'pending',
|
||||
subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
|
||||
})
|
||||
// The manager owns wire truth; the store is its projection. Manager
|
||||
// notifications are already microtask-batched.
|
||||
const disposeManagerProjection = this.manager.subscribe(() => {
|
||||
this.projectList()
|
||||
})
|
||||
// Stage follower: every current write (open() and projection alike)
|
||||
// re-evaluates staging, so startup restore (persisted selection validated
|
||||
// by the projection) and reconnect resurfacing open their window with no
|
||||
// dedicated code path. Safe to run synchronously inside the store notify:
|
||||
// the follower writes no list state — session.open()'s synchronous prefix
|
||||
// touches only session-side state and its own microtask-batched notifier.
|
||||
const disposeStageFollower = this.list.subscribe(() => {
|
||||
this.followCurrent()
|
||||
})
|
||||
rootCtx.effect(() => async () => {
|
||||
disposeStageFollower()
|
||||
disposeManagerProjection()
|
||||
const scopes = [...this.scopes]
|
||||
this.scopes.clear()
|
||||
this.deferredRemovals.clear()
|
||||
this.watched = undefined
|
||||
for (const [id, record] of scopes) this.startScopeDrop(id, record)
|
||||
await this.drainScopeDrops()
|
||||
await this.manager.dispose()
|
||||
}, 'session-controller.client.sessions')
|
||||
rootCtx.reflect.provide('sessions', this, undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a listed or retained catalog-addressed session as current.
|
||||
* @param id - listed or addressed session id.
|
||||
*/
|
||||
open(id: SessionId): void {
|
||||
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 Session Controller 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)
|
||||
}
|
||||
|
||||
noteAgentPreset(sessionId: SessionId, agentPreset: string): void {
|
||||
this.manager.noteAgentPreset(sessionId, agentPreset)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current selection so the layout shows the no-session empty
|
||||
* state (new-session affordance and the workspace preselection flow).
|
||||
* Wipes the persisted selection too — a reload stays on empty until the
|
||||
* user opens or starts a session. The staged scope keeps its frozen view
|
||||
* per the masked-gap contract until the next open() moves the stage.
|
||||
*/
|
||||
clear(): void {
|
||||
this.manager.clearSelection()
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the real Session baseline, reusing an in-flight pull.
|
||||
* @returns completion of the current or newly started baseline pull.
|
||||
*/
|
||||
refresh(): Promise<void> {
|
||||
return this.manager.refreshList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the Host's visible message-content index. Results stay
|
||||
* request-local; the list snapshot remains the metadata authority.
|
||||
* @param query - non-blank literal phrase.
|
||||
* @param signal - cancellation for a superseded search.
|
||||
* @returns bounded results or a business/transport error.
|
||||
*/
|
||||
search(
|
||||
query: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<ClientResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>> {
|
||||
return this.manager.search(query, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one Session Controller live-control frame.
|
||||
* @param frame - baseline or live control replacement.
|
||||
*/
|
||||
handleControlFrame(frame: Parameters<SessionManager['handleControlFrame']>[0]): void {
|
||||
this.manager.handleControlFrame(frame)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one remotely forwarded Session-list addition.
|
||||
* @param summary - current Host summary for the added Session.
|
||||
*/
|
||||
handleSessionAdded(summary: Parameters<SessionManager['handleSessionAdded']>[0]): void {
|
||||
this.manager.handleSessionAdded(summary)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one remotely forwarded Session removal.
|
||||
* @param sessionId - removed Session identity.
|
||||
*/
|
||||
handleSessionRemoved(sessionId: Parameters<SessionManager['handleSessionRemoved']>[0]): void {
|
||||
this.manager.handleSessionRemoved(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one remotely forwarded running-state change.
|
||||
* @param args - Session identity and current Agent running state.
|
||||
*/
|
||||
handleSessionStatus(...args: Parameters<SessionManager['handleSessionStatus']>): void {
|
||||
this.manager.handleSessionStatus(...args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one remotely forwarded list-activity change.
|
||||
* @param args - Session identity and durable activity timestamp.
|
||||
*/
|
||||
handleSessionActivity(...args: Parameters<SessionManager['handleSessionActivity']>): void {
|
||||
this.manager.handleSessionActivity(...args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one remotely forwarded Agent failure.
|
||||
* @param args - Session identity and caller-visible failure description.
|
||||
*/
|
||||
handleSessionError(...args: Parameters<SessionManager['handleSessionError']>): void {
|
||||
this.manager.handleSessionError(...args)
|
||||
}
|
||||
|
||||
/** Rebuild the Session baseline and every opened window after connection. */
|
||||
handleConnected(): void {
|
||||
this.manager.handleConnected()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session on the host. Resolution guarantee: by the time the
|
||||
* promise resolves, the created session is in the list store and
|
||||
* {@link ClientSessions.binding} resolves it — callers (New Session
|
||||
* draft hand-off) may address the scope synchronously, without waiting a
|
||||
* notifier flush. The synchronous projection below makes this structural
|
||||
* rather than an accident of microtask ordering.
|
||||
* @param opts - target workspace or directory and an optional preallocated id.
|
||||
* @returns the new session id.
|
||||
* @throws {SessionCreateError} with the requested id.
|
||||
*/
|
||||
async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise<SessionId> {
|
||||
const result = await this.manager.create(opts)
|
||||
if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId)
|
||||
this.projectList()
|
||||
return result.value.sessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Fork a session from a completed-turn prefix of the source (same
|
||||
* synchronous-addressability guarantee as {@link ClientSessions.create}:
|
||||
* on resolution the child is in the list store and open() can target it).
|
||||
* @param opts - source session id, the optional event seq anchoring the
|
||||
* cut (the boundary is the first turn/end at or after it; an in-log
|
||||
* anchor in an open turn is unavailable rather than clipped backward),
|
||||
* and whether to increment an inherited durable title before resolving.
|
||||
* A fractional anchor floors to a real event seq: the frozen nodes of an
|
||||
* interrupted turn carry flow-ordering seqs between two events, and the
|
||||
* wire takes integers only.
|
||||
* @returns the child session id.
|
||||
* @throws {SessionForkError} with the source id.
|
||||
* @throws {Error} when a requested child-title rename fails after creation.
|
||||
*/
|
||||
async fork(opts: {
|
||||
sessionId: SessionId
|
||||
atSeq?: number
|
||||
increaseTitle?: boolean
|
||||
}): Promise<SessionId> {
|
||||
const sourceTitle = opts.increaseTitle
|
||||
? this.list.getSnapshot().byId[opts.sessionId]?.title
|
||||
: undefined
|
||||
const result = await this.manager.fork({
|
||||
sessionId: opts.sessionId,
|
||||
// Flooring lands inside the anchor's own turn (every turn opens with a
|
||||
// turn/start), so the host's first-turn/end-at-or-after cut still ends
|
||||
// on that turn — never clipped back to the previous one.
|
||||
...(opts.atSeq === undefined ? {} : { atSeq: Math.floor(opts.atSeq) }),
|
||||
})
|
||||
if (!result.ok) throw new SessionForkError(result.error, opts.sessionId)
|
||||
this.projectList()
|
||||
const childId = result.value.sessionId
|
||||
if (sourceTitle !== undefined) {
|
||||
const child = this.binding(childId)?.session
|
||||
if (child === undefined) throw new Error(`fork child "${childId}" is not locally addressable`)
|
||||
const renamed = await child.rename(increasedForkTitle(sourceTitle))
|
||||
if (!renamed.ok) throw new Error(`fork child rename failed: ${renamed.error.code}: ${renamed.error.message}`)
|
||||
}
|
||||
return childId
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an Agent-scoped context view (use-and-discard).
|
||||
* @param id - session id (the agent identity — 1:1 same axis).
|
||||
* @returns scoped ctx, or undefined for a session neither listed nor already scoped.
|
||||
*/
|
||||
scope(id: SessionId): AgentContext | undefined {
|
||||
return this.resolve(id)?.ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize the Agent scope named by a validated Host Remote Event.
|
||||
* The first successful Session-list baseline becomes authoritative for its
|
||||
* lifetime; until then, transport streams may address the scope in either
|
||||
* arrival order.
|
||||
* @param id - Host-projected Agent identity (the matching Session id).
|
||||
* @returns the identity-stable Agent Context.
|
||||
*/
|
||||
resolveAgentScope(id: SessionId): AgentContext {
|
||||
return (this.scopes.get(id) ?? this.materializeScope(id)).ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the Agent scope tag off a context. Service-method boundary: fetch
|
||||
* bundles must reach scope resolution through ctx.sessions — a cross-bundle
|
||||
* value import of the standalone helper would inline a second module
|
||||
* instance whose private tag Symbol never matches.
|
||||
* @param ctx - any client context.
|
||||
* @returns the session id, or undefined on root contexts.
|
||||
*/
|
||||
scopeOf(ctx: Context): SessionId | undefined {
|
||||
return scopeTagOf(ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the business Session behind an Agent-scoped context — the one
|
||||
* hop every scoped consumer (event listeners, per-session controllers)
|
||||
* takes from ctx-space into object-space (the client mirror of host
|
||||
* `agent.session`). Same service-method boundary as
|
||||
* {@link ClientSessions.scopeOf}.
|
||||
* @param ctx - an Agent-scoped context.
|
||||
* @returns the session face, or undefined when the ctx is untagged or its scope was pruned.
|
||||
*/
|
||||
sessionOf(ctx: Context): SessionFace | undefined {
|
||||
const id = scopeTagOf(ctx)
|
||||
if (id === undefined) return undefined
|
||||
return this.scopes.get(id)?.binding.session
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the stable session binding (scope-addressed assembly feed). Pure
|
||||
* resolution — no staging, no window side effects.
|
||||
* @param id - session id.
|
||||
* @returns binding, or undefined for a session neither listed nor already scoped.
|
||||
*/
|
||||
binding(id: SessionId): SessionBinding | undefined {
|
||||
return this.resolve(id)?.binding
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the stage to the list's current session: sweep teardowns deferred
|
||||
* behind the previous occupant and pull the new occupant's history window.
|
||||
* Staging IS the open signal — the window opens ⟺ the session is on stage
|
||||
* — and open() is idempotent (an in-flight or completed open no-ops; a
|
||||
* failed one retries the next time current is touched).
|
||||
*/
|
||||
private followCurrent(): void {
|
||||
const snapshot = this.list.getSnapshot()
|
||||
const current = snapshot.current
|
||||
// A masked gap (current blanked while the selection's session is
|
||||
// transiently absent) holds the stage: tearing down on the gap would
|
||||
// destroy exactly the frozen scope the mask exists to preserve.
|
||||
if (current === undefined || snapshot.byId[current] === undefined || current === this.watched) return
|
||||
this.watched = current
|
||||
this.sweepDeferred()
|
||||
const record = this.resolve(current)
|
||||
/* v8 ignore next 3 -- defensive: current is always a listed id (open()
|
||||
* validates and the projection masks absent selections), so resolve
|
||||
* cannot miss; kept so a future current writer cannot crash the notify. */
|
||||
if (record !== undefined) {
|
||||
void record.session.open()
|
||||
void this.manager.refreshSubagents(current)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily mint the scope + binding for an eligible session. Eligibility and
|
||||
* prune share one predicate: listed on the host or selected
|
||||
* through a retained subagent address. Breadcrumb-only ancestors remain
|
||||
* summary data and do not keep scopes alive.
|
||||
*/
|
||||
private resolve(id: SessionId): ScopeRecord | undefined {
|
||||
const existing = this.scopes.get(id)
|
||||
if (existing !== undefined) return existing
|
||||
if (!this.eligible(id)) return undefined
|
||||
return this.materializeScope(id)
|
||||
}
|
||||
|
||||
/** Materialize one scope after its caller establishes that the id may be addressed. */
|
||||
private materializeScope(id: SessionId): ScopeRecord {
|
||||
const { fiber, ctx } = createScope(this.rootCtx, id)
|
||||
const session = this.manager.get(id)
|
||||
// The Session owns its scoped dispatch point (host Agent.loopCtx mirror);
|
||||
// mint and bind are one step so a live scope record implies a bound actx.
|
||||
session.bindScope(ctx)
|
||||
const binding: SessionBinding = { sessionId: id, session, eventSource: session.eventSource, ctx }
|
||||
const record: ScopeRecord = {
|
||||
fiber,
|
||||
ctx,
|
||||
binding,
|
||||
session,
|
||||
}
|
||||
this.scopes.set(id, record)
|
||||
return record
|
||||
}
|
||||
|
||||
/** The one aliveness predicate shared by scope mint and prune: host-listed or currently addressed. */
|
||||
private eligible(id: SessionId): boolean {
|
||||
const { ids, current } = this.list.getSnapshot()
|
||||
return current === id || ids.includes(id)
|
||||
}
|
||||
|
||||
/** Project the manager's list snapshot into the store (title derivation is display-only). */
|
||||
private projectList(): void {
|
||||
const {
|
||||
items, current, phase, subagentsByParent, jobsBySession, currentAddress,
|
||||
} = this.manager.getListSnapshot()
|
||||
const ids: SessionId[] = []
|
||||
const byId: Record<SessionId, SessionSummary> = {}
|
||||
for (const entry of items) {
|
||||
ids.push(entry.sessionId)
|
||||
byId[entry.sessionId] = {
|
||||
id: entry.sessionId,
|
||||
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
|
||||
running: entry.running,
|
||||
...(entry.completed ? { completed: true } : {}),
|
||||
blank: entry.blank,
|
||||
updatedAt: entry.updatedAt,
|
||||
...(entry.projectionValues === undefined
|
||||
? {}
|
||||
: { projectionValues: entry.projectionValues }),
|
||||
...(entry.title !== undefined ? { title: entry.title } : {}),
|
||||
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
|
||||
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
|
||||
...(entry.origin !== undefined ? { origin: entry.origin } : {}),
|
||||
...(entry.agentPreset !== undefined ? { agentPreset: entry.agentPreset } : {}),
|
||||
}
|
||||
}
|
||||
if (current !== undefined && currentAddress !== undefined) {
|
||||
const seen = new Set<SessionId>()
|
||||
let address: SubagentAddress | undefined = currentAddress
|
||||
while (address !== undefined && !seen.has(address.childSessionId)) {
|
||||
const childId = address.childSessionId
|
||||
seen.add(childId)
|
||||
const child = subagentsByParent[address.parentSessionId]?.entries
|
||||
.find(entry => entry.kind === 'child' && entry.id === childId)
|
||||
if (child?.kind !== 'child') break
|
||||
const displayTitle = child.label ?? childId
|
||||
const summary = byId[childId]
|
||||
if (summary === undefined) {
|
||||
byId[childId] = {
|
||||
id: childId,
|
||||
displayTitle,
|
||||
parentId: address.parentSessionId,
|
||||
origin: 'subagent',
|
||||
running: child.activity === 'running',
|
||||
blank: false,
|
||||
updatedAt: 0,
|
||||
}
|
||||
} else if (summary.displayTitle !== displayTitle) {
|
||||
byId[childId] = { ...summary, displayTitle }
|
||||
}
|
||||
const parent = byId[address.parentSessionId]
|
||||
if (parent !== undefined && parent.origin !== 'subagent') break
|
||||
address = this.manager.navigationAddress(address.parentSessionId)
|
||||
}
|
||||
}
|
||||
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.getSnapshot().subagentAddress?.childSessionId !== currentAddress?.childSessionId
|
||||
|| this.selection.getSnapshot().subagentAddress?.parentSessionId !== currentAddress?.parentSessionId
|
||||
|| this.selection.getSnapshot().subagentAddress?.mode !== currentAddress?.mode)) {
|
||||
this.selection.set({
|
||||
sessionId: current,
|
||||
...(currentAddress === undefined ? {} : { subagentAddress: currentAddress }),
|
||||
})
|
||||
}
|
||||
this.list.set({ ids, byId, current, phase, subagentsByParent, jobsBySession, currentAddress })
|
||||
this.pruneScopes()
|
||||
}
|
||||
|
||||
/** Tear down scope + instance for no-longer-eligible sessions off stage; the staged one defers until the stage moves. */
|
||||
private pruneScopes(): void {
|
||||
if (this.list.getSnapshot().phase === 'pending') return
|
||||
for (const [id, record] of this.scopes) {
|
||||
if (this.eligible(id)) continue
|
||||
if (id === this.watched) {
|
||||
this.deferredRemovals.add(id)
|
||||
continue
|
||||
}
|
||||
this.scopes.delete(id)
|
||||
this.deferredRemovals.delete(id)
|
||||
this.startScopeDrop(id, record)
|
||||
}
|
||||
}
|
||||
|
||||
private startScopeDrop(id: SessionId, record: ScopeRecord): void {
|
||||
const drop = this.dropScope(id, record)
|
||||
this.scopeDrops.add(drop)
|
||||
void drop.then(
|
||||
() => { this.scopeDrops.delete(drop) },
|
||||
() => { this.scopeDrops.delete(drop) },
|
||||
)
|
||||
}
|
||||
|
||||
private async drainScopeDrops(): Promise<void> {
|
||||
while (this.scopeDrops.size > 0) {
|
||||
await Promise.allSettled([...this.scopeDrops])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One teardown for the whole per-session axis: the scope
|
||||
* fiber (cascading every actx-registered effect: input shell, slash
|
||||
* controller, popup, plugin stores, listeners), the session-keyed slot
|
||||
* registrations and the Session instance itself — the host session log is the
|
||||
* durable truth, a reopen lazily rebuilds and backfills via open().
|
||||
*/
|
||||
private async dropScope(id: SessionId, record: ScopeRecord): Promise<void> {
|
||||
// Release the Session's dispatch point with the scope it belongs to (a
|
||||
// surviving instance — the live Intent — rebinds when resolve re-mints).
|
||||
record.session.unbindScope()
|
||||
await Promise.allSettled([
|
||||
record.fiber.dispose(),
|
||||
this.manager.drop(id),
|
||||
])
|
||||
}
|
||||
|
||||
/** Run deferred teardowns whose session is no longer staged (called when the stage moves). */
|
||||
private sweepDeferred(): void {
|
||||
for (const id of [...this.deferredRemovals]) {
|
||||
/* v8 ignore next -- defensive: only the staged id ever defers, and every
|
||||
* stage move sweeps first, so the set cannot contain the id the stage just
|
||||
* moved to; kept as a guard against future extra sweep call sites. */
|
||||
if (id === this.watched) continue
|
||||
// Eligible again? (A re-added id cancels the deferred teardown.)
|
||||
if (this.eligible(id)) {
|
||||
this.deferredRemovals.delete(id)
|
||||
continue
|
||||
}
|
||||
const record = this.scopes.get(id)
|
||||
this.deferredRemovals.delete(id)
|
||||
/* v8 ignore next -- defensive: prune deletes a scope and its deferral
|
||||
* together, so a deferred id always still owns its record; kept so a
|
||||
* future teardown path cannot double-dispose. */
|
||||
if (record !== undefined) {
|
||||
this.scopes.delete(id)
|
||||
this.startScopeDrop(id, record)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,659 @@
|
||||
// Sessions remain resident after creation so their open Remote sources keep running off-screen.
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
|
||||
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type {
|
||||
IApiClient, SubagentAddress,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import {
|
||||
SessionEventStream,
|
||||
sessionStreamFailure,
|
||||
} from '../transport.ts'
|
||||
import type { SessionJournalChange } from '../transport.ts'
|
||||
import type {
|
||||
PromptContentPart,
|
||||
QueueAction,
|
||||
SessionAddress,
|
||||
SessionControlFrame,
|
||||
SessionEventEntry,
|
||||
SessionQueuedItem,
|
||||
SessionRequestId,
|
||||
SessionError,
|
||||
} from '../../types.ts'
|
||||
import type { ClientFailure, ClientResult } from '../contract/result.ts'
|
||||
import { transportResult } from '../contract/result.ts'
|
||||
import type { SessionFace } from '../contract/session.ts'
|
||||
import type {
|
||||
OpenState, PromptError, SessionSnapshot,
|
||||
} from '../contract/snapshot.ts'
|
||||
import { MutableSessionEventSource } from '../contract/events.ts'
|
||||
import { Notifier } from './notifier.ts'
|
||||
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { SessionRemotes } from './remotes.ts'
|
||||
import { ProjectionValueStore } from './projection-store.ts'
|
||||
import type { ProjectionsBaseline } from './projection-store.ts'
|
||||
import { resolvedClientTimeZone } from '../time-zone.ts'
|
||||
import { SessionQueueMirror } from './queue-mirror.ts'
|
||||
|
||||
/** Messages requested per history page. */
|
||||
export const PAGE_MESSAGES = 50
|
||||
|
||||
/** 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
|
||||
* into its list row so the session surfaces without waiting for a host
|
||||
* frame. Acceptance is the flip point because it proves the user message
|
||||
* is in the host log; a rejected first prompt keeps the session blank
|
||||
* (hidden, still reusable by connectWorkspace).
|
||||
*/
|
||||
onEngaged?(session: Session): void
|
||||
/**
|
||||
* Manager-owned projection value store to adopt (frames route through the
|
||||
* manager and values outlive instantiation); omitted, the Session owns a
|
||||
* private store (bare object-layer construction).
|
||||
*/
|
||||
projections?: ProjectionValueStore
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns a session's event window, lifecycle state, and observable
|
||||
* snapshot. React bindings remain outside this data layer. Features see only
|
||||
* the {@link SessionFace} slice (ISession verbs + the snapshot source); the
|
||||
* remaining public members are Session Controller internals.
|
||||
*/
|
||||
export class Session implements SessionFace {
|
||||
// ---- Window and derived state (all private; the snapshot is the only read API) ----
|
||||
private eventWindow: SessionEvent[] = []
|
||||
private baseSeq = 0
|
||||
private hasMore = false
|
||||
private openState: OpenState = 'cold'
|
||||
private openError: ClientFailure | null = null
|
||||
private openPromise: Promise<void> | null = null
|
||||
/** Bumped by stream replacement to invalidate an in-flight doOpen. Stale
|
||||
* passes drop all writes once the generation moves on. */
|
||||
private openGeneration = 0
|
||||
private loadingOlder = false
|
||||
/** Authoritative stream-only inbox snapshot; pending work never hits history. */
|
||||
private readonly queueMirror = new SessionQueueMirror()
|
||||
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 →
|
||||
* engaging edge of the phase machine (see ComposerPhase).
|
||||
*/
|
||||
private promptAttempted = false
|
||||
/** A first accepted prompt stays in the engaging phase until its turn is observable. */
|
||||
private firstPromptPendingTurn = false
|
||||
/** Empty-log mirror (see ConversationSnapshot.blank); unknown bare sessions begin conservatively blank. */
|
||||
private blankBit = true
|
||||
private removed = false
|
||||
private promptError: PromptError | null = null
|
||||
private lastAgentError: string | null = null
|
||||
/** Owns the addressed page/follow lifecycle while this Session is open. */
|
||||
private events: SessionEventStream | undefined
|
||||
|
||||
/**
|
||||
* Per-session projection value store (push model; see the session-projection
|
||||
* subsystem page, docs/subsystems/session-projection.md): finished whole
|
||||
* values computed on the Host, seeded by the tail page's
|
||||
* projections block and updated by Session Controller control frames under the
|
||||
* one higher-seq-wins rule. Keys are read via `projections.faceOf(key)`
|
||||
* (the useProjection resolution face); the conversation snapshot never
|
||||
* carries projection values, and no client-side domain folding exists.
|
||||
* Manager-owned when constructed through SessionManager (frames route and
|
||||
* the store outlives instantiation, the title-snapshot precedent); a bare
|
||||
* construction gets a private store.
|
||||
*/
|
||||
readonly projections: ProjectionValueStore
|
||||
|
||||
/** Contiguous history and live tail consumed by Conversation assembly. */
|
||||
readonly eventSource = new MutableSessionEventSource()
|
||||
private snapshotCache: SessionSnapshot
|
||||
private readonly notifier: Notifier
|
||||
/**
|
||||
* Agent-scoped cordis context, bound once by ClientSessions when it
|
||||
* mints the scope (the client mirror of the host Agent's loopCtx). The
|
||||
* Session dispatches its own scoped events through it; undefined means
|
||||
* unbound (bare object-layer construction) or already pruned — both skip
|
||||
* dispatch-dependent behavior rather than fail.
|
||||
*/
|
||||
private actx: Context | undefined
|
||||
|
||||
/**
|
||||
* @param sessionId - Host session identity (client sessions are always Host-born).
|
||||
* @param api - shared wire client.
|
||||
* @param remote - generated Remote namespaces this session calls.
|
||||
* @param options - optional manager-owned state observers.
|
||||
*/
|
||||
constructor(
|
||||
readonly sessionId: SessionId,
|
||||
private readonly api: IApiClient,
|
||||
private readonly remote: SessionRemotes,
|
||||
private readonly options: SessionOptions = {},
|
||||
) {
|
||||
this.projections = options.projections ?? new ProjectionValueStore()
|
||||
this.address = options.address
|
||||
this.parentAvailable = options.parentAvailable ?? false
|
||||
this.notifier = new Notifier(() => {
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
})
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind the Agent-scoped context minted by ClientSessions (single write;
|
||||
* a second bind is a wiring error and throws). Direction stays one-way at
|
||||
* this binding boundary: consumers still reach the Session via `sessions.sessionOf`,
|
||||
* while the Session holds its own dispatch point (host Agent.loopCtx
|
||||
* mirror).
|
||||
* @param actx - the agent's scoped context.
|
||||
*/
|
||||
bindScope(actx: Context): void {
|
||||
if (this.actx !== undefined) throw new Error(`session ${this.sessionId} already has a bound scope`)
|
||||
this.actx = actx
|
||||
}
|
||||
|
||||
/** Release the bound scope at prune time (a later rebind accompanies a freshly minted scope). */
|
||||
unbindScope(): void {
|
||||
this.actx = undefined
|
||||
}
|
||||
|
||||
// ---- Operations ----
|
||||
|
||||
/**
|
||||
* Send (queue/steer passed through 1:1); failures land in the snapshot's promptError.
|
||||
* @param content - text plus browser-owned temporary image uploads.
|
||||
* @param mode - queue appends after the current turn; steer interrupts it.
|
||||
* @returns the prompt result (also mirrored into promptError on failure).
|
||||
*/
|
||||
async prompt(
|
||||
content: PromptContentPart[],
|
||||
mode: 'queue' | 'steer',
|
||||
signal?: AbortSignal,
|
||||
): Promise<ClientResult<{ accepted: true }>> {
|
||||
this.promptError = null
|
||||
this.lastAgentError = null
|
||||
// Synchronous, before the first await: the blank → engaging edge must be
|
||||
// visible on the session area's very first frame when a caller sends
|
||||
// ahead of navigation (first-send flow).
|
||||
this.promptAttempted = true
|
||||
if (this.blankBit) this.firstPromptPendingTurn = true
|
||||
this.notifier.markDirty()
|
||||
let result: ClientResult<{ accepted: true }>
|
||||
try {
|
||||
if (this.address === undefined) {
|
||||
const clientTimeZone = resolvedClientTimeZone()
|
||||
result = toSessionResult(await this.remote.session.prompt({
|
||||
requestId: randomUUID() as SessionRequestId,
|
||||
sessionId: this.sessionId,
|
||||
mode,
|
||||
content,
|
||||
clientTimeZone,
|
||||
}, signal))
|
||||
} 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 {
|
||||
if (content.some(part => part.type === 'image')) {
|
||||
result = {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'attachment-error',
|
||||
message: 'Image input is unavailable for subagent continuations.',
|
||||
details: { reason: 'SUBAGENT_IMAGE_UNSUPPORTED' },
|
||||
},
|
||||
}
|
||||
} else {
|
||||
const routed = (await this.api.subagents.prompt({
|
||||
...this.address,
|
||||
content: content.flatMap(part => part.type === 'text'
|
||||
? [{ type: 'text' as const, text: part.text }]
|
||||
: []),
|
||||
clientTimeZone: resolvedClientTimeZone(),
|
||||
}, signal)).result
|
||||
result = routed.ok ? { ok: true, value: { accepted: true } } : routed
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
result = transportResult(error)
|
||||
}
|
||||
if (!result.ok) {
|
||||
this.promptError = { op: 'send', error: result.error }
|
||||
this.notifier.markDirty()
|
||||
return result
|
||||
}
|
||||
// Blank flips on ACCEPTANCE, not attempt: an accepted prompt starts the
|
||||
// conversation's first turn on the host (the host criterion — a logged
|
||||
// turn/start — is fact, not optimism; standalone command and projection
|
||||
// events never flip it), while a rejected first prompt must keep the
|
||||
// session blank — the client-side blank mirror only ever lowers, so
|
||||
// flipping early on a failure would surface the session forever and
|
||||
// strip its connectWorkspace reuse eligibility against the host's
|
||||
// authority.
|
||||
if (this.blankBit) {
|
||||
this.blankBit = false
|
||||
this.options.onEngaged?.(this)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one image referenced by this session into browser-consumable bytes.
|
||||
* @param attachmentId - opaque id found in the folded session log.
|
||||
* @returns the authenticated reference and decoded bytes.
|
||||
*/
|
||||
async readAttachment(
|
||||
attachmentId: AttachmentIdType,
|
||||
): Promise<ClientResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>> {
|
||||
try {
|
||||
const result = await this.remote.session.attachment({
|
||||
sessionId: this.sessionId,
|
||||
attachmentId,
|
||||
})
|
||||
if (!result.ok) return toSessionResult(result)
|
||||
const binary = atob(result.value.data)
|
||||
const data = Uint8Array.from(binary, char => char.charCodeAt(0))
|
||||
return { ok: true, value: { attachment: result.value.attachment, data } }
|
||||
} catch (error) {
|
||||
return transportResult(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply one operation to a still-pending queue occurrence. */
|
||||
async updateQueue(itemId: MessageId, action: QueueAction): Promise<ClientResult<{ accepted: true }>> {
|
||||
try {
|
||||
return toSessionResult(await this.remote.session.updateQueue({ sessionId: this.sessionId, itemId, action }))
|
||||
} catch (error) {
|
||||
return transportResult(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the active turn while the Host preserves pending inbox work; failures
|
||||
* land in promptError (same error-strip display slot). A continuable
|
||||
* subagent address routes through `subagent.interrupt`, whose durable
|
||||
* parent-address authority works without a live parent Agent; a one-shot
|
||||
* address stays uncancellable (the UI offers no stop action, so this arm is
|
||||
* defensive).
|
||||
* @returns the cancel result.
|
||||
*/
|
||||
async cancel(): Promise<ClientResult<{ accepted: true }>> {
|
||||
const address = this.address
|
||||
if (address !== undefined && address.mode === 'one-shot') {
|
||||
const result: ClientResult<{ accepted: true }> = {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'subagent-delivery-unavailable',
|
||||
message: 'subagent activation cancellation is unavailable',
|
||||
details: { childSessionId: address.childSessionId },
|
||||
},
|
||||
}
|
||||
this.promptError = { op: 'stop', error: result.error }
|
||||
this.notifier.markDirty()
|
||||
return result
|
||||
}
|
||||
let result: ClientResult<{ accepted: true }>
|
||||
try {
|
||||
result = address !== undefined
|
||||
? (await this.api.subagents.interrupt(address)).result
|
||||
: toSessionResult(await this.remote.session.cancel({ sessionId: this.sessionId }))
|
||||
} catch (error) {
|
||||
result = transportResult(error)
|
||||
}
|
||||
if (!result.ok) {
|
||||
this.promptError = { op: 'stop', error: result.error }
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename: contract session.rename 1:1. On success settle the 'title'
|
||||
* projection cell from the response's `{title, seq}` under the store's
|
||||
* higher-seq-wins rule (the push frame arriving later is a no-op replay),
|
||||
* so the list row and any useProjection('title') reader update without
|
||||
* waiting for the control-stream projection update.
|
||||
* @param title - raw title text (the host normalizes acceptance).
|
||||
* @returns the rename result (normalized accepted title + title event seq).
|
||||
*/
|
||||
async rename(title: string): Promise<ClientResult<{ title: string; seq: number }>> {
|
||||
try {
|
||||
const result = toSessionResult(await this.remote.session.rename({ sessionId: this.sessionId, title }))
|
||||
if (result.ok) this.projections.apply('title', result.value.title, result.value.seq)
|
||||
return result
|
||||
} catch (error) {
|
||||
return transportResult(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute one slash-command line against this session's agent — pure
|
||||
* admission semantics (the host executor durably logs the lifecycle;
|
||||
* outcomes render as flow nodes, never as a response echo).
|
||||
* @param line - the full command line, leading slash included.
|
||||
* @returns the admission result, or the error branch on transport failure.
|
||||
*/
|
||||
async command(line: string): Promise<RemoteResult<{ matched: boolean }>> {
|
||||
const result = await this.remote.commands.execute(this.sessionId, line, [])
|
||||
if (!result.ok) return result
|
||||
return { ok: true, value: { matched: result.value !== undefined } }
|
||||
}
|
||||
|
||||
/** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */
|
||||
open(): Promise<void> {
|
||||
if (this.openState === 'open') return Promise.resolve()
|
||||
if (this.openPromise !== null) return this.openPromise
|
||||
const promise = this.doOpen(this.openGeneration).finally(() => {
|
||||
// Identity-guarded: a superseded open must not null out the promise resync just started.
|
||||
if (this.openPromise === promise) this.openPromise = null
|
||||
})
|
||||
this.openPromise = promise
|
||||
return promise
|
||||
}
|
||||
|
||||
/** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend. */
|
||||
async loadOlder(): Promise<void> {
|
||||
if (this.openState !== 'open' || !this.hasMore || this.loadingOlder) return
|
||||
const events = this.events
|
||||
if (events === undefined) return
|
||||
this.loadingOlder = true
|
||||
this.notifier.markDirty()
|
||||
try {
|
||||
await events.prepend({ beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES })
|
||||
} catch (error) {
|
||||
if (sessionStreamFailure(error) === undefined) {
|
||||
console.error('[session-controller] loadOlder failed:', error)
|
||||
}
|
||||
} finally {
|
||||
this.loadingOlder = false
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuild an opened history source after address replacement.
|
||||
* Invalidates any in-flight open first; queue state belongs to the independently
|
||||
* reconnecting control stream and remains untouched. */
|
||||
async resync(): Promise<void> {
|
||||
if (this.openState === 'cold') return // never opened: no window to rebuild (doOpen flips to 'loading' synchronously, so cold implies no in-flight open)
|
||||
this.openGeneration++
|
||||
const events = this.events
|
||||
this.events = undefined
|
||||
await events?.dispose()
|
||||
this.openPromise = null
|
||||
this.openState = 'cold'
|
||||
this.openError = null
|
||||
this.eventWindow = []
|
||||
this.baseSeq = 0
|
||||
this.notifier.markDirty()
|
||||
await this.open()
|
||||
}
|
||||
|
||||
// ---- Subscription API (useSyncExternalStore direct wiring) ----
|
||||
|
||||
/**
|
||||
* uSES subscription entry.
|
||||
* @param listener - change callback.
|
||||
* @returns the unsubscribe function.
|
||||
*/
|
||||
subscribe(listener: () => void): () => void {
|
||||
return this.notifier.subscribe(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached Session snapshot (rebuilt lazily when dirty with no listeners).
|
||||
* @returns the cached reference (stable until the next flush).
|
||||
*/
|
||||
getSnapshot(): SessionSnapshot {
|
||||
this.notifier.ensureFresh()
|
||||
return this.snapshotCache
|
||||
}
|
||||
|
||||
// ---- Manager-only entry points (@internal; never called by the UI) ----
|
||||
|
||||
/**
|
||||
* Replace every transient control value for this Session from one stream baseline.
|
||||
* @param queue - complete pending queue for this Session.
|
||||
*/
|
||||
replaceControl(queue: readonly SessionQueuedItem[]): void {
|
||||
this.queueMirror.replace(queue)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one Session-addressed live control update.
|
||||
* @param frame - queue replacement addressed to this Session.
|
||||
*/
|
||||
handleControlFrame(frame: Extract<SessionControlFrame, { type: 'queue' }>): void {
|
||||
this.queueMirror.replace(frame.items)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Running-bit relay from the host stream (list entry and snapshot stay consistent).
|
||||
* @param running - the new running state.
|
||||
*/
|
||||
handleRunning(running: boolean): void {
|
||||
// Turn-start conversion: a blank session never runs, so the first
|
||||
// running:true proves another side's first message landed.
|
||||
if (running && this.blankBit) {
|
||||
this.blankBit = false
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
if (running) this.firstPromptPendingTurn = false
|
||||
if (this.running === running) return
|
||||
this.running = running
|
||||
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?.mode === address?.mode
|
||||
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 (`session.list` and
|
||||
* `api-session/added`). Monotone: once any signal (local first send,
|
||||
* running flip, an earlier summary) cleared it, a stale true never
|
||||
* re-blanks.
|
||||
* @param blank - the summary's derived empty-log bit.
|
||||
*/
|
||||
handleBlank(blank: boolean): void {
|
||||
if (blank === this.blankBit) return
|
||||
if (blank && (this.promptAttempted || this.running)) return
|
||||
this.blankBit = blank
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/** `api-session/removed` relay: flag the snapshot while retaining the resident instance. */
|
||||
handleRemoved(): void {
|
||||
this.removed = true
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/**
|
||||
* `api-session/error` relay: the outlet for live failures with no turn position.
|
||||
* @param message - the stringified error.
|
||||
*/
|
||||
handleAgentError(message: string): void {
|
||||
this.lastAgentError = message
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the Session's live Remote source.
|
||||
* @returns when the Remote iterator has completed teardown.
|
||||
*/
|
||||
async dispose(): Promise<void> {
|
||||
this.openGeneration++
|
||||
const events = this.events
|
||||
this.events = undefined
|
||||
await events?.dispose()
|
||||
}
|
||||
|
||||
// ---- Private ----
|
||||
|
||||
/** @param generation - openGeneration at launch; stale passes cannot publish after replacement. */
|
||||
private async doOpen(generation: number): Promise<void> {
|
||||
this.openState = 'loading'
|
||||
this.openError = null
|
||||
this.notifier.markDirty()
|
||||
const events = new SessionEventStream(this.remote, this.sessionAddress(), {
|
||||
publish: (change) => {
|
||||
if (generation !== this.openGeneration || this.events !== events) return
|
||||
this.acceptEventChange(change)
|
||||
},
|
||||
failed: (error) => {
|
||||
this.failEventStream(events, generation, error)
|
||||
},
|
||||
})
|
||||
this.events = events
|
||||
try {
|
||||
await events.open({ maxMessages: PAGE_MESSAGES })
|
||||
if (generation !== this.openGeneration || this.events !== events) return
|
||||
this.openState = 'open'
|
||||
} catch (error) {
|
||||
if (generation !== this.openGeneration || this.events !== events) return
|
||||
this.events = undefined
|
||||
this.openState = 'error'
|
||||
this.openError = openFailure(error)
|
||||
} finally {
|
||||
if (generation === this.openGeneration) this.notifier.markDirty()
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply one contiguous journal update already reconciled by the Remote stream. */
|
||||
private acceptEventChange(change: SessionJournalChange): void {
|
||||
switch (change.type) {
|
||||
case 'replace':
|
||||
this.installWindow(change.entries, change.hasMore, change.page.projections)
|
||||
return
|
||||
case 'prepend':
|
||||
this.prependWindow(change.entries, change.hasMore)
|
||||
return
|
||||
case 'append':
|
||||
if (this.appendLive(change.entry)) this.notifier.markDirty()
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace the complete contiguous window and apply page-owned projection metadata. */
|
||||
private installWindow(entries: readonly SessionEventEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void {
|
||||
this.eventWindow = entries.map(entry => entry.event as SessionEvent)
|
||||
this.baseSeq = this.eventWindow[0]?.seq ?? 0
|
||||
this.hasMore = hasMore
|
||||
if (this.eventWindow.some(event => event.type === 'turn/start')) this.firstPromptPendingTurn = false
|
||||
if (projections !== undefined) this.projections.seed(projections)
|
||||
this.eventSource.replace(entries, hasMore)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/** Prepend one stream-validated history page. */
|
||||
private prependWindow(entries: readonly SessionEventEntry[], hasMore: boolean): void {
|
||||
this.eventWindow = [...entries.map(entry => entry.event as SessionEvent), ...this.eventWindow]
|
||||
this.baseSeq = this.eventWindow[0]?.seq ?? 0
|
||||
this.hasMore = hasMore
|
||||
this.eventSource.prepend(entries, hasMore)
|
||||
}
|
||||
|
||||
/** Append one stream-validated live event. */
|
||||
private appendLive(entry: SessionEventEntry): boolean {
|
||||
const event = entry.event as SessionEvent
|
||||
this.eventWindow.push(event)
|
||||
const awaitingFirstTurn = this.firstPromptPendingTurn
|
||||
if (event.type === 'turn/start') this.firstPromptPendingTurn = false
|
||||
const queueChanged = this.queueMirror.acceptDurable(event)
|
||||
this.eventSource.append(entry)
|
||||
return queueChanged || awaitingFirstTurn !== this.firstPromptPendingTurn
|
||||
}
|
||||
|
||||
/** Publish a terminal background failure only while this stream still owns the Session. */
|
||||
private failEventStream(events: SessionEventStream, generation: number, error: unknown): void {
|
||||
if (generation !== this.openGeneration || this.events !== events) return
|
||||
this.openGeneration++
|
||||
this.events = undefined
|
||||
this.openPromise = null
|
||||
this.openState = 'error'
|
||||
this.openError = openFailure(error)
|
||||
void events.dispose()
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
private buildSnapshot(): SessionSnapshot {
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
queue: this.queueMirror.snapshot(),
|
||||
running: this.running,
|
||||
subagent: this.address === undefined
|
||||
? null
|
||||
: { address: this.address, parentAvailable: this.parentAvailable },
|
||||
removed: this.removed,
|
||||
openState: this.openState,
|
||||
openError: this.openError,
|
||||
hasMore: this.hasMore,
|
||||
loadingOlder: this.loadingOlder,
|
||||
promptError: this.promptError,
|
||||
blank: this.blankBit,
|
||||
lastAgentError: this.lastAgentError,
|
||||
promptAttempted: this.promptAttempted,
|
||||
awaitingFirstTurn: this.firstPromptPendingTurn,
|
||||
}
|
||||
}
|
||||
|
||||
private sessionAddress(): SessionAddress {
|
||||
return this.address === undefined
|
||||
? { kind: 'session', sessionId: this.sessionId }
|
||||
: { kind: 'subagent', ...this.address }
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert a terminal Session stream failure to the Client error vocabulary. */
|
||||
function openFailure(error: unknown): ClientFailure {
|
||||
const failure = sessionStreamFailure(error)
|
||||
if (failure !== undefined) return failure as SessionError
|
||||
const folded = transportResult<never>(error)
|
||||
/* v8 ignore next -- transportResult never returns an ok result. */
|
||||
if (folded.ok) throw new Error('transportResult returned an unexpected success')
|
||||
return folded.error
|
||||
}
|
||||
/** Narrow a generated Session Remote failure to its service-owned error vocabulary. */
|
||||
function toSessionResult<T>(result: RemoteResult<T>): ClientResult<T> {
|
||||
return result.ok ? result : { ok: false, error: result.error as SessionError }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/** Browser-owned time-zone sampling for prompt RPC provenance. */
|
||||
|
||||
/**
|
||||
* Resolve the current browser IANA zone for one outbound operation.
|
||||
* @returns The browser-provided canonical zone.
|
||||
* @throws when the runtime cannot provide a non-empty zone.
|
||||
*/
|
||||
export function resolvedClientTimeZone(): string {
|
||||
const timeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
if (typeof timeZone !== 'string' || timeZone.length === 0) {
|
||||
throw new Error('browser time zone is unavailable')
|
||||
}
|
||||
return timeZone
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/** Session-specific adapters for Gateway-owned Remote stream lifecycles. */
|
||||
|
||||
import type {} from '@deepseek-ai/dsh-api-session-controller/remote'
|
||||
import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import {
|
||||
RemoteJournalStream,
|
||||
RemoteSnapshotStream,
|
||||
RemoteStreamCarrierError,
|
||||
RemoteStreamError,
|
||||
type ClientRemote,
|
||||
type RemoteJournalChange,
|
||||
type RemoteJournalFrame,
|
||||
} from '@deepseek-ai/dsh-api-gateway/client'
|
||||
import type {
|
||||
SessionAddress,
|
||||
SessionControlFrame,
|
||||
SessionEventEntry,
|
||||
SessionPage,
|
||||
SessionPageRequest,
|
||||
} from '../types.ts'
|
||||
|
||||
export {
|
||||
SESSION_SEARCH_RESULT_LIMIT,
|
||||
SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
|
||||
} from '../types.ts'
|
||||
|
||||
/** Pagination fields bound to an already-addressed Session journal. */
|
||||
export type ClientSessionPageRequest = Omit<SessionPageRequest, 'address' | 'throughSeq'>
|
||||
|
||||
/** Complete generated `ctx.remote.session` namespace. */
|
||||
export type SessionRemote = ClientRemote['session']
|
||||
|
||||
/** One complete publication from the Session journal stream. */
|
||||
export type SessionJournalChange = RemoteJournalChange<SessionPage, SessionEventEntry>
|
||||
|
||||
type SessionControlBaselineFrame = Extract<SessionControlFrame, { type: 'baseline' }>
|
||||
type SessionControlDeltaFrame = Exclude<SessionControlFrame, SessionControlBaselineFrame>
|
||||
|
||||
/** Gateway-owned control snapshot stream configured for Session frames. */
|
||||
export type SessionControlStream = RemoteSnapshotStream<
|
||||
SessionControlBaselineFrame,
|
||||
SessionControlDeltaFrame
|
||||
>
|
||||
|
||||
type SessionStreamRemote = Pick<ClientRemote, '$stream' | 'session'>
|
||||
|
||||
/** Domain sinks used by the Host-wide Session control stream. */
|
||||
export interface SessionControlStreamOptions {
|
||||
/** Apply a complete baseline or one later update. */
|
||||
readonly accept: (frame: SessionControlFrame) => void
|
||||
/** Observe a retryable carrier loss before reconnection. */
|
||||
readonly carrierFailed?: (error: RemoteStreamCarrierError) => void
|
||||
/** Publish a terminal business or protocol failure. */
|
||||
readonly failed: (error: unknown) => void
|
||||
}
|
||||
|
||||
/** Domain sinks used by one addressed Session event journal. */
|
||||
export interface SessionEventStreamOptions {
|
||||
/** Apply one complete event-window change. */
|
||||
readonly publish: (change: SessionJournalChange) => void
|
||||
/** Observe a retryable carrier loss before reconnection. */
|
||||
readonly carrierFailed?: (error: RemoteStreamCarrierError) => void
|
||||
/** Publish a terminal stream, page, or protocol failure after opening. */
|
||||
readonly failed: (error: unknown) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the Host-wide Session control snapshot stream.
|
||||
* @param remote - generated Session namespace and Gateway stream factory.
|
||||
* @param options - Session state destinations.
|
||||
* @returns an unstarted stream owned by the Client Session runtime.
|
||||
*/
|
||||
export function createSessionControlStream(
|
||||
remote: SessionStreamRemote,
|
||||
options: SessionControlStreamOptions,
|
||||
): SessionControlStream {
|
||||
const stream = remote.$stream<SessionControlFrame>({
|
||||
name: 'session control stream',
|
||||
open: signal => remote.session.control(signal),
|
||||
ended: accepted => accepted
|
||||
? new RemoteStreamCarrierError('session control stream ended without a terminal result')
|
||||
: new Error('session control stream ended before its opening snapshot'),
|
||||
...(options.carrierFailed === undefined ? {} : { carrierFailed: options.carrierFailed }),
|
||||
})
|
||||
return new RemoteSnapshotStream(stream, {
|
||||
name: 'session control stream',
|
||||
isSnapshot: (frame): frame is SessionControlBaselineFrame => frame.type === 'baseline',
|
||||
replace: options.accept,
|
||||
update: options.accept,
|
||||
failed: options.failed,
|
||||
})
|
||||
}
|
||||
|
||||
/** Gateway-owned event journal bound to one ordinary or direct-subagent Session address. */
|
||||
export class SessionEventStream extends RemoteJournalStream<
|
||||
SessionPage,
|
||||
SessionEventEntry,
|
||||
number,
|
||||
ClientSessionPageRequest
|
||||
> {
|
||||
/**
|
||||
* @param remote - generated Session namespace and Gateway stream factory.
|
||||
* @param address - durable ordinary-Session or direct-subagent address.
|
||||
* @param options - Session event-window destinations.
|
||||
*/
|
||||
constructor(
|
||||
private readonly remote: SessionStreamRemote,
|
||||
private readonly address: SessionAddress,
|
||||
options: SessionEventStreamOptions,
|
||||
) {
|
||||
super(remote, {
|
||||
name: 'session event stream',
|
||||
emptyCursor: -1,
|
||||
entries: page => page.events,
|
||||
hasMore: page => page.hasMore,
|
||||
cursor: entry => entry.event.seq,
|
||||
compare: (left, right) => left - right,
|
||||
follows: (left, right) => right === left + 1,
|
||||
publish: options.publish,
|
||||
...(options.carrierFailed === undefined
|
||||
? {}
|
||||
: { carrierFailed: options.carrierFailed }),
|
||||
failed: options.failed,
|
||||
})
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
protected override async * follow(
|
||||
afterSeq: number | undefined,
|
||||
signal: AbortSignal,
|
||||
): AsyncIterable<RemoteJournalFrame<SessionEventEntry, number>> {
|
||||
const request = afterSeq === undefined
|
||||
? { address: this.address }
|
||||
: { address: this.address, afterSeq }
|
||||
for await (const frame of this.remote.session.follow(request, signal)) {
|
||||
if (frame.type === 'opened') {
|
||||
yield frame
|
||||
continue
|
||||
}
|
||||
const { type: _type, ...entry } = frame
|
||||
yield { type: 'entry', entry }
|
||||
}
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
protected override async readPage(
|
||||
request: ClientSessionPageRequest,
|
||||
throughSeq: number,
|
||||
signal: AbortSignal,
|
||||
): Promise<SessionPage> {
|
||||
const result = await this.remote.session.page(
|
||||
{ address: this.address, throughSeq, ...request },
|
||||
signal,
|
||||
)
|
||||
if (!result.ok) {
|
||||
throw new RemoteStreamError(
|
||||
result.error.code,
|
||||
result.error.message,
|
||||
result.error.details,
|
||||
)
|
||||
}
|
||||
return result.value
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
protected override repairRequest(
|
||||
request: ClientSessionPageRequest,
|
||||
): ClientSessionPageRequest {
|
||||
return request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover a Host Session failure from a Remote stream terminal error.
|
||||
* @param error - value thrown while opening or consuming a Session stream.
|
||||
* @returns the Host failure, or `undefined` for carrier and local failures.
|
||||
*/
|
||||
export function sessionStreamFailure(error: unknown): RemoteFailure | undefined {
|
||||
if (!(error instanceof RemoteStreamError)) return undefined
|
||||
return { code: error.code, message: error.message, details: error.details }
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
/** Session commands whose activation policy is explicit at each Remote method. */
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent'
|
||||
import {
|
||||
PresetMountError, UnknownPresetError, resolveSessionPreset,
|
||||
} from '@deepseek-ai/dsh-agent-presets'
|
||||
import { AttachmentError, admitEncodedImages } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import {
|
||||
ReasoningEffortId, createUserMessage, freezeMessage,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
|
||||
import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { Workspace } from '@deepseek-ai/dsh-workspace'
|
||||
import {
|
||||
ApiSessionAgentController,
|
||||
ApiSessionCwdConflict,
|
||||
ApiSessionNotFound,
|
||||
ApiSessionPresetConflict,
|
||||
ApiSessionSubagentOwnership,
|
||||
apiSessionSubagentOwnershipError,
|
||||
hasApiSessionSubagentOwner,
|
||||
inspectApiSession,
|
||||
} from './agent.ts'
|
||||
import { buildModelCatalog } from './catalog.ts'
|
||||
import type {
|
||||
SessionAttachmentRequest,
|
||||
SessionAttachmentValue,
|
||||
SessionCancelRequest,
|
||||
SessionCancelValue,
|
||||
SessionCreateRequest,
|
||||
SessionCreateValue,
|
||||
SessionForkRequest,
|
||||
SessionForkValue,
|
||||
SessionModels,
|
||||
SessionModelsRequest,
|
||||
SessionPromptRequest,
|
||||
SessionPromptValue,
|
||||
SessionRenameRequest,
|
||||
SessionRenameValue,
|
||||
SessionSelectModelRequest,
|
||||
SessionSelectModelValue,
|
||||
SessionUpdateQueueRequest,
|
||||
SessionUpdateQueueValue,
|
||||
} from './types.ts'
|
||||
|
||||
interface SessionReadState {
|
||||
readonly id: SessionId
|
||||
readonly header: SessionHeader
|
||||
readonly events: SessionEvent[]
|
||||
}
|
||||
|
||||
/** Implements Session business commands delegated by the Session Controller Remote service. */
|
||||
export class SessionCommandController {
|
||||
/**
|
||||
* @param ctx - Host context carrying Agent, model, attachment, title, and Workspace services.
|
||||
* @param agents - sole owner of create, resume, and Session-local model selection.
|
||||
* @param defaultCwd - project directory used when create names neither a Workspace nor a cwd.
|
||||
*/
|
||||
constructor(
|
||||
private readonly ctx: Context,
|
||||
private readonly agents: ApiSessionAgentController,
|
||||
private readonly defaultCwd: string,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create or idempotently adopt one ordinary Session.
|
||||
* @param request - requested identity, location, and Agent preset.
|
||||
* @returns the Session identity and resolved preset when configured.
|
||||
*/
|
||||
async create(request: SessionCreateRequest): Promise<SessionCreateValue> {
|
||||
if (request.workspaceId !== undefined && request.cwd !== undefined) {
|
||||
reject('bad-request', 'session.create accepts workspaceId or cwd, not both', {})
|
||||
}
|
||||
const sessionId = request.sessionId ?? SessionId(`session-${randomUUID()}`)
|
||||
let workspace: Workspace | undefined
|
||||
if (request.workspaceId !== undefined) {
|
||||
workspace = this.ctx.workspaceRegistry.get(request.workspaceId)
|
||||
if (workspace === undefined) {
|
||||
reject('workspace-not-found', `workspace "${request.workspaceId}" not found`, {
|
||||
workspaceId: request.workspaceId,
|
||||
})
|
||||
}
|
||||
}
|
||||
const cwd = workspace?.path ?? request.cwd ?? this.defaultCwd
|
||||
let adopted: Agent
|
||||
try {
|
||||
adopted = await this.agents.ensureSession(
|
||||
sessionId,
|
||||
cwd,
|
||||
request.sessionId !== undefined,
|
||||
request.agentPreset,
|
||||
)
|
||||
} catch (error) {
|
||||
this.rejectCreation(sessionId, error)
|
||||
}
|
||||
if (workspace !== undefined) {
|
||||
try {
|
||||
await workspace.attachSession(sessionId)
|
||||
} catch (error) {
|
||||
reject(
|
||||
'workspace-attach-failed',
|
||||
`session "${sessionId}" was created but could not attach to workspace "${workspace.id}": ${String(error)}`,
|
||||
{ sessionId, workspaceId: workspace.id },
|
||||
)
|
||||
}
|
||||
}
|
||||
const agentPreset = resolveSessionPreset(adopted.session)
|
||||
return { sessionId, ...(agentPreset === undefined ? {} : { agentPreset }) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current selection and advisory model catalog, explicitly resuming the Session.
|
||||
* @param request - Session whose model state is requested.
|
||||
* @returns the current selection and available model groups.
|
||||
*/
|
||||
async models(request: SessionModelsRequest): Promise<SessionModels> {
|
||||
const agent = await this.resolveAgent(request.sessionId)
|
||||
const current = this.agents.selectionFor(agent).current
|
||||
const { groups, failures } = await buildModelCatalog(this.ctx)
|
||||
return {
|
||||
current: { ...current },
|
||||
routable: routeServed(this.ctx, current.provider),
|
||||
groups,
|
||||
failures,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and install one Session-local model selection.
|
||||
* @param request - Session identity and requested model selection.
|
||||
* @returns the normalized selection installed for the Session.
|
||||
*/
|
||||
async selectModel(request: SessionSelectModelRequest): Promise<SessionSelectModelValue> {
|
||||
const agent = await this.resolveAgent(request.sessionId)
|
||||
return this.agents.serializeImageAdmission(agent, async () => {
|
||||
try {
|
||||
const resolved = await this.ctx.llm.resolveCallConfig({
|
||||
provider: request.provider,
|
||||
model: request.model,
|
||||
...(request.reasoningEffort === undefined
|
||||
? {}
|
||||
: { reasoningEffort: ReasoningEffortId(request.reasoningEffort) }),
|
||||
})
|
||||
const selected: AgentModelSelection = {
|
||||
provider: resolved.provider,
|
||||
model: resolved.model,
|
||||
...(resolved.reasoningEffort === undefined
|
||||
? {}
|
||||
: { reasoningEffort: resolved.reasoningEffort }),
|
||||
}
|
||||
this.agents.selectionFor(agent).current = selected
|
||||
try {
|
||||
await this.ctx.agentDefaultModel.saveSelection(selected)
|
||||
} catch (error) {
|
||||
this.ctx.logger.warn(
|
||||
`session-controller: model selection changed for the Session but the default was not saved: ${String(error)}`,
|
||||
)
|
||||
}
|
||||
return { selected: { ...selected } }
|
||||
} catch (error) {
|
||||
if (error instanceof TypertRemoteFailure) throw error
|
||||
reject(
|
||||
'model-unavailable',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
{ provider: request.provider, model: request.model },
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize and append a user-owned Session title.
|
||||
* @param request - Session identity and proposed title.
|
||||
* @returns the accepted title and durable event sequence.
|
||||
*/
|
||||
async rename(request: SessionRenameRequest): Promise<SessionRenameValue> {
|
||||
const agent = await this.resolveAgent(request.sessionId)
|
||||
const titles = this.ctx.get('sessionTitle')
|
||||
if (titles === undefined) {
|
||||
reject('internal', 'renaming is unavailable: this deployment mounts no session-title service', {})
|
||||
}
|
||||
try {
|
||||
const accepted = titles.rename(agent.session, request.title)
|
||||
return { title: accepted.title, seq: accepted.eventSeq }
|
||||
} catch (error) {
|
||||
if (error instanceof SessionTitleInvalidError) {
|
||||
reject('title-invalid', error.message, { sessionId: request.sessionId })
|
||||
}
|
||||
reject(
|
||||
'internal',
|
||||
`failed to rename session "${request.sessionId}": ${String(error)}`,
|
||||
{},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new ordinary Session from one completed-turn prefix.
|
||||
* @param request - source Session and optional event anchor.
|
||||
* @returns the new Session identity.
|
||||
*/
|
||||
async fork(request: SessionForkRequest): Promise<SessionForkValue> {
|
||||
if (request.atSeq !== undefined
|
||||
&& (!Number.isInteger(request.atSeq) || request.atSeq < 0)) {
|
||||
reject('bad-request', 'atSeq must be a non-negative integer', {})
|
||||
}
|
||||
let source: SessionReadState
|
||||
try {
|
||||
source = await this.readSessionState(request.sessionId)
|
||||
} catch (error) {
|
||||
if (error instanceof ApiSessionNotFound) {
|
||||
reject('session-not-found', error.message, { sessionId: request.sessionId })
|
||||
}
|
||||
reject(
|
||||
'internal',
|
||||
`fork source unavailable for session "${request.sessionId}": ${String(error)}`,
|
||||
{},
|
||||
)
|
||||
}
|
||||
const lastSeq = source.events.at(-1)?.seq ?? -1
|
||||
const atSeq = request.atSeq
|
||||
const anchoredBoundary = atSeq === undefined
|
||||
? undefined
|
||||
: source.events.find(event => event.type === 'turn/end' && event.seq >= atSeq)
|
||||
const boundary = anchoredBoundary
|
||||
?? (atSeq === undefined || atSeq > lastSeq
|
||||
? source.events.findLast(event => event.type === 'turn/end')
|
||||
: undefined)
|
||||
if (boundary === undefined) {
|
||||
reject(
|
||||
'fork-unavailable',
|
||||
atSeq !== undefined && atSeq <= lastSeq
|
||||
? `session "${request.sessionId}" has not completed the turn containing event ${String(atSeq)}`
|
||||
: `session "${request.sessionId}" has no completed turn to fork from`,
|
||||
{ sessionId: request.sessionId },
|
||||
)
|
||||
}
|
||||
let cut = boundary.seq + 1
|
||||
while (cut < source.events.length && source.events[cut]?.type !== 'turn/start') cut++
|
||||
let workspace: Workspace | undefined
|
||||
try {
|
||||
workspace = await this.forkWorkspace(source)
|
||||
} catch (error) {
|
||||
reject(
|
||||
'internal',
|
||||
`failed to resolve fork workspace for session "${request.sessionId}": ${String(error)}`,
|
||||
{},
|
||||
)
|
||||
}
|
||||
const childId = SessionId(`session-${randomUUID()}`)
|
||||
const composition = await this.agents.composeAgent(resolveSessionPreset(source))
|
||||
try {
|
||||
const { provider, model } = this.ctx.agentDefaultModel.currentSelection()
|
||||
await this.ctx.agents.create({
|
||||
sessionId: childId,
|
||||
seed: source.events.slice(0, cut),
|
||||
meta: {
|
||||
...(source.header.cwd === undefined ? {} : { cwd: source.header.cwd }),
|
||||
parentSession: source.id,
|
||||
seedLength: cut,
|
||||
...(composition.agentPreset === undefined
|
||||
? {}
|
||||
: { agentPreset: composition.agentPreset }),
|
||||
},
|
||||
agentOptions: { provider, model },
|
||||
setup: composition.setup,
|
||||
})
|
||||
} catch (error) {
|
||||
reject(
|
||||
'internal',
|
||||
`failed to fork session "${request.sessionId}": ${String(error)}`,
|
||||
{},
|
||||
)
|
||||
}
|
||||
if (workspace !== undefined) {
|
||||
try {
|
||||
await workspace.attachSession(childId)
|
||||
} catch (error) {
|
||||
reject(
|
||||
'workspace-attach-failed',
|
||||
`session "${childId}" was forked but could not attach to workspace "${workspace.id}": ${String(error)}`,
|
||||
{ sessionId: childId, workspaceId: workspace.id },
|
||||
)
|
||||
}
|
||||
}
|
||||
return { sessionId: childId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Admit one browser prompt after explicit Agent resume and image validation.
|
||||
* @param request - Session identity, prompt content, source metadata, and delivery mode.
|
||||
* @returns acknowledgement that the Agent accepted the prompt.
|
||||
*/
|
||||
async prompt(request: SessionPromptRequest): Promise<SessionPromptValue> {
|
||||
const clientTimeZone = request.clientTimeZone === undefined
|
||||
? undefined
|
||||
: canonicalClientTimeZone(request.clientTimeZone)
|
||||
if (request.clientTimeZone !== undefined && clientTimeZone === undefined) {
|
||||
reject(
|
||||
'invalid-time-zone',
|
||||
'clientTimeZone must be UTC or a valid IANA Area/Location name',
|
||||
{ value: request.clientTimeZone },
|
||||
)
|
||||
}
|
||||
const agent = await this.resolveAgent(request.sessionId)
|
||||
const selection = this.agents.selectionFor(agent).current
|
||||
if (!routeServed(this.ctx, selection.provider)) {
|
||||
reject(
|
||||
'model-unavailable',
|
||||
`no adapter serves provider "${selection.provider}"; select a model for this session`,
|
||||
{ provider: selection.provider, model: selection.model },
|
||||
)
|
||||
}
|
||||
const source: MessageSource = {
|
||||
kind: 'user',
|
||||
rpcId: request.requestId,
|
||||
...(clientTimeZone === undefined ? {} : { clientTimeZone }),
|
||||
}
|
||||
const hasImage = request.content.some(part => part.type === 'image')
|
||||
const admit = async (): Promise<SessionPromptValue> => {
|
||||
try {
|
||||
if (hasImage) {
|
||||
const current = this.agents.selectionFor(agent).current
|
||||
const model = await this.ctx.llm.resolveModelInfo(current.provider, current.model)
|
||||
if (model.inputModalities !== undefined && !model.inputModalities.includes('image')) {
|
||||
reject(
|
||||
'attachment-error',
|
||||
`Model "${current.model}" does not support image input.`,
|
||||
{ reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' },
|
||||
)
|
||||
}
|
||||
}
|
||||
const content = await durablePromptContent(this.ctx, request.content)
|
||||
const message: UserMessage = createUserMessage({ content, source })
|
||||
if (request.mode === 'steer') agent.steer(message)
|
||||
else agent.followup(message)
|
||||
} catch (error) {
|
||||
if (error instanceof TypertRemoteFailure) throw error
|
||||
if (error instanceof AttachmentError) {
|
||||
reject('attachment-error', error.message, { reason: error.code })
|
||||
}
|
||||
reject('agent-busy', 'prompt rejected', { reason: String(error) })
|
||||
}
|
||||
return { accepted: true }
|
||||
}
|
||||
return hasImage ? this.agents.serializeImageAdmission(agent, admit) : admit()
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one durable image after proving the Session log references it.
|
||||
* @param request - Session and attachment identities used for authorization.
|
||||
* @returns the durable attachment reference and base64-encoded bytes.
|
||||
*/
|
||||
async attachment(request: SessionAttachmentRequest): Promise<SessionAttachmentValue> {
|
||||
let source: SessionReadState
|
||||
try {
|
||||
source = await this.readSessionState(request.sessionId)
|
||||
} catch (error) {
|
||||
if (error instanceof ApiSessionNotFound) {
|
||||
reject('session-not-found', error.message, { sessionId: request.sessionId })
|
||||
}
|
||||
reject(
|
||||
'internal',
|
||||
`attachment authorization unavailable for session "${request.sessionId}": ${String(error)}`,
|
||||
{},
|
||||
)
|
||||
}
|
||||
const ref = referencedImage(source.events, String(request.attachmentId))
|
||||
if (ref === undefined) {
|
||||
reject(
|
||||
'attachment-error',
|
||||
'Image is not referenced by this session.',
|
||||
{ reason: 'ATTACHMENT_NOT_REFERENCED' },
|
||||
)
|
||||
}
|
||||
try {
|
||||
const stored = await this.ctx.attachments.readImage(ref)
|
||||
return {
|
||||
attachment: stored.ref,
|
||||
data: Buffer.from(stored.data).toString('base64'),
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AttachmentError) {
|
||||
reject('attachment-error', error.message, { reason: error.code })
|
||||
}
|
||||
reject('internal', 'Unable to read image attachment.', {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutate one still-pending queue occurrence without resuming a cold Agent.
|
||||
* @param request - Session, queue item, and requested mutation.
|
||||
* @returns acknowledgement that the queue mutation was applied.
|
||||
*/
|
||||
updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue {
|
||||
if (request.action.kind === 'edit'
|
||||
&& request.action.content.some(block => block.type !== 'text')) {
|
||||
reject(
|
||||
'attachment-error',
|
||||
'queue edits accept text content only',
|
||||
{ reason: 'QUEUE_EDIT_NON_TEXT' },
|
||||
)
|
||||
}
|
||||
const agent = this.ctx.agents.get(request.sessionId)
|
||||
if (agent !== undefined && hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) {
|
||||
rejectFailure(apiSessionSubagentOwnershipError(request.sessionId))
|
||||
}
|
||||
if (agent === undefined) {
|
||||
reject('queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId })
|
||||
}
|
||||
const nextTurn = agent.inbox.nextTurn.find(message => message.id === request.itemId)
|
||||
const nextStep = agent.inbox.nextStep.find(message => message.id === request.itemId)
|
||||
const located = nextTurn === undefined
|
||||
? nextStep === undefined ? undefined : { target: 'next-step' as const, message: nextStep }
|
||||
: { target: 'next-turn' as const, message: nextTurn }
|
||||
if (located === undefined) {
|
||||
reject('queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId })
|
||||
}
|
||||
const { target, message } = located
|
||||
if (request.action.kind === 'steer' && (target !== 'next-turn' || agent.status !== 'running')) {
|
||||
reject('steer-unavailable', 'current turn no longer accepts steering', { itemId: request.itemId })
|
||||
}
|
||||
if (request.action.kind === 'edit') {
|
||||
agent.inbox.replace(request.itemId, freezeMessage<UserMessage>({
|
||||
...message,
|
||||
content: [...request.action.content],
|
||||
}))
|
||||
} else {
|
||||
agent.inbox.remove(request.itemId)
|
||||
if (request.action.kind === 'steer') agent.steer(message)
|
||||
}
|
||||
return { accepted: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel one live ordinary Agent while retaining pending inbox work.
|
||||
* @param request - Session whose active Agent turn is cancelled.
|
||||
* @returns acknowledgement that cancellation was requested.
|
||||
*/
|
||||
cancel(request: SessionCancelRequest): SessionCancelValue {
|
||||
const agent = this.ctx.agents.get(request.sessionId)
|
||||
if (agent === undefined) {
|
||||
reject(
|
||||
'session-not-found',
|
||||
`session "${request.sessionId}" not found (not attached)`,
|
||||
{ sessionId: request.sessionId },
|
||||
)
|
||||
}
|
||||
if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) {
|
||||
rejectFailure(apiSessionSubagentOwnershipError(request.sessionId))
|
||||
}
|
||||
agent.cancel({ kind: 'user' }, { keepInbox: true })
|
||||
return { accepted: true }
|
||||
}
|
||||
|
||||
private async resolveAgent(sessionId: SessionId): Promise<Agent> {
|
||||
const found = await this.agents.resolveAgent(sessionId)
|
||||
if ('error' in found) rejectFailure(found.error)
|
||||
return found.agent
|
||||
}
|
||||
|
||||
private rejectCreation(sessionId: SessionId, error: unknown): never {
|
||||
if (error instanceof ApiSessionPresetConflict) {
|
||||
reject('agent-preset-conflict', error.message, {
|
||||
sessionId: error.sessionId,
|
||||
requestedPreset: error.requestedPreset,
|
||||
...(error.existingPreset === undefined ? {} : { existingPreset: error.existingPreset }),
|
||||
})
|
||||
}
|
||||
if (error instanceof UnknownPresetError) {
|
||||
reject('agent-preset-not-found', error.message, {
|
||||
agentPreset: error.presetId,
|
||||
available: [...error.available],
|
||||
})
|
||||
}
|
||||
if (error instanceof PresetMountError) {
|
||||
reject('agent-preset-invalid', error.message, {
|
||||
agentPreset: error.presetId,
|
||||
reason: error.reason,
|
||||
})
|
||||
}
|
||||
if (error instanceof ApiSessionCwdConflict) {
|
||||
reject('session-conflict', error.message, {
|
||||
sessionId: error.sessionId,
|
||||
requestedCwd: error.requestedCwd,
|
||||
...(error.existingCwd === undefined ? {} : { existingCwd: error.existingCwd }),
|
||||
})
|
||||
}
|
||||
if (error instanceof ApiSessionSubagentOwnership) {
|
||||
rejectFailure(apiSessionSubagentOwnershipError(error.sessionId))
|
||||
}
|
||||
reject('internal', `failed to create session "${sessionId}": ${String(error)}`, {})
|
||||
}
|
||||
|
||||
private async readSessionState(sessionId: SessionId): Promise<SessionReadState> {
|
||||
const attached = this.ctx.sessions.get(sessionId)
|
||||
if (attached !== undefined) {
|
||||
return { id: attached.id, header: attached.header, events: [...attached.events] }
|
||||
}
|
||||
const inspected = await inspectApiSession(this.ctx, sessionId)
|
||||
return { id: inspected.meta.id, header: inspected.meta, events: inspected.events }
|
||||
}
|
||||
|
||||
private async forkWorkspace(source: Pick<Session, 'id' | 'header'>): Promise<Workspace | undefined> {
|
||||
const workspaces = this.ctx.workspaceRegistry.list()
|
||||
const direct = workspaces.find(workspace => workspace.sessionIds.includes(source.id))
|
||||
if (direct !== undefined || source.header.origin !== 'subagent') return direct
|
||||
const lineage = await this.ctx.sessionQuery.traceSession(source.id)
|
||||
for (const ancestor of lineage.ancestors) {
|
||||
const workspace = workspaces.find(candidate => candidate.sessionIds.includes(ancestor.header.id))
|
||||
if (workspace !== undefined) return workspace
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function rejectFailure(error: { readonly code: string; readonly message: string; readonly details: object }): never {
|
||||
throw new TypertRemoteFailure(error)
|
||||
}
|
||||
|
||||
function reject(code: string, message: string, details: object): never {
|
||||
throw new TypertRemoteFailure({ code, message, details })
|
||||
}
|
||||
|
||||
async function durablePromptContent(
|
||||
ctx: Context,
|
||||
content: readonly SessionPromptRequest['content'][number][],
|
||||
): Promise<ContentBlock[]> {
|
||||
if (content.every(part => part.type === 'text')) {
|
||||
return content.map(part => ({ type: 'text', text: part.text }))
|
||||
}
|
||||
const refs = await admitEncodedImages(ctx.attachments, content.filter(part => part.type === 'image'))
|
||||
let next = 0
|
||||
return content.map(part => part.type === 'text'
|
||||
? { type: 'text', text: part.text }
|
||||
// admitEncodedImages returns one reference per image part in order.
|
||||
: { type: 'image', attachment: refs[next++] as ImageAttachmentRef })
|
||||
}
|
||||
|
||||
function imageBlockIn(
|
||||
content: unknown,
|
||||
match: (ref: ImageAttachmentRef) => boolean,
|
||||
): ImageAttachmentRef | undefined {
|
||||
if (!Array.isArray(content)) return undefined
|
||||
for (const value of content) {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) continue
|
||||
const block = value as { readonly type?: unknown; readonly attachment?: unknown; readonly content?: unknown }
|
||||
if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) {
|
||||
const ref = block.attachment as ImageAttachmentRef
|
||||
if (match(ref)) return ref
|
||||
}
|
||||
if (block.type === 'tool-result') {
|
||||
const nested = imageBlockIn(block.content, match)
|
||||
if (nested !== undefined) return nested
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function imageInEvent(
|
||||
event: SessionEvent,
|
||||
match: (ref: ImageAttachmentRef) => boolean,
|
||||
): ImageAttachmentRef | undefined {
|
||||
const data = event.data as {
|
||||
readonly content?: unknown
|
||||
readonly message?: { readonly content?: unknown }
|
||||
readonly inserted?: readonly { readonly content?: unknown }[]
|
||||
readonly chunk?: { readonly type?: unknown; readonly block?: unknown }
|
||||
}
|
||||
const direct = imageBlockIn(data.content, match)
|
||||
if (direct !== undefined) return direct
|
||||
const message = imageBlockIn(data.message?.content, match)
|
||||
if (message !== undefined) return message
|
||||
for (const inserted of data.inserted ?? []) {
|
||||
const found = imageBlockIn(inserted.content, match)
|
||||
if (found !== undefined) return found
|
||||
}
|
||||
return event.type === 'assistant/chunk' && data.chunk?.type === 'block-end'
|
||||
? imageBlockIn([data.chunk.block], match)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function referencedImage(
|
||||
events: readonly SessionEvent[],
|
||||
attachmentId: string,
|
||||
): ImageAttachmentRef | undefined {
|
||||
for (const event of events) {
|
||||
const found = imageInEvent(event, ref => String(ref.attachmentId) === attachmentId)
|
||||
if (found !== undefined) return found
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/
|
||||
|
||||
function canonicalClientTimeZone(value: string): string | undefined {
|
||||
if (value.length === 0 || value.trim() !== value
|
||||
|| (value !== 'UTC' && !IANA_TIME_ZONE.test(value))) return undefined
|
||||
try {
|
||||
return new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function routeServed(ctx: Context, provider: string): boolean {
|
||||
return ctx.llm.listProviders().some(entry => entry.id === provider)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/** Live Session queue, jobs, and projection state with reconnect baselines. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { JobSnapshot } from '@deepseek-ai/dsh-jobs'
|
||||
import type {
|
||||
JsonValue, Session, SessionEvent, SessionEventMap, SessionId, UserMessage,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import type {
|
||||
SessionControlBaseline,
|
||||
SessionControlFrame,
|
||||
SessionJob,
|
||||
SessionProjectionsBlock,
|
||||
SessionProjectionValues,
|
||||
SessionQueuedItem,
|
||||
} from './types.ts'
|
||||
|
||||
/** Owns the Host-wide Session control stream. */
|
||||
export class SessionControlController {
|
||||
private readonly streams = new Set<ControlQueue>()
|
||||
|
||||
/** @param ctx - Host context carrying live Agent, projection, and jobs services. */
|
||||
constructor(private readonly ctx: Context) {
|
||||
ctx.on('session/event', (session, event) => { this.onSessionEvent(session, event) })
|
||||
ctx.on('session/created', (session) => {
|
||||
const jobs = this.jobsFor(this.ctx.agents.get(session.id))
|
||||
if (jobs.length > 0) this.broadcast({ type: 'jobs', sessionId: session.id, jobs })
|
||||
})
|
||||
ctx.inject(['sessionProjections'], (projectionCtx) => {
|
||||
projectionCtx.sessionProjections.onChanged((session, key, value, seq) => {
|
||||
this.broadcast({
|
||||
type: 'projection',
|
||||
sessionId: session.id,
|
||||
key,
|
||||
value: value as JsonValue,
|
||||
seq,
|
||||
})
|
||||
})
|
||||
})
|
||||
ctx.inject(['jobs'], (jobsCtx) => {
|
||||
jobsCtx.jobs.onJobsChanged((owner) => { this.onJobsChanged(owner) })
|
||||
})
|
||||
ctx.effect(() => () => {
|
||||
for (const stream of this.streams) stream.end()
|
||||
this.streams.clear()
|
||||
}, 'session-controller.control')
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one generation of Host-wide live control state.
|
||||
* @param signal - Remote stream cancellation.
|
||||
* @returns one complete baseline followed by live replacement frames.
|
||||
*/
|
||||
async *control(signal: AbortSignal): AsyncIterable<SessionControlFrame> {
|
||||
signal.throwIfAborted()
|
||||
const queue = new ControlQueue()
|
||||
this.streams.add(queue)
|
||||
try {
|
||||
yield { type: 'baseline', value: this.baseline() }
|
||||
yield* queue.iterate(signal)
|
||||
} finally {
|
||||
this.streams.delete(queue)
|
||||
queue.end()
|
||||
}
|
||||
}
|
||||
|
||||
private baseline(): SessionControlBaseline {
|
||||
const sessions = this.ctx.sessions.list()
|
||||
const queues = Object.create(null) as Record<SessionId, readonly SessionQueuedItem[]>
|
||||
const jobs = Object.create(null) as Record<SessionId, readonly SessionJob[]>
|
||||
for (const session of sessions) {
|
||||
const agent = this.ctx.agents.get(session.id)
|
||||
queues[session.id] = agent?.session === session ? queueItems(agent) : []
|
||||
jobs[session.id] = this.jobsFor(agent)
|
||||
}
|
||||
return {
|
||||
queues,
|
||||
jobs,
|
||||
projections: this.projectionBaseline(sessions),
|
||||
}
|
||||
}
|
||||
|
||||
private projectionBaseline(
|
||||
sessions: readonly Session[],
|
||||
): Readonly<Record<SessionId, SessionProjectionsBlock>> {
|
||||
const registry = this.ctx.get('sessionProjections')
|
||||
const blocks = Object.create(null) as Record<SessionId, SessionProjectionsBlock>
|
||||
for (const session of sessions) {
|
||||
const snapshot = registry?.snapshot(session)
|
||||
blocks[session.id] = snapshot === undefined
|
||||
? { asOfSeq: session.seq - 1, values: {} }
|
||||
: {
|
||||
asOfSeq: snapshot.asOfSeq,
|
||||
// Every projection definition validates its value before snapshot publication.
|
||||
values: snapshot.values as SessionProjectionValues,
|
||||
}
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
private onSessionEvent(session: Session, event: SessionEvent): void {
|
||||
if (event.type !== 'agent/inbox/spliced') return
|
||||
const agent = this.ctx.agents.get(session.id)
|
||||
if (agent?.session !== session) return
|
||||
this.broadcast({
|
||||
type: 'queue',
|
||||
sessionId: session.id,
|
||||
items: queueItems(agent, event.data),
|
||||
})
|
||||
}
|
||||
|
||||
private onJobsChanged(owner: Agent | undefined): void {
|
||||
if (owner !== undefined) {
|
||||
this.broadcast({ type: 'jobs', sessionId: owner.id, jobs: this.jobsFor(owner) })
|
||||
return
|
||||
}
|
||||
for (const session of this.ctx.sessions.list()) {
|
||||
this.broadcast({
|
||||
type: 'jobs',
|
||||
sessionId: session.id,
|
||||
jobs: this.jobsFor(this.ctx.agents.get(session.id)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private jobsFor(agent: Agent | undefined): SessionJob[] {
|
||||
const jobs = this.ctx.get('jobs')
|
||||
return jobs === undefined ? [] : jobs.list(agent).map(jobView)
|
||||
}
|
||||
|
||||
private broadcast(frame: SessionControlFrame): void {
|
||||
for (const stream of this.streams) stream.push(frame)
|
||||
}
|
||||
}
|
||||
|
||||
class ControlQueue {
|
||||
private readonly buffer: SessionControlFrame[] = []
|
||||
private wake: (() => void) | undefined
|
||||
private done = false
|
||||
|
||||
push(frame: SessionControlFrame): void {
|
||||
if (this.done) return
|
||||
this.buffer.push(frame)
|
||||
const wake = this.wake
|
||||
this.wake = undefined
|
||||
wake?.()
|
||||
}
|
||||
|
||||
end(): void {
|
||||
if (this.done) return
|
||||
this.done = true
|
||||
const wake = this.wake
|
||||
this.wake = undefined
|
||||
wake?.()
|
||||
}
|
||||
|
||||
async *iterate(signal: AbortSignal): AsyncIterable<SessionControlFrame> {
|
||||
const onAbort = (): void => { this.end() }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
try {
|
||||
while (!this.done && !signal.aborted) {
|
||||
const frame = this.buffer.shift()
|
||||
if (frame !== undefined) {
|
||||
yield frame
|
||||
continue
|
||||
}
|
||||
await new Promise<void>((resolve) => { this.wake = resolve })
|
||||
}
|
||||
while (this.buffer.length > 0 && !signal.aborted) yield this.buffer.shift() as SessionControlFrame
|
||||
} finally {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
this.end()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function queueItems(
|
||||
agent: Agent,
|
||||
splice?: SessionEventMap['agent/inbox/spliced'],
|
||||
): SessionQueuedItem[] {
|
||||
const project = (target: 'next-turn' | 'next-step'): readonly UserMessage[] => {
|
||||
const messages = target === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep
|
||||
return splice?.target === target
|
||||
? messages.toSpliced(splice.start, splice.removedCount ?? 0, ...splice.inserted)
|
||||
: messages
|
||||
}
|
||||
return [
|
||||
...project('next-turn').map(message => ({
|
||||
id: message.id,
|
||||
placement: 'queued' as const,
|
||||
message: { id: message.id, content: message.content as unknown as JsonValue[] },
|
||||
})),
|
||||
...project('next-step').map(message => ({
|
||||
id: message.id,
|
||||
placement: message.source.kind === 'user' ? 'steering' as const : 'context' as const,
|
||||
message: { id: message.id, content: message.content as unknown as JsonValue[] },
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
function jobView(job: JobSnapshot): SessionJob {
|
||||
return {
|
||||
id: job.id,
|
||||
kind: job.kind,
|
||||
label: job.label,
|
||||
status: job.status,
|
||||
...(job.detail === undefined ? {} : { detail: job.detail }),
|
||||
startedAt: job.startedAt,
|
||||
...(job.finishedAt === undefined ? {} : { finishedAt: job.finishedAt }),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
/** Cold Session history pagination and live-event source. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
|
||||
import type {} from '@deepseek-ai/dsh-session-projection'
|
||||
import type {} from '@deepseek-ai/dsh-session-projection-cache'
|
||||
import { foldSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
|
||||
import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type {
|
||||
SessionAddress,
|
||||
SessionEventEntry,
|
||||
SessionFollowRequest,
|
||||
SessionFollowFrame,
|
||||
SessionPage,
|
||||
SessionPageRequest,
|
||||
SessionProjectionsBlock,
|
||||
SessionProjectionValues,
|
||||
SessionWireEvent,
|
||||
} from './types.ts'
|
||||
|
||||
const DEFAULT_MAX_MESSAGES = 50
|
||||
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
|
||||
|
||||
type SessionSource =
|
||||
| { readonly kind: 'attached'; readonly session: Session }
|
||||
| { readonly kind: 'detached'; readonly header: SessionHeader; readonly events: readonly SessionEvent[] }
|
||||
|
||||
/** Implements cold-safe history operations delegated by the Session Controller. */
|
||||
export class SessionHistoryController {
|
||||
private readonly closeFollowers = new Set<() => void>()
|
||||
|
||||
/** @param ctx - Host context carrying Session, persistence, and projection services. */
|
||||
constructor(private readonly ctx: Context) {
|
||||
ctx.effect(() => () => {
|
||||
for (const close of this.closeFollowers) close()
|
||||
this.closeFollowers.clear()
|
||||
}, 'session-controller.history')
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one message-aligned history page without activating an Agent.
|
||||
* @param request - durable address and backwards-page cursor.
|
||||
* @param signal - caller cancellation for persistence reads.
|
||||
* @returns a contiguous event page and a projection baseline on tail reads.
|
||||
*/
|
||||
async page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage> {
|
||||
validatePageRequest(request)
|
||||
const source = await this.sourceFor(request.address, signal)
|
||||
signal.throwIfAborted()
|
||||
const sourceLog = sourceEvents(source)
|
||||
const sourceCursor = sourceLog.at(-1)?.seq ?? -1
|
||||
if (request.throughSeq > sourceCursor) {
|
||||
reject(
|
||||
'bad-request',
|
||||
`session page through seq ${String(request.throughSeq)} is past cursor ${String(sourceCursor)}`,
|
||||
{},
|
||||
)
|
||||
}
|
||||
const events = sourceLog.filter(event => event.seq <= request.throughSeq)
|
||||
if ((events.at(-1)?.seq ?? -1) !== request.throughSeq) {
|
||||
reject('internal', `session log does not contain through seq ${String(request.throughSeq)}`, {})
|
||||
}
|
||||
const page = paginate(events, request.beforeSeq, request.maxMessages ?? DEFAULT_MAX_MESSAGES)
|
||||
const entries = page.events.map(entryFor)
|
||||
const projections = request.beforeSeq === undefined
|
||||
? this.projectionsFor(request.address, source, events)
|
||||
: undefined
|
||||
return {
|
||||
events: entries,
|
||||
hasMore: page.hasMore,
|
||||
...(projections === undefined ? {} : { projections }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow events appended after an initial cursor on one durable address.
|
||||
* @param request - durable address and last committed sequence already held by the caller.
|
||||
* @param signal - stream cancellation owned by the Remote carrier.
|
||||
* @returns an opened cursor followed by gap-free event frames.
|
||||
*/
|
||||
async *follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame> {
|
||||
validateFollowRequest(request)
|
||||
const { address, afterSeq } = request
|
||||
const target = addressId(address)
|
||||
const buffered: SessionEvent[] = []
|
||||
let wake: (() => void) | undefined
|
||||
const notify = (): void => {
|
||||
const resume = wake
|
||||
wake = undefined
|
||||
resume?.()
|
||||
}
|
||||
const follower = { closed: false }
|
||||
const close = (): void => {
|
||||
follower.closed = true
|
||||
notify()
|
||||
}
|
||||
this.closeFollowers.add(close)
|
||||
const disposeEvent = this.ctx.on('session/event', (session, event) => {
|
||||
if (session.id !== target) return
|
||||
buffered.push(event)
|
||||
notify()
|
||||
}, { global: true })
|
||||
const disposeCreated = this.ctx.on('session/created', (session) => {
|
||||
if (session.id !== target) return
|
||||
// Session construction appends session/end-seed before attachment, so the
|
||||
// marker has no session/event notification. Earlier session/created listeners
|
||||
// may publish later setup events first; this suffix must precede those notifications.
|
||||
const suffix = session.events.slice(session.firstLiveSeq)
|
||||
buffered.unshift(...suffix)
|
||||
notify()
|
||||
}, { global: true })
|
||||
const onAbort = (): void => { notify() }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
try {
|
||||
const source = await this.sourceFor(address, signal)
|
||||
const events = [...sourceEvents(source)]
|
||||
signal.throwIfAborted()
|
||||
const cursor = events.at(-1)?.seq ?? -1
|
||||
if (afterSeq !== undefined && afterSeq > cursor) {
|
||||
reject('bad-request', `session event resume seq ${String(afterSeq)} is past cursor ${String(cursor)}`, {})
|
||||
}
|
||||
let nextSeq = (afterSeq ?? cursor) + 1
|
||||
yield { type: 'opened', cursor }
|
||||
if (afterSeq !== undefined) {
|
||||
for (const event of events) {
|
||||
if (event.seq < nextSeq) continue
|
||||
if (event.seq !== nextSeq) {
|
||||
reject('internal', `session event replay skipped seq ${String(nextSeq)}`, {})
|
||||
}
|
||||
nextSeq++
|
||||
yield { type: 'event', ...entryFor(event) }
|
||||
}
|
||||
}
|
||||
while (!follower.closed && !signal.aborted) {
|
||||
const item = buffered.shift()
|
||||
if (item === undefined) {
|
||||
await new Promise<void>((resolve) => { wake = resolve })
|
||||
continue
|
||||
}
|
||||
if (item.seq < nextSeq) continue
|
||||
if (item.seq !== nextSeq) {
|
||||
reject('internal', `session event stream skipped seq ${String(nextSeq)}`, {})
|
||||
}
|
||||
nextSeq++
|
||||
yield { type: 'event', ...entryFor(item) }
|
||||
}
|
||||
} finally {
|
||||
this.closeFollowers.delete(close)
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
disposeCreated()
|
||||
disposeEvent()
|
||||
}
|
||||
}
|
||||
|
||||
private async sourceFor(address: SessionAddress, signal: AbortSignal): Promise<SessionSource> {
|
||||
const sessionId = addressId(address)
|
||||
const attached = this.ctx.sessions.get(sessionId)
|
||||
if (attached !== undefined) {
|
||||
validateAddress(address, attached.header, attached.events)
|
||||
return { kind: 'attached', session: attached }
|
||||
}
|
||||
const persistence = this.ctx.get('sessionPersistence')
|
||||
if (persistence === undefined) {
|
||||
reject('internal', 'session persistence is not configured', {})
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
const header = (await persistence.list(signal)).find(candidate => candidate.id === sessionId)
|
||||
if (header === undefined || header.cwd === undefined) rejectNotFound(address)
|
||||
const inspected: SessionInspection = await persistence.inspect(sessionId, signal)
|
||||
signal.throwIfAborted()
|
||||
if (inspected.meta.cwd === undefined) rejectNotFound(address)
|
||||
validateAddress(address, inspected.meta, inspected.events)
|
||||
return { kind: 'detached', header: inspected.meta, events: inspected.events }
|
||||
}
|
||||
|
||||
private projectionsFor(
|
||||
address: SessionAddress,
|
||||
source: SessionSource,
|
||||
events: readonly SessionEvent[],
|
||||
): SessionProjectionsBlock | undefined {
|
||||
const registry = this.ctx.get('sessionProjections')
|
||||
if (registry === undefined) return undefined
|
||||
try {
|
||||
const throughSeq = events.at(-1)?.seq ?? -1
|
||||
const snapshot = source.kind === 'attached'
|
||||
? source.session.seq - 1 === throughSeq
|
||||
? registry.snapshot(source.session)
|
||||
: registry.restore({}, events, 0).snapshot
|
||||
// The cache owns the cold-read recipe (seed from the cached
|
||||
// checkpoint, skip the cached prefix's applies, write the refreshed
|
||||
// checkpoint back); without the cache the registry restores from
|
||||
// init over the full log.
|
||||
: this.ctx.get('sessionProjectionCache')?.coldSnapshot(source.header, events)
|
||||
?? registry.restore({}, events, 0).snapshot
|
||||
return {
|
||||
asOfSeq: snapshot.asOfSeq,
|
||||
// Projection definitions validate whole JSON values before snapshot publication.
|
||||
values: snapshot.values as SessionProjectionValues,
|
||||
}
|
||||
} catch (error) {
|
||||
if (address.kind === 'session') throw error
|
||||
this.ctx.logger.warn(`session.page: projections for "${address.childSessionId}" failed: ${String(error)}`)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validatePageRequest(request: SessionPageRequest): void {
|
||||
if (!Number.isSafeInteger(request.throughSeq) || request.throughSeq < -1) {
|
||||
reject('bad-request', 'throughSeq must be an integer greater than or equal to -1', {})
|
||||
}
|
||||
if (request.beforeSeq !== undefined
|
||||
&& (!Number.isSafeInteger(request.beforeSeq) || request.beforeSeq < 0)) {
|
||||
reject('bad-request', 'beforeSeq must be a non-negative safe integer', {})
|
||||
}
|
||||
if (request.maxMessages !== undefined
|
||||
&& (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) {
|
||||
reject('bad-request', 'maxMessages must be a positive safe integer', {})
|
||||
}
|
||||
}
|
||||
|
||||
function validateFollowRequest(request: SessionFollowRequest): void {
|
||||
if (request.afterSeq !== undefined
|
||||
&& (!Number.isSafeInteger(request.afterSeq) || request.afterSeq < -1)) {
|
||||
reject('bad-request', 'afterSeq must be an integer greater than or equal to -1', {})
|
||||
}
|
||||
}
|
||||
|
||||
function addressId(address: SessionAddress): SessionId {
|
||||
return address.kind === 'session' ? address.sessionId : address.childSessionId
|
||||
}
|
||||
|
||||
function validateAddress(
|
||||
address: SessionAddress,
|
||||
header: SessionHeader,
|
||||
events: readonly SessionEvent[],
|
||||
): void {
|
||||
if (address.kind === 'session') {
|
||||
if (header.origin === 'subagent') {
|
||||
reject('agent-busy', 'subagent Sessions require their durable parent address', {
|
||||
reason: 'use subagent delivery for this child session',
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
if (header.origin !== 'subagent' || header.parentSession !== address.parentSessionId) {
|
||||
reject('subagent-unauthorized', 'subagent does not belong to the supplied parent', {
|
||||
childSessionId: address.childSessionId,
|
||||
})
|
||||
}
|
||||
let descriptor
|
||||
try {
|
||||
descriptor = foldSubagentDescriptor(events.slice(header.seedLength ?? 0))
|
||||
} catch {
|
||||
reject('subagent-catalog-diagnostic', 'subagent descriptor is corrupt', {
|
||||
parentSessionId: address.parentSessionId,
|
||||
childSessionId: address.childSessionId,
|
||||
reason: 'corrupt',
|
||||
})
|
||||
}
|
||||
if (descriptor === undefined) {
|
||||
reject('subagent-catalog-diagnostic', 'subagent descriptor is unavailable', {
|
||||
parentSessionId: address.parentSessionId,
|
||||
childSessionId: address.childSessionId,
|
||||
reason: 'unsupported',
|
||||
})
|
||||
}
|
||||
if (descriptor.mode !== address.mode) {
|
||||
reject('subagent-unauthorized', 'subagent mode does not match the supplied address', {
|
||||
childSessionId: address.childSessionId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function rejectNotFound(address: SessionAddress): never {
|
||||
if (address.kind === 'session') {
|
||||
reject('session-not-found', `session "${address.sessionId}" not found`, { sessionId: address.sessionId })
|
||||
}
|
||||
reject('subagent-not-found', 'subagent is unavailable', {
|
||||
parentSessionId: address.parentSessionId,
|
||||
childSessionId: address.childSessionId,
|
||||
})
|
||||
}
|
||||
|
||||
function reject(code: string, message: string, details: object): never {
|
||||
throw new TypertRemoteFailure({ code, message, details })
|
||||
}
|
||||
|
||||
function sourceEvents(source: SessionSource): readonly SessionEvent[] {
|
||||
return source.kind === 'attached' ? source.session.events : source.events
|
||||
}
|
||||
|
||||
function paginate(
|
||||
events: readonly SessionEvent[],
|
||||
beforeSeq: number | undefined,
|
||||
maxMessages: number,
|
||||
): { readonly events: SessionEvent[]; readonly hasMore: boolean } {
|
||||
const window = beforeSeq === undefined ? [...events] : events.filter(event => event.seq < beforeSeq)
|
||||
let count = 0
|
||||
let cut = 0
|
||||
for (let index = window.length - 1; index >= 0; index--) {
|
||||
const event = window[index] as SessionEvent
|
||||
if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue
|
||||
count++
|
||||
const sources = (event as { readonly sourceEventSeqs?: readonly number[] }).sourceEventSeqs
|
||||
let groupStart = event.seq
|
||||
if (sources !== undefined) {
|
||||
for (const source of sources) groupStart = Math.min(groupStart, source)
|
||||
}
|
||||
if (count >= maxMessages) {
|
||||
cut = groupStart
|
||||
break
|
||||
}
|
||||
}
|
||||
return { events: window.filter(event => event.seq >= cut), hasMore: cut > 0 }
|
||||
}
|
||||
|
||||
function entryFor(event: SessionEvent): SessionEventEntry {
|
||||
return {
|
||||
// Session.append validates and freezes event data as JSON before publication.
|
||||
event: event as unknown as SessionWireEvent,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
/** Session Remote owner: cold reads, explicit Agent commands, and live control state. */
|
||||
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import {
|
||||
ApiSessionAgentController,
|
||||
inspectApiSession,
|
||||
type ApiSessionAgentResult,
|
||||
} from './agent.ts'
|
||||
import { SessionCommandController } from './commands.ts'
|
||||
import { SessionControlController } from './control.ts'
|
||||
import { SessionHistoryController } from './history.ts'
|
||||
import { ApiSessionList, DEFAULT_COLD_BLANK_PROBE_MAX_BYTES } from './list.ts'
|
||||
import type {
|
||||
SessionAttachmentRequest,
|
||||
SessionAttachmentValue,
|
||||
SessionCancelRequest,
|
||||
SessionCancelValue,
|
||||
SessionControlFrame,
|
||||
SessionCreateRequest,
|
||||
SessionCreateValue,
|
||||
SessionFollowFrame,
|
||||
SessionFollowRequest,
|
||||
SessionForkRequest,
|
||||
SessionForkValue,
|
||||
SessionListRequest,
|
||||
SessionListValue,
|
||||
SessionModels,
|
||||
SessionModelsRequest,
|
||||
SessionPage,
|
||||
SessionPageRequest,
|
||||
SessionPromptRequest,
|
||||
SessionPromptValue,
|
||||
SessionRenameRequest,
|
||||
SessionRenameValue,
|
||||
SessionSearchRequest,
|
||||
SessionSearchValue,
|
||||
SessionSelectModelRequest,
|
||||
SessionSelectModelValue,
|
||||
SessionUpdateQueueRequest,
|
||||
SessionUpdateQueueValue,
|
||||
} from './types.ts'
|
||||
|
||||
export type * from './types.ts'
|
||||
export { ApiSessionNotFound } from './agent.ts'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** Host Session business API and Remote namespace owner. */
|
||||
sessionController: SessionController
|
||||
}
|
||||
}
|
||||
|
||||
/** Session Controller deployment policy. */
|
||||
export interface Config {
|
||||
/** Maximum cold Session artifact size read to determine blankness. */
|
||||
readonly coldBlankProbeMaxBytes?: number
|
||||
}
|
||||
|
||||
/** Host service backing the generated `ctx.remote.session` namespace. */
|
||||
export class SessionController extends TypertRemoteService {
|
||||
static inject = [
|
||||
'agentDefaultModel',
|
||||
'agents',
|
||||
'attachments',
|
||||
'llm',
|
||||
'sessions',
|
||||
'sessionQuery',
|
||||
'typert',
|
||||
'workspaceRegistry',
|
||||
]
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
coldBlankProbeMaxBytes: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_BYTES),
|
||||
})
|
||||
|
||||
private readonly agents: ApiSessionAgentController
|
||||
private readonly commands: SessionCommandController
|
||||
private readonly controlState: SessionControlController
|
||||
private readonly history: SessionHistoryController
|
||||
private readonly listState: ApiSessionList
|
||||
|
||||
/**
|
||||
* @param ctx - Host context containing the Session capability assembly.
|
||||
* @param config - cold-list read policy.
|
||||
*/
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx, 'sessionController', { namespace: 'session' })
|
||||
this.agents = new ApiSessionAgentController(ctx)
|
||||
this.commands = new SessionCommandController(ctx, this.agents, process.cwd())
|
||||
this.controlState = new SessionControlController(ctx)
|
||||
this.history = new SessionHistoryController(ctx)
|
||||
this.listState = new ApiSessionList(
|
||||
ctx,
|
||||
config.coldBlankProbeMaxBytes ?? DEFAULT_COLD_BLANK_PROBE_MAX_BYTES,
|
||||
)
|
||||
|
||||
ctx.on('session/created', (session) => {
|
||||
ctx.emit('api-session/added', this.listState.summaryFor(session))
|
||||
})
|
||||
ctx.on('session/disposed', (session) => {
|
||||
ctx.emit('api-session/removed', session.id)
|
||||
})
|
||||
ctx.on('agent/status', ({ agent, status }) => {
|
||||
ctx.emit('api-session/status', agent.id, status === 'running')
|
||||
})
|
||||
ctx.on('agent/error', ({ agent, error }) => {
|
||||
ctx.emit('api-session/error', agent.id, errorChain(error))
|
||||
})
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (event.type !== 'user/message' || event.data.source.kind !== 'user') return
|
||||
ctx.emit('api-session/activity', session.id, event.time)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve or resume one ordinary Session for another Host API domain.
|
||||
* @param sessionId - Session identity whose Agent owns the operation.
|
||||
* @returns the live Agent or the stable Session-domain failure.
|
||||
*/
|
||||
resolveAgent(sessionId: SessionId): Promise<ApiSessionAgentResult> {
|
||||
return this.agents.resolveAgent(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect one attached or persisted Session without activating its Agent.
|
||||
* @param sessionId - durable Session identity.
|
||||
* @param signal - optional caller cancellation for persistence reads.
|
||||
* @returns the current attached state or persisted header and event prefix.
|
||||
*/
|
||||
inspect(
|
||||
sessionId: SessionId,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
const attached = this.ctx.sessions.get(sessionId)
|
||||
if (attached !== undefined) {
|
||||
return Promise.resolve({ meta: attached.header, events: [...attached.events] })
|
||||
}
|
||||
return inspectApiSession(this.ctx, sessionId, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all visible Session rows without resuming an Agent.
|
||||
* @param _request - reserved empty list request.
|
||||
* @param signal - cancellation for persistence reads.
|
||||
* @returns visible Session summaries ordered by activity.
|
||||
*/
|
||||
@Remote('list')
|
||||
async list(_request: SessionListRequest, signal: AbortSignal): Promise<SessionListValue> {
|
||||
return { items: await this.listState.list(signal) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Search visible Session content without resuming an Agent.
|
||||
* @param request - literal message-content query.
|
||||
* @param signal - cancellation for list and search reads.
|
||||
* @returns authorized bounded Session search results.
|
||||
*/
|
||||
@Remote('search')
|
||||
search(request: SessionSearchRequest, signal: AbortSignal): Promise<SessionSearchValue> {
|
||||
return this.listState.search(request.query, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or idempotently adopt one ordinary Session.
|
||||
* @param request - requested identity, location, and Agent preset.
|
||||
* @returns the Session identity and resolved preset when configured.
|
||||
*/
|
||||
@Remote('create')
|
||||
create(request: SessionCreateRequest): Promise<SessionCreateValue> {
|
||||
return this.commands.create(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read model choices after explicitly resuming the addressed Session.
|
||||
* @param request - Session whose model state is requested.
|
||||
* @returns the current selection and available model groups.
|
||||
*/
|
||||
@Remote('models')
|
||||
models(request: SessionModelsRequest): Promise<SessionModels> {
|
||||
return this.commands.models(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Select one Session-local model after explicitly resuming the Session.
|
||||
* @param request - Session identity and requested model selection.
|
||||
* @returns the normalized selection installed for the Session.
|
||||
*/
|
||||
@Remote('selectModel')
|
||||
selectModel(request: SessionSelectModelRequest): Promise<SessionSelectModelValue> {
|
||||
return this.commands.selectModel(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename one Session after explicitly resuming it.
|
||||
* @param request - Session identity and proposed title.
|
||||
* @returns the accepted title and durable event sequence.
|
||||
*/
|
||||
@Remote('rename')
|
||||
rename(request: SessionRenameRequest): Promise<SessionRenameValue> {
|
||||
return this.commands.rename(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fork one cold-readable completed-turn prefix into a new Session.
|
||||
* @param request - source Session and optional event anchor.
|
||||
* @returns the new Session identity.
|
||||
*/
|
||||
@Remote('fork')
|
||||
fork(request: SessionForkRequest): Promise<SessionForkValue> {
|
||||
return this.commands.fork(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Admit one prompt after explicitly resuming its Session.
|
||||
* @param request - Session identity, prompt content, source metadata, and delivery mode.
|
||||
* @param signal - caller cancellation before prompt admission begins.
|
||||
* @returns acknowledgement that the Agent accepted the prompt.
|
||||
*/
|
||||
@Remote('prompt')
|
||||
prompt(request: SessionPromptRequest, signal: AbortSignal): Promise<SessionPromptValue> {
|
||||
signal.throwIfAborted()
|
||||
return this.commands.prompt(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one image proven reachable from the addressed Session log.
|
||||
* @param request - Session and attachment identities used for authorization.
|
||||
* @returns the durable attachment reference and base64-encoded bytes.
|
||||
*/
|
||||
@Remote('attachment')
|
||||
attachment(request: SessionAttachmentRequest): Promise<SessionAttachmentValue> {
|
||||
return this.commands.attachment(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutate one still-pending queue occurrence on a live Agent.
|
||||
* @param request - Session, queue item, and requested mutation.
|
||||
* @returns acknowledgement that the queue mutation was applied.
|
||||
*/
|
||||
@Remote('updateQueue')
|
||||
updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue {
|
||||
return this.commands.updateQueue(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel one active Agent turn without dropping its pending inbox.
|
||||
* @param request - Session whose active Agent turn is cancelled.
|
||||
* @returns acknowledgement that cancellation was requested.
|
||||
*/
|
||||
@Remote('cancel')
|
||||
cancel(request: SessionCancelRequest): SessionCancelValue {
|
||||
return this.commands.cancel(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one cold-safe, message-aligned Session history page.
|
||||
* @param request - durable address, backward cursor, and page budget.
|
||||
* @param signal - cancellation for persistence reads.
|
||||
* @returns one chronological page and optional latest projections.
|
||||
*/
|
||||
@Remote('page')
|
||||
page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage> {
|
||||
return this.history.page(request, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow one Session log from its opening or resume cursor.
|
||||
* @param request - durable address and last committed sequence already held by the caller.
|
||||
* @param signal - cancellation owned by the Remote stream carrier.
|
||||
* @returns an opened cursor followed by gap-free event frames.
|
||||
*/
|
||||
@Remote({ mode: 'stream' })
|
||||
follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame> {
|
||||
return this.history.follow(request, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a complete live-control baseline followed by replacement frames.
|
||||
* @param signal - cancellation owned by the Remote stream carrier.
|
||||
* @returns one complete baseline followed by live replacement frames.
|
||||
*/
|
||||
@Remote({ mode: 'stream' })
|
||||
control(signal: AbortSignal): AsyncIterable<SessionControlFrame> {
|
||||
return this.controlState.control(signal)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { buildModelCatalog } from './catalog.ts'
|
||||
export default SessionController
|
||||
@@ -0,0 +1,20 @@
|
||||
/** Package-owned invariant companion. @module @deepseek-ai/dsh-api-session-controller/invariant */
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-api-session-controller'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'api-session-controller-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** No runtime invariant: every page and frame is checked against the addressed durable Session. */
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/** Register this package's invariant companion. */
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,408 @@
|
||||
/** Cold-safe Session list and search projection. */
|
||||
|
||||
import { stat } from 'node:fs/promises'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { resolveSessionPreset } from '@deepseek-ai/dsh-agent-presets'
|
||||
import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import type {} from '@deepseek-ai/dsh-session-projection'
|
||||
import type {} from '@deepseek-ai/dsh-session-projection-cache'
|
||||
import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
|
||||
import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
SESSION_SEARCH_RESULT_LIMIT,
|
||||
SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
|
||||
} from './types.ts'
|
||||
import type {
|
||||
SessionListMetadata, SessionProjectionsBlock, SessionProjectionValues, SessionSearchItem,
|
||||
SessionSearchValue, SessionSummary,
|
||||
} from './types.ts'
|
||||
|
||||
/** Default maximum artifact size eligible for one cold blankness read. */
|
||||
export const DEFAULT_COLD_BLANK_PROBE_MAX_BYTES = 1024
|
||||
|
||||
const COLD_SUMMARY_BATCH_SIZE = 16
|
||||
const SEARCH_PROVIDER_CALL_LIMIT = 100
|
||||
const SESSION_SEARCH_QUERY_MAX_CHARS = 500
|
||||
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
|
||||
|
||||
const sessionListMetadataSchema: z.ZodType<SessionListMetadata> = z.object({
|
||||
blank: z.boolean(),
|
||||
lastPromptAt: z.number().nullable(),
|
||||
})
|
||||
|
||||
const imageLimitsSchema = z.object({
|
||||
maxImageBytes: z.number().int().positive(),
|
||||
maxImagesPerMessage: z.number().int().positive(),
|
||||
maxMessageImageBytes: z.number().int().positive(),
|
||||
maxImagePixels: z.number().int().positive(),
|
||||
maxImageDimension: z.number().int().positive(),
|
||||
mediaTypes: z.array(z.string()),
|
||||
}) as unknown as z.ZodType<ImageAttachmentLimits>
|
||||
|
||||
/**
|
||||
* Advance the Session-list metadata projection by one committed event.
|
||||
* @param state - metadata before the event.
|
||||
* @param event - next committed Session event.
|
||||
* @returns the original or advanced metadata value.
|
||||
*/
|
||||
export function applySessionListMetadata(
|
||||
state: SessionListMetadata,
|
||||
event: SessionEvent,
|
||||
): SessionListMetadata {
|
||||
const blank = state.blank && event.type !== 'turn/start'
|
||||
const lastPromptAt = event.type === 'user/message' && event.data.source.kind === 'user'
|
||||
? event.time
|
||||
: state.lastPromptAt
|
||||
return blank === state.blank && lastPromptAt === state.lastPromptAt
|
||||
? state
|
||||
: { blank, lastPromptAt }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold exact list metadata for an attached Session.
|
||||
* @param events - complete attached Session event log.
|
||||
* @returns metadata derived from the event prefix.
|
||||
*/
|
||||
export function sessionListMetadata(events: readonly SessionEvent[]): SessionListMetadata {
|
||||
let state: SessionListMetadata = { blank: true, lastPromptAt: null }
|
||||
for (const event of events) state = applySessionListMetadata(state, event)
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the longest prefix containing at most `maximum` Unicode code points.
|
||||
* @param value - source text.
|
||||
* @param maximum - maximum number of Unicode code points.
|
||||
* @returns the source text or its longest allowed prefix.
|
||||
*/
|
||||
export function truncateUnicodeCodePoints(value: string, maximum: number): string {
|
||||
let count = 0
|
||||
let end = 0
|
||||
for (const codePoint of value) {
|
||||
if (count === maximum) return value.slice(0, end)
|
||||
count++
|
||||
end += codePoint.length
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/** Owns list projection registration, cold summaries, and authorized search. */
|
||||
export class ApiSessionList {
|
||||
/**
|
||||
* @param ctx - Host context carrying Session, persistence, and projection services.
|
||||
* @param coldBlankProbeMaxBytes - maximum physical artifact size read to verify cold blankness.
|
||||
*/
|
||||
constructor(
|
||||
private readonly ctx: Context,
|
||||
private readonly coldBlankProbeMaxBytes: number,
|
||||
) {
|
||||
ctx.inject(['sessionProjections'], (projectionCtx) => {
|
||||
projectionCtx.sessionProjections.register<'sessionListMetadata', SessionListMetadata>({
|
||||
key: 'sessionListMetadata',
|
||||
stateSchema: sessionListMetadataSchema,
|
||||
init: () => ({ blank: true, lastPromptAt: null }),
|
||||
apply: applySessionListMetadata,
|
||||
wire: { viewSchema: sessionListMetadataSchema, view: state => state },
|
||||
stateVersion: 1,
|
||||
})
|
||||
})
|
||||
ctx.inject(['sessionProjections', 'attachments'], (projectionCtx) => {
|
||||
projectionCtx.sessionProjections.register<'imageLimits', null>({
|
||||
key: 'imageLimits',
|
||||
stateSchema: z.null(),
|
||||
init: () => null,
|
||||
apply: state => state,
|
||||
wire: {
|
||||
viewSchema: imageLimitsSchema,
|
||||
view: () => projectionCtx.attachments.imageLimits,
|
||||
},
|
||||
stateVersion: 1,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one current attached-Session summary.
|
||||
* @param session - attached Session to summarize.
|
||||
* @returns current list metadata and available projections.
|
||||
*/
|
||||
summaryFor(session: Session): SessionSummary {
|
||||
const metadata = sessionListMetadata(session.events)
|
||||
const projections = this.projectionsFor(session.header, session)
|
||||
return {
|
||||
sessionId: session.id,
|
||||
updatedAt: updatedAt(session.header, metadata),
|
||||
running: this.ctx.agents.get(session.id)?.status === 'running',
|
||||
blank: metadata.blank,
|
||||
...listFields(session.header, session.events),
|
||||
...(projections === undefined ? {} : { projections }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every visible attached and persisted Session without activating an Agent.
|
||||
* @param signal - optional cancellation for persistence reads.
|
||||
* @returns visible Session summaries ordered by activity.
|
||||
*/
|
||||
async list(signal?: AbortSignal): Promise<SessionSummary[]> {
|
||||
signal?.throwIfAborted()
|
||||
const items = this.ctx.sessions.list().map(session => this.summaryFor(session))
|
||||
const attached = new Set(items.map(item => item.sessionId))
|
||||
const persistence = this.ctx.get('sessionPersistence')
|
||||
if (persistence !== undefined) {
|
||||
const cold = (await persistence.list(signal))
|
||||
.filter(meta => !attached.has(meta.id) && meta.cwd !== undefined)
|
||||
signal?.throwIfAborted()
|
||||
for (let offset = 0; offset < cold.length; offset += COLD_SUMMARY_BATCH_SIZE) {
|
||||
const settled = await Promise.allSettled(cold.slice(offset, offset + COLD_SUMMARY_BATCH_SIZE)
|
||||
.map(async (meta) => {
|
||||
const projections = this.projectionsFor(meta, undefined)
|
||||
const summary = await summarizeCold(
|
||||
this.ctx,
|
||||
persistence,
|
||||
meta,
|
||||
projections?.values.sessionListMetadata,
|
||||
this.coldBlankProbeMaxBytes,
|
||||
signal,
|
||||
)
|
||||
const raced = this.ctx.sessions.get(meta.id)
|
||||
if (raced !== undefined) return this.summaryFor(raced)
|
||||
return { ...summary, ...(projections === undefined ? {} : { projections }) }
|
||||
}))
|
||||
const summaries = settled.map((result) => {
|
||||
if (result.status === 'rejected') throw result.reason
|
||||
return result.value
|
||||
})
|
||||
signal?.throwIfAborted()
|
||||
items.push(...summaries)
|
||||
}
|
||||
}
|
||||
items.sort((left, right) => right.updatedAt - left.updatedAt)
|
||||
return items
|
||||
}
|
||||
|
||||
/**
|
||||
* Search current visible message content without activating any matching Session.
|
||||
* @param query - literal message-content query.
|
||||
* @param signal - cancellation for list and search reads.
|
||||
* @returns authorized bounded Session search results.
|
||||
*/
|
||||
async search(query: string, signal: AbortSignal): Promise<SessionSearchValue> {
|
||||
const normalizedQuery = normalizeSearchQuery(query)
|
||||
signal.throwIfAborted()
|
||||
const provider = this.ctx.get('sessionQuery')
|
||||
if (provider === undefined) {
|
||||
reject(
|
||||
'internal',
|
||||
'session search is unavailable: this deployment does not mount @deepseek-ai/dsh-session-query',
|
||||
{},
|
||||
)
|
||||
}
|
||||
try {
|
||||
const visible = await this.list(signal)
|
||||
signal.throwIfAborted()
|
||||
if (visible.length === 0) return { items: [], hasMore: false }
|
||||
const visibleIds = new Set(visible.map(item => item.sessionId))
|
||||
const authorized: SessionSearchItem[] = []
|
||||
const acceptedIds = new Set<SessionId>()
|
||||
const seenCursors = new Set<SessionSearchCursor>()
|
||||
let cursor: SessionSearchCursor | undefined
|
||||
let providerCalls = 0
|
||||
let pageLimit = SESSION_SEARCH_RESULT_LIMIT
|
||||
while (authorized.length <= SESSION_SEARCH_RESULT_LIMIT) {
|
||||
signal.throwIfAborted()
|
||||
if (providerCalls >= SEARCH_PROVIDER_CALL_LIMIT) {
|
||||
throw new Error(`session search provider exceeded the ${SEARCH_PROVIDER_CALL_LIMIT}-call work budget`)
|
||||
}
|
||||
providerCalls++
|
||||
const requestedCursor = cursor
|
||||
const requestedLimit = pageLimit
|
||||
let page
|
||||
try {
|
||||
page = await provider.searchSessions({
|
||||
query: normalizedQuery,
|
||||
eventFilters: [
|
||||
{ kind: 'type', values: ['user/message', 'assistant/message'] },
|
||||
{ kind: 'surface', values: ['current'] },
|
||||
],
|
||||
limit: requestedLimit,
|
||||
...(requestedCursor === undefined ? {} : { cursor: requestedCursor }),
|
||||
}, { signal })
|
||||
signal.throwIfAborted()
|
||||
} catch (error: unknown) {
|
||||
signal.throwIfAborted()
|
||||
if (requestedCursor === undefined
|
||||
&& error instanceof SessionQueryError
|
||||
&& error.code === 'SESSION_QUERY_INVALID_LIMIT'
|
||||
&& requestedLimit > 1) {
|
||||
pageLimit = Math.max(1, Math.floor(requestedLimit / 2))
|
||||
continue
|
||||
}
|
||||
if (requestedCursor !== undefined
|
||||
&& error instanceof SessionQueryError
|
||||
&& error.code === 'SESSION_QUERY_STALE_CURSOR') {
|
||||
authorized.length = 0
|
||||
acceptedIds.clear()
|
||||
seenCursors.clear()
|
||||
cursor = undefined
|
||||
continue
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (page.items.length > requestedLimit) {
|
||||
throw new Error(`session search provider returned ${String(page.items.length)} items; maximum is ${String(requestedLimit)}`)
|
||||
}
|
||||
for (const hit of page.items) {
|
||||
if (authorized.length > SESSION_SEARCH_RESULT_LIMIT) continue
|
||||
if (!visibleIds.has(hit.header.id)
|
||||
|| hit.bestMatch.sessionId !== hit.header.id
|
||||
|| hit.bestMatch.surface !== 'current'
|
||||
|| !MESSAGE_TYPES.has(hit.bestMatch.type)
|
||||
|| acceptedIds.has(hit.header.id)) continue
|
||||
acceptedIds.add(hit.header.id)
|
||||
authorized.push({
|
||||
sessionId: hit.header.id,
|
||||
snippet: truncateUnicodeCodePoints(hit.bestMatch.snippet, SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS),
|
||||
})
|
||||
}
|
||||
if (page.nextCursor !== undefined) {
|
||||
if (seenCursors.has(page.nextCursor)) {
|
||||
throw new Error('session search provider repeated a continuation cursor')
|
||||
}
|
||||
seenCursors.add(page.nextCursor)
|
||||
}
|
||||
if (authorized.length > SESSION_SEARCH_RESULT_LIMIT || page.nextCursor === undefined) break
|
||||
cursor = page.nextCursor
|
||||
}
|
||||
return {
|
||||
items: authorized.slice(0, SESSION_SEARCH_RESULT_LIMIT),
|
||||
hasMore: authorized.length > SESSION_SEARCH_RESULT_LIMIT,
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
signal.throwIfAborted()
|
||||
if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED') {
|
||||
reject('cancelled', 'session search was aborted', {})
|
||||
}
|
||||
reject('internal', `session search failed: ${String(error)}`, {})
|
||||
}
|
||||
}
|
||||
|
||||
private projectionsFor(
|
||||
header: SessionHeader,
|
||||
session: Session | undefined,
|
||||
): SessionProjectionsBlock | undefined {
|
||||
try {
|
||||
const block = session === undefined
|
||||
? this.ctx.get('sessionProjectionCache')?.cachedSnapshot(header)
|
||||
: this.ctx.get('sessionProjections')?.snapshot(session)
|
||||
return block !== undefined && Object.keys(block.values).length > 0
|
||||
? {
|
||||
asOfSeq: block.asOfSeq,
|
||||
// Projection definitions validate whole JSON values before snapshot publication.
|
||||
values: block.values as SessionProjectionValues,
|
||||
}
|
||||
: undefined
|
||||
} catch (error) {
|
||||
this.ctx.logger.warn(
|
||||
`api-session.list: projection column for "${header.id}" failed; serving the row without it: ${String(error)}`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSearchQuery(query: string): string {
|
||||
const normalized = query.trim()
|
||||
if (normalized.length === 0) {
|
||||
reject('bad-request', 'session search query must not be empty', {})
|
||||
}
|
||||
if (normalized.length > SESSION_SEARCH_QUERY_MAX_CHARS) {
|
||||
reject(
|
||||
'bad-request',
|
||||
`session search query must contain at most ${SESSION_SEARCH_QUERY_MAX_CHARS} UTF-16 code units`,
|
||||
{},
|
||||
)
|
||||
}
|
||||
if (normalized.includes('\0')) {
|
||||
reject('bad-request', 'session search query must not contain NUL', {})
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function reject(code: string, message: string, details: object): never {
|
||||
throw new TypertRemoteFailure({ code, message, details })
|
||||
}
|
||||
|
||||
function updatedAt(header: SessionHeader, metadata: SessionListMetadata | undefined): number {
|
||||
return Math.max(header.createdAt, metadata?.lastPromptAt ?? 0)
|
||||
}
|
||||
|
||||
function listFields(header: SessionHeader, events: readonly SessionEvent[] = []): {
|
||||
readonly parentSessionId?: SessionId
|
||||
readonly origin?: 'subagent'
|
||||
readonly cwd?: string
|
||||
readonly agentPreset?: string
|
||||
} {
|
||||
const agentPreset = resolveSessionPreset({ header, events })
|
||||
return {
|
||||
...(header.parentSession === undefined ? {} : { parentSessionId: header.parentSession }),
|
||||
...(header.origin === undefined ? {} : { origin: header.origin }),
|
||||
...(header.cwd === undefined ? {} : { cwd: header.cwd }),
|
||||
...(agentPreset === undefined ? {} : { agentPreset }),
|
||||
}
|
||||
}
|
||||
|
||||
async function summarizeCold(
|
||||
ctx: Context,
|
||||
persistence: SessionPersistence,
|
||||
header: SessionHeader,
|
||||
metadata: SessionListMetadata | undefined,
|
||||
blankProbeMaxBytes: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SessionSummary> {
|
||||
const probed = metadata?.blank === false
|
||||
? undefined
|
||||
: await probeColdMetadata(ctx, persistence, header, blankProbeMaxBytes, signal)
|
||||
return {
|
||||
sessionId: header.id,
|
||||
updatedAt: updatedAt(header, probed ?? metadata),
|
||||
running: false,
|
||||
blank: metadata?.blank === false ? false : probed?.blank ?? false,
|
||||
...listFields(header),
|
||||
}
|
||||
}
|
||||
|
||||
async function probeColdMetadata(
|
||||
ctx: Context,
|
||||
persistence: SessionPersistence,
|
||||
header: SessionHeader,
|
||||
maxBytes: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SessionListMetadata | undefined> {
|
||||
if (maxBytes === 0) return undefined
|
||||
signal?.throwIfAborted()
|
||||
const location = persistence.locate(header)
|
||||
if (location === undefined) return undefined
|
||||
let size: number
|
||||
try {
|
||||
size = (await stat(location.path)).size
|
||||
} catch {
|
||||
signal?.throwIfAborted()
|
||||
return undefined
|
||||
}
|
||||
if (size > maxBytes) return undefined
|
||||
try {
|
||||
const { events } = await persistence.readFrom(header.id, 0, signal)
|
||||
signal?.throwIfAborted()
|
||||
return sessionListMetadata(events)
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted()
|
||||
ctx.logger.warn(
|
||||
`api-session.list: blank probe for "${header.id}" failed; serving it as visible: ${String(error)}`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/** Session Controller events forwarded unchanged through the Remote Event carrier. */
|
||||
export const SESSION_CONTROLLER_REMOTE_EVENTS = [
|
||||
'api-session/activity',
|
||||
'api-session/added',
|
||||
'api-session/error',
|
||||
'api-session/removed',
|
||||
'api-session/status',
|
||||
] as const
|
||||
|
||||
declare module '@deepseek-ai/dsh-typert-protocol' {
|
||||
interface TypertRemoteEventSelection extends
|
||||
Record<typeof SESSION_CONTROLLER_REMOTE_EVENTS[number], true> {}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
/** Browser-safe request, result, and lifecycle vocabulary for the Session Remote service. */
|
||||
|
||||
import type {
|
||||
AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType,
|
||||
} from '@deepseek-ai/dsh-attachment'
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { JsonValue, SessionId, SurfaceOp } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
|
||||
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
interface SessionProjectionStateMap {
|
||||
/** Host state persisted for cold Session list summaries. */
|
||||
sessionListMetadata: SessionListMetadata
|
||||
/** Host state for the boot-constant image-limit view. */
|
||||
imageLimits: null
|
||||
}
|
||||
interface SessionProjectionMap {
|
||||
/** Persisted facts used to summarize a Session without activating it. */
|
||||
sessionListMetadata: SessionListMetadata
|
||||
/** Image-intake limits enforced by the Session prompt endpoint. */
|
||||
imageLimits: ImageAttachmentLimits
|
||||
}
|
||||
}
|
||||
|
||||
/** Persisted hints used to summarize a cold Session. */
|
||||
export interface SessionListMetadata {
|
||||
/** Whether the folded prefix contains no turn. */
|
||||
readonly blank: boolean
|
||||
/** Latest human-authored prompt time in the folded prefix. */
|
||||
readonly lastPromptAt: number | null
|
||||
}
|
||||
|
||||
/** Projection values and the durable event position they represent. */
|
||||
export interface SessionProjectionsBlock {
|
||||
readonly asOfSeq: number
|
||||
/** Provider-validated values across the merge-extensible projection key space. */
|
||||
readonly values: SessionProjectionValues
|
||||
}
|
||||
|
||||
/** Typed known projections plus JSON-safe values contributed outside this compilation face. */
|
||||
export type SessionProjectionValues = Partial<SessionProjectionMap>
|
||||
& Readonly<Record<string, SessionProjectionValue>>
|
||||
|
||||
/** Browser-submitted prompt content; the Host promotes image bytes to durable references. */
|
||||
export type PromptContentPart =
|
||||
| { readonly type: 'text'; readonly text: string }
|
||||
| {
|
||||
readonly type: 'image'
|
||||
readonly mediaType: ImageMediaType
|
||||
readonly data: string
|
||||
readonly name?: string
|
||||
}
|
||||
|
||||
/** Complete model selection for one Session. */
|
||||
export interface ModelSelection {
|
||||
readonly provider: string
|
||||
readonly model: string
|
||||
readonly reasoningEffort?: string
|
||||
}
|
||||
|
||||
/** One adapter-owned reasoning effort for an exact model route. */
|
||||
export interface ModelReasoningEffort {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly description?: string
|
||||
}
|
||||
|
||||
/** Selectable reasoning metadata for one exact model route. */
|
||||
export interface ModelReasoning {
|
||||
readonly efforts: readonly ModelReasoningEffort[]
|
||||
readonly defaultEffort?: string
|
||||
}
|
||||
|
||||
/** One model displayed inside its provider group. */
|
||||
export interface ModelCatalogModel {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly description?: string
|
||||
readonly reasoning?: ModelReasoning
|
||||
}
|
||||
|
||||
/** One provider and its successfully loaded model catalog. */
|
||||
export interface ModelProviderGroup {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly models: readonly ModelCatalogModel[]
|
||||
}
|
||||
|
||||
/** One provider whose model catalog lookup failed. */
|
||||
export interface ModelCatalogFailure {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
/** Detached model-directory snapshot for one Session. */
|
||||
export interface SessionModels {
|
||||
readonly current: ModelSelection
|
||||
readonly routable: boolean
|
||||
readonly groups: readonly ModelProviderGroup[]
|
||||
readonly failures: readonly ModelCatalogFailure[]
|
||||
}
|
||||
|
||||
/** One client-requested mutation of a still-pending queue item. */
|
||||
export type QueueAction =
|
||||
| { readonly kind: 'edit'; readonly content: readonly ContentBlock[] }
|
||||
| { readonly kind: 'remove' }
|
||||
| { readonly kind: 'steer' }
|
||||
|
||||
/** One Session list entry. */
|
||||
export interface SessionSummary {
|
||||
readonly sessionId: SessionId
|
||||
readonly updatedAt: number
|
||||
readonly running: boolean
|
||||
readonly blank: boolean
|
||||
readonly parentSessionId?: SessionId
|
||||
readonly origin?: 'subagent'
|
||||
readonly cwd?: string
|
||||
readonly agentPreset?: string
|
||||
readonly projections?: SessionProjectionsBlock
|
||||
}
|
||||
|
||||
/** One session-content search result. */
|
||||
export interface SessionSearchItem {
|
||||
readonly sessionId: SessionId
|
||||
readonly snippet: string
|
||||
}
|
||||
|
||||
/** Maximum number of Sessions returned by one search. */
|
||||
export const SESSION_SEARCH_RESULT_LIMIT = 20
|
||||
|
||||
/** Maximum search snippet length in Unicode code points. */
|
||||
export const SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS = 240
|
||||
|
||||
/** Error details returned by Session Remote methods. */
|
||||
export interface SessionErrorDetailsMap {
|
||||
'bad-request': Record<never, never>
|
||||
cancelled: Record<never, never>
|
||||
'session-not-found': { readonly sessionId: SessionId }
|
||||
'model-unavailable': { readonly provider: string; readonly model: string }
|
||||
'session-conflict': {
|
||||
readonly sessionId: SessionId
|
||||
readonly requestedCwd: string
|
||||
readonly existingCwd?: string
|
||||
}
|
||||
'invalid-time-zone': { readonly value: string }
|
||||
'workspace-attach-failed': { readonly sessionId: SessionId; readonly workspaceId: string }
|
||||
'workspace-not-found': { readonly workspaceId: string }
|
||||
'agent-preset-conflict': {
|
||||
readonly sessionId: SessionId
|
||||
readonly requestedPreset: string
|
||||
readonly existingPreset?: string
|
||||
}
|
||||
'agent-preset-not-found': { readonly agentPreset: string; readonly available: readonly string[] }
|
||||
'agent-preset-invalid': { readonly agentPreset: string; readonly reason: string }
|
||||
'agent-busy': { readonly reason: string }
|
||||
'attachment-error': { readonly reason: string }
|
||||
'queue-item-not-found': { readonly itemId: MessageId }
|
||||
'steer-unavailable': { readonly itemId: MessageId }
|
||||
'title-invalid': { readonly sessionId: SessionId }
|
||||
'fork-unavailable': { readonly sessionId: SessionId }
|
||||
'subagent-not-found': {
|
||||
readonly parentSessionId: SessionId
|
||||
readonly childSessionId: SessionId
|
||||
}
|
||||
'subagent-catalog-diagnostic': {
|
||||
readonly parentSessionId: SessionId
|
||||
readonly childSessionId: SessionId
|
||||
readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
|
||||
}
|
||||
'subagent-unauthorized': { readonly childSessionId: SessionId }
|
||||
internal: Record<never, never>
|
||||
}
|
||||
|
||||
/** Session business failure returned without throwing a carrier error. */
|
||||
export type SessionError = {
|
||||
[Code in keyof SessionErrorDetailsMap]: {
|
||||
readonly code: Code
|
||||
readonly message: string
|
||||
readonly details: SessionErrorDetailsMap[Code]
|
||||
}
|
||||
}[keyof SessionErrorDetailsMap]
|
||||
|
||||
/** Session list request. */
|
||||
export interface SessionListRequest {
|
||||
readonly cursor?: string
|
||||
}
|
||||
|
||||
/** Session list response value. */
|
||||
export interface SessionListValue {
|
||||
readonly items: readonly SessionSummary[]
|
||||
}
|
||||
|
||||
/** Session search request. */
|
||||
export interface SessionSearchRequest {
|
||||
readonly query: string
|
||||
}
|
||||
|
||||
/** Session search response value. */
|
||||
export interface SessionSearchValue {
|
||||
readonly items: readonly SessionSearchItem[]
|
||||
readonly hasMore: boolean
|
||||
}
|
||||
|
||||
/** Session creation or explicit-id adoption request. */
|
||||
export interface SessionCreateRequest {
|
||||
readonly workspaceId?: WorkspaceId
|
||||
readonly cwd?: string
|
||||
readonly sessionId?: SessionId
|
||||
readonly agentPreset?: string
|
||||
}
|
||||
|
||||
/** Session creation response value. */
|
||||
export interface SessionCreateValue {
|
||||
readonly sessionId: SessionId
|
||||
readonly agentPreset?: string
|
||||
}
|
||||
|
||||
/** Model-directory request. */
|
||||
export interface SessionModelsRequest {
|
||||
readonly sessionId: SessionId
|
||||
}
|
||||
|
||||
/** Session model-selection request. */
|
||||
export interface SessionSelectModelRequest extends ModelSelection {
|
||||
readonly sessionId: SessionId
|
||||
}
|
||||
|
||||
/** Accepted model selection after Host resolution. */
|
||||
export interface SessionSelectModelValue {
|
||||
readonly selected: ModelSelection
|
||||
}
|
||||
|
||||
/** Session rename request. */
|
||||
export interface SessionRenameRequest {
|
||||
readonly sessionId: SessionId
|
||||
readonly title: string
|
||||
}
|
||||
|
||||
/** Normalized title and the durable event position that committed it. */
|
||||
export interface SessionRenameValue {
|
||||
readonly title: string
|
||||
readonly seq: number
|
||||
}
|
||||
|
||||
/** Session fork request. */
|
||||
export interface SessionForkRequest {
|
||||
readonly sessionId: SessionId
|
||||
readonly atSeq?: number
|
||||
}
|
||||
|
||||
/** Identity of a newly forked Session. */
|
||||
export interface SessionForkValue {
|
||||
readonly sessionId: SessionId
|
||||
}
|
||||
|
||||
/** Session prompt request. */
|
||||
export interface SessionPromptRequest {
|
||||
/** Client-minted identity persisted on the exact accepted user message. */
|
||||
readonly requestId: SessionRequestId
|
||||
readonly sessionId: SessionId
|
||||
readonly mode: 'queue' | 'steer'
|
||||
readonly content: readonly PromptContentPart[]
|
||||
readonly clientTimeZone?: string
|
||||
}
|
||||
|
||||
/** Receipt after one prompt enters the target Agent inbox. */
|
||||
export interface SessionPromptValue {
|
||||
readonly accepted: true
|
||||
}
|
||||
|
||||
/** Durable image read request. */
|
||||
export interface SessionAttachmentRequest {
|
||||
readonly sessionId: SessionId
|
||||
readonly attachmentId: AttachmentIdType
|
||||
}
|
||||
|
||||
/** Durable image read response value. */
|
||||
export interface SessionAttachmentValue {
|
||||
readonly attachment: ImageAttachmentRef
|
||||
readonly data: string
|
||||
}
|
||||
|
||||
/** Pending queue mutation request. */
|
||||
export interface SessionUpdateQueueRequest {
|
||||
readonly sessionId: SessionId
|
||||
readonly itemId: MessageId
|
||||
readonly action: QueueAction
|
||||
}
|
||||
|
||||
/** Receipt after one pending queue mutation commits. */
|
||||
export interface SessionUpdateQueueValue {
|
||||
readonly accepted: true
|
||||
}
|
||||
|
||||
/** Active-turn cancellation request. */
|
||||
export interface SessionCancelRequest {
|
||||
readonly sessionId: SessionId
|
||||
}
|
||||
|
||||
/** Receipt after cancellation is admitted to the live Agent. */
|
||||
export interface SessionCancelValue {
|
||||
readonly accepted: true
|
||||
}
|
||||
|
||||
/** Client-minted prompt identity used to reconcile optimistic and durable messages. */
|
||||
export type SessionRequestId = Branded<'session-request-id'>
|
||||
|
||||
declare module '@deepseek-ai/dsh-llm' {
|
||||
interface MessageSourceMap {
|
||||
/** Browser prompt correlation and optional Host-validated time zone. */
|
||||
'user-rpc': { kind: 'user'; rpcId: SessionRequestId; clientTimeZone?: string }
|
||||
}
|
||||
}
|
||||
|
||||
/** Durable identity selecting an ordinary Session or one direct subagent child. */
|
||||
export type SessionAddress =
|
||||
| { readonly kind: 'session'; readonly sessionId: SessionId }
|
||||
| {
|
||||
readonly kind: 'subagent'
|
||||
readonly parentSessionId: SessionId
|
||||
readonly childSessionId: SessionId
|
||||
readonly mode: 'one-shot' | 'continuable'
|
||||
}
|
||||
|
||||
/** One raw Session event in the Remote journal. */
|
||||
export interface SessionEventEntry {
|
||||
readonly event: SessionWireEvent
|
||||
}
|
||||
|
||||
/** Session event wire form; durable readers own recognition of merge-extensible event names. */
|
||||
export interface SessionWireEvent {
|
||||
readonly type: string
|
||||
readonly seq: number
|
||||
readonly time: number
|
||||
readonly data: JsonValue
|
||||
readonly ignorable?: true
|
||||
readonly sourceEventSeqs?: number[]
|
||||
readonly surfaceOp?: SurfaceOp
|
||||
}
|
||||
|
||||
/** One message-aligned backwards-history request. */
|
||||
export interface SessionPageRequest {
|
||||
readonly address: SessionAddress
|
||||
/** Inclusive log cut obtained from the corresponding follow opening frame. */
|
||||
readonly throughSeq: number
|
||||
readonly beforeSeq?: number
|
||||
readonly maxMessages?: number
|
||||
}
|
||||
|
||||
/** One live event request, optionally resuming after an already-applied event. */
|
||||
export interface SessionFollowRequest {
|
||||
readonly address: SessionAddress
|
||||
readonly afterSeq?: number
|
||||
}
|
||||
|
||||
/** One contiguous backwards page of a Session log. */
|
||||
export interface SessionPage {
|
||||
readonly events: readonly SessionEventEntry[]
|
||||
readonly hasMore: boolean
|
||||
readonly projections?: SessionProjectionsBlock
|
||||
}
|
||||
|
||||
/** Initial cursor followed by ordered events appended after that cursor. */
|
||||
export type SessionFollowFrame =
|
||||
| { readonly type: 'opened'; readonly cursor: number }
|
||||
| ({ readonly type: 'event' } & SessionEventEntry)
|
||||
|
||||
/** One pending inbox occurrence in the authoritative queue snapshot. */
|
||||
export interface SessionQueuedItem {
|
||||
readonly id: MessageId
|
||||
readonly placement: 'queued' | 'steering' | 'context'
|
||||
/** JSON-safe message fields consumed by pending-queue presentation. */
|
||||
readonly message: {
|
||||
readonly id: MessageId
|
||||
readonly content: readonly JsonValue[]
|
||||
}
|
||||
}
|
||||
|
||||
/** Browser-safe background-job row. */
|
||||
export interface SessionJob {
|
||||
readonly id: JobId
|
||||
readonly kind: string
|
||||
readonly label: string
|
||||
readonly status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
|
||||
readonly detail?: string
|
||||
readonly startedAt: number
|
||||
readonly finishedAt?: number
|
||||
}
|
||||
|
||||
/** Complete live control baseline emitted once per control stream generation. */
|
||||
export interface SessionControlBaseline {
|
||||
readonly queues: Readonly<Record<SessionId, readonly SessionQueuedItem[]>>
|
||||
readonly jobs: Readonly<Record<SessionId, readonly SessionJob[]>>
|
||||
readonly projections: Readonly<Record<SessionId, SessionProjectionsBlock>>
|
||||
}
|
||||
|
||||
/** One finished projection value and its durable watermark. */
|
||||
export interface SessionProjectionUpdate {
|
||||
readonly sessionId: SessionId
|
||||
readonly key: string
|
||||
readonly value: JsonValue
|
||||
readonly seq: number
|
||||
}
|
||||
|
||||
/** Host-wide live state stream. Each generation starts with exactly one baseline. */
|
||||
export type SessionControlFrame =
|
||||
| { readonly type: 'baseline'; readonly value: SessionControlBaseline }
|
||||
| { readonly type: 'queue'; readonly sessionId: SessionId; readonly items: readonly SessionQueuedItem[] }
|
||||
| { readonly type: 'jobs'; readonly sessionId: SessionId; readonly jobs: readonly SessionJob[] }
|
||||
| ({ readonly type: 'projection' } & SessionProjectionUpdate)
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
* A Session became visible to Session list consumers.
|
||||
* @mode emit
|
||||
* @param summary - initial list row for the Session.
|
||||
*/
|
||||
'api-session/added'(summary: SessionSummary): void
|
||||
/**
|
||||
* A Session left the live Host registry.
|
||||
* @mode emit
|
||||
* @param sessionId - removed Session identity.
|
||||
*/
|
||||
'api-session/removed'(sessionId: SessionId): void
|
||||
/**
|
||||
* One Agent changed running state.
|
||||
* @mode emit
|
||||
* @param sessionId - Agent and Session identity.
|
||||
* @param running - whether the Agent is running.
|
||||
*/
|
||||
'api-session/status'(sessionId: SessionId, running: boolean): void
|
||||
/**
|
||||
* One user-authored durable message advanced Session list activity.
|
||||
* @mode emit
|
||||
* @param sessionId - addressed Session identity.
|
||||
* @param updatedAt - durable message time used for list ordering.
|
||||
*/
|
||||
'api-session/activity'(sessionId: SessionId, updatedAt: number): void
|
||||
/**
|
||||
* One Agent failed outside a durable turn position.
|
||||
* @mode emit
|
||||
* @param sessionId - Agent and Session identity.
|
||||
* @param message - user-safe failure chain.
|
||||
*/
|
||||
'api-session/error'(sessionId: SessionId, message: string): void
|
||||
}
|
||||
}
|
||||
|
||||
/** JSON-compatible projection value accepted by list consumers. */
|
||||
export type SessionProjectionValue = JsonValue
|
||||
Reference in new issue
Block a user