Merge remote-tracking branch 'github/master' into xtr/session-format-migration

# Conflicts:
#	docs/config-catalog.i18n.yaml
#	docs/config-catalog.md
#	docs/config-catalog.zh.md
#	packages/session/session-persistence-jsonl/src/index.ts
#	packages/session/session-persistence/src/coordinator.ts
#	packages/session/session-persistence/src/index.ts
This commit is contained in:
_Kerman
2026-08-25 12:41:23 +08:00
2357 changed files with 46214 additions and 18025 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
README.md: cda9349e432472a0ed9fd623afef0b689ff72f73
README.zh.md: 2aaee8f968cf7373110e291c197adbeca21f490e
README.md: 7631e1623f90f9349eca78bc76d46505d13d2e0e
README.zh.md: 7a733b45b1cdbb17096d1e76bb25b54d3bdc0e06
@@ -4,6 +4,8 @@ English | [中文](README.zh.md)
`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `ctx.remote.session` namespace. It serves Session list, search, creation, model selection, rename, fork, prompt, attachment, queue, cancellation, message-aligned history, live log following, and Host-wide control state.
History pages and follow event frames carry only raw `SessionWireEvent` values. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data.
Each endpoint states its activation policy. List, search, attachment, history pages, and log following can inspect persistence without activating an Agent; queue mutation and cancellation require the corresponding live state; model, rename, and prompt commands may explicitly resume an ordinary Session. Create and fork are the only operations that create a new Agent. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces.
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events.
@@ -4,6 +4,8 @@
`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务和生成的 Client `ctx.remote.session` namespace。它提供 Session 列表、搜索、创建、模型选择、重命名、fork、prompt、附件、queue、取消、按消息对齐的历史、live 日志跟随和 Host 范围 control 状态。
历史页与 follow event frame 只携带原始 `SessionWireEvent`。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;controller 不解析 Tool definition、不运行 presenter,也不附加 UI 数据。
每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页和日志跟随可以在不激活 Agent 的情况下检查 persistencequeue 变更和取消要求对应 live 状态仍然存在;模型、重命名和 prompt 命令可以显式恢复普通 Session。只有 create 和 fork 会创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。
Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace``prepend``append` 变更,并通过 tail page 修复重连或 seq 缺口。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。
+1 -5
View File
@@ -96,7 +96,6 @@
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"@deepseek-ai/dsh-workspace": "workspace:^",
@@ -105,9 +104,7 @@
"peerDependenciesMeta": {
"@deepseek-ai/dsh-jobs": { "optional": true },
"@deepseek-ai/dsh-session-persistence": { "optional": true },
"@deepseek-ai/dsh-session-projection": { "optional": true },
"@deepseek-ai/dsh-session-projection-cache": { "optional": true },
"@deepseek-ai/dsh-tools": { "optional": true }
"@deepseek-ai/dsh-session-projection-cache": { "optional": true }
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
@@ -131,7 +128,6 @@
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"@deepseek-ai/dsh-util-crypto": "workspace:^",
+158 -45
View File
@@ -7,12 +7,13 @@ 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 {} from '@deepseek-ai/dsh-agent-presets'
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-persistence'
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol'
import type {} from '@deepseek-ai/dsh-typert-registry'
import type { SessionError } from './types.ts'
import type { ModelSelection, SessionError } from './types.ts'
/** Cold Session identity absent from persistence. */
export class ApiSessionNotFound extends Error {}
@@ -66,7 +67,10 @@ export type ApiSessionAgentResult =
| { readonly agent: Agent }
| { readonly error: ApiSessionAgentError }
type InstalledSelection = ModelSelectionRef & { current: AgentModelSelection }
type InstalledSelection = ModelSelectionRef & {
current: AgentModelSelection
consume(provider: string, model: string, reasoningEffort: string | undefined): boolean
}
/**
* Test whether generic Session routing must leave an identity to subagent routing.
@@ -112,19 +116,22 @@ export async function inspectApiSession(
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)')
try {
using observation = await ctx.sessionQuery.observeSession(sessionId, {
...(signal === undefined ? {} : { signal }),
projectionMode: 'none',
})
if (observation.header.cwd === undefined) {
throw new ApiSessionNotFound(`session "${sessionId}" not found`)
}
return { meta: observation.header, events: [...observation.events] }
} catch (error: unknown) {
if (error instanceof SessionQueryError
&& error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
throw new ApiSessionNotFound(`session "${sessionId}" not found`)
}
throw error
}
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. */
@@ -159,6 +166,22 @@ export class ApiSessionAgentController {
* @returns the live Agent or a stable Session-domain failure.
*/
async resolveAgent(sessionId: SessionId): Promise<ApiSessionAgentResult> {
return this.resolve(sessionId)
}
/**
* Resolve one ordinary Session from an already-retained exact observation.
* @param observation - Host-owned observation whose preparation stays pinned through setup.
* @returns the live Agent or a stable Session-domain failure.
*/
async resolveObservedAgent(observation: SessionObservation): Promise<ApiSessionAgentResult> {
return this.resolve(observation.header.id, observation)
}
private async resolve(
sessionId: SessionId,
observation?: SessionObservation,
): Promise<ApiSessionAgentResult> {
const live = this.liveAgent(sessionId)
if (live !== undefined) return live
const attached = this.ctx.sessions.get(sessionId)
@@ -168,7 +191,7 @@ export class ApiSessionAgentController {
let resume = this.resumes.get(sessionId)
if (resume === undefined) {
resume = this.resume(sessionId).finally(() => { this.resumes.delete(sessionId) })
resume = this.resume(sessionId, observation).finally(() => { this.resumes.delete(sessionId) })
this.resumes.set(sessionId, resume)
}
try {
@@ -240,7 +263,9 @@ export class ApiSessionAgentController {
if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) {
throw new ApiSessionSubagentOwnership(sessionId)
}
this.assertPresetUnchanged(sessionId, presetId, resolveSessionPreset(agent.session))
if (presetId !== undefined) {
this.assertPresetUnchanged(sessionId, presetId, this.presetForSession(agent.session))
}
if (agent.session.header.cwd !== cwd) {
throw new ApiSessionCwdConflict(sessionId, cwd, agent.session.header.cwd)
}
@@ -255,7 +280,13 @@ export class ApiSessionAgentController {
selectionFor(agent: Agent): InstalledSelection {
const installed = this.selections.get(agent)
if (installed !== undefined) return installed
let picked: AgentModelSelection | undefined
const projectionState = this.ctx.sessionProjections.stateOf(agent.session, 'modelSelection')
if (projectionState === undefined) {
throw new Error('api-session: required modelSelection projection is not registered')
}
let picked = projectionState.pending === null
? undefined
: agentModelSelection(projectionState.pending)
const defaultModel = this.ctx.agentDefaultModel
const selection: InstalledSelection = {
get current(): AgentModelSelection {
@@ -271,6 +302,13 @@ export class ApiSessionAgentController {
set current(next: AgentModelSelection) {
picked = next
},
consume(provider: string, model: string, reasoningEffort: string | undefined): boolean {
if (picked?.provider !== provider
|| picked.model !== model
|| picked.reasoningEffort !== reasoningEffort) return false
picked = undefined
return true
},
assembled: undefined,
}
installModelSelection(agent.ctx, selection)
@@ -278,6 +316,42 @@ export class ApiSessionAgentController {
return selection
}
/**
* Commit and cache one validated selection for the next prompt assembly.
* @param agent - live Agent that owns the selection.
* @param selection - validated selection to record and apply.
*/
selectForNextRequest(agent: Agent, selection: AgentModelSelection): void {
agent.session.append('model/selection', selection)
this.selectionFor(agent).current = selection
}
/**
* Let a matching durable request header retire the execution cache.
* @param agent - live Agent whose request was recorded.
* @param provider - provider route used by the request.
* @param model - provider-owned model used by the request.
* @param reasoningEffort - adapter-owned effort used by the request.
* @returns whether the pending selection was consumed.
*/
consumeSelection(
agent: Agent,
provider: string,
model: string,
reasoningEffort: string | undefined,
): boolean {
return this.selections.get(agent)?.consume(provider, model, reasoningEffort) ?? false
}
/**
* Read the current Agent preset from the Session projection.
* @param session - live Session whose projection state is available.
* @returns the current preset, or undefined when the capability is absent.
*/
presetForSession(session: Session): string | undefined {
return this.ctx.sessionProjections.stateOf(session, 'agentPreset') ?? undefined
}
/**
* Serialize image admission and model selection for one Agent.
* @param agent - live Agent that owns the serialization chain.
@@ -319,15 +393,31 @@ export class ApiSessionAgentController {
: { agent }
}
private async resume(sessionId: SessionId): Promise<Agent> {
const inspected = await inspectApiSession(this.ctx, sessionId)
if (hasApiSessionSubagentOwner(this.ctx, { header: inspected.meta }, undefined)) {
private async resume(sessionId: SessionId, supplied?: SessionObservation): Promise<Agent> {
if (supplied !== undefined) return this.resumeObserved(sessionId, supplied)
try {
using observation = await this.ctx.sessionQuery.observeSession(sessionId)
return await this.resumeObserved(sessionId, observation)
} catch (error: unknown) {
if (error instanceof SessionQueryError
&& error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
throw new ApiSessionNotFound(`session "${sessionId}" not found`)
}
throw error
}
}
private async resumeObserved(
sessionId: SessionId,
observation: SessionObservation,
): Promise<Agent> {
if (observation.header.id !== sessionId || observation.header.cwd === undefined) {
throw new ApiSessionNotFound(`session "${sessionId}" not found`)
}
if (hasApiSessionSubagentOwner(this.ctx, { header: observation.header }, undefined)) {
throw new ApiSessionSubagentOwnership(sessionId)
}
const composition = await this.composeAgent(resolveSessionPreset({
header: inspected.meta,
events: inspected.events,
}))
const composition = await this.composeAgent(this.presetForObservation(observation))
const published = this.ctx.sessions.get(sessionId)
const live = this.ctx.agents.get(sessionId)
if (published !== undefined && hasApiSessionSubagentOwner(this.ctx, published, live)) {
@@ -353,26 +443,27 @@ export class ApiSessionAgentController {
}
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 (checkPersistedIdentity) {
try {
using observation = await this.ctx.sessionQuery.observeSession(sessionId)
if (hasApiSessionSubagentOwner(this.ctx, { header: observation.header }, undefined)) {
throw new ApiSessionSubagentOwnership(sessionId)
}
if (observation.header.cwd !== cwd) {
throw new ApiSessionCwdConflict(sessionId, cwd, observation.header.cwd)
}
const storedPreset = this.presetForObservation(observation)
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
} catch (error: unknown) {
if (!(error instanceof SessionQueryError)
|| error.code !== 'SESSION_QUERY_SESSION_NOT_FOUND') throw error
}
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 {
@@ -403,6 +494,18 @@ export class ApiSessionAgentController {
this.selectionFor(agent)
}
/**
* Read the current Agent preset from an all-projections observation.
* @param observation - exact Session observation carrying its projection snapshot.
* @returns the current preset, or undefined when the capability is absent.
*/
presetForObservation(observation: SessionObservation): string | undefined {
if (observation.projections === undefined) {
throw new Error('api-session: Agent activation requires a projected Session observation')
}
return observation.projections.values.agentPreset ?? undefined
}
private assertPresetUnchanged(
sessionId: SessionId,
requested: string | undefined,
@@ -412,3 +515,13 @@ export class ApiSessionAgentController {
throw new ApiSessionPresetConflict(sessionId, requested, existing)
}
}
function agentModelSelection(selection: ModelSelection): AgentModelSelection {
return {
provider: selection.provider,
model: selection.model,
...(selection.reasoningEffort === undefined
? {}
: { reasoningEffort: ReasoningEffortId(selection.reasoningEffort) }),
}
}
+11 -7
View File
@@ -2,21 +2,23 @@
import type { Context } from '@deepseek-ai/cordis'
import type {
ModelCatalogFailure,
ModelProviderGroup,
ModelCatalog,
ModelReasoning,
ModelSelection,
} from './types.ts'
/**
* Build the browser model catalog without requiring a Session.
* @param ctx - Host context carrying the live LLM registry.
* @param defaultSelection - deployment default used before a Session selects a model.
* @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) => {
export async function buildModelCatalog(
ctx: Context,
defaultSelection: ModelSelection = ctx.agentDefaultModel.currentSelection(),
): Promise<ModelCatalog> {
const providers = ctx.llm.listProviders()
const catalog = await Promise.all(providers.map(async (provider) => {
try {
const models = await ctx.llm.listModels(provider.id)
const entries = await Promise.all(models.map(async (model) => {
@@ -56,6 +58,8 @@ export async function buildModelCatalog(ctx: Context): Promise<{
}
}))
return {
default: { ...defaultSelection },
routableProviders: providers.map(provider => provider.id),
groups: catalog.flatMap(item => item.kind === 'group' ? [item.group] : [])
.filter(group => group.models.length > 0),
failures: catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : []),
@@ -66,14 +66,6 @@ export interface ISessions {
*/
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
/**
@@ -29,7 +29,11 @@ export interface SessionSnapshot {
readonly sessionId: SessionId
readonly queue: readonly QueuedMessage[]
readonly running: boolean
readonly subagent: { readonly address: SubagentAddress; readonly parentAvailable: boolean } | null
readonly subagent: {
readonly address: SubagentAddress
/** Absent until the direct-parent catalog resolves. */
readonly parentAvailable?: boolean
} | null
readonly removed: boolean
readonly openState: OpenState
readonly openError: ClientFailure | null
@@ -25,8 +25,6 @@ export interface SessionListEntry {
/** 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). */
@@ -61,11 +61,19 @@ export interface SessionListSnapshot {
}
/** One parent-addressed durable catalog projected through the sessions snapshot. */
export interface SubagentCatalogSnapshot extends SubagentCatalog {
export type SubagentCatalogSnapshot = Omit<SubagentCatalog, 'parentAvailable'> & {
/** Absent until the first successful catalog read. */
readonly parentAvailable?: boolean
state: 'loading' | 'ready' | 'error'
error: ClientFailure | null
}
function catalogAvailability(parentAvailable: boolean | undefined): {
readonly parentAvailable?: boolean
} {
return parentAvailable === undefined ? {} : { parentAvailable }
}
interface CatalogInflight {
readonly promise: Promise<void>
readonly expandableRows: Set<SessionId>
@@ -166,8 +174,8 @@ export class SessionManager {
this.sessions.get(sessionId)?.configureSubagent(
address,
address === undefined
? false
: this.catalogs.get(address.parentSessionId)?.parentAvailable ?? false,
? undefined
: this.catalogs.get(address.parentSessionId)?.parentAvailable,
)
this.selected = sessionId
// Looking at the session consumes its completion reminder (dot clears).
@@ -187,7 +195,7 @@ export class SessionManager {
throw new Error(`sessions.selectSubagent: ${address.childSessionId} is not a healthy catalog child`)
}
this.addresses.set(address.childSessionId, address)
this.sessions.get(address.childSessionId)?.configureSubagent(address, catalog?.parentAvailable ?? false)
this.sessions.get(address.childSessionId)?.configureSubagent(address, catalog?.parentAvailable)
this.selected = address.childSessionId
this.completedNotifications.delete(address.childSessionId)
void this.refreshSubagents(address.childSessionId)
@@ -310,10 +318,13 @@ export class SessionManager {
private createSession(sessionId: SessionId): Session {
const address = this.addresses.get(sessionId)
const parentAvailable = address === undefined
? undefined
: this.catalogs.get(address.parentSessionId)?.parentAvailable
return new Session(sessionId, this.api, this.remote, {
...(address === undefined ? {} : {
address,
parentAvailable: this.catalogs.get(address.parentSessionId)?.parentAvailable ?? false,
...catalogAvailability(parentAvailable),
}),
// The sender's local first-send flip mirrors into the list row so the
// session surfaces (lists filter on blank) before any host frame lands.
@@ -349,7 +360,9 @@ export class SessionManager {
const activityRows = new Map<SessionId, 'running' | 'inactive'>()
this.catalogs.set(parentSessionId, {
entries: previous?.entries ?? [],
parentAvailable: previous?.parentAvailable ?? false,
...(previous?.parentAvailable === undefined
? {}
: { parentAvailable: previous.parentAvailable }),
state: 'loading',
error: null,
})
@@ -376,8 +389,10 @@ export class SessionManager {
entries: this.withCatalogMutations(
previous?.entries ?? [], expandableRows, activityRows,
),
parentAvailable: this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
?? previous?.parentAvailable ?? false,
...catalogAvailability(
this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
?? previous?.parentAvailable,
),
state: 'error',
error: result.error,
})
@@ -388,8 +403,10 @@ export class SessionManager {
entries: this.withCatalogMutations(
previous?.entries ?? [], expandableRows, activityRows,
),
parentAvailable: this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
?? previous?.parentAvailable ?? false,
...catalogAvailability(
this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
?? previous?.parentAvailable,
),
state: 'error',
error: folded.ok ? null : folded.error,
})
@@ -555,7 +572,6 @@ export class SessionManager {
this.recordMutation({ kind: 'upsert', summary: {
sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true,
...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
...(result.value.agentPreset !== undefined ? { agentPreset: result.value.agentPreset } : {}),
} })
} else {
const publishedSessionId = workspaceAttachSessionId(result.error)
@@ -621,17 +637,6 @@ export class SessionManager {
this.recordMutation({ kind: 'upsert', summary })
}
/**
* Record a host-confirmed composition switch (see ISessions.noteAgentPreset).
* @param sessionId - the switched session.
* @param agentPreset - the preset id the host confirmed.
*/
noteAgentPreset(sessionId: SessionId, agentPreset: string): void {
this.recordMutation({ kind: 'upsert', summary: {
sessionId, updatedAt: Date.now(), running: false, blank: true, agentPreset,
} })
}
/** Apply immediately and retain for replay when a list response is in flight. */
private recordMutation(mutation: SessionListMutation): void {
this.listMutations?.push(mutation)
@@ -932,7 +937,7 @@ export class SessionManager {
const prev = this.entryCache.get(entry.sessionId)
if (
prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running
&& prev.blank === entry.blank && prev.agentPreset === entry.agentPreset
&& prev.blank === entry.blank
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
&& prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth
&& prev.projectionValues === entry.projectionValues
@@ -980,15 +985,10 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi
? { parentSessionId: mutation.summary.parentSessionId } : {}),
...(existing.origin === undefined && mutation.summary.origin !== undefined
? { origin: mutation.summary.origin } : {}),
// Newest wins, not fill-only: a blank-session preset switch replaces
// the creation-time value, and every producer of this field (the
// create echo, the select echo, a list row) reports the CURRENT one.
...(mutation.summary.agentPreset !== undefined
? { agentPreset: mutation.summary.agentPreset } : {}),
}
if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId
&& filled.origin === existing.origin && filled.blank === existing.blank
&& filled.agentPreset === existing.agentPreset) return [...summaries]
) return [...summaries]
return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary)
}
case 'remove':
@@ -2,8 +2,8 @@
* 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,
* whole values per key — `key → { value, seq }` — seeded by a follow opening
* baseline 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).
@@ -40,8 +40,7 @@ export type UseProjection = {
}
/**
* Tail-page projections baseline — structurally identical to Session
* Controller's `SessionProjectionsBlock`, restated here so the
* Follow-opening projection baseline, restated here so the
* React-free store depends only on the type table, not the wire package's
* response vocabulary.
*/
@@ -45,12 +45,6 @@ export interface SessionSummary {
/** 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'
@@ -317,10 +311,6 @@ export class ClientSessions implements ISessions {
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).
@@ -611,7 +601,6 @@ export class ClientSessions implements ISessions {
...(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) {
@@ -22,7 +22,6 @@ import type {
SessionQueuedItem,
SessionRequestId,
SessionError,
SessionToolView,
} from '../../types.ts'
import type { ClientFailure, ClientResult } from '../contract/result.ts'
import { transportResult } from '../contract/result.ts'
@@ -46,7 +45,7 @@ export const PAGE_MESSAGES = 50
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. */
/** Whether the exact direct parent Agent was live at the latest catalog read; absent before that read. */
parentAvailable?: boolean
/**
* First ACCEPTED prompt on a blank session (fires at most once, on the
@@ -74,9 +73,6 @@ export interface SessionOptions {
export class Session implements SessionFace {
// ---- Window and derived state (all private; the snapshot is the only read API) ----
private eventWindow: SessionEvent[] = []
/** Wire views aligned with `eventWindow` by index (envelope annotations; undefined = no view).
* Kept parallel so `eventWindow` remains the raw log slice (model-visible ⟺ logged). */
private views: (SessionToolView | undefined)[] = []
private baseSeq = 0
private hasMore = false
private openState: OpenState = 'cold'
@@ -90,7 +86,7 @@ export class Session implements SessionFace {
private readonly queueMirror = new SessionQueueMirror()
private running = false
private address: SubagentAddress | undefined
private parentAvailable = false
private parentAvailable: boolean | undefined
/**
* Sticky send marker, private input of the composerPhase derivation: set
* synchronously before prompt()'s first await, never reset — the blank →
@@ -148,7 +144,7 @@ export class Session implements SessionFace {
) {
this.projections = options.projections ?? new ProjectionValueStore()
this.address = options.address
this.parentAvailable = options.parentAvailable ?? false
this.parentAvailable = options.parentAvailable
this.notifier = new Notifier(() => {
this.snapshotCache = this.buildSnapshot()
})
@@ -405,7 +401,6 @@ export class Session implements SessionFace {
this.openState = 'cold'
this.openError = null
this.eventWindow = []
this.views = []
this.baseSeq = 0
this.notifier.markDirty()
await this.open()
@@ -472,9 +467,9 @@ export class Session implements SessionFace {
* 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.
* @param parentAvailable - latest exact-parent availability hint, or undefined before a catalog read.
*/
configureSubagent(address: SubagentAddress | undefined, parentAvailable = false): void {
configureSubagent(address: SubagentAddress | undefined, parentAvailable?: boolean): void {
const same = this.address?.parentSessionId === address?.parentSessionId
&& this.address?.childSessionId === address?.childSessionId
&& this.address?.mode === address?.mode
@@ -582,7 +577,6 @@ export class Session implements SessionFace {
/** 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.views = entries.map(entry => entry.view)
this.baseSeq = this.eventWindow[0]?.seq ?? 0
this.hasMore = hasMore
if (this.eventWindow.some(event => event.type === 'turn/start')) this.firstPromptPendingTurn = false
@@ -594,7 +588,6 @@ export class Session implements SessionFace {
/** 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.views = [...entries.map(entry => entry.view), ...this.views]
this.baseSeq = this.eventWindow[0]?.seq ?? 0
this.hasMore = hasMore
this.eventSource.prepend(entries, hasMore)
@@ -604,7 +597,6 @@ export class Session implements SessionFace {
private appendLive(entry: SessionEventEntry): boolean {
const event = entry.event as SessionEvent
this.eventWindow.push(event)
this.views.push(entry.view)
const awaitingFirstTurn = this.firstPromptPendingTurn
if (event.type === 'turn/start') this.firstPromptPendingTurn = false
const queueChanged = this.queueMirror.acceptDurable(event)
@@ -631,7 +623,10 @@ export class Session implements SessionFace {
running: this.running,
subagent: this.address === undefined
? null
: { address: this.address, parentAvailable: this.parentAvailable },
: {
address: this.address,
...(this.parentAvailable === undefined ? {} : { parentAvailable: this.parentAvailable }),
},
removed: this.removed,
openState: this.openState,
openError: this.openError,
@@ -17,6 +17,7 @@ import type {
SessionEventEntry,
SessionPage,
SessionPageRequest,
SessionProjectionBaseline,
} from '../types.ts'
export {
@@ -30,8 +31,13 @@ export type ClientSessionPageRequest = Omit<SessionPageRequest, 'address' | 'thr
/** Complete generated `ctx.remote.session` namespace. */
export type SessionRemote = ClientRemote['session']
/** Opening metadata carried only by a follow snapshot, never by loadOlder pages. */
interface SessionJournalPage extends SessionPage {
readonly projections?: SessionProjectionBaseline
}
/** One complete publication from the Session journal stream. */
export type SessionJournalChange = RemoteJournalChange<SessionPage, SessionEventEntry>
export type SessionJournalChange = RemoteJournalChange<SessionJournalPage, SessionEventEntry>
type SessionControlBaselineFrame = Extract<SessionControlFrame, { type: 'baseline' }>
type SessionControlDeltaFrame = Exclude<SessionControlFrame, SessionControlBaselineFrame>
@@ -93,7 +99,7 @@ export function createSessionControlStream(
/** Gateway-owned event journal bound to one ordinary or direct-subagent Session address. */
export class SessionEventStream extends RemoteJournalStream<
SessionPage,
SessionJournalPage,
SessionEventEntry,
number,
ClientSessionPageRequest
@@ -126,15 +132,23 @@ export class SessionEventStream extends RemoteJournalStream<
/** @inheritdoc */
protected override async * follow(
afterSeq: number | undefined,
request: ClientSessionPageRequest,
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
): AsyncIterable<RemoteJournalFrame<SessionEventEntry, number, SessionJournalPage>> {
for await (const frame of this.remote.session.follow({
address: this.address,
...(request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }),
}, signal)) {
if (frame.type === 'snapshot') {
yield {
type: 'opened',
cursor: frame.cursor,
page: {
events: frame.events,
hasMore: frame.hasMore,
projections: frame.projections,
},
}
continue
}
const { type: _type, ...entry } = frame
@@ -147,7 +161,7 @@ export class SessionEventStream extends RemoteJournalStream<
request: ClientSessionPageRequest,
throughSeq: number,
signal: AbortSignal,
): Promise<SessionPage> {
): Promise<SessionJournalPage> {
const result = await this.remote.session.page(
{ address: this.address, throughSeq, ...request },
signal,
+18 -35
View File
@@ -3,9 +3,7 @@
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 { PresetMountError, UnknownPresetError } from '@deepseek-ai/dsh-agent-presets'
import { AttachmentError, admitEncodedImages } from '@deepseek-ai/dsh-attachment'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import {
@@ -13,7 +11,8 @@ import {
} 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 type { SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session'
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
import type { Workspace } from '@deepseek-ai/dsh-workspace'
@@ -27,7 +26,6 @@ import {
hasApiSessionSubagentOwner,
inspectApiSession,
} from './agent.ts'
import { buildModelCatalog } from './catalog.ts'
import type {
SessionAttachmentRequest,
SessionAttachmentValue,
@@ -37,8 +35,6 @@ import type {
SessionCreateValue,
SessionForkRequest,
SessionForkValue,
SessionModels,
SessionModelsRequest,
SessionPromptRequest,
SessionPromptValue,
SessionRenameRequest,
@@ -110,27 +106,10 @@ export class SessionCommandController {
)
}
}
const agentPreset = resolveSessionPreset(adopted.session)
const agentPreset = this.agents.presetForSession(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.
@@ -154,7 +133,7 @@ export class SessionCommandController {
? {}
: { reasoningEffort: resolved.reasoningEffort }),
}
this.agents.selectionFor(agent).current = selected
this.agents.selectForNextRequest(agent, selected)
try {
await this.ctx.agentDefaultModel.saveSelection(selected)
} catch (error) {
@@ -210,12 +189,15 @@ export class SessionCommandController {
&& (!Number.isInteger(request.atSeq) || request.atSeq < 0)) {
reject('bad-request', 'atSeq must be a non-negative integer', {})
}
let source: SessionReadState
let observed: SessionObservation
try {
source = await this.readSessionState(request.sessionId)
observed = await this.ctx.sessionQuery.observeSession(request.sessionId)
} catch (error) {
if (error instanceof ApiSessionNotFound) {
reject('session-not-found', error.message, { sessionId: request.sessionId })
if (error instanceof SessionQueryError
&& error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
reject('session-not-found', `session "${request.sessionId}" not found`, {
sessionId: request.sessionId,
})
}
reject(
'internal',
@@ -223,6 +205,7 @@ export class SessionCommandController {
{},
)
}
using source = observed
const lastSeq = source.events.at(-1)?.seq ?? -1
const atSeq = request.atSeq
const anchoredBoundary = atSeq === undefined
@@ -245,7 +228,7 @@ export class SessionCommandController {
while (cut < source.events.length && source.events[cut]?.type !== 'turn/start') cut++
let workspace: Workspace | undefined
try {
workspace = await this.forkWorkspace(source)
workspace = await this.forkWorkspace(source.header)
} catch (error) {
reject(
'internal',
@@ -254,7 +237,7 @@ export class SessionCommandController {
)
}
const childId = SessionId(`session-${randomUUID()}`)
const composition = await this.agents.composeAgent(resolveSessionPreset(source))
const composition = await this.agents.composeAgent(this.agents.presetForObservation(source))
try {
const { provider, model } = this.ctx.agentDefaultModel.currentSelection()
await this.ctx.agents.create({
@@ -262,7 +245,7 @@ export class SessionCommandController {
seed: source.events.slice(0, cut),
meta: {
...(source.header.cwd === undefined ? {} : { cwd: source.header.cwd }),
parentSession: source.id,
parentSession: source.header.id,
seedLength: cut,
...(composition.agentPreset === undefined
? {}
@@ -507,10 +490,10 @@ export class SessionCommandController {
return { id: inspected.meta.id, header: inspected.meta, events: inspected.events }
}
private async forkWorkspace(source: Pick<Session, 'id' | 'header'>): Promise<Workspace | undefined> {
private async forkWorkspace(source: SessionHeader): 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
if (direct !== undefined || source.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))
@@ -10,7 +10,7 @@ import type {
SessionControlBaseline,
SessionControlFrame,
SessionJob,
SessionProjectionsBlock,
SessionProjectionBaseline,
SessionProjectionValues,
SessionQueuedItem,
} from './types.ts'
@@ -22,10 +22,6 @@ export class SessionControlController {
/** @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({
@@ -40,6 +36,10 @@ export class SessionControlController {
ctx.inject(['jobs'], (jobsCtx) => {
jobsCtx.jobs.onJobsChanged((owner) => { this.onJobsChanged(owner) })
})
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.effect(() => () => {
for (const stream of this.streams) stream.end()
this.streams.clear()
@@ -82,9 +82,9 @@ export class SessionControlController {
private projectionBaseline(
sessions: readonly Session[],
): Readonly<Record<SessionId, SessionProjectionsBlock>> {
): Readonly<Record<SessionId, SessionProjectionBaseline>> {
const registry = this.ctx.get('sessionProjections')
const blocks = Object.create(null) as Record<SessionId, SessionProjectionsBlock>
const blocks = Object.create(null) as Record<SessionId, SessionProjectionBaseline>
for (const session of sessions) {
const snapshot = registry?.snapshot(session)
blocks[session.id] = snapshot === undefined
+109 -233
View File
@@ -1,14 +1,10 @@
/** Cold Session history pagination and live-event source. */
import type { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { resolveSessionPreset } from '@deepseek-ai/dsh-agent-presets'
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 { ScopeKey } from '@deepseek-ai/dsh-scope'
import { foldSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
import type {} from '@deepseek-ai/dsh-subagent'
import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
import type {
SessionAddress,
@@ -17,37 +13,26 @@ import type {
SessionFollowFrame,
SessionPage,
SessionPageRequest,
SessionProjectionsBlock,
SessionProjectionBaseline,
SessionProjectionValues,
SessionToolCallView,
SessionToolView,
SessionWireEvent,
} from './types.ts'
const DEFAULT_MAX_MESSAGES = 50
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
interface ToolCallData {
readonly callId: string
readonly name: string
readonly arguments: string
}
type SessionSource =
| { readonly kind: 'attached'; readonly session: Session }
| { readonly kind: 'detached'; readonly header: SessionHeader; readonly events: readonly SessionEvent[] }
interface BufferedEvent {
readonly session: Session
readonly event: 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, presenter, and projection services. */
constructor(private readonly ctx: Context) {
/**
* @param ctx - Host context carrying Session query and projection services.
* @param promote - starts ordinary Session activation after snapshot delivery.
*/
constructor(
private readonly ctx: Context,
private readonly promote: (observation: SessionObservation) => void,
) {
ctx.effect(() => () => {
for (const close of this.closeFollowers) close()
this.closeFollowers.clear()
@@ -57,14 +42,14 @@ export class SessionHistoryController {
/**
* Read one message-aligned history page without activating an Agent.
* @param request - durable address and backwards-page cursor.
* @param signal - caller cancellation for persistence and preset reads.
* @returns a contiguous event page and a projection baseline on tail reads.
* @param signal - caller cancellation for persistence reads.
* @returns a contiguous event page.
*/
async page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage> {
validatePageRequest(request)
const source = await this.sourceFor(request.address, signal)
using source = await this.sourceFor(request.address, signal, false)
signal.throwIfAborted()
const sourceLog = sourceEvents(source)
const sourceLog = source.events
const sourceCursor = sourceLog.at(-1)?.seq ?? -1
if (request.throughSeq > sourceCursor) {
reject(
@@ -73,22 +58,20 @@ export class SessionHistoryController {
{},
)
}
const events = sourceLog.filter(event => event.seq <= request.throughSeq)
if ((events.at(-1)?.seq ?? -1) !== request.throughSeq) {
/* v8 ignore next -- Session and persistence validation guarantee a dense zero-based event prefix. */
if (request.throughSeq >= 0 && sourceLog[request.throughSeq]?.seq !== request.throughSeq) {
reject('internal', `session log does not contain through seq ${String(request.throughSeq)}`, {})
}
const scope = await this.presenterScopeFor(addressId(request.address), source, events)
signal.throwIfAborted()
const page = paginate(events, request.beforeSeq, request.maxMessages ?? DEFAULT_MAX_MESSAGES)
const argsFor = (callId: string) => backscanArgs(page.events, callId)
const entries = page.events.map(event => entryFor(this.ctx, event, argsFor, scope))
const projections = request.beforeSeq === undefined
? this.projectionsFor(request.address, source, events)
: undefined
const page = paginate(
sourceLog,
request.beforeSeq,
request.maxMessages ?? DEFAULT_MAX_MESSAGES,
request.throughSeq,
)
const entries = page.events.map(entryFor)
return {
events: entries,
hasMore: page.hasMore,
...(projections === undefined ? {} : { projections }),
}
}
@@ -96,18 +79,14 @@ export class SessionHistoryController {
* 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.
* @returns a complete opening snapshot followed by gap-free event frames.
*/
async *follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame> {
validateFollowRequest(request)
const { address, afterSeq } = request
const { address } = request
const target = addressId(address)
const buffered: BufferedEvent[] = []
const openCalls = new Map<string, { readonly name: string; readonly args: unknown }>()
let fallbackEvents: readonly SessionEvent[] = []
const argsFor = (callId: string): { readonly name: string; readonly args: unknown } | undefined => (
openCalls.get(callId) ?? backscanArgs(fallbackEvents, callId)
)
const buffered: SessionEvent[] = []
let snapshotCursor: number | undefined
let wake: (() => void) | undefined
const notify = (): void => {
const resume = wake
@@ -122,68 +101,61 @@ export class SessionHistoryController {
this.closeFollowers.add(close)
const disposeEvent = this.ctx.on('session/event', (session, event) => {
if (session.id !== target) return
buffered.push({ session, event })
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).map(event => ({ session, event }))
// Constructor seed events have no session/event notification. Normally
// only the end-seed suffix is new; if persistence advanced after the
// opening observation, replay everything beyond that snapshot cursor.
const suffix = session.events.slice(snapshotCursor === undefined
? session.firstLiveSeq
: snapshotCursor + 1)
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)]
fallbackEvents = events
const scope = await this.presenterScopeFor(target, source, events)
using source = await this.sourceFor(address, signal, true)
const events = source.events
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)}`, {})
const cursor = source.cursor
snapshotCursor = cursor
const page = paginate(events, undefined, request.maxMessages ?? DEFAULT_MAX_MESSAGES)
yield {
type: 'snapshot',
header: source.header,
cursor,
events: page.events.map(entryFor),
hasMore: page.hasMore,
projections: source.projections === undefined
? { asOfSeq: cursor, values: {} }
: projectionBlock(source.projections),
}
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(this.ctx, event, argsFor, scope) }
if (address.kind === 'session' && source.source === 'prepared') {
const promotion = source.retain()
try {
this.promote(promotion)
} catch (error: unknown) {
promotion[Symbol.dispose]()
throw error
}
}
let nextSeq = cursor + 1
while (!follower.closed && !signal.aborted) {
const item = buffered.shift()
if (item === undefined) {
await new Promise<void>((resolve) => { wake = resolve })
continue
}
if (item.event.seq < nextSeq) continue
if (item.event.seq !== nextSeq) {
if (item.seq < nextSeq) continue
if (item.seq !== nextSeq) {
reject('internal', `session event stream skipped seq ${String(nextSeq)}`, {})
}
nextSeq++
if (item.event.type === 'tool/call') {
const data = item.event.data as ToolCallData
const call = parseToolCall(data)
/* v8 ignore next -- malformed durable tool arguments intentionally skip the live presentation cache. */
if (call !== undefined) openCalls.set(data.callId, call)
} else if (item.event.type === 'turn/end') {
openCalls.clear()
}
if (item.event.type === 'tool/result'
&& !openCalls.has(item.event.data.message.source.callId)) {
fallbackEvents = item.session.events
}
const liveScope: Agent | undefined = this.ctx.get('agents')?.get(target)
const entry = entryFor(this.ctx, item.event, argsFor, liveScope ?? scope)
yield { type: 'event', ...entry }
yield { type: 'event', ...entryFor(item) }
}
} finally {
this.closeFollowers.delete(close)
@@ -193,69 +165,45 @@ export class SessionHistoryController {
}
}
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 async presenterScopeFor(
sessionId: SessionId,
source: SessionSource,
events: readonly SessionEvent[],
): Promise<ScopeKey | undefined> {
const live = this.ctx.get('agents')?.get(sessionId)
if (live !== undefined) return live
const presets = this.ctx.get('agentPresets')
if (presets === undefined) return undefined
const session = source.kind === 'attached'
? { header: source.session.header, events }
: { header: source.header, events }
try {
return await presets.standingKeyFor(resolveSessionPreset(session))
} catch {
return undefined
}
}
private projectionsFor(
private async sourceFor(
address: SessionAddress,
source: SessionSource,
events: readonly SessionEvent[],
): SessionProjectionsBlock | undefined {
const registry = this.ctx.get('sessionProjections')
if (registry === undefined) return undefined
signal: AbortSignal,
withProjections: boolean,
): Promise<SessionObservation> {
const sessionId = addressId(address)
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
return {
asOfSeq: snapshot.asOfSeq,
// Projection definitions validate whole JSON values before snapshot publication.
values: snapshot.values as SessionProjectionValues,
const observation = await this.ctx.sessionQuery.observeSession(sessionId, {
signal,
projectionMode: withProjections || address.kind === 'subagent' ? 'all' : 'none',
})
if (observation.header.cwd === undefined) {
observation[Symbol.dispose]()
rejectNotFound(address)
}
} catch (error) {
if (address.kind === 'session') throw error
this.ctx.logger.warn(`session.page: projections for "${address.childSessionId}" failed: ${String(error)}`)
return undefined
try {
validateAddress(address, observation.header, observation.projections)
} catch (error: unknown) {
observation[Symbol.dispose]()
throw error
}
return observation
} catch (error: unknown) {
if (error instanceof SessionQueryError
&& error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') rejectNotFound(address)
throw error
}
}
}
function projectionBlock(
snapshot: NonNullable<SessionObservation['projections']>,
): SessionProjectionBaseline {
return {
asOfSeq: snapshot.asOfSeq,
// Projection definitions validate whole JSON values before snapshot publication.
values: snapshot.values as SessionProjectionValues,
}
}
function validatePageRequest(request: SessionPageRequest): void {
@@ -273,9 +221,9 @@ function validatePageRequest(request: SessionPageRequest): void {
}
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', {})
if (request.maxMessages !== undefined
&& (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) {
reject('bad-request', 'maxMessages must be a positive safe integer', {})
}
}
@@ -286,7 +234,7 @@ function addressId(address: SessionAddress): SessionId {
function validateAddress(
address: SessionAddress,
header: SessionHeader,
events: readonly SessionEvent[],
projections: SessionObservation['projections'],
): void {
if (address.kind === 'session') {
if (header.origin === 'subagent') {
@@ -301,24 +249,22 @@ function validateAddress(
childSessionId: address.childSessionId,
})
}
let descriptor
try {
descriptor = foldSubagentDescriptor(events.slice(header.seedLength ?? 0))
} catch {
const identity = projections?.values.subagent
if (identity === null) {
reject('subagent-catalog-diagnostic', 'subagent descriptor is corrupt', {
parentSessionId: address.parentSessionId,
childSessionId: address.childSessionId,
reason: 'corrupt',
})
}
if (descriptor === undefined) {
if (identity === undefined || identity.seq < (header.seedLength ?? 0)) {
reject('subagent-catalog-diagnostic', 'subagent descriptor is unavailable', {
parentSessionId: address.parentSessionId,
childSessionId: address.childSessionId,
reason: 'unsupported',
})
}
if (descriptor.mode !== address.mode) {
if (identity.mode !== address.mode) {
reject('subagent-unauthorized', 'subagent mode does not match the supplied address', {
childSessionId: address.childSessionId,
})
@@ -339,20 +285,17 @@ 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,
throughSeq = events.at(-1)?.seq ?? -1,
): { readonly events: SessionEvent[]; readonly hasMore: boolean } {
const window = beforeSeq === undefined ? [...events] : events.filter(event => event.seq < beforeSeq)
const end = Math.min(throughSeq + 1, beforeSeq ?? throughSeq + 1)
let count = 0
let cut = 0
for (let index = window.length - 1; index >= 0; index--) {
const event = window[index] as SessionEvent
for (let index = end - 1; index >= 0; index--) {
const event = events[index] as SessionEvent
if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue
count++
const sources = (event as { readonly sourceEventSeqs?: readonly number[] }).sourceEventSeqs
@@ -365,79 +308,12 @@ function paginate(
break
}
}
return { events: window.filter(event => event.seq >= cut), hasMore: cut > 0 }
return { events: events.slice(cut, end), hasMore: cut > 0 }
}
function entryFor(
ctx: Context,
event: SessionEvent,
argsFor: (callId: string) => { readonly name: string; readonly args: unknown } | undefined,
scope?: ScopeKey,
): SessionEventEntry {
const view = viewFor(ctx, event, argsFor, scope)
function entryFor(event: SessionEvent): SessionEventEntry {
return {
// Session.append validates and freezes event data as JSON before publication.
event: event as unknown as SessionWireEvent,
...(view === undefined ? {} : { view }),
}
}
function viewFor(
ctx: Context,
event: SessionEvent,
argsFor: (callId: string) => { readonly name: string; readonly args: unknown } | undefined,
scope?: ScopeKey,
): SessionToolView | undefined {
if (event.type !== 'tool/call' && event.type !== 'tool/result') return undefined
const tools = ctx.get('tools')
/* v8 ignore next -- deployments without the optional Tools service omit presentation metadata. */
if (tools === undefined) return undefined
try {
if (event.type === 'tool/call') {
const data = event.data as ToolCallData
const view: ToolCallView | undefined = tools.get(data.name, scope)?.presentCall?.(JSON.parse(data.arguments))
return view === undefined ? undefined : { for: 'call', view: jsonView(view) }
}
const [result] = event.data.message.content
const call = argsFor(event.data.message.source.callId)
if (call === undefined) return undefined
const view: ToolResultView | undefined = tools.get(call.name, scope)?.presentResult?.(call.args, {
content: result.content,
isError: result.isError === true,
...(event.data.meta === undefined ? {} : { meta: event.data.meta }),
})
return view === undefined ? undefined : { for: 'result', view: jsonView(view) }
} catch (error) {
ctx.logger.warn(`session: presenter failed for ${event.type}: ${String(error)}`)
}
return undefined
}
function backscanArgs(
events: readonly SessionEvent[],
callId: string,
): { readonly name: string; readonly args: unknown } | undefined {
for (let index = events.length - 1; index >= 0; index--) {
const event = events[index] as SessionEvent
if (event.type !== 'tool/call') continue
const data = event.data as ToolCallData
if (data.callId !== callId) continue
return parseToolCall(data)
}
return undefined
}
function parseToolCall(data: ToolCallData): { readonly name: string; readonly args: unknown } | undefined {
try {
return { name: data.name, args: JSON.parse(data.arguments) }
} catch {
return undefined
}
}
function jsonView(view: ToolCallView): SessionToolCallView
function jsonView(view: ToolResultView): ToolResultView
function jsonView(view: ToolCallView | ToolResultView): SessionToolCallView | ToolResultView {
const encoded = JSON.stringify(view)
return JSON.parse(encoded) as SessionToolCallView | ToolResultView
}
+38 -19
View File
@@ -4,6 +4,7 @@ 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 type { SessionObservation } from '@deepseek-ai/dsh-session-query'
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import {
ApiSessionAgentController,
@@ -14,6 +15,7 @@ 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 { installModelSelectionProjection } from './model-selection-projection.ts'
import type {
SessionAttachmentRequest,
SessionAttachmentValue,
@@ -28,8 +30,6 @@ import type {
SessionForkValue,
SessionListRequest,
SessionListValue,
SessionModels,
SessionModelsRequest,
SessionPage,
SessionPageRequest,
SessionPromptRequest,
@@ -56,7 +56,7 @@ declare module '@deepseek-ai/cordis' {
/** Session Controller deployment policy. */
export interface Config {
/** Maximum cold Session artifact size read to determine blankness. */
/** Maximum cold Session artifact size eligible for one full projection observation. */
readonly coldBlankProbeMaxBytes?: number
}
@@ -68,8 +68,8 @@ export class SessionController extends TypertRemoteService {
'attachments',
'llm',
'sessions',
'sessionProjections',
'sessionQuery',
'tools',
'typert',
'workspaceRegistry',
]
@@ -83,17 +83,24 @@ export class SessionController extends TypertRemoteService {
private readonly controlState: SessionControlController
private readonly history: SessionHistoryController
private readonly listState: ApiSessionList
private readonly promotions = new Set<Promise<void>>()
/**
* @param ctx - Host context containing the Session capability assembly.
* @param config - cold-list read policy.
* @param config - cold-list observation policy.
*/
constructor(ctx: Context, config: Config) {
super(ctx, 'sessionController', { namespace: 'session' })
installModelSelectionProjection(ctx)
this.agents = new ApiSessionAgentController(ctx)
this.commands = new SessionCommandController(ctx, this.agents, process.cwd())
this.controlState = new SessionControlController(ctx)
this.history = new SessionHistoryController(ctx)
// Registered before history so reverse-order teardown closes every
// follower before waiting for already-admitted promotions.
ctx.effect(() => async () => {
await Promise.allSettled([...this.promotions])
}, 'session-controller.promotions')
this.history = new SessionHistoryController(ctx, (observation) => { this.promote(observation) })
this.listState = new ApiSessionList(
ctx,
config.coldBlankProbeMaxBytes ?? DEFAULT_COLD_BLANK_PROBE_MAX_BYTES,
@@ -112,11 +119,33 @@ export class SessionController extends TypertRemoteService {
ctx.emit('api-session/error', agent.id, errorChain(error))
})
ctx.on('session/event', (session, event) => {
if (event.type === 'request/header') {
const agent = ctx.agents.get(session.id)
if (agent?.session === session) this.agents.consumeSelection(
agent,
event.data.header.config.provider,
event.data.header.config.model,
event.data.header.config.reasoningEffort,
)
}
if (event.type !== 'user/message' || event.data.source.kind !== 'user') return
ctx.emit('api-session/activity', session.id, event.time)
})
}
private promote(observation: SessionObservation): void {
const sessionId = observation.header.id
const task = (async () => {
using ownedObservation = observation
const result = await this.agents.resolveObservedAgent(ownedObservation)
if ('error' in result) this.ctx.emit('api-session/error', sessionId, result.error.message)
})().catch((error: unknown) => {
this.ctx.logger.error(`session-controller: background activation for "${sessionId}" failed: ${errorChain(error)}`)
})
this.promotions.add(task)
void task.finally(() => { this.promotions.delete(task) })
}
/**
* Resolve or resume one ordinary Session for another Host API domain.
* @param sessionId - Session identity whose Agent owns the operation.
@@ -175,16 +204,6 @@ export class SessionController extends TypertRemoteService {
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.
@@ -260,8 +279,8 @@ export class SessionController extends TypertRemoteService {
/**
* Read one cold-safe, message-aligned Session history page.
* @param request - durable address, backward cursor, and page budget.
* @param signal - cancellation for persistence and presentation reads.
* @returns one chronological page and optional latest projections.
* @param signal - cancellation for persistence reads.
* @returns one chronological page.
*/
@Remote('page')
page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage> {
@@ -272,7 +291,7 @@ export class SessionController extends TypertRemoteService {
* 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.
* @returns a complete opening snapshot followed by gap-free event frames.
*/
@Remote({ mode: 'stream' })
follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame> {
+93 -112
View File
@@ -2,10 +2,9 @@
import { stat } from 'node:fs/promises'
import type { Context } from '@deepseek-ai/cordis'
import { resolveSessionPreset } from '@deepseek-ai/dsh-agent-presets'
import type {} 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'
@@ -16,11 +15,11 @@ import {
SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
} from './types.ts'
import type {
SessionListMetadata, SessionProjectionsBlock, SessionProjectionValues, SessionSearchItem,
SessionListMetadata, SessionProjectionHints, SessionProjectionValues, SessionSearchItem,
SessionSearchValue, SessionSummary,
} from './types.ts'
/** Default maximum artifact size eligible for one cold blankness read. */
/** Default maximum artifact size eligible for one cold projection observation. */
export const DEFAULT_COLD_BLANK_PROBE_MAX_BYTES = 1024
const COLD_SUMMARY_BATCH_SIZE = 16
@@ -61,17 +60,6 @@ export function applySessionListMetadata(
: { 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.
@@ -89,11 +77,11 @@ export function truncateUnicodeCodePoints(value: string, maximum: number): strin
return value
}
/** Owns list projection registration, cold summaries, and authorized search. */
/** Owns list projection registration, bounded 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.
* @param ctx - Host context carrying Session, query, persistence, and projection services.
* @param coldBlankProbeMaxBytes - maximum physical artifact size eligible for a full observation.
*/
constructor(
private readonly ctx: Context,
@@ -130,14 +118,14 @@ export class ApiSessionList {
* @returns current list metadata and available projections.
*/
summaryFor(session: Session): SessionSummary {
const metadata = sessionListMetadata(session.events)
const projections = this.projectionsFor(session.header, session)
const metadata = projections?.values.sessionListMetadata
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),
blank: metadata?.blank ?? session.seq === 0,
...listFields(session.header),
...(projections === undefined ? {} : { projections }),
}
}
@@ -149,41 +137,86 @@ export class ApiSessionList {
*/
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)
const records = await this.ctx.sessionQuery.listSessions(signal)
signal?.throwIfAborted()
const items: SessionSummary[] = []
const cold: SessionHeader[] = []
for (const record of records) {
const live = this.ctx.sessions.get(record.header.id)
if (live !== undefined) {
items.push(this.summaryFor(live))
continue
}
if (record.header.cwd === undefined) continue
cold.push(record.header)
}
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(header => this.summarizeCold(header, signal)))
for (const result of settled) {
if (result.status === 'rejected') throw result.reason
items.push(result.value)
}
}
items.sort((left, right) => right.updatedAt - left.updatedAt)
return items
}
private async summarizeCold(
header: SessionHeader,
signal: AbortSignal | undefined,
): Promise<SessionSummary> {
const cached = this.projectionsFor(header, undefined)
const projections = cached?.values.sessionListMetadata?.blank === false
? cached
: await this.probeSmallCold(header, signal) ?? cached
const raced = this.ctx.sessions.get(header.id)
if (raced !== undefined) return this.summaryFor(raced)
const metadata = projections?.values.sessionListMetadata
return {
sessionId: header.id,
updatedAt: updatedAt(header, metadata),
running: false,
// A large or inaccessible cache miss remains unknown and visible.
blank: metadata?.blank ?? false,
...listFields(header),
...(projections === undefined ? {} : { projections }),
}
}
private async probeSmallCold(
header: SessionHeader,
signal: AbortSignal | undefined,
): Promise<SessionProjectionHints | undefined> {
if (this.coldBlankProbeMaxBytes === 0) return undefined
const persistence = this.ctx.get('sessionPersistence')
const location = persistence?.locate(header)
if (location === undefined) return undefined
signal?.throwIfAborted()
try {
if ((await stat(location.path)).size > this.coldBlankProbeMaxBytes) return undefined
} catch {
signal?.throwIfAborted()
return undefined
}
try {
using observation = await this.ctx.sessionQuery.observeSession(header.id, {
...(signal === undefined ? {} : { signal }),
projectionMode: 'all',
})
const block = observation.projections
return block === undefined
? undefined
: { asOfSeq: block.asOfSeq, values: block.values as SessionProjectionValues }
} catch (error: unknown) {
signal?.throwIfAborted()
this.ctx.logger.warn(
`api-session.list: small cold observation for "${header.id}" failed; serving it as visible: ${String(error)}`,
)
return undefined
}
}
/**
* Search current visible message content without activating any matching Session.
* @param query - literal message-content query.
@@ -202,10 +235,12 @@ export class ApiSessionList {
)
}
try {
const visible = await this.list(signal)
const visible = await provider.listSessions(signal)
signal.throwIfAborted()
if (visible.length === 0) return { items: [], hasMore: false }
const visibleIds = new Set(visible.map(item => item.sessionId))
const visibleIds = new Set(visible
.filter(record => record.header.cwd !== undefined)
.map(record => record.header.id))
if (visibleIds.size === 0) return { items: [], hasMore: false }
const authorized: SessionSearchItem[] = []
const acceptedIds = new Set<SessionId>()
const seenCursors = new Set<SessionSearchCursor>()
@@ -293,15 +328,16 @@ export class ApiSessionList {
private projectionsFor(
header: SessionHeader,
session: Session | undefined,
): SessionProjectionsBlock | undefined {
): SessionProjectionHints | undefined {
try {
const block = session === undefined
? this.ctx.get('sessionProjectionCache')?.cachedSnapshot(header)
: this.ctx.get('sessionProjections')?.snapshot(session)
: this.ctx.get('sessionProjections')?.cachedSnapshot(session)
return block !== undefined && Object.keys(block.values).length > 0
? {
asOfSeq: block.asOfSeq,
// Projection definitions validate whole JSON values before snapshot publication.
// Listing hints contain every currently cached wire value but remain
// partial: missing cells and cache rows are never materialized here.
values: block.values as SessionProjectionValues,
}
: undefined
@@ -340,69 +376,14 @@ function updatedAt(header: SessionHeader, metadata: SessionListMetadata | undefi
return Math.max(header.createdAt, metadata?.lastPromptAt ?? 0)
}
function listFields(header: SessionHeader, events: readonly SessionEvent[] = []): {
function listFields(header: SessionHeader): {
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,83 @@
/** Durable model-selection intent and request-use projection. */
import type { Context } from '@deepseek-ai/cordis'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import { z } from 'zod'
import type {
ModelSelection,
ModelSelectionProjection,
ModelSelectionProjectionState,
} from './types.ts'
const modelSelectionSchema = z.object({
provider: z.string().min(1),
model: z.string().min(1),
reasoningEffort: z.string().min(1).optional(),
}) as unknown as z.ZodType<ModelSelection>
const modelSelectionProjectionStateSchema = z.object({
lastUsed: modelSelectionSchema.nullable(),
pending: modelSelectionSchema.nullable(),
}) as unknown as z.ZodType<ModelSelectionProjectionState>
const modelSelectionProjectionSchema = z.object({
lastUsed: modelSelectionSchema.nullable(),
next: modelSelectionSchema.nullable(),
}) as unknown as z.ZodType<ModelSelectionProjection>
/**
* Advance durable model-selection state by one Session event.
* @param state - selection state before the event.
* @param event - next committed Session event.
* @returns the original or advanced selection state.
*/
function applyModelSelectionProjection(
state: ModelSelectionProjectionState,
event: SessionEvent,
): ModelSelectionProjectionState {
if (event.type === 'model/selection') {
return sameSelection(state.pending, event.data)
? state
: { lastUsed: state.lastUsed, pending: event.data }
}
if (event.type !== 'request/header') return state
const lastUsed: ModelSelection = {
provider: event.data.header.config.provider,
model: event.data.header.config.model,
...(event.data.header.config.reasoningEffort === undefined
? {}
: { reasoningEffort: String(event.data.header.config.reasoningEffort) }),
}
const pending = sameSelection(state.pending, lastUsed) ? null : state.pending
return sameSelection(state.lastUsed, lastUsed) && pending === state.pending
? state
: { lastUsed, pending }
}
const modelSelectionProjection = {
key: 'modelSelection',
stateSchema: modelSelectionProjectionStateSchema,
init: () => ({ lastUsed: null, pending: null }),
apply: applyModelSelectionProjection,
wire: {
viewSchema: modelSelectionProjectionSchema,
view: state => ({ lastUsed: state.lastUsed, next: state.pending ?? state.lastUsed }),
},
stateVersion: 2,
} satisfies ProjectionDefinition<'modelSelection', ModelSelectionProjectionState>
function sameSelection(left: ModelSelection | null, right: ModelSelection | null): boolean {
return left === right || (left !== null && right !== null
&& left.provider === right.provider
&& left.model === right.model
&& left.reasoningEffort === right.reasoningEffort)
}
/**
* Register the durable model-selection projection when the registry is present.
* @param ctx - Session Controller context.
*/
export function installModelSelectionProjection(ctx: Context): void {
ctx.sessionProjections.register(modelSelectionProjection)
}
+60 -40
View File
@@ -6,16 +6,10 @@ import type {
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 { JsonValue, SessionHeader, 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'
import type {
DiffCallView,
GenericCallView,
TerminalCallView,
ToolResultView,
} from '@deepseek-ai/dsh-tools/presentation'
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionStateMap {
@@ -23,12 +17,26 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
sessionListMetadata: SessionListMetadata
/** Host state for the boot-constant image-limit view. */
imageLimits: null
/** Durable model selection already used by a request and still pending for a later request. */
modelSelection: ModelSelectionProjectionState
}
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
/** Durable model selection already used and selected for the next request. */
modelSelection: ModelSelectionProjection
}
}
declare module '@deepseek-ai/dsh-session/types' {
interface SessionEventMap {
/**
* Complete validated model selection requested for subsequent prompt
* assembly. Log-only: it never enters derived model history.
*/
'model/selection': ModelSelection
}
}
@@ -40,10 +48,17 @@ export interface SessionListMetadata {
readonly lastPromptAt: number | null
}
/** Projection values and the durable event position they represent. */
export interface SessionProjectionsBlock {
/** Every available cached wire value used as partial, possibly stale Session-list hints. */
export interface SessionProjectionHints {
readonly asOfSeq: number
/** Provider-validated values across the merge-extensible projection key space. */
/** Provider-validated values present in the cache; omitted keys remain unknown. */
readonly values: SessionProjectionValues
}
/** Complete projection values at an exact Session event cursor. */
export interface SessionProjectionBaseline {
readonly asOfSeq: number
/** Provider-validated values; omitted keys are absent capabilities at this cut. */
readonly values: SessionProjectionValues
}
@@ -68,6 +83,22 @@ export interface ModelSelection {
readonly reasoningEffort?: string
}
/** Host fold state for durable model selection. */
export interface ModelSelectionProjectionState {
/** Selection consumed by the latest recorded model request. */
readonly lastUsed: ModelSelection | null
/** Later user selection not yet consumed by a matching model request. */
readonly pending: ModelSelection | null
}
/** Client view of the durable model-selection fold. */
export interface ModelSelectionProjection {
/** Selection consumed by the latest recorded model request. */
readonly lastUsed: ModelSelection | null
/** Selection the next request should use, falling back to {@link lastUsed}. */
readonly next: ModelSelection | null
}
/** One adapter-owned reasoning effort for an exact model route. */
export interface ModelReasoningEffort {
readonly id: string
@@ -103,10 +134,11 @@ export interface ModelCatalogFailure {
readonly message: string
}
/** Detached model-directory snapshot for one Session. */
export interface SessionModels {
readonly current: ModelSelection
readonly routable: boolean
/** Host-generation model catalog and the default used by unconfigured Sessions. */
export interface ModelCatalog {
readonly default: ModelSelection
/** Provider routes currently able to serve a request, including empty catalogs. */
readonly routableProviders: readonly string[]
readonly groups: readonly ModelProviderGroup[]
readonly failures: readonly ModelCatalogFailure[]
}
@@ -126,8 +158,7 @@ export interface SessionSummary {
readonly parentSessionId?: SessionId
readonly origin?: 'subagent'
readonly cwd?: string
readonly agentPreset?: string
readonly projections?: SessionProjectionsBlock
readonly projections?: SessionProjectionHints
}
/** One session-content search result. */
@@ -226,11 +257,6 @@ export interface SessionCreateValue {
readonly agentPreset?: string
}
/** Model-directory request. */
export interface SessionModelsRequest {
readonly sessionId: SessionId
}
/** Session model-selection request. */
export interface SessionSelectModelRequest extends ModelSelection {
readonly sessionId: SessionId
@@ -333,21 +359,9 @@ export type SessionAddress =
readonly mode: 'one-shot' | 'continuable'
}
/** JSON-safe call render intent crossing the Session Remote boundary. */
export type SessionToolCallView =
| (Omit<GenericCallView, 'rawInput'> & { readonly rawInput?: JsonValue })
| TerminalCallView
| DiffCallView
/** Host-computed render intent accompanying one tool event. */
export type SessionToolView =
| { readonly for: 'call'; readonly view: SessionToolCallView }
| { readonly for: 'result'; readonly view: ToolResultView }
/** One raw Session event plus its optional transient render intent. */
/** One raw Session event in the Remote journal. */
export interface SessionEventEntry {
readonly event: SessionWireEvent
readonly view?: SessionToolView
}
/** Session event wire form; durable readers own recognition of merge-extensible event names. */
@@ -370,22 +384,28 @@ export interface SessionPageRequest {
readonly maxMessages?: number
}
/** One live event request, optionally resuming after an already-applied event. */
/** One live event request for a durable Session address. */
export interface SessionFollowRequest {
readonly address: SessionAddress
readonly afterSeq?: number
readonly maxMessages?: 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. */
/** Complete opening window followed by ordered events appended after its cursor. */
export type SessionFollowFrame =
| { readonly type: 'opened'; readonly cursor: number }
| {
readonly type: 'snapshot'
readonly header: SessionHeader
readonly cursor: number
readonly events: readonly SessionEventEntry[]
readonly hasMore: boolean
readonly projections: SessionProjectionBaseline
}
| ({ readonly type: 'event' } & SessionEventEntry)
/** One pending inbox occurrence in the authoritative queue snapshot. */
@@ -414,7 +434,7 @@ export interface SessionJob {
export interface SessionControlBaseline {
readonly queues: Readonly<Record<SessionId, readonly SessionQueuedItem[]>>
readonly jobs: Readonly<Record<SessionId, readonly SessionJob[]>>
readonly projections: Readonly<Record<SessionId, SessionProjectionsBlock>>
readonly projections: Readonly<Record<SessionId, SessionProjectionBaseline>>
}
/** One finished projection value and its durable watermark. */
@@ -4,8 +4,10 @@ import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -16,6 +18,8 @@ import {
ApiSessionSubagentOwnership,
inspectApiSession,
} from '../src/agent.ts'
import { installModelSelectionProjection } from '../src/model-selection-projection.ts'
import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
const roots: Context[] = []
@@ -29,6 +33,9 @@ async function harness(): Promise<{ ctx: Context; agents: ApiSessionAgentControl
await ctx.plugin(TypertRegistry)
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
installSessionReadTestServices(ctx)
ctx.sessionProjections.register(agentPresetProjectionDefinition)
installModelSelectionProjection(ctx)
ctx.provide('agentDefaultModel', {
currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
saveSelection: () => Promise.resolve(),
@@ -45,6 +52,10 @@ function header(id: string, cwd: string | null = '/workspace'): SessionHeader {
}
}
function providePersistence(ctx: Context, persistence: Record<string, unknown>): () => void {
return ctx.provide('sessionPersistence', testSessionPersistence(ctx, persistence) as never)
}
function agent(ctx: Context, meta: SessionHeader): Agent {
const session = ctx.sessions.create(meta.id, { meta })
return { id: meta.id, session, status: 'idle', ctx } as Agent
@@ -67,48 +78,94 @@ describe('ApiSession identity failures', () => {
.toContain('belongs to "/existing"')
})
it('rejects absent persistence, catalog misses, and cwd-less inspected artifacts', async () => {
it('maps absent and cwd-less point observations to not found', async () => {
const ctx = new Context()
roots.push(ctx)
await ctx.plugin(SessionStore)
installSessionReadTestServices(ctx)
await expect(inspectApiSession(ctx, SessionId('missing')))
.rejects.toThrow('session persistence is not configured')
.rejects.toBeInstanceOf(ApiSessionNotFound)
const inspect = vi.fn(() => Promise.resolve({ meta: header('missing'), events: [] as SessionEvent[] }))
const disposeMissing = ctx.provide('sessionPersistence', {
const inspect = vi.fn(() => Promise.resolve(undefined))
const disposeMissing = providePersistence(ctx, {
list: () => Promise.resolve([]),
inspect,
} as never)
})
await expect(inspectApiSession(ctx, SessionId('missing'))).rejects.toBeInstanceOf(ApiSessionNotFound)
expect(inspect).not.toHaveBeenCalled()
expect(inspect).toHaveBeenCalledOnce()
disposeMissing()
const listed = header('cwd-less-catalog', null)
const disposeListed = ctx.provide('sessionPersistence', {
const disposeListed = providePersistence(ctx, {
list: () => Promise.resolve([listed]),
inspect,
} as never)
inspect: () => Promise.resolve({ meta: listed, events: [] }),
})
await expect(inspectApiSession(ctx, listed.id)).rejects.toBeInstanceOf(ApiSessionNotFound)
disposeListed()
const catalog = header('cwd-less-inspect')
const inspected = header('cwd-less-inspect', null)
ctx.provide('sessionPersistence', {
providePersistence(ctx, {
list: () => Promise.resolve([catalog]),
inspect: () => Promise.resolve({ meta: inspected, events: [] }),
} as never)
})
await expect(inspectApiSession(ctx, catalog.id)).rejects.toBeInstanceOf(ApiSessionNotFound)
})
it('forwards an explicit inspection signal', async () => {
const ctx = new Context()
roots.push(ctx)
await ctx.plugin(SessionStore)
installSessionReadTestServices(ctx)
const meta = header('signalled-inspection')
const inspect = vi.fn(() => Promise.resolve({ meta, events: [] }))
providePersistence(ctx, { inspect })
const signal = new AbortController().signal
await expect(inspectApiSession(ctx, meta.id, signal)).resolves.toEqual({ meta, events: [] })
expect(inspect).toHaveBeenCalledWith(meta.id, signal)
})
})
describe('ApiSession Agent lookup and recovery', () => {
it('resumes directly from a retained observation and rejects an invalid observed header', async () => {
const { ctx, agents } = await harness()
const meta = header('observed-resume')
const resumed = unpublishedAgent(ctx, meta)
const resume = vi.spyOn(ctx.agents, 'resume').mockResolvedValue({
agent: resumed,
dispose: () => Promise.resolve(),
})
const observed = {
source: 'prepared',
header: meta,
events: [],
cursor: -1,
projections: { asOfSeq: -1, values: {} },
retain: vi.fn(),
[Symbol.dispose]: vi.fn(),
} as unknown as SessionObservation
await expect(agents.resolveObservedAgent(observed)).resolves.toEqual({ agent: resumed })
expect(resume).toHaveBeenCalledWith(expect.objectContaining({ resumeSessionId: meta.id }))
const invalid = {
...observed,
header: header('observed-without-cwd', null),
} as SessionObservation
await expect(agents.resolveObservedAgent(invalid)).resolves.toMatchObject({
error: { code: 'session-not-found' },
})
})
it('projects live Agent contexts and maps missing cold identities through Typert lookup failures', async () => {
const { ctx } = await harness()
const live = agent(ctx, header('live'))
ctx.agents.register(live)
ctx.provide('sessionPersistence', {
providePersistence(ctx, {
list: () => Promise.resolve([]),
inspect: vi.fn(),
} as never)
})
const host = ctx.typert.contexts.getHost('agent')
if (host === undefined) throw new Error('Agent Context resolver was not registered')
@@ -119,10 +176,10 @@ describe('ApiSession Agent lookup and recovery', () => {
it('returns raced ordinary Agents and ownership failures after resume throws', async () => {
const ordinary = await harness()
const ordinaryMeta = header('ordinary-race')
ordinary.ctx.provide('sessionPersistence', {
providePersistence(ordinary.ctx, {
list: () => Promise.resolve([ordinaryMeta]),
inspect: () => Promise.resolve({ meta: ordinaryMeta, events: [] }),
} as never)
})
const winner = agent(ordinary.ctx, ordinaryMeta)
vi.spyOn(ordinary.ctx.agents, 'resume').mockImplementation(async () => {
ordinary.ctx.agents.register(winner)
@@ -132,10 +189,10 @@ describe('ApiSession Agent lookup and recovery', () => {
const child = await harness()
const childMeta = header('child-race')
child.ctx.provide('sessionPersistence', {
providePersistence(child.ctx, {
list: () => Promise.resolve([childMeta]),
inspect: () => Promise.resolve({ meta: childMeta, events: [] }),
} as never)
})
vi.spyOn(child.ctx.agents, 'resume').mockImplementation(async () => {
child.ctx.sessions.create(childMeta.id, {
meta: { ...childMeta, parentSession: SessionId('parent'), origin: 'subagent' },
@@ -149,25 +206,84 @@ describe('ApiSession Agent lookup and recovery', () => {
it('reports not-found and ordinary resume failures without fabricating an Agent', async () => {
const missing = await harness()
missing.ctx.provide('sessionPersistence', {
providePersistence(missing.ctx, {
list: () => Promise.resolve([]),
inspect: vi.fn(),
} as never)
})
await expect(missing.agents.resolveAgent(SessionId('missing'))).resolves.toMatchObject({
error: { code: 'session-not-found' },
})
const failed = await harness()
const meta = header('failed')
failed.ctx.provide('sessionPersistence', {
providePersistence(failed.ctx, {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events: [] }),
} as never)
})
vi.spyOn(failed.ctx.agents, 'resume').mockRejectedValue(new Error('factory unavailable'))
await expect(failed.agents.resolveAgent(meta.id)).resolves.toMatchObject({
error: { code: 'internal', message: expect.stringContaining('factory unavailable') as string },
})
})
it('requires projected observations before activation', async () => {
const { agents } = await harness()
const meta = header('unprojected-observation')
const observed = {
source: 'prepared',
header: meta,
events: [],
cursor: -1,
retain: vi.fn(),
[Symbol.dispose]: vi.fn(),
} as unknown as SessionObservation
expect(() => agents.presetForObservation(observed)).toThrow(
'Agent activation requires a projected Session observation',
)
})
})
describe('ApiSession model selection', () => {
it('requires the model-selection projection', async () => {
const { ctx, agents } = await harness()
const live = agent(ctx, header('missing-model-projection'))
vi.spyOn(ctx.sessionProjections, 'stateOf').mockReturnValue(undefined)
expect(() => agents.selectionFor(live)).toThrow('required modelSelection projection')
})
it('reads a reasoning-free request and consumes only the exact pending selection', async () => {
const { ctx, agents } = await harness()
const logged = agent(ctx, header('logged-model'))
logged.session.append('request/header', {
header: { config: { provider: 'logged-provider', model: 'logged-model' } },
reason: 'initial',
})
expect(agents.selectionFor(logged).current).toEqual({
provider: 'logged-provider',
model: 'logged-model',
})
const pending = agent(ctx, header('pending-model'))
const selection = agents.selectionFor(pending)
agents.selectForNextRequest(pending, {
provider: 'selected-provider',
model: 'selected-model',
reasoningEffort: 'high' as never,
})
expect(selection.current).toMatchObject({
provider: 'selected-provider', model: 'selected-model', reasoningEffort: 'high',
})
expect(agents.consumeSelection(pending, 'other-provider', 'selected-model', 'high')).toBe(false)
expect(agents.consumeSelection(pending, 'selected-provider', 'other-model', 'high')).toBe(false)
expect(agents.consumeSelection(pending, 'selected-provider', 'selected-model', 'low')).toBe(false)
expect(agents.consumeSelection(pending, 'selected-provider', 'selected-model', 'high')).toBe(true)
expect(selection.current).toEqual({ provider: 'fixture', model: 'fixture-model' })
const untouched = agent(ctx, header('uninstalled-model'))
expect(agents.consumeSelection(untouched, 'fixture', 'fixture-model', undefined)).toBe(false)
})
})
describe('ApiSession create or adoption', () => {
@@ -252,10 +368,10 @@ describe('ApiSession create or adoption', () => {
time: 1,
data: { agentPreset: 'minimal' },
}] as SessionEvent[]
ctx.provide('sessionPersistence', {
providePersistence(ctx, {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events }),
} as never)
})
ctx.provide('agentPresets', {
resolve: (id?: string) => Promise.resolve({ id: id ?? 'minimal' }),
mount: () => Promise.resolve(),
@@ -278,10 +394,10 @@ describe('ApiSession create or adoption', () => {
it('rejects an ownership race before resume and a persisted cwd conflict', async () => {
const child = await harness()
const childMeta = header('resume-child-race')
child.ctx.provide('sessionPersistence', {
providePersistence(child.ctx, {
list: () => Promise.resolve([childMeta]),
inspect: () => Promise.resolve({ meta: childMeta, events: [] }),
} as never)
})
child.ctx.provide('agentPresets', {
resolve: () => {
child.ctx.sessions.create(childMeta.id, {
@@ -297,10 +413,10 @@ describe('ApiSession create or adoption', () => {
const conflict = await harness()
const stored = header('stored-cwd-conflict', '/stored')
conflict.ctx.provide('sessionPersistence', {
providePersistence(conflict.ctx, {
list: () => Promise.resolve([stored]),
inspect: () => Promise.resolve({ meta: stored, events: [] }),
} as never)
})
await expect(conflict.agents.ensureSession(stored.id, '/requested', true))
.rejects.toBeInstanceOf(ApiSessionCwdConflict)
})
@@ -11,6 +11,7 @@ import {
ApiSessionCwdConflict,
} from '../src/agent.ts'
import { SessionCommandController } from '../src/commands.ts'
import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
async function expectFailure(operation: Promise<unknown>, code: string): Promise<void> {
await expect(operation).rejects.toMatchObject({ failure: { code } })
@@ -20,6 +21,8 @@ function controllerAgents(overrides: object = {}): ApiSessionAgentController {
return {
ensureSession: () => Promise.resolve(),
composeAgent: () => Promise.resolve({ setup: () => {} }),
presetForSession: () => undefined,
presetForObservation: () => undefined,
...overrides,
} as unknown as ApiSessionAgentController
}
@@ -28,6 +31,7 @@ async function baseContext(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
installSessionReadTestServices(ctx)
ctx.provide('agentDefaultModel', {
currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
saveSelection: () => Promise.resolve(),
@@ -167,23 +171,23 @@ function resolvedHandle(ctx: Context, sessionId: SessionId): AgentHandle {
}
describe('Session fork failures', () => {
it('distinguishes missing cold sources from unavailable persistence', async () => {
const unavailable = await baseContext()
unavailable.provide('workspaceRegistry', { list: () => [] } as never)
it('maps missing cold sources with and without persistence', async () => {
const withoutPersistence = await baseContext()
withoutPersistence.provide('workspaceRegistry', { list: () => [] } as never)
const unavailableController = new SessionCommandController(
unavailable, controllerAgents(), '/default',
withoutPersistence, controllerAgents(), '/default',
)
await expectFailure(unavailableController.fork({
sessionId: SessionId('missing'),
}), 'internal')
await unavailable.fiber.dispose()
}), 'session-not-found')
await withoutPersistence.fiber.dispose()
const missing = await baseContext()
missing.provide('workspaceRegistry', { list: () => [] } as never)
missing.provide('sessionPersistence', {
missing.provide('sessionPersistence', testSessionPersistence(missing, {
list: () => Promise.resolve([]),
inspect: vi.fn(),
} as never)
}) as never)
const missingController = new SessionCommandController(missing, controllerAgents(), '/default')
await expectFailure(missingController.fork({
sessionId: SessionId('missing'),
@@ -191,6 +195,16 @@ describe('Session fork failures', () => {
await missing.fiber.dispose()
})
it('maps an observation failure to an internal fork error', async () => {
const ctx = await baseContext()
ctx.provide('workspaceRegistry', { list: () => [] } as never)
vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('storage offline'))
const controller = new SessionCommandController(ctx, controllerAgents(), '/default')
await expectFailure(controller.fork({ sessionId: SessionId('unreadable') }), 'internal')
await ctx.fiber.dispose()
})
it('rejects a Session with no completed turn', async () => {
const ctx = await baseContext()
ctx.provide('workspaceRegistry', { list: () => [] } as never)
@@ -204,9 +218,8 @@ describe('Session fork failures', () => {
it('maps lineage lookup and Agent creation failures', async () => {
const lineage = await baseContext()
lineage.provide('workspaceRegistry', { list: () => [] } as never)
lineage.provide('sessionQuery', {
traceSession: () => Promise.reject(new Error('lineage unavailable')),
} as never)
vi.spyOn(lineage.sessionQuery, 'traceSession')
.mockRejectedValue(new Error('lineage unavailable'))
const child = completedSession(lineage, 'subagent-source', '/workspace', {
parentSession: SessionId('parent'),
origin: 'subagent',
@@ -3,12 +3,13 @@ import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
import { createAssistantMessage, createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import { describe, expect, it, vi } from 'vitest'
import { ApiSessionAgentController } from '../src/agent.ts'
import { SessionCommandController } from '../src/commands.ts'
import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
async function commandHarness(): Promise<{
ctx: Context
@@ -142,10 +143,11 @@ async function persistedController(
await ctx.plugin(SessionStore)
const sessionId = SessionId('cold-attachment')
const meta: SessionHeader = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
ctx.provide('sessionPersistence', {
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events }),
} as never)
}) as never)
installSessionReadTestServices(ctx)
ctx.provide('attachments', { readImage } as never)
const agents = { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController
return { ctx, controller: new SessionCommandController(ctx, agents, '/workspace'), sessionId }
@@ -158,15 +160,31 @@ describe('Session attachment authorization', () => {
const inserted = imageRef('inserted')
const streamed = imageRef('streamed')
const events = [
event('fixture/direct', 0, {
{ ...event('fixture/direct', 0, {
content: [null, [], { type: 'tool-result', content: [{ type: 'text', text: 'none' }] }, {
type: 'tool-result', content: [{ type: 'image', attachment: nested }],
}],
}), ignorable: true as const },
{ ...event('assistant/message', 1, {
turn: 1,
step: 1,
message: createAssistantMessage({
content: [{ type: 'image', attachment: message }],
source: { provider: 'fixture', model: 'fixture' },
}),
}), surfaceOp: 'append' as const },
event('agent/inbox/spliced', 2, {
target: 'next-turn',
start: 0,
inserted: [createUserMessage({
content: [{ type: 'image', attachment: inserted }],
source: { kind: 'user' },
})],
}),
event('assistant/message', 1, { message: { content: [{ type: 'image', attachment: message }] } }),
event('agent/inbox/spliced', 2, { inserted: [{ content: [{ type: 'image', attachment: inserted }] }] }),
event('assistant/chunk', 3, {
chunk: { type: 'block-end', block: { type: 'image', attachment: streamed } },
turn: 1,
step: 1,
chunk: { type: 'block-end', index: 0, block: { type: 'image', attachment: streamed } },
}),
]
const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve({ ref, data: Uint8Array.of(1) }))
@@ -183,6 +201,7 @@ describe('Session attachment authorization', () => {
it('maps missing persistence identities and attachment backend failures', async () => {
const noPersistence = new Context()
await noPersistence.plugin(SessionStore)
installSessionReadTestServices(noPersistence)
const noPersistenceController = new SessionCommandController(
noPersistence,
{ resolveAgent: vi.fn() } as unknown as ApiSessionAgentController,
@@ -190,14 +209,15 @@ describe('Session attachment authorization', () => {
)
await expectFailure(noPersistenceController.attachment({
sessionId: SessionId('missing'), attachmentId: AttachmentId('att'),
}), 'internal')
}), 'session-not-found')
const missing = new Context()
await missing.plugin(SessionStore)
missing.provide('sessionPersistence', {
missing.provide('sessionPersistence', testSessionPersistence(missing, {
list: () => Promise.resolve([]),
inspect: vi.fn(),
} as never)
}) as never)
installSessionReadTestServices(missing)
const missingController = new SessionCommandController(
missing,
{ resolveAgent: vi.fn() } as unknown as ApiSessionAgentController,
@@ -223,4 +243,21 @@ describe('Session attachment authorization', () => {
await fixture.ctx.fiber.dispose()
}
})
it('maps a cold observation failure to an internal authorization error', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
installSessionReadTestServices(ctx)
vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('storage offline'))
const controller = new SessionCommandController(
ctx,
{ resolveAgent: vi.fn() } as unknown as ApiSessionAgentController,
'/workspace',
)
await expectFailure(controller.attachment({
sessionId: SessionId('unreadable'), attachmentId: AttachmentId('att'),
}), 'internal')
await ctx.fiber.dispose()
})
})
@@ -104,6 +104,29 @@ describe('Session control jobs baseline', () => {
})
describe('Session control jobs updates', () => {
it('publishes existing unowned jobs when a Session attaches after the stream opens', async () => {
const { ctx, control } = await harness(true)
const abort = new AbortController()
const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'baseline' } })
const task = producer('already running')
const id = ctx.jobs.start(task.spec)
await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'jobs' } })
const created = ctx.sessions.create(SessionId('late-session'))
await expect(iterator.next()).resolves.toMatchObject({
value: {
type: 'jobs',
sessionId: created.id,
jobs: [expect.objectContaining({ id, label: 'already running' })],
},
})
task.settle({ status: 'completed' })
abort.abort()
await iterator.return?.()
})
it('pushes the owner whole set on registration, stopping, and settlement', async () => {
const { ctx, session, agent, control } = await harness(true)
const abort = new AbortController()
@@ -200,16 +223,4 @@ describe('Session control jobs updates', () => {
expect(task.reads.count).toBe(0)
})
it('publishes existing unowned jobs for a session created after stream open', async () => {
const { ctx, control } = await harness(true)
const abort = new AbortController()
const collected = collectJobs(control.control(abort.signal), 2, abort)
ctx.jobs.start(producer('visible to every caller').spec)
const created = ctx.sessions.create()
const frames = await collected
const forNew = frames.filter(frame => frame.sessionId === created.id)
expect(forNew.at(-1)?.jobs[0]?.label).toBe('visible to every caller')
})
})
@@ -5,7 +5,9 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import { describe, expect, it, vi } from 'vitest'
import { createSessionTestController } from './test-remote.ts'
import SessionController from '../src/index.ts'
import type { ApiSessionAgentController } from '../src/agent.ts'
import { createSessionTestController, testSessionPersistence } from './test-remote.ts'
const defaults = {
defaultModelSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
@@ -13,6 +15,10 @@ const defaults = {
}
describe('SessionController facade', () => {
it('does not require the Tools service', () => {
expect(SessionController.inject).not.toContain('tools')
})
it('owns Host service methods and publishes Agent lifecycle projections', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -26,10 +32,10 @@ describe('SessionController facade', () => {
}
const events: SessionEvent[] = []
const inspect = vi.fn(() => Promise.resolve({ meta: header, events }))
ctx.provide('sessionPersistence', {
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
list: () => Promise.resolve([header]),
inspect,
} as never)
}) as never)
const controller = createSessionTestController(ctx, defaults)
const status = vi.fn()
const failure = vi.fn()
@@ -49,6 +55,10 @@ describe('SessionController facade', () => {
ctx,
} as Agent
ctx.agents.register(agent)
const consumeSelection = vi.spyOn(
(controller as unknown as { agents: ApiSessionAgentController }).agents,
'consumeSelection',
)
await expect(controller.resolveAgent(sessionId)).resolves.toEqual({ agent })
await expect(controller.inspect(sessionId)).resolves.toEqual({ meta: header, events })
@@ -62,6 +72,21 @@ describe('SessionController facade', () => {
expect(status).toHaveBeenCalledWith(sessionId, true)
expect(failure).toHaveBeenCalledWith(sessionId, expect.stringContaining('fixture failure'))
expect(activity).toHaveBeenCalledWith(sessionId, expect.any(Number))
session.append('request/header', {
header: { config: { provider: 'fixture', model: 'fixture-model' } },
reason: 'initial',
})
expect(consumeSelection).toHaveBeenCalledWith(
agent, 'fixture', 'fixture-model', undefined,
)
const unowned = ctx.sessions.create(SessionId('controller-unowned'), {
meta: { cwd: '/workspace' },
})
unowned.append('request/header', {
header: { config: { provider: 'fixture', model: 'other-model' } },
reason: 'initial',
})
expect(consumeSelection).toHaveBeenCalledTimes(1)
const abort = new AbortController()
const iterator = controller.follow({
@@ -69,9 +94,103 @@ describe('SessionController facade', () => {
}, abort.signal)[Symbol.asyncIterator]()
await expect(iterator.next()).resolves.toMatchObject({
done: false,
value: { type: 'opened', cursor: 0 },
value: { type: 'snapshot', cursor: 1 },
})
abort.abort()
await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined })
})
it.each(['success', 'domain-error', 'throw'] as const)(
'promotes a prepared follow observation in the background: %s',
async (outcome) => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const sessionId = SessionId(`background-${outcome}`)
const header: SessionHeader = {
version: 0, id: sessionId, createdAt: 1, cwd: '/workspace',
}
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
list: () => Promise.resolve([header]),
inspect: () => Promise.resolve({ meta: header, events: [] }),
}) as never)
const controller = createSessionTestController(ctx, defaults)
const agents = (controller as unknown as { agents: ApiSessionAgentController }).agents
const apiError = vi.fn()
ctx.on('api-session/error', apiError)
const logError = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
const live = { id: sessionId, session: { id: sessionId }, ctx, status: 'idle' } as unknown as Agent
const resolve = vi.spyOn(agents, 'resolveObservedAgent')
if (outcome === 'success') resolve.mockResolvedValue({ agent: live })
else if (outcome === 'domain-error') {
resolve.mockResolvedValue({
error: { code: 'internal', message: 'activation unavailable', details: {} },
})
} else {
resolve.mockRejectedValue(new Error('activation crashed'))
}
const abort = new AbortController()
const iterator = controller.follow({
address: { kind: 'session', sessionId },
}, abort.signal)[Symbol.asyncIterator]()
await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } })
const waiting = iterator.next()
await vi.waitFor(() => { expect(resolve).toHaveBeenCalledOnce() })
if (outcome === 'domain-error') {
await vi.waitFor(() => {
expect(apiError).toHaveBeenCalledWith(sessionId, 'activation unavailable')
})
} else if (outcome === 'throw') {
await vi.waitFor(() => {
expect(logError).toHaveBeenCalledWith(expect.stringContaining('activation crashed'))
})
} else {
expect(apiError).not.toHaveBeenCalled()
}
abort.abort()
await expect(waiting).resolves.toMatchObject({ done: true })
await ctx.fiber.dispose()
},
)
it('waits for an admitted background promotion during teardown', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const sessionId = SessionId('background-disposal')
const header: SessionHeader = {
version: 0, id: sessionId, createdAt: 1, cwd: '/workspace',
}
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
list: () => Promise.resolve([header]),
inspect: () => Promise.resolve({ meta: header, events: [] }),
}) as never)
const controller = createSessionTestController(ctx, defaults)
const agents = (controller as unknown as { agents: ApiSessionAgentController }).agents
const started = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
vi.spyOn(agents, 'resolveObservedAgent').mockImplementation(async () => {
started.resolve(undefined)
await release.promise
return {
agent: { id: sessionId, session: { id: sessionId }, ctx, status: 'idle' } as unknown as Agent,
}
})
const iterator = controller.follow({
address: { kind: 'session', sessionId },
}, new AbortController().signal)[Symbol.asyncIterator]()
await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } })
const waiting = iterator.next()
await started.promise
let disposed = false
const disposal = ctx.fiber.dispose().then(() => { disposed = true })
await Promise.resolve()
expect(disposed).toBe(false)
release.resolve(undefined)
await disposal
await expect(waiting).resolves.toMatchObject({ done: true })
})
})
@@ -142,7 +142,7 @@ export function plainTurn(startSeq: number, turn: number, ask: string, answer: s
]
}
/** Wrap raw events as view-less history entries (the wire shape history returns). */
/** Wrap raw events in the journal envelope returned by history. */
export function entries(events: readonly SessionEvent[]): { event: SessionEvent }[] {
return events.map(event => ({ event }))
}
@@ -12,9 +12,9 @@ import type {
SessionControlFrame,
SessionFollowFrame,
SessionFollowRequest,
SessionModels,
SessionPage,
SessionPageRequest,
SessionProjectionBaseline,
SessionSelectModelRequest,
SessionSelectModelValue,
} from '@deepseek-ai/dsh-api-session-controller/types'
@@ -23,6 +23,7 @@ import type { WorkspaceFollowFrame } from '@deepseek-ai/dsh-api-workspace-contro
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import {
RemoteStream,
RemoteStreamError,
type RemoteStreamOptions,
} from '@deepseek-ai/dsh-api-gateway/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
@@ -54,12 +55,6 @@ function addressSessionId(address: SessionAddress): SessionId {
return address.kind === 'session' ? address.sessionId : address.childSessionId
}
function addressKey(address: SessionAddress): string {
return address.kind === 'session'
? `session:${address.sessionId}`
: `subagent:${address.parentSessionId}:${address.childSessionId}:${address.mode}`
}
export interface Deferred<T> {
promise: Promise<T>
resolve(value: T): void
@@ -128,12 +123,6 @@ export class FakeApiClient implements IApiClient {
onSearch: (payload: unknown) => Promise<RpcResponse<{ items: SessionSearchItem[]; hasMore: boolean }>> =
() => Promise.resolve(ok({ items: [], hasMore: false }))
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
current: { provider: 'fixture', model: 'fixture' },
routable: true,
groups: [],
failures: [],
}))
onSelectModel: (payload: SessionSelectModelRequest) => Promise<RpcResponse<SessionSelectModelValue>> =
payload => Promise.resolve(ok({
selected: {
@@ -147,7 +136,7 @@ export class FakeApiClient implements IApiClient {
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
onHistory: (payload: { sessionId: SessionId; throughSeq?: number; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<SessionPage>> =
=> Promise<RpcResponse<SessionPage & { readonly projections?: SessionProjectionBaseline }>> =
() => Promise.resolve(ok({ events: [], hasMore: false }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
@@ -186,7 +175,6 @@ export class FakeApiClient implements IApiClient {
private readonly followConns = new Map<SessionId, ValueStreamConn<SessionFollowFrame>[]>()
private readonly controlConns: ValueStreamConn<SessionControlFrame>[] = []
private readonly workspaceConns: ValueStreamConn<WorkspaceFollowFrame>[] = []
private readonly openingPages = new Map<string, Promise<RpcResponse<SessionPage>>>()
/** Optional Host opening cursor override for stale-page and reconnect tests. */
followCursor: number | undefined
controlBaseline: SessionControlBaseline = {
@@ -292,7 +280,12 @@ export class FakeApiClient implements IApiClient {
readonly llm: IApiClient['llm'] = {
providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))),
models: payload => this.record('llm.models', payload, Promise.resolve(ok({ groups: [], failures: [] }))),
models: payload => this.record('llm.models', payload, Promise.resolve(ok({
default: { provider: 'fixture', model: 'fixture' },
routableProviders: [],
groups: [],
failures: [],
}))),
discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))),
}
@@ -312,7 +305,6 @@ export class FakeApiClient implements IApiClient {
return this.remoteResult('session.search', payload, this.onSearch(payload))
},
create: payload => this.remoteResult('session.create', payload, this.onCreate(payload)),
models: payload => this.remoteResult('session.models', payload, this.onModels(payload)),
selectModel: payload => this.remoteResult(
'session.selectModel',
payload,
@@ -412,14 +404,6 @@ export class FakeApiClient implements IApiClient {
}
private page(request: SessionPageRequest): Promise<RemoteResult<SessionPage>> {
const key = addressKey(request.address)
if (request.beforeSeq === undefined && request.maxMessages === 50) {
const opening = this.openingPages.get(key)
if (opening !== undefined) {
this.openingPages.delete(key)
return this.fetchPage(request, opening)
}
}
return this.fetchPage(request)
}
@@ -466,25 +450,42 @@ export class FakeApiClient implements IApiClient {
): AsyncGenerator<SessionFollowFrame> {
const sessionId = addressSessionId(request.address)
this.followStarts.push(sessionId)
const key = addressKey(request.address)
const initialPage = this.followCursor === undefined
? this.onHistory({ sessionId, maxMessages: 50 })
: undefined
if (initialPage !== undefined) this.openingPages.set(key, initialPage)
this.calls.push({ method: 'session.follow', payload: request })
const conns = this.followConns.get(sessionId) ?? []
if (!this.followConns.has(sessionId)) this.followConns.set(sessionId, conns)
const stream = this.openValueStream(conns, signal)
try {
const page = initialPage === undefined ? undefined : (await initialPage).result
const cursor = this.followCursor
?? (page?.ok ? page.value.events.at(-1)?.event.seq ?? -1 : -1)
yield { type: 'opened', cursor }
const response = await this.onHistory({
sessionId,
maxMessages: request.maxMessages ?? 50,
})
if (!response.result.ok) {
throw new RemoteStreamError(
response.result.error.code,
response.result.error.message,
response.result.error.details,
)
}
const page = response.result.value
const cursor = this.followCursor ?? page.events.at(-1)?.event.seq ?? -1
yield {
type: 'snapshot',
header: {
version: 0,
id: sessionId,
createdAt: 0,
...(request.address.kind === 'subagent'
? { origin: 'subagent' as const, parentSession: request.address.parentSessionId }
: {}),
},
cursor,
events: page.events.filter(entry => entry.event.seq <= cursor),
hasMore: page.hasMore,
projections: page.projections ?? { asOfSeq: cursor, values: {} },
}
yield* stream.values
} finally {
stream.dispose()
if (initialPage !== undefined && this.openingPages.get(key) === initialPage) {
this.openingPages.delete(key)
}
}
}
@@ -310,9 +310,15 @@ describe('subagent catalogs', () => {
})
await manager.get(S2).open()
await manager.get(S2).prompt([{ type: 'text', text: 'continue' }], 'queue')
expect(api.callsOf('subagent.history')).toEqual([
{ parentSessionId: S1, childSessionId: S2, mode: 'continuable', throughSeq: -1, maxMessages: 50 },
expect(api.callsOf('session.follow')).toEqual([
{
address: {
kind: 'subagent', parentSessionId: S1, childSessionId: S2, mode: 'continuable',
},
maxMessages: 50,
},
])
expect(api.callsOf('subagent.history')).toEqual([])
expect(api.callsOf('subagent.prompt')).toEqual([
{
parentSessionId: S1, childSessionId: S2, mode: 'continuable',
@@ -507,6 +513,7 @@ describe('subagent catalogs', () => {
api.onSubagentList = () => first.promise
const manager = new SessionManager(api, fakeRemote(api), root)
const refresh = manager.refreshSubagents(root)
manager.setSubagentCatalogOpen(root, true)
// A membership frame arrives while the pull is in flight; the debounced
// refresh it schedules fires 50ms later and is coalesced into the pull —
@@ -768,17 +775,37 @@ describe('connected generation', () => {
expect(api.callsOf('session.history')).toHaveLength(historyCallsBefore)
})
it('reloads the durable parent address for a restored child selection', async () => {
it('retains the durable parent address and refreshes its catalogs across reconnect', async () => {
const api = new FakeApiClient()
const address = {
parentSessionId: S1, childSessionId: S2, mode: 'continuable' as const,
}
const parent = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
const child = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = payload => (
(payload as { parentSessionId: SessionId }).parentSessionId === S1
? parent.promise
: child.promise
)
const manager = new SessionManager(api, fakeRemote(api), S2, address)
manager.handleConnected()
expect(manager.get(S2).getSnapshot().subagent).toEqual({ address })
parent.resolve(ok({ entries: [], parentAvailable: true }))
child.resolve(ok({ entries: [], parentAvailable: true }))
await vi.waitFor(() => {
expect(api.callsOf('subagent.list')).toContainEqual({ parentSessionId: S1 })
expect(api.callsOf('session.list')).toHaveLength(1)
})
await vi.waitFor(() => {
expect(api.callsOf('subagent.list')).toEqual([
{ parentSessionId: S1 },
{ parentSessionId: S2 },
])
})
expect(manager.get(S2).getSnapshot().subagent).toEqual({
address,
parentAvailable: true,
})
expect(manager.getListSnapshot().currentAddress).toEqual(address)
})
@@ -4,19 +4,21 @@
* isolation, and prompt failure mapping.
*/
import { describe, expect, it, vi } from 'vitest'
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import SessionStore from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts'
import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts'
import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts'
import {
PersistenceCoordinator,
@@ -24,7 +26,12 @@ import {
type PersistenceBackend,
type StoredSessionSource,
} from '@deepseek-ai/dsh-session-persistence'
import { createSessionTestRemote } from './test-remote.ts'
import { ApiSessionList } from '../src/list.ts'
import {
createSessionTestRemote,
installSessionReadTestServices,
testSessionPersistence,
} from './test-remote.ts'
const sid = (id: string): SessionId => id as SessionId
@@ -46,8 +53,12 @@ function header(id: string, createdAt: number, extra: Partial<SessionHeader> = {
return { version: 0, id: sid(id), createdAt, cwd: '/proj', ...extra }
}
function providePersistence(ctx: Context, persistence: Record<string, unknown>): () => void {
return ctx.provide('sessionPersistence', testSessionPersistence(ctx, persistence) as never)
}
describe('sessions.list cold merge', () => {
it('verifies only small possibly-blank artifacts and treats every unavailable probe as visible', async () => {
it('fully observes only small possibly-blank artifacts and treats unavailable probes as visible', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const root = mkdtempSync(join(tmpdir(), 'dsh-cold-'))
@@ -63,8 +74,9 @@ describe('sessions.list cold merge', () => {
header('locationless', 500, { parentSession: sid('session-parent'), origin: 'subagent' }),
header('vanished', 600),
header('read-failure', 700),
{ version: 0, id: sid('missing-cwd'), createdAt: 800 },
]
const readFrom = vi.fn(async (id: SessionId) => {
const inspect = vi.fn(async (id: SessionId) => {
if (id === sid('small-blank')) {
return {
meta: metas[0]!,
@@ -87,7 +99,7 @@ describe('sessions.list cold merge', () => {
if (id === sid('read-failure')) throw new Error('simulated read failure')
throw new Error(`unexpected cold read: ${id}`)
})
ctx.provide('sessionPersistence', {
providePersistence(ctx, {
list: () => Promise.resolve(metas),
locate: (meta: SessionHeader) => {
if (meta.id === sid('large-unknown')) return { kind: 'jsonl', path: largePath }
@@ -95,8 +107,8 @@ describe('sessions.list cold merge', () => {
if (meta.id === sid('vanished')) return { kind: 'jsonl', path: join(root, 'vanished.log') }
return { kind: 'jsonl', path: smallPath }
},
readFrom,
} as never)
inspect,
})
ctx.provide('sessionProjectionCache', {
cachedSnapshot: (meta: SessionHeader) => {
if (meta.id === sid('small-blank')) {
@@ -110,6 +122,8 @@ describe('sessions.list cold merge', () => {
}
return undefined
},
hydratePrepared: (session: Session, _meta: SessionHeader, events: readonly SessionEvent[]) =>
ctx.sessionProjections.hydrate(session, {}, events, 0),
} as never)
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
@@ -118,10 +132,8 @@ describe('sessions.list cold merge', () => {
if (!response.ok) throw new Error('unreachable')
const byId = Object.fromEntries(response.value.items.map(item => [item.sessionId, item]))
expect(byId['small-blank']).toMatchObject({ blank: true, updatedAt: 100, running: false })
// A stale true hint cannot hide the turn found in the bounded read.
expect(byId['small-conversation']).toMatchObject({ blank: false, updatedAt: 1200 })
expect(byId['large-unknown']).toMatchObject({ blank: false, updatedAt: 300 })
// false is monotonic, so this row skips stat/read and keeps cached recency.
expect(byId['cached-nonblank']).toMatchObject({ blank: false, updatedAt: 1000 })
expect(byId['locationless']).toMatchObject({
blank: false,
@@ -131,24 +143,25 @@ describe('sessions.list cold merge', () => {
})
expect(byId['vanished']).toMatchObject({ blank: false, updatedAt: 600 })
expect(byId['read-failure']).toMatchObject({ blank: false, updatedAt: 700 })
expect(readFrom).toHaveBeenCalledTimes(3)
expect(readFrom.mock.calls.map(([id]) => id)).toEqual(expect.arrayContaining([
expect(byId['missing-cwd']).toBeUndefined()
expect(inspect).toHaveBeenCalledTimes(3)
expect(inspect.mock.calls.map(([id]) => id)).toEqual(expect.arrayContaining([
sid('small-blank'),
sid('small-conversation'),
sid('read-failure'),
]))
})
it('can disable bounded blank probes without hiding cold Sessions', async () => {
it('can disable bounded cold observations without hiding cold Sessions', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const meta = header('probe-disabled', 100)
const readFrom = vi.fn()
ctx.provide('sessionPersistence', {
const inspect = vi.fn()
providePersistence(ctx, {
list: () => Promise.resolve([meta]),
locate: () => ({ kind: 'jsonl', path: '/not-read' }),
readFrom,
} as never)
inspect,
})
const remote = createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
cwd: '/tmp',
@@ -160,31 +173,23 @@ describe('sessions.list cold merge', () => {
expect(response.value.items).toEqual([
expect.objectContaining({ sessionId: meta.id, blank: false, updatedAt: meta.createdAt }),
])
expect(readFrom).not.toHaveBeenCalled()
expect(inspect).not.toHaveBeenCalled()
})
it('replaces a probed cold row with the live Session that attached during the read', async () => {
it('prefers a live row attached during the query without folding its seed', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const meta = header('attached-during-probe', 100)
const root = mkdtempSync(join(tmpdir(), 'dsh-cold-race-'))
const path = join(root, 'small.log')
writeFileSync(path, 'x')
const meta = header('attached-during-list', 100)
const started = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
locate: () => ({ kind: 'jsonl', path }),
readFrom: async () => {
providePersistence(ctx, {
list: async () => {
started.resolve(undefined)
await release.promise
return {
meta,
events: [{ type: 'session/end-seed', seq: 0, time: 110, data: {} }] as SessionEvent[],
}
return [meta]
},
} as never)
})
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const listing = remote.list(request({}))
@@ -213,10 +218,89 @@ describe('sessions.list cold merge', () => {
sessionId: meta.id,
blank: false,
running: true,
updatedAt: 300,
updatedAt: 100,
}),
])
})
it('prefers a Session that attaches during its bounded cold observation', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const root = mkdtempSync(join(tmpdir(), 'dsh-cold-race-'))
const path = join(root, 'small.log')
writeFileSync(path, 'small')
const meta = header('attached-during-probe', 100)
providePersistence(ctx, {
list: () => Promise.resolve([meta]),
locate: () => ({ kind: 'jsonl', path }),
inspect: () => {
const session = ctx.sessions.create(meta.id, {
meta,
seed: [{ type: 'turn/start', seq: 0, time: 200, data: { turn: 1 } }],
})
ctx.agents.register({ id: session.id, session, status: 'running', ctx } as Agent)
return Promise.resolve({ meta, events: [] })
},
})
const remote = createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp',
})
const response = await remote.list(request({}))
if (!response.ok) throw new Error('list failed')
expect(response.value.items).toEqual([
expect.objectContaining({ sessionId: meta.id, running: true, blank: false }),
])
await ctx.fiber.dispose()
})
it('propagates a cold location failure instead of returning a partial list', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const meta = header('broken-cache', 100)
providePersistence(ctx, {
list: () => Promise.resolve([meta]),
locate: () => { throw new Error('location failed') },
})
const remote = createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp',
})
await expect(remote.list(request({}))).resolves.toMatchObject({
ok: false,
error: { message: expect.stringContaining('location failed') as string },
})
await ctx.fiber.dispose()
})
it('supports an unsignalled probe whose observation has no projection registry', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
installSessionReadTestServices(ctx)
const root = mkdtempSync(join(tmpdir(), 'dsh-cold-unprojected-'))
const path = join(root, 'small.log')
writeFileSync(path, 'small')
const meta = header('unprojected-small', 100)
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
locate: () => ({ kind: 'jsonl', path }),
} as never)
vi.spyOn(ctx.sessionQuery, 'listSessions').mockResolvedValue([{
header: meta, live: false, persisted: true,
}])
vi.spyOn(ctx.sessionQuery, 'observeSession').mockResolvedValue({
source: 'prepared', header: meta, events: [], cursor: -1,
retain: vi.fn(), [Symbol.dispose]: vi.fn(),
})
const list = new ApiSessionList(ctx, 1024)
await expect(list.list()).resolves.toEqual([
expect.objectContaining({ sessionId: meta.id, blank: false }),
])
await ctx.fiber.dispose()
})
})
describe('attached updatedAt tracks human prompts', () => {
@@ -225,6 +309,7 @@ describe('attached updatedAt tracks human prompts', () => {
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
await new Promise(resolve => setTimeout(resolve, 0))
// Old work, resumed just now: the log tail would report the pickup.
const worked = 1_000_000
@@ -248,7 +333,7 @@ describe('attached updatedAt tracks human prompts', () => {
const listed = await remote.list(request({}))
if (!listed.ok) throw new Error('list failed')
const summary = listed.value.items.find(item => item.sessionId === 'resumed-untouched')
expect(summary?.updatedAt).toBe(worked)
expect(summary?.updatedAt).toBe(500)
// A lifecycle boundary is not a human update.
resumed.append('turn/start', { turn: 2 })
@@ -300,11 +385,12 @@ describe('cold history recovery view', () => {
list: () => Promise.resolve([structuredClone(meta)]),
}
const coordinator = new PersistenceCoordinator(ctx, backend)
ctx.provide('sessionPersistence', {
providePersistence(ctx, {
list: (signal?: AbortSignal) => backend.list(signal),
inspect: (id: SessionId, signal?: AbortSignal) => coordinator.inspect(id, signal),
borrowSession: (id: SessionId, signal?: AbortSignal) => coordinator.borrowSession(id, signal),
locate: () => undefined,
} as never)
})
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const history = await remote.page({
@@ -351,11 +437,11 @@ describe('Remote Agent and Session lookup policy', () => {
const sessionId = sid('session-remote-cold')
const meta = header(sessionId, 1000)
const inspect = vi.fn(() => Promise.resolve({ meta, events: [] as SessionEvent[] }))
ctx.provide('sessionPersistence', {
providePersistence(ctx, {
list: () => Promise.resolve([meta]),
inspect,
locate: () => undefined,
} as never)
})
const resumedSession = { id: sessionId, header: meta, events: [] } as unknown as import('@deepseek-ai/dsh-session').Session
const resumedAgent = { id: sessionId, session: resumedSession, status: 'idle', ctx } as Agent
const release = Promise.withResolvers<undefined>()
@@ -395,11 +481,11 @@ describe('Remote Agent and Session lookup policy', () => {
origin: 'subagent',
})
const inspect = vi.fn(() => Promise.resolve({ meta: coldMeta, events: [] as SessionEvent[] }))
ctx.provide('sessionPersistence', {
providePersistence(ctx, {
list: () => Promise.resolve([coldMeta]),
inspect,
locate: () => undefined,
} as never)
})
const liveSession = ctx.sessions.create(sid('session-remote-live-child'), {
meta: { cwd: '/proj', parentSession: sid('session-parent'), origin: 'subagent' },
})
@@ -451,27 +537,35 @@ describe('subagent ownership fence', () => {
type: 'user/message',
seq: 1,
time: 2,
data: { content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } },
data: createUserMessage({ content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } }),
surfaceOp: 'append',
},
{
type: 'subagent/descriptor',
seq: 2,
time: 3,
data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'child' },
data: snapshotSubagentDescriptor({
mode: 'continuable',
provider: 'spawn',
label: 'child',
}),
},
{ type: 'turn/end', seq: 3, time: 4, data: { turn: 1, reason: { kind: 'completed' } } },
] as SessionEvent[]
const inspect = vi.fn(() => Promise.resolve({ meta, events }))
ctx.provide('sessionPersistence', {
providePersistence(ctx, {
list: () => Promise.resolve([meta]),
inspect,
locate: () => undefined,
} as never)
})
const resume = vi.spyOn(ctx.agents, 'resume')
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
ctx.sessionProjections.register(subagentIdentityProjectionDefinition)
const history = await new SessionHistoryController(ctx).page({
const history = await new SessionHistoryController(
ctx,
(observation) => { observation[Symbol.dispose]() },
).page({
address: {
kind: 'subagent',
parentSessionId: meta.parentSession as SessionId,
@@ -521,11 +615,11 @@ describe('subagent ownership fence', () => {
data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'child' },
},
] as SessionEvent[]
ctx.provide('sessionPersistence', {
providePersistence(ctx, {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events }),
locate: () => undefined,
} as never)
})
// Stores whose headers predate `origin` classify a child only through the
// descriptor event; the pre-release decision stops recognizing them, so
// the ownership fence lets generic resume reach the registry instead of
@@ -588,9 +682,13 @@ describe('subagent ownership fence', () => {
if (!queued.ok) expect(queued.error.code).toBe('agent-busy')
expect(updateInbox).not.toHaveBeenCalled()
const models = await remote.models(request({ sessionId: startingChild.id }))
expect(models.ok).toBe(false)
if (!models.ok) expect(models.error.code).toBe('agent-busy')
const selection = await remote.selectModel(request({
sessionId: startingChild.id,
provider: 'p',
model: 'm',
}))
expect(selection.ok).toBe(false)
if (!selection.ok) expect(selection.error.code).toBe('agent-busy')
const create = await remote.create(request({ sessionId: originChild.id, cwd: '/proj' }))
expect(create.ok).toBe(false)
@@ -695,7 +793,7 @@ describe('subagent ownership fence', () => {
})
describe('degenerate composition (no persistence, no factory)', () => {
it('list skips the cold merge and history reports missing persistence as internal', async () => {
it('lists no cold rows and reports an absent point source as not found', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
@@ -712,20 +810,19 @@ describe('degenerate composition (no persistence, no factory)', () => {
})
expect(response.ok).toBe(false)
if (!response.ok) {
expect(response.error.code).toBe('internal')
expect(response.error.message).toMatch(/session persistence is not configured/)
expect(response.error.code).toBe('session-not-found')
}
})
it('maps a persistence catalog miss to session-not-found without inspection', async () => {
it('maps a missing direct persistence read to session-not-found', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const inspect = vi.fn()
ctx.provide('sessionPersistence', {
providePersistence(ctx, {
list: () => Promise.resolve([]),
inspect,
} as never)
})
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const response = await remote.page({
@@ -734,7 +831,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
})
expect(response.ok).toBe(false)
if (!response.ok) expect(response.error.code).toBe('session-not-found')
expect(inspect).not.toHaveBeenCalled()
expect(inspect).toHaveBeenCalledOnce()
})
})
@@ -777,11 +874,11 @@ describe('sessions.prompt synchronous rejection', () => {
await ctx.plugin(AgentRegistry)
const sessionId = sid('race-resume')
const meta: SessionHeader = header('race-resume', 1000)
ctx.provide('sessionPersistence', {
providePersistence(ctx, {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events: [] as SessionEvent[] }),
locate: () => undefined,
} as never)
})
// The raced winner: a live parent-owned subagent publishes the identity
// while the generic cold resume is in flight, so the resume collides.
const parentSession = ctx.sessions.create(sid('race-parent'), { meta: { cwd: '/proj' } })
@@ -799,10 +896,10 @@ describe('sessions.prompt synchronous rejection', () => {
})
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const models = await remote.models(request({ sessionId }))
expect(models.ok).toBe(false)
if (!models.ok) {
expect(models.error).toMatchObject({
const selection = await remote.selectModel(request({ sessionId, provider: 'p', model: 'm' }))
expect(selection.ok).toBe(false)
if (!selection.ok) {
expect(selection.error).toMatchObject({
code: 'agent-busy',
details: { reason: 'use subagent delivery for this child session' },
})
@@ -10,7 +10,9 @@ import SessionStore from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import type { Workspace } from '@deepseek-ai/dsh-workspace'
import { createSessionTestRemote } from './test-remote.ts'
import {
createSessionTestRemote, installSessionReadTestServices, testSessionPersistence,
} from './test-remote.ts'
const sid = (id: string): SessionId => id as SessionId
@@ -23,6 +25,7 @@ async function composed(workspaces: readonly Workspace[] = []): Promise<Context>
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(AgentRegistry)
installSessionReadTestServices(ctx)
ctx.provide('workspaceRegistry', { list: () => workspaces } as never)
ctx.agents.setFactory({
createAgent: async (ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> => {
@@ -116,18 +119,16 @@ describe('sessions.fork', () => {
parentSession: child.id,
origin: 'subagent',
})
ctx.provide('sessionQuery', {
traceSession: vi.fn(() => Promise.resolve({
target: { header: grandchild.header, live: true, persisted: false },
ancestors: [
{ header: child.header, live: true, persisted: false },
{ header: owner.header, live: true, persisted: false },
],
descendants: [],
complete: true,
root: { header: owner.header, live: true, persisted: false },
})),
} as never)
vi.spyOn(ctx.sessionQuery, 'traceSession').mockResolvedValue({
target: { header: grandchild.header, live: true, persisted: false },
ancestors: [
{ header: child.header, live: true, persisted: false },
{ header: owner.header, live: true, persisted: false },
],
descendants: [],
complete: true,
root: { header: owner.header, live: true, persisted: false },
})
const response = await remote(ctx).fork(request({ sessionId: grandchild.id }))
@@ -165,19 +166,10 @@ describe('sessions.fork', () => {
},
{ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } },
] as SessionEvent[]
ctx.provide('sessionPersistence', {
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
list: () => Promise.resolve([header]),
inspect: () => Promise.resolve({ meta: header, events }),
} as never)
ctx.provide('sessionQuery', {
traceSession: () => Promise.resolve({
target: { header, live: false, persisted: true },
ancestors: [],
descendants: [],
complete: true,
root: { header, live: false, persisted: true },
}),
} as never)
}) as never)
const resume = vi.spyOn(ctx.agents, 'resume')
const response = await remote(ctx).fork(request({ sessionId: sourceId }))
@@ -0,0 +1,304 @@
/** Raw Session journal transport and message-aligned pagination coverage. */
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import { CallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts'
import type { SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types'
import { createSessionTestRemote, installSessionReadTestServices } from './test-remote.ts'
/** Append a production-shaped human prompt to the session surface. */
function appendUserText(session: Session, text: string): SessionEvent {
return session.append('user/message', createUserMessage({
content: [{ type: 'text', text }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
}
/** Append a production-shaped assistant message to the session surface. */
function appendAssistantText(session: Session, text: string, step: number): SessionEvent {
return session.append('assistant/message', {
turn: 1,
step,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text }],
source: { kind: 'model', provider: 'p', model: 'm' },
}),
}, { surfaceOp: 'append' })
}
/**
* Append a plugin-owned log-only event. The host proxy is projection-only, so it
* declares no compaction vocabulary; the cast writes the real event shape without
* depending on the owning package.
*/
function appendExtension(session: Session, type: string, data: unknown): SessionEvent {
return (session.append as unknown as (type: string, data: unknown) => SessionEvent)(type, data)
}
async function harness(): Promise<{ ctx: Context }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
installSessionReadTestServices(ctx)
return { ctx }
}
/** Drain one Session follow until `count` event frames arrive. */
async function collect(
iterable: AsyncIterable<SessionFollowFrame>,
count: number,
abort: AbortController,
): Promise<SessionFollowFrame[]> {
const frames: SessionFollowFrame[] = []
for await (const frame of iterable) {
frames.push(frame)
if (frames.filter(candidate => candidate.type === 'event').length >= count) abort.abort()
}
return frames
}
/** Open follow and wait until its cursor is fixed before appending fixtures. */
async function openFollow(
history: SessionHistoryController,
sessionId: SessionId,
signal: AbortSignal,
): Promise<AsyncIterable<SessionFollowFrame>> {
const iterator = history.follow({
address: { kind: 'session', sessionId },
}, signal)[Symbol.asyncIterator]()
await expect(iterator.next()).resolves.toMatchObject({
done: false,
value: { type: 'snapshot' },
})
return { [Symbol.asyncIterator]: () => iterator }
}
describe('Session history raw journal', () => {
it('follows raw tool events and preserves result metadata without a Tools service', async () => {
const { ctx } = await harness()
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() })
const abort = new AbortController()
const stream = await openFollow(history, session.id, abort.signal)
const collected = collect(stream, 2, abort)
const call = session.append('tool/call', {
turn: 1, step: 1, callId: CallId('raw-call'), name: 'custom', arguments: '{malformed',
})
const result = session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('raw-call'),
content: [{ type: 'text', text: 'raw output' }],
isError: false,
}),
meta: { nested: { count: 2 }, paths: ['a.ts', 'b.ts'] },
}, { surfaceOp: 'append' })
const frames = await collected
expect(frames).toEqual([
{ type: 'event', event: call },
{ type: 'event', event: result },
])
expect((frames[1] as Extract<SessionFollowFrame, { type: 'event' }>).event.data)
.toMatchObject({ meta: { nested: { count: 2 }, paths: ['a.ts', 'b.ts'] } })
})
it('follows live results without rescanning Session history', async () => {
const { ctx } = await harness()
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() })
const abort = new AbortController()
const stream = await openFollow(history, session.id, abort.signal)
const iterator = stream[Symbol.asyncIterator]()
session.append('tool/call', {
turn: 1, step: 1, callId: CallId('live-fast'), name: 'term', arguments: '{"cmd":"pwd"}',
})
await expect(iterator.next()).resolves.toMatchObject({
value: { type: 'event', event: { type: 'tool/call', data: { callId: 'live-fast' } } },
})
const events = vi.spyOn(session, 'events', 'get').mockImplementation(() => {
throw new Error('live result rescanned Session history')
})
try {
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('live-fast'),
content: [{ type: 'text', text: 'ok' }],
isError: false,
}),
}, { surfaceOp: 'append' })
await expect(iterator.next()).resolves.toMatchObject({
value: { type: 'event', event: { type: 'tool/result', data: { message: { source: { callId: 'live-fast' } } } } },
})
} finally {
events.mockRestore()
abort.abort()
await iterator.next()
await ctx.fiber.dispose()
}
})
it('serves raw call and result entries without parsing tool arguments', async () => {
const { ctx } = await harness()
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
const start = session.append('turn/start', { turn: 1 })
const call = session.append('tool/call', {
turn: 1, step: 1, callId: CallId('history-call'), name: 'custom', arguments: '{broken',
})
const result = session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('history-call'),
content: [{ type: 'text', text: 'failed raw output' }],
isError: true,
}),
meta: { persisted: true, count: 3 },
}, { surfaceOp: 'append' })
const response = await remote.page({
address: { kind: 'session', sessionId: session.id },
throughSeq: session.seq - 1,
})
expect(response.ok).toBe(true)
if (!response.ok) throw new Error('unreachable')
expect(response.value.events).toEqual([
{ event: start },
{ event: call },
{ event: result },
])
})
it('counts only append-origin messages toward maxMessages and keeps each compaction summary with its replacement', async () => {
const { ctx } = await harness()
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
session.append('turn/start', { turn: 1 })
const first = appendUserText(session, 'first prompt')
appendAssistantText(session, 'first reply', 1)
const third = appendUserText(session, 'second prompt')
appendAssistantText(session, 'second reply', 2)
const shadowed = [...session.surface.nodes]
// A compaction transaction: a log-only summary record immediately followed by the
// replacement that shadows the range.
const summary = appendExtension(session, 'compaction/summary', {
summary: [{ type: 'text', text: 'summary' }],
shadowedRange: { start: shadowed[0], end: shadowed.at(-1) },
shadowedSeqs: shadowed,
shadowedTokenCount: 0,
provider: 'p',
model: 'm',
})
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: '<context_checkpoint>summary</context_checkpoint>' }],
source: { kind: 'plugin', plugin: 'compact' },
}), {
surfaceOp: { op: 'replace', start: shadowed[0] as number, end: shadowed.at(-1) as number },
sourceEventSeqs: [...shadowed, summary.seq],
})
const response = await remote.page({
address: { kind: 'session', sessionId: session.id },
throughSeq: session.seq - 1,
maxMessages: 2,
})
if (!response.ok) throw new Error('unreachable')
const page = response.value.events.map(entry => entry.event)
// Two append-origin messages fill the page even though a replacement copy of
// the same event type sits in the window: the copy is model-only.
const messages = page.filter(event => event.type === 'user/message' || event.type === 'assistant/message')
expect(messages.map(event => event.seq)).toEqual([third.seq, third.seq + 1, third.seq + 3])
expect(page.some(event => event.seq === first.seq)).toBe(false)
expect(response.value.hasMore).toBe(true)
// The range stays contiguous, so the checkpoint's summary record is readable on
// the same page as the checkpoint itself.
const summaryIndex = page.findIndex(event => event.seq === summary.seq)
expect(summaryIndex).toBeGreaterThan(-1)
expect(page[summaryIndex + 1]?.seq).toBe(summary.seq + 1)
expect(page.map(event => event.seq)).toEqual(page.map((_event, index) => third.seq + index))
})
it('paginates a message with many provenance sources without variadic argument expansion', async () => {
const { ctx } = await harness()
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
session.append('turn/start', { turn: 1 })
const sources = Array.from({ length: 128 }, (_unused, index) => session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index, text: 'x' },
}).seq)
const message = session.append('assistant/message', {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'x'.repeat(sources.length) }],
source: { kind: 'model', provider: 'p', model: 'm' },
}),
}, { surfaceOp: 'append', sourceEventSeqs: sources })
const scalarMin = Math.min
const min = vi.spyOn(Math, 'min').mockImplementation((...values) => {
if (values.length > 2) throw new RangeError('variadic minimum rejected by regression harness')
return scalarMin(...values)
})
try {
const response = await remote.page({
address: { kind: 'session', sessionId: session.id },
throughSeq: message.seq,
maxMessages: 1,
})
if (!response.ok) throw new Error('unreachable')
expect(response.value.events.map(entry => entry.event.seq)).toEqual([...sources, message.seq])
expect(response.value.hasMore).toBe(true)
} finally {
min.mockRestore()
}
})
it('follows a result after turn/end without reading the addressed Session log', async () => {
const { ctx } = await harness()
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() })
const abort = new AbortController()
const stream = await openFollow(history, session.id, abort.signal)
const iterator = stream[Symbol.asyncIterator]()
session.append('turn/start', { turn: 1 })
await expect(iterator.next()).resolves.toMatchObject({ value: { event: { type: 'turn/start' } } })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-late'), name: 'term', arguments: '{"cmd":"tail"}' })
await expect(iterator.next()).resolves.toMatchObject({ value: { event: { type: 'tool/call' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await expect(iterator.next()).resolves.toMatchObject({ value: { event: { type: 'turn/end' } } })
const events = vi.spyOn(session, 'events', 'get').mockImplementation(() => {
throw new Error('live result rescanned Session history')
})
try {
const result = session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('c-late'),
content: [{ type: 'text', text: 'ok' }],
isError: false,
}),
}, { surfaceOp: 'append' })
await expect(iterator.next()).resolves.toEqual({
done: false,
value: { type: 'event', event: result },
})
} finally {
events.mockRestore()
abort.abort()
await iterator.next()
await ctx.fiber.dispose()
}
})
})
@@ -1,411 +0,0 @@
/**
* Tool-card view computation over Session Controller history and follow: three standard card types
* arrive on the frame, a presenterless tool ships no view field, a call-only
* presenter keeps raw result content out of the view payload, and a throwing
* presenter soft-falls to no view (the event still ships). Result pairing
* works for both paged and live entries.
*/
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { CallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts'
import type { SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types'
import { createSessionTestRemote } from './test-remote.ts'
const reply = (text: string): Promise<ContentBlock[]> => Promise.resolve([{ type: 'text', text }])
function tool(name: string, presenters: Pick<ToolDefinition, 'presentCall' | 'presentResult'>): ToolDefinition {
return defineContentToolFixture({
name,
description: `tool ${name}`,
parameters: {},
execute: () => reply(`ran:${name}`),
...presenters,
})
}
/** Append a production-shaped human prompt to the session surface. */
function appendUserText(session: Session, text: string): SessionEvent {
return session.append('user/message', createUserMessage({
content: [{ type: 'text', text }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
}
/** Append a production-shaped assistant message to the session surface. */
function appendAssistantText(session: Session, text: string, step: number): SessionEvent {
return session.append('assistant/message', {
turn: 1,
step,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text }],
source: { kind: 'model', provider: 'p', model: 'm' },
}),
}, { surfaceOp: 'append' })
}
/**
* Append a plugin-owned log-only event. The host proxy is projection-only, so it
* declares no compaction vocabulary; the cast writes the real event shape without
* depending on the owning package.
*/
function appendExtension(session: Session, type: string, data: unknown): SessionEvent {
return (session.append as unknown as (type: string, data: unknown) => SessionEvent)(type, data)
}
async function harness(): Promise<{ ctx: Context }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(ToolRuntime)
await ctx.plugin(AgentRegistry)
ctx.tools.register(tool('gen', {
presentCall: () => ({ card: 'generic', title: 'gen call' }),
presentResult: (_args, result) => ({ card: 'generic', title: result.isError ? 'gen failed' : 'gen done' }),
}))
ctx.tools.register(tool('term', {
presentCall: args => ({ card: 'terminal', title: (args as { cmd?: string }).cmd ?? '' }),
presentResult: () => ({ card: 'terminal', output: 'done' }),
}))
ctx.tools.register(tool('diffy', {
presentCall: () => ({ card: 'diff', title: 'Write f.txt', diffs: [{ path: 'f.txt', oldText: null, newText: 'x' }] }),
}))
ctx.tools.register(tool('call-only', {
presentCall: () => ({ card: 'generic', title: 'program', kind: 'execute', rawInput: 'return value' }),
}))
ctx.tools.register(tool('plain', {}))
ctx.tools.register(tool('boom', {
presentCall: () => { throw new Error('presenter exploded') },
}))
return { ctx }
}
/** Drain one Session follow until `count` event frames arrive. */
async function collect(
iterable: AsyncIterable<SessionFollowFrame>,
count: number,
abort: AbortController,
): Promise<SessionFollowFrame[]> {
const frames: SessionFollowFrame[] = []
for await (const frame of iterable) {
frames.push(frame)
if (frames.filter(candidate => candidate.type === 'event').length >= count) abort.abort()
}
return frames
}
/** Open follow and wait until its cursor is fixed before appending fixtures. */
async function openFollow(
history: SessionHistoryController,
sessionId: SessionId,
signal: AbortSignal,
): Promise<AsyncIterable<SessionFollowFrame>> {
const iterator = history.follow({
address: { kind: 'session', sessionId },
}, signal)[Symbol.asyncIterator]()
await expect(iterator.next()).resolves.toMatchObject({
done: false,
value: { type: 'opened' },
})
return { [Symbol.asyncIterator]: () => iterator }
}
describe('Session history view computation', () => {
it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => {
const { ctx } = await harness()
const session = ctx.sessions.create()
const history = new SessionHistoryController(ctx)
const abort = new AbortController()
const stream = await openFollow(history, session.id, abort.signal)
const collected = collect(stream, 9, abort)
const rawResult = `RAW_RESULT:${'x'.repeat(64 * 1024)}`
session.append('turn/start', { turn: 1 })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-gen'), name: 'gen', arguments: '{}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-term'), name: 'term', arguments: '{"cmd":"echo hi"}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-diff'), name: 'diffy', arguments: '{}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-call-only'), name: 'call-only', arguments: '{}' })
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('c-call-only'),
content: [{ type: 'text', text: rawResult }],
isError: false,
}),
}, { surfaceOp: 'append' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-plain'), name: 'plain', arguments: '{}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-boom'), name: 'boom', arguments: '{}' })
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('c-gen'),
content: [{ type: 'text', text: 'ok' }],
isError: false,
}),
}, { surfaceOp: 'append' })
const frames = await collected
const events = frames.filter(f => f.type === 'event')
const byCall = new Map(events
.filter(f => f.event.type === 'tool/call' || f.event.type === 'tool/result')
.map(f => [
`${f.event.type}:${f.event.type === 'tool/call'
? (f.event.data as unknown as SessionEvent<'tool/call'>['data']).callId
: (f.event.data as unknown as SessionEvent<'tool/result'>['data']).message.source.callId}`,
f,
]))
expect(byCall.get('tool/call:c-gen')?.view).toEqual({ for: 'call', view: { card: 'generic', title: 'gen call' } })
expect(byCall.get('tool/call:c-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'echo hi' } })
expect(byCall.get('tool/call:c-diff')?.view?.view.card).toBe('diff')
expect(byCall.get('tool/call:c-call-only')?.view).toEqual({
for: 'call',
view: { card: 'generic', title: 'program', kind: 'execute', rawInput: 'return value' },
})
const callOnlyResult = byCall.get('tool/result:c-call-only')
expect('view' in (callOnlyResult ?? {})).toBe(false)
const serializedResult = JSON.stringify(callOnlyResult)
expect(serializedResult.indexOf(rawResult)).toBeGreaterThanOrEqual(0)
expect(serializedResult.indexOf(rawResult)).toBe(serializedResult.lastIndexOf(rawResult))
// No presenter → the frame carries no view property at all.
expect('view' in (byCall.get('tool/call:c-plain') ?? {})).toBe(false)
// Throwing presenter → soft-fall: event ships, no view.
expect(byCall.get('tool/call:c-boom')).toBeDefined()
expect('view' in (byCall.get('tool/call:c-boom') ?? {})).toBe(false)
// Result pairing through the live table: presentResult saw the call's args.
expect(byCall.get('tool/result:c-gen')?.view).toEqual({ for: 'result', view: { card: 'generic', title: 'gen done' } })
})
it('pairs live results from the open-call table without rescanning Session history', async () => {
const { ctx } = await harness()
const session = ctx.sessions.create()
const history = new SessionHistoryController(ctx)
const abort = new AbortController()
const stream = await openFollow(history, session.id, abort.signal)
const iterator = stream[Symbol.asyncIterator]()
session.append('tool/call', {
turn: 1, step: 1, callId: CallId('live-fast'), name: 'term', arguments: '{"cmd":"pwd"}',
})
await expect(iterator.next()).resolves.toMatchObject({
value: { type: 'event', view: { for: 'call', view: { card: 'terminal', title: 'pwd' } } },
})
const events = vi.spyOn(session, 'events', 'get').mockImplementation(() => {
throw new Error('live result rescanned Session history')
})
try {
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('live-fast'),
content: [{ type: 'text', text: 'ok' }],
isError: false,
}),
}, { surfaceOp: 'append' })
await expect(iterator.next()).resolves.toMatchObject({
value: { type: 'event', view: { for: 'result', view: { card: 'terminal', output: 'done' } } },
})
} finally {
events.mockRestore()
abort.abort()
await iterator.next()
await ctx.fiber.dispose()
}
})
it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => {
const { ctx } = await harness()
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create()
// history resolves the agent first; a live structural stub is enough (only
// .session is read on this path).
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
session.append('turn/start', { turn: 1 })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-term'), name: 'term', arguments: '{"cmd":"ls"}' })
// meta rides through to presentResult's ToolResult (the spread arm).
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('h-term'),
content: [{ type: 'text', text: 'ok' }],
isError: false,
}),
meta: { n: 1 },
}, { surfaceOp: 'append' })
// Unpaired result: no tool/call with this id anywhere in the page.
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('h-orphan'),
content: [{ type: 'text', text: 'x' }],
isError: false,
}),
}, { surfaceOp: 'append' })
// Paired, but the call's stored arguments do not parse: backscan soft-falls.
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-bad'), name: 'term', arguments: '{broken' })
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('h-bad'),
content: [{ type: 'text', text: 'y' }],
isError: false,
}),
}, { surfaceOp: 'append' })
// Presenterless tool: pairing succeeds but presentResult is absent.
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-plain'), name: 'plain', arguments: '{}' })
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('h-plain'),
content: [{ type: 'text', text: 'z' }],
isError: false,
}),
}, { surfaceOp: 'append' })
const response = await remote.page({
address: { kind: 'session', sessionId: session.id },
throughSeq: session.seq - 1,
})
expect(response.ok).toBe(true)
if (!response.ok) throw new Error('unreachable')
const entries = response.value.events
const byKey = new Map(entries
.filter(entry => entry.event.type === 'tool/call' || entry.event.type === 'tool/result')
.map(entry => [
`${entry.event.type}:${entry.event.type === 'tool/call'
? (entry.event.data as unknown as SessionEvent<'tool/call'>['data']).callId
: (entry.event.data as unknown as SessionEvent<'tool/result'>['data']).message.source.callId}`,
entry,
]))
expect(byKey.get('tool/call:h-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'ls' } })
expect(byKey.get('tool/result:h-term')?.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } })
expect('view' in (byKey.get('tool/result:h-orphan') ?? {})).toBe(false)
expect('view' in (byKey.get('tool/result:h-bad') ?? {})).toBe(false)
expect('view' in (byKey.get('tool/result:h-plain') ?? {})).toBe(false)
})
it('counts only append-origin messages toward maxMessages and keeps each compaction summary with its replacement', async () => {
const { ctx } = await harness()
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create()
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
session.append('turn/start', { turn: 1 })
const first = appendUserText(session, 'first prompt')
appendAssistantText(session, 'first reply', 1)
const third = appendUserText(session, 'second prompt')
appendAssistantText(session, 'second reply', 2)
const shadowed = [...session.surface.nodes]
// A compaction transaction: a log-only summary record immediately followed by the
// replacement that shadows the range.
const summary = appendExtension(session, 'compaction/summary', {
summary: [{ type: 'text', text: 'summary' }],
shadowedRange: { start: shadowed[0], end: shadowed.at(-1) },
shadowedSeqs: shadowed,
shadowedTokenCount: 0,
provider: 'p',
model: 'm',
})
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: '<context_checkpoint>summary</context_checkpoint>' }],
source: { kind: 'plugin', plugin: 'compact' },
}), {
surfaceOp: { op: 'replace', start: shadowed[0] as number, end: shadowed.at(-1) as number },
sourceEventSeqs: [...shadowed, summary.seq],
})
const response = await remote.page({
address: { kind: 'session', sessionId: session.id },
throughSeq: session.seq - 1,
maxMessages: 2,
})
if (!response.ok) throw new Error('unreachable')
const page = response.value.events.map(entry => entry.event)
// Two append-origin messages fill the page even though a replacement copy of
// the same event type sits in the window: the copy is model-only.
const messages = page.filter(event => event.type === 'user/message' || event.type === 'assistant/message')
expect(messages.map(event => event.seq)).toEqual([third.seq, third.seq + 1, third.seq + 3])
expect(page.some(event => event.seq === first.seq)).toBe(false)
expect(response.value.hasMore).toBe(true)
// The range stays contiguous, so the checkpoint's summary record is readable on
// the same page as the checkpoint itself.
const summaryIndex = page.findIndex(event => event.seq === summary.seq)
expect(summaryIndex).toBeGreaterThan(-1)
expect(page[summaryIndex + 1]?.seq).toBe(summary.seq + 1)
expect(page.map(event => event.seq)).toEqual(page.map((_event, index) => third.seq + index))
})
it('paginates a message with many provenance sources without variadic argument expansion', async () => {
const { ctx } = await harness()
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create()
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
session.append('turn/start', { turn: 1 })
const sources = Array.from({ length: 128 }, (_unused, index) => session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index, text: 'x' },
}).seq)
const message = session.append('assistant/message', {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'x'.repeat(sources.length) }],
source: { kind: 'model', provider: 'p', model: 'm' },
}),
}, { surfaceOp: 'append', sourceEventSeqs: sources })
const scalarMin = Math.min
const min = vi.spyOn(Math, 'min').mockImplementation((...values) => {
if (values.length > 2) throw new RangeError('variadic minimum rejected by regression harness')
return scalarMin(...values)
})
try {
const response = await remote.page({
address: { kind: 'session', sessionId: session.id },
throughSeq: message.seq,
maxMessages: 1,
})
if (!response.ok) throw new Error('unreachable')
expect(response.value.events.map(entry => entry.event.seq)).toEqual([...sources, message.seq])
expect(response.value.hasMore).toBe(true)
} finally {
min.mockRestore()
}
})
it('pairs a followed result after turn/end from the addressed Session log', async () => {
const { ctx } = await harness()
const session = ctx.sessions.create()
const history = new SessionHistoryController(ctx)
const abort = new AbortController()
const stream = await openFollow(history, session.id, abort.signal)
const collected = collect(stream, 4, abort)
session.append('turn/start', { turn: 1 })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-late'), name: 'term', arguments: '{"cmd":"tail"}' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('c-late'),
content: [{ type: 'text', text: 'ok' }],
isError: false,
}),
}, { surfaceOp: 'append' })
const frames = await collected
const result = frames.find(f => f.type === 'event' && f.event.type === 'tool/result')
expect(result?.type === 'event' && result.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } })
})
})
@@ -19,6 +19,7 @@ import type {
import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts'
import { buildModelCatalog } from '../src/catalog.ts'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
import { createSessionTestRemote } from './test-remote.ts'
@@ -146,6 +147,14 @@ function registerTextOnly(ctx: Context): void {
}('Text Only', []))
}
/** Resolve the Client-visible next selection from durable state and the Host default. */
function currentSelection(ctx: Context, sessionId: SessionId) {
const session = ctx.sessions.get(sessionId)
if (session === undefined) throw new Error('expected a live test Session')
return ctx.sessionProjections.snapshot(session).values.modelSelection?.next
?? ctx.agentDefaultModel.currentSelection()
}
describe('Web session model selection', () => {
it('validates an ordered image batch before persisting any member', async () => {
const { ctx, agent, sessionId } = await harness()
@@ -227,7 +236,7 @@ describe('Web session model selection', () => {
type: 'image' as const,
attachment: { attachmentId: 'att-history', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 },
}
agent.session.append('user/message', {
const imageEvent = agent.session.append('user/message', {
id: 'image-message', role: 'user', source: { kind: 'user' }, content: [image],
} as never, { surfaceOp: 'append' })
expect(expectValue(await remote.selectModel(request({
@@ -238,8 +247,8 @@ describe('Web session model selection', () => {
id: 'summary', role: 'user', source: { kind: 'plugin', plugin: 'compact' },
content: [{ type: 'text', text: 'image summarized' }],
} as never, {
surfaceOp: { op: 'replace', start: 0, end: agent.session.events.length - 1 },
sourceEventSeqs: agent.session.events.map(event => event.seq),
surfaceOp: { op: 'replace', start: imageEvent.seq, end: imageEvent.seq },
sourceEventSeqs: [imageEvent.seq],
})
;(agent.inbox.nextTurn as UserMessage[]).push({
id: 'pending-image', role: 'user', source: { kind: 'user' }, content: [image],
@@ -290,10 +299,10 @@ describe('Web session model selection', () => {
model: 'private-preview',
reasoningEffort: ReasoningEffortId('max'),
})
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' })
createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' })
const catalog = expectValue(await remote.models(request({ sessionId })))
expect(catalog.current).toEqual({
const catalog = await buildModelCatalog(ctx)
expect(currentSelection(ctx, sessionId)).toEqual({
provider: 'deepseek-official',
model: 'private-preview',
reasoningEffort: 'max',
@@ -324,7 +333,7 @@ describe('Web session model selection', () => {
})
it('preserves optional catalog metadata and string provider failures', async () => {
const { ctx, sessionId } = await harness()
const { ctx } = await harness()
ctx.llm.registerAdapter(['plain'], new CatalogAdapter('Plain', [
{ provider: 'plain', id: 'plain-model', name: 'Plain Model' },
]))
@@ -339,12 +348,12 @@ describe('Web session model selection', () => {
return Promise.reject('string catalog failure')
}
}('String Failure', []))
const remote = createSessionTestRemote(ctx, {
createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
})
const catalog = expectValue(await remote.models(request({ sessionId })))
const catalog = await buildModelCatalog(ctx)
expect(catalog.groups).toEqual(expect.arrayContaining([
{ id: 'plain', name: 'Plain', models: [{ id: 'plain-model', name: 'Plain Model' }] },
{
@@ -371,10 +380,8 @@ describe('Web session model selection', () => {
const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
const signal = new AbortController().signal
expect(expectValue(await remote.models(request({ sessionId }))).current)
expect(currentSelection(ctx, sessionId))
.toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
expect((await ctx.systemPrompt.assemble()).variables)
.toMatchObject({ provider: 'deepseek-official', model: 'deepseek-chat' })
const selected = expectValue(await remote.selectModel(request({
sessionId,
@@ -389,7 +396,7 @@ describe('Web session model selection', () => {
})
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed),
)).resolves.toMatchObject({ provider: 'deepseek-official', model: 'deepseek-chat' })
)).resolves.toEqual(seed)
expect((await ctx.systemPrompt.assemble()).variables)
.toMatchObject({ provider: 'deepseek-official', model: 'private-preview' })
@@ -440,7 +447,7 @@ describe('Web session model selection', () => {
details: { provider: 'remote-rejected' },
},
})
expect(expectValue(await remote.models(request({ sessionId }))).current)
expect(currentSelection(ctx, sessionId))
.toEqual({ provider: 'deepseek-official', model: 'private-preview', reasoningEffort: 'max' })
await ctx.fiber.dispose()
})
@@ -448,18 +455,18 @@ describe('Web session model selection', () => {
it('reads the Agent default live for a session whose log names no selection', async () => {
const { ctx, sessionId } = await harness()
let stored = { provider: 'deepseek-official', model: 'deepseek-chat' }
const remote = createSessionTestRemote(ctx, {
createSessionTestRemote(ctx, {
defaultModelSelection: () => stored,
cwd: '/tmp',
})
expect(expectValue(await remote.models(request({ sessionId }))).current)
expect(currentSelection(ctx, sessionId))
.toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
// The default moving after the session exists still reaches it: New
// Session reuses a blank session rather than minting another, so a seed
// captured at creation would show the superseded model there.
stored = { provider: 'deepseek-official', model: 'deepseek-reasoner' }
expect(expectValue(await remote.models(request({ sessionId }))).current)
expect(currentSelection(ctx, sessionId))
.toEqual({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
await ctx.fiber.dispose()
})
@@ -470,13 +477,13 @@ describe('Web session model selection', () => {
model: 'deepseek-chat',
})
let stored = { provider: 'deepseek-official', model: 'deepseek-chat' }
const remote = createSessionTestRemote(ctx, {
createSessionTestRemote(ctx, {
defaultModelSelection: () => stored,
cwd: '/tmp',
})
stored = { provider: 'duplicate', model: 'same' }
expect(expectValue(await remote.models(request({ sessionId }))).current)
expect(currentSelection(ctx, sessionId))
.toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
await ctx.fiber.dispose()
})
@@ -512,7 +519,7 @@ describe('Web session model selection', () => {
sessionId, provider: 'deepseek-official', model: 'deepseek-chat',
})))
expect(stillAccepted.selected).toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' })
expect(expectValue(await remote.models(request({ sessionId }))).current)
expect(currentSelection(ctx, sessionId))
.toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' })
await ctx.fiber.dispose()
})
@@ -533,15 +540,16 @@ describe('Web session model selection', () => {
ok: false,
error: { code: 'model-unavailable', details: { provider: 'deleted-gateway', model: 'deleted-model' } },
})
expect(expectValue(await remote.models(request({ sessionId }))).routable).toBe(false)
const unavailableCatalog = await buildModelCatalog(ctx)
expect(unavailableCatalog.routableProviders.includes(currentSelection(ctx, sessionId).provider)).toBe(false)
// An advisory-unlisted model on a live route is NOT this: the route
// serves it, so the prompt goes through and nothing blocks.
expectValue(await remote.selectModel(request({
sessionId, provider: 'deepseek-official', model: 'unlisted-but-served',
})))
const catalog = expectValue(await remote.models(request({ sessionId })))
expect(catalog.routable).toBe(true)
const catalog = await buildModelCatalog(ctx)
expect(catalog.routableProviders.includes(currentSelection(ctx, sessionId).provider)).toBe(true)
expect(catalog.groups.flatMap(group => group.models.map(model => model.id)))
.not.toContain('unlisted-but-served')
await ctx.fiber.dispose()
@@ -549,18 +557,18 @@ describe('Web session model selection', () => {
it('serves a session and its catalog when the stored default names a route that is gone', async () => {
const { ctx, sessionId } = await harness()
const remote = createSessionTestRemote(ctx, {
createSessionTestRemote(ctx, {
// What a Models-page removal leaves behind: the settings document still
// names the route the user last picked, and nothing serves it.
defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
cwd: '/tmp',
})
const catalog = expectValue(await remote.models(request({ sessionId })))
const catalog = await buildModelCatalog(ctx)
// Passed through rather than repaired: matching no group is precisely what
// makes the composer seat prompt for a selection instead of naming a model
// the deployment cannot reach.
expect(catalog.current).toEqual({ provider: 'deleted-gateway', model: 'deleted-model' })
expect(currentSelection(ctx, sessionId)).toEqual({ provider: 'deleted-gateway', model: 'deleted-model' })
expect(catalog.groups.flatMap(group => group.models.map(model => `${group.id}/${model.id}`)))
.not.toContain('deleted-gateway/deleted-model')
await ctx.fiber.dispose()
@@ -6,7 +6,7 @@ import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent'
import { UnknownPresetError } from '@deepseek-ai/dsh-agent-presets'
import { agentPresetProjectionDefinition, UnknownPresetError } from '@deepseek-ai/dsh-agent-presets'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import { describe, expect, it } from 'vitest'
@@ -38,8 +38,9 @@ async function harness(presets?: readonly string[]) {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
ctx.provide('sessionPersistence', { list: () => Promise.resolve([]) } as never)
if (presets !== undefined) ctx.provide('agentPresets', roster(presets) as never)
if (presets !== undefined) {
ctx.provide('agentPresets', roster(presets) as never)
}
const factory: AgentFactory = {
async createAgent(_ownerCtx, options) {
@@ -63,6 +64,7 @@ async function harness(presets?: readonly string[]) {
defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
cwd,
})
if (presets !== undefined) ctx.sessionProjections.register(agentPresetProjectionDefinition)
return { ctx, remote }
}
@@ -12,6 +12,7 @@ import { Context } from '@deepseek-ai/cordis'
import { z } from 'zod'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import { AttachmentStore } from '@deepseek-ai/dsh-attachment'
import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
@@ -19,7 +20,7 @@ import type { Session } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import { SessionControlController } from '@deepseek-ai/dsh-api-session-controller/src/control.ts'
import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types'
import type { SessionControlFrame, SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types'
import { createSessionTestRemote, type TestSessionRemote } from './test-remote.ts'
declare module '@deepseek-ai/dsh-session-projection/types' {
@@ -48,6 +49,24 @@ function page(
})
}
/** Read and close one snapshot-first follow generation. */
async function opening(
remote: TestSessionRemote,
sessionId: SessionId,
maxMessages?: number,
): Promise<Extract<SessionFollowFrame, { type: 'snapshot' }>> {
const abort = new AbortController()
const iterator = remote.follow({
address: { kind: 'session', sessionId },
...(maxMessages === undefined ? {} : { maxMessages }),
}, abort.signal)[Symbol.asyncIterator]()
const first = await iterator.next()
abort.abort()
await iterator.return?.()
if (first.done || first.value.type !== 'snapshot') throw new Error('follow did not open with a snapshot')
return first.value
}
/** Whole-value unit folding the latest user/message text; null before the first. */
type LastUserState = { text: string } | null
const lastUserUnit = () => ({
@@ -77,7 +96,7 @@ async function harness(withRegistry: boolean): Promise<{ ctx: Context; session:
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
if (withRegistry) await ctx.plugin(SessionProjectionRegistry)
const session = ctx.sessions.create()
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
// The gateway reads both the session and durable inbox baseline.
ctx.agents.register({ id: session.id, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), status: 'idle', ctx } as Agent)
return { ctx, session }
@@ -96,33 +115,57 @@ function seedMessages(session: Session, count: number): void {
const remote = (ctx: Context) => createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
describe('session.history projections block', () => {
it('tracks pending and used model selections across repeated request headers', async () => {
const { ctx, session } = await harness(true)
remote(ctx)
await new Promise(resolve => setTimeout(resolve, 0))
const selected = { provider: 'p', model: 'next' }
session.append('model/selection', selected)
session.append('model/selection', selected)
session.append('request/header', {
header: { config: { provider: 'p', model: 'used' } }, reason: 'initial',
})
session.append('request/header', {
header: { config: { provider: 'p', model: 'used' } }, reason: 'initial',
})
expect(ctx.sessionProjections.snapshot(session).values.modelSelection).toEqual({
lastUsed: { provider: 'p', model: 'used' },
next: selected,
})
session.append('request/header', {
header: { config: selected }, reason: 'initial',
})
expect(ctx.sessionProjections.snapshot(session).values.modelSelection).toEqual({
lastUsed: selected,
next: selected,
})
})
it('serves the unit value on the tail page with asOfSeq = last event seq', async () => {
const { ctx, session } = await harness(true)
ctx.sessionProjections.register(lastUserUnit())
seedMessages(session, 3)
const response = await page(remote(ctx), request({ sessionId: session.id, throughSeq: session.seq - 1 }))
expect(response.ok).toBe(true)
if (!response.ok) throw new Error('unreachable')
const { events, projections } = response.value
expect(projections).toBeDefined()
expect(projections?.asOfSeq).toBe(session.seq - 1)
expect(projections?.values['test/last-user']).toEqual({ text: 'm2' })
const snapshot = await opening(remote(ctx), session.id)
const { events, projections } = snapshot
expect(projections.asOfSeq).toBe(session.seq - 1)
expect(projections.values['test/last-user']).toEqual({ text: 'm2' })
// asOfSeq IS the window tail: the last served event carries it.
expect(events.at(-1)?.event.seq).toBe(projections?.asOfSeq)
expect(events.at(-1)?.event.seq).toBe(projections.asOfSeq)
})
it('cuts attached projections and events at the requested follow cursor', async () => {
it('returns a complete current replacement cut on each follow generation', async () => {
const { ctx, session } = await harness(true)
ctx.sessionProjections.register(lastUserUnit())
seedMessages(session, 2)
const response = await page(remote(ctx), request({ sessionId: session.id, throughSeq: 0 }))
if (!response.ok) throw new Error('history failed')
const snapshot = await opening(remote(ctx), session.id)
expect(response.value.events.map(entry => entry.event.seq)).toEqual([0])
expect(response.value.projections?.asOfSeq).toBe(0)
expect(response.value.projections?.values).toEqual(
expect.objectContaining({ 'test/last-user': { text: 'm0' } }),
expect(snapshot.events.map(entry => entry.event.seq)).toEqual([0, 1])
expect(snapshot.projections.asOfSeq).toBe(1)
expect(snapshot.projections.values).toEqual(
expect.objectContaining({ 'test/last-user': { text: 'm1' } }),
)
})
@@ -130,12 +173,11 @@ describe('session.history projections block', () => {
const { ctx, session } = await harness(true)
ctx.sessionProjections.register(lastUserUnit())
const response = await page(remote(ctx), request({ sessionId: session.id, throughSeq: -1 }))
if (!response.ok) throw new Error('history failed')
const snapshot = await opening(remote(ctx), session.id)
expect(response.value.events).toEqual([])
expect(response.value.projections?.asOfSeq).toBe(-1)
expect(response.value.projections?.values).toEqual(
expect(snapshot.events).toEqual([])
expect(snapshot.projections.asOfSeq).toBe(-1)
expect(snapshot.projections.values).toEqual(
expect.objectContaining({ 'test/last-user': null }),
)
})
@@ -157,10 +199,10 @@ describe('session.history projections block', () => {
readImage(): Promise<never> { return Promise.reject(new Error('unused')) }
})
const gateway = remote(ctx)
await new Promise(resolve => setTimeout(resolve, 0))
seedMessages(session, 2)
const response = await page(gateway, request({ sessionId: session.id, throughSeq: session.seq - 1 }))
if (!response.ok) throw new Error('history failed')
expect(response.value.projections?.values['imageLimits']).toEqual(limits)
const snapshot = await opening(gateway, session.id)
expect(snapshot.projections.values['imageLimits']).toEqual(limits)
// Constant unit: appending events must never broadcast an imageLimits projection.
await new Promise(resolve => setTimeout(resolve, 0))
const abort = new AbortController()
@@ -186,10 +228,8 @@ describe('session.history projections block', () => {
it('leaves the imageLimits key absent while no attachment service is composed', async () => {
const { ctx, session } = await harness(true)
seedMessages(session, 1)
const response = await page(remote(ctx), request({ sessionId: session.id, throughSeq: session.seq - 1 }))
if (!response.ok) throw new Error('history failed')
expect(response.value.projections).toBeDefined()
expect('imageLimits' in (response.value.projections?.values ?? {})).toBe(false)
const snapshot = await opening(remote(ctx), session.id)
expect('imageLimits' in snapshot.projections.values).toBe(false)
})
it('never carries the block on loadOlder pages (beforeSeq present)', async () => {
@@ -236,9 +276,8 @@ describe('session.history projections block', () => {
abort.abort()
await iterator.return?.()
const history = await page(proxy, request({ sessionId: session.id, throughSeq: session.seq - 1 }))
if (!history.ok) throw new Error('history failed')
expect('test/internal-count' in (history.value.projections?.values ?? {})).toBe(false)
const history = await opening(proxy, session.id)
expect('test/internal-count' in history.projections.values).toBe(false)
const listing = await proxy.list(request({}))
if (!listing.ok) throw new Error('listing failed')
const row = listing.value.items.find(item => item.sessionId === session.id)
@@ -250,18 +289,16 @@ describe('session.history projections block', () => {
const dispose = ctx.sessionProjections.register(lastUserUnit())
seedMessages(session, 1)
const proxy = remote(ctx)
const before = await page(proxy, request({ sessionId: session.id, throughSeq: session.seq - 1 }))
if (!before.ok) throw new Error('unreachable')
expect(before.value.projections?.values['test/last-user']).toEqual({ text: 'm0' })
const before = await opening(proxy, session.id)
expect(before.projections.values['test/last-user']).toEqual({ text: 'm0' })
dispose()
const after = await page(proxy, request({ sessionId: session.id, throughSeq: session.seq - 1 }))
if (!after.ok) throw new Error('unreachable')
const after = await opening(proxy, session.id)
// The registry stays mounted; only the disposed key leaves while the
// gateway-owned Session-list unit remains.
expect(after.value.projections?.asOfSeq).toBe(session.seq - 1)
expect('test/last-user' in (after.value.projections?.values ?? {})).toBe(false)
expect(after.value.projections?.values.sessionListMetadata).toEqual({
expect(after.projections.asOfSeq).toBe(session.seq - 1)
expect('test/last-user' in after.projections.values).toBe(false)
expect(after.projections.values.sessionListMetadata).toEqual({
blank: true,
lastPromptAt: session.events.at(-1)?.time,
})
@@ -284,7 +321,7 @@ describe('session.history projections block', () => {
})
describe('session.list projections column', () => {
it('serves attached rows from the live registry cut, watermarked for client seeding', async () => {
it('serves every already-materialized wire value from the live registry without folding', async () => {
const { ctx, session } = await harness(true)
ctx.sessionProjections.register(lastUserUnit())
const gateway = remote(ctx)
@@ -302,6 +339,37 @@ describe('session.list projections column', () => {
expect(row?.projections?.asOfSeq).toBe(session.seq - 1)
})
it('lists the latest preset selected by a blank Session instead of its creation preset', async () => {
const { ctx } = await harness(true)
const session = ctx.sessions.create(SessionId('preset-list'), {
meta: { cwd: '/workspace', agentPreset: 'standard' },
})
ctx.sessionProjections.register(agentPresetProjectionDefinition)
const gateway = remote(ctx)
await new Promise(resolve => setTimeout(resolve, 0))
session.append('agent-preset/selected', { agentPreset: 'minimal' })
const response = await gateway.list(request({}))
if (!response.ok) throw new Error('unreachable')
const row = response.value.items.find(item => item.sessionId === session.id)
expect(row?.projections?.values.agentPreset).toBe('minimal')
})
it('omits an unmaterialized live projection instead of folding history for listing', async () => {
const { ctx, session } = await harness(true)
seedMessages(session, 1)
const unit = lastUserUnit()
const apply = vi.fn(unit.apply)
ctx.sessionProjections.register({ ...unit, apply })
const response = await remote(ctx).list(request({}))
if (!response.ok) throw new Error('unreachable')
const row = response.value.items.find(item => item.sessionId === session.id)
expect(row).toBeDefined()
expect('test/last-user' in (row?.projections?.values ?? {})).toBe(false)
expect(apply).not.toHaveBeenCalled()
})
it('omits the column entirely when no registry is mounted', async () => {
const { ctx, session } = await harness(false)
seedMessages(session, 1)
@@ -312,7 +380,7 @@ describe('session.list projections column', () => {
expect(row !== undefined && 'projections' in row).toBe(false)
})
it('serves cold rows from the persisted projection cache with zero log loads', async () => {
it('serves every available cold projection hint from the cache with zero log loads', async () => {
const { ctx } = await harness(true)
const coldId = SessionId('session-cold-listing')
const load = () => { throw new Error('list must not load event logs') }
@@ -327,14 +395,28 @@ describe('session.list projections column', () => {
// The carrier hands the listed header through as the identity witness.
cachedSnapshot: (meta: { id: unknown; createdAt: number }) =>
(meta.id === coldId && meta.createdAt === 5
? { asOfSeq: 7, values: { 'test/last-user': { text: 'cached' } } }
? {
asOfSeq: 7,
values: {
'test/last-user': { text: 'cached' },
sessionListMetadata: { blank: false, lastPromptAt: 6 },
title: 'Cached title',
},
}
: undefined),
} as never)
const response = await remote(ctx).list(request({}))
if (!response.ok) throw new Error('unreachable')
const row = response.value.items.find(item => item.sessionId === coldId)
expect(row?.running).toBe(false)
expect(row?.projections).toEqual({ asOfSeq: 7, values: { 'test/last-user': { text: 'cached' } } })
expect(row?.projections).toEqual({
asOfSeq: 7,
values: {
'test/last-user': { text: 'cached' },
sessionListMetadata: { blank: false, lastPromptAt: 6 },
title: 'Cached title',
},
})
})
it('cold rows without a cache plugin (or without a stored row) just lack the column', async () => {
@@ -421,9 +503,8 @@ describe('Session control projection frames', () => {
{ type: 'projection', sessionId: session.id, key: 'sessionListMetadata', value: { blank: false, lastPromptAt: 300 }, seq: 2 },
])
// Frame seq aligns with the tail block's asOfSeq vocabulary (higher-seq-wins compatible).
const tail = await page(proxy, request({ sessionId: session.id, throughSeq: session.seq - 1 }))
if (!tail.ok) throw new Error('unreachable')
expect(tail.value.projections?.asOfSeq).toBe(pushes.at(-1)?.seq)
const tail = await opening(proxy, session.id)
expect(tail.projections.asOfSeq).toBe(pushes.at(-1)?.seq)
})
it('emits no projection frames when the composition has no registry', async () => {
@@ -6,22 +6,18 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { stat } from 'node:fs/promises'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import {
SessionQueryEngine,
SessionQueryError,
type SessionSearchHit,
type SessionSearchRequest,
} from '@deepseek-ai/dsh-session-query'
import { createSessionTestRemote } from './test-remote.ts'
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return { ...actual, stat: vi.fn(actual.stat) }
})
import { ApiSessionList } from '../src/list.ts'
const sid = (value: string): SessionId => value as SessionId
const defaults = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }
@@ -63,7 +59,48 @@ async function baseContext(): Promise<Context> {
return ctx
}
/** Real query core with a programmable full-text provider for Host search tests. */
class SearchSessionQuery extends SessionQueryEngine {
constructor(
ctx: Context,
private readonly search: (
...args: Parameters<SessionQueryEngine['searchSessions']>
) => Promise<unknown>,
) {
super(ctx)
}
override searchSessions(
...args: Parameters<SessionQueryEngine['searchSessions']>
): ReturnType<SessionQueryEngine['searchSessions']> {
return this.search(...args) as ReturnType<SessionQueryEngine['searchSessions']>
}
override searchEvents(): Promise<never> {
return Promise.reject(new Error('event search is not configured in this test'))
}
}
function installSearchQuery(
ctx: Context,
searchSessions: (
...args: Parameters<SessionQueryEngine['searchSessions']>
) => Promise<unknown>,
): void {
new SearchSessionQuery(ctx, searchSessions)
}
describe('session.search', () => {
it('rejects search when the query service is absent', async () => {
const ctx = await baseContext()
const list = new ApiSessionList(ctx, 0)
await expect(list.search('query', new AbortController().signal)).rejects.toMatchObject({
failure: { code: 'internal' },
})
await ctx.fiber.dispose()
})
it('searches only list-visible ids and current conversation-message events', async () => {
const ctx = await baseContext()
const live = ctx.sessions.create(sid('live'), { meta: header('live', '/live') })
@@ -111,7 +148,7 @@ describe('session.search', () => {
},
],
}))
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const remote = createSessionTestRemote(ctx, defaults)
const signal = new AbortController().signal
@@ -147,7 +184,7 @@ describe('session.search', () => {
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const searchSessions = vi.fn()
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const remote = createSessionTestRemote(ctx, defaults)
for (const query of ['', ' ', 'contains\0nul', 'x'.repeat(501)]) {
@@ -161,7 +198,7 @@ describe('session.search', () => {
it('returns an empty page without invoking the index when no session is visible', async () => {
const ctx = await baseContext()
const searchSessions = vi.fn()
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const remote = createSessionTestRemote(ctx, defaults)
const response = await remote.search(
@@ -187,16 +224,14 @@ describe('session.search', () => {
const base = hit('visible', index)
return { ...base, bestMatch: { ...base.bestMatch, ...bestMatch } }
}
ctx.provide('sessionQuery', {
searchSessions: () => Promise.resolve({
items: [
withBestMatch(0, { sessionId: sid('hidden') }),
withBestMatch(1, { surface: 'shadowed' }),
withBestMatch(2, { type: 'tool/result' }),
withBestMatch(3, { type: 'user/message', snippet: 'allowed snippet' }),
],
}),
} as never)
installSearchQuery(ctx, () => Promise.resolve({
items: [
withBestMatch(0, { sessionId: sid('hidden') }),
withBestMatch(1, { surface: 'shadowed' }),
withBestMatch(2, { type: 'tool/result' }),
withBestMatch(3, { type: 'user/message', snippet: 'allowed snippet' }),
],
}))
const response = await createSessionTestRemote(ctx, defaults).search(
request('match'),
@@ -224,9 +259,7 @@ describe('session.search', () => {
nextCursor: 'page-2',
})
.mockResolvedValueOnce({ items: items.slice(19) })
ctx.provide('sessionQuery', {
searchSessions,
} as never)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('match'),
new AbortController().signal,
@@ -266,7 +299,7 @@ describe('session.search', () => {
...end < items.length ? { nextCursor: `offset-${end}` } : {},
})
})
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('adaptive-page-limit'),
@@ -309,7 +342,7 @@ describe('session.search', () => {
nextCursor: `page-${searchSessions.mock.calls.length}`,
})
})
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('endless-pages'),
@@ -374,7 +407,7 @@ describe('session.search', () => {
return Promise.reject(new Error('unexpected provider call'))
}
})
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('stale-restart'),
@@ -413,7 +446,7 @@ describe('session.search', () => {
nextCursor: `cursor-${searchSessions.mock.calls.length}`,
})
})
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('stale-churn'),
@@ -442,7 +475,7 @@ describe('session.search', () => {
controller.abort()
return Promise.reject(stale)
})
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('abort-stale'),
@@ -463,7 +496,7 @@ describe('session.search', () => {
'provider generation changed before paging',
'SESSION_QUERY_STALE_CURSOR',
)))
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('first-page-stale'),
@@ -487,7 +520,7 @@ describe('session.search', () => {
'continuation limit is invalid',
'SESSION_QUERY_INVALID_LIMIT',
))
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('continuation-invalid-limit'),
@@ -514,7 +547,7 @@ describe('session.search', () => {
'SESSION_QUERY_INVALID_LIMIT',
),
))
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('minimum-page-limit'),
@@ -540,7 +573,7 @@ describe('session.search', () => {
'SESSION_QUERY_INVALID_LIMIT',
))
})
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('abort-invalid-limit'),
@@ -559,7 +592,7 @@ describe('session.search', () => {
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const oversized = Array.from({ length: 21 }, (_, index) => hit(`oversized-${index}`))
const searchSessions = vi.fn(() => Promise.resolve({ items: oversized }))
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('oversized-page'),
@@ -585,7 +618,7 @@ describe('session.search', () => {
}
return Promise.resolve({ items: oversized })
})
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('adapted-oversized-page'),
@@ -611,9 +644,7 @@ describe('session.search', () => {
snippet: `${expected}${'y'.repeat(10_000)}`,
},
}
ctx.provide('sessionQuery', {
searchSessions: () => Promise.resolve({ items: [overlong] }),
} as never)
installSearchQuery(ctx, () => Promise.resolve({ items: [overlong] }))
const response = await createSessionTestRemote(ctx, defaults).search(
request('bounded-snippet'),
@@ -635,7 +666,7 @@ describe('session.search', () => {
const searchSessions = vi.fn()
.mockResolvedValueOnce({ items: [], nextCursor: 'repeated' })
.mockResolvedValueOnce({ items: [], nextCursor: 'repeated' })
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('repeated-cursor'),
@@ -658,7 +689,7 @@ describe('session.search', () => {
const searchSessions = vi.fn()
.mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'repeated' })
.mockResolvedValueOnce({ items: items.slice(20), nextCursor: 'repeated' })
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('repeated-lookahead-cursor'),
@@ -685,7 +716,7 @@ describe('session.search', () => {
.mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'page-2' })
.mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'page-3' })
.mockResolvedValueOnce({ items: items.slice(20) })
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('duplicate-pages'),
@@ -713,7 +744,7 @@ describe('session.search', () => {
controller.abort()
return Promise.resolve({ items: [] })
})
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('cancel-continuation'),
@@ -743,7 +774,7 @@ describe('session.search', () => {
const searchSessions = vi.fn((_request: SessionSearchRequest) => Promise.resolve({
items: [hit('cold-32750')],
}))
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('large corpus'),
@@ -761,12 +792,13 @@ describe('session.search', () => {
expect(searchSessions.mock.calls[0]?.[0]).not.toHaveProperty('sessionFilters')
})
it('propagates cancellation through visible-session collection and stops cold-summary work', async () => {
it('propagates cancellation through the lightweight visibility listing', async () => {
const ctx = await baseContext()
const controller = new AbortController()
const cold = Array.from({ length: 32 }, (_, index) => header(`cold-${index}`, `/cold-${index}`))
const list = vi.fn((signal?: AbortSignal) => {
expect(signal).toBe(controller.signal)
controller.abort()
return Promise.resolve(cold)
})
let locateCalls = 0
@@ -774,12 +806,11 @@ describe('session.search', () => {
list,
locate: () => {
locateCalls++
controller.abort()
return undefined
},
} as never)
const searchSessions = vi.fn()
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('cancel-during-visibility'),
@@ -791,53 +822,34 @@ describe('session.search', () => {
error: { code: 'cancelled' },
})
expect(list).toHaveBeenCalledOnce()
expect(locateCalls).toBe(1)
expect(locateCalls).toBe(0)
expect(searchSessions).not.toHaveBeenCalled()
})
it('awaits every started cold-summary stat before returning cancellation', async () => {
it('does not stat or locate cold artifacts while collecting search visibility', async () => {
const ctx = await baseContext()
const controller = new AbortController()
const cold = Array.from({ length: 16 }, (_, index) => header(`cold-${index}`, `/cold-${index}`))
const statGates = cold.map(() => Promise.withResolvers<{ mtimeMs: number }>())
const statMock = vi.mocked(stat)
statMock.mockClear()
for (const gate of statGates) {
statMock.mockImplementationOnce((() => gate.promise) as never)
}
const locate = vi.fn((meta: SessionHeader) => ({ kind: 'jsonl', path: `/logs/${meta.id}.jsonl` }))
ctx.provide('sessionPersistence', {
list: () => Promise.resolve(cold),
locate: (meta: SessionHeader) => ({ kind: 'jsonl', path: `/logs/${meta.id}.jsonl` }),
locate,
} as never)
const searchSessions = vi.fn()
ctx.provide('sessionQuery', { searchSessions } as never)
const searchSessions = vi.fn(() => Promise.resolve({ items: [] }))
installSearchQuery(ctx, searchSessions)
let settled = false
const responsePromise = createSessionTestRemote(ctx, defaults).search(
request('cancel-during-cold-stats'),
controller.signal,
).finally(() => {
settled = true
})
await vi.waitFor(() => {
expect(statMock).toHaveBeenCalledTimes(16)
})
controller.abort()
statGates[0]!.resolve({ mtimeMs: 101 })
await new Promise<void>(resolve => setImmediate(resolve))
expect(settled).toBe(false)
for (const gate of statGates.slice(1)) gate.resolve({ mtimeMs: 102 })
const response = await responsePromise
const response = await createSessionTestRemote(ctx, defaults).search(
request('header-only-visibility'),
new AbortController().signal,
)
expect(response).toMatchObject({
ok: false,
error: { code: 'cancelled' },
ok: true,
value: { items: [], hasMore: false },
})
expect(searchSessions).not.toHaveBeenCalled()
expect(locate).not.toHaveBeenCalled()
expect(searchSessions).toHaveBeenCalledOnce()
})
it('maps missing composition, query cancellation, and provider failure', async () => {
it('maps preflight cancellation, query cancellation, and provider failure', async () => {
const missingCtx = await baseContext()
missingCtx.sessions.create(sid('visible'), { meta: header('visible') })
const missingApi = createSessionTestRemote(missingCtx, defaults)
@@ -852,22 +864,13 @@ describe('session.search', () => {
error: { code: 'cancelled' },
})
const missing = await missingApi.search(
request('needle'),
new AbortController().signal,
)
expect(missing.ok).toBe(false)
if (missing.ok) throw new Error('unreachable')
expect(missing.error.code).toBe('internal')
expect(missing.error.message).toContain('does not mount')
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const aborted = new SessionQueryError('provider stopped', 'SESSION_QUERY_ABORTED')
const searchSessions = vi.fn()
.mockRejectedValueOnce(aborted)
.mockRejectedValueOnce(new Error('database unavailable'))
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const remote = createSessionTestRemote(ctx, defaults)
const cancelled = await remote.search(
@@ -4,7 +4,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { RemoteStreamError } from '@deepseek-ai/dsh-api-gateway/client'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionToolView } from '@deepseek-ai/dsh-api-session-controller/types'
import { Session, type SessionOptions } from '../src/client/sessions/session.ts'
import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts'
import { entries, ev, plainTurn } from './event-script.client.ts'
@@ -26,12 +25,10 @@ function makeSession(
function follow(
api: FakeApiClient,
event: SessionEvent,
view?: SessionToolView,
): Promise<void> {
return api.pushFollow(SID, {
type: 'event',
event: event as never,
...(view === undefined ? {} : { view }),
})
}
@@ -44,7 +41,7 @@ function eventSeqs(session: Session): number[] {
}
function histResponse(events: SessionEvent[], hasMore = false) {
// history returns HistoryEntry[] ({event, view?}); these tests are view-less.
// History returns raw journal envelopes around each event.
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
}
@@ -72,11 +69,12 @@ describe('Session open', () => {
expect(session.eventSource.getSnapshot().change).toMatchObject({ kind: 'replace' })
})
it('is idempotent: concurrent opens share one history call, reopening when open is a no-op', async () => {
it('is idempotent: concurrent opens share one follow, reopening when open is a no-op', async () => {
const { api, session } = makeSession()
await Promise.all([session.open(), session.open()])
await session.open()
expect(api.callsOf('session.history')).toHaveLength(1)
expect(api.callsOf('session.follow')).toHaveLength(1)
expect(api.callsOf('session.history')).toEqual([])
})
it('lands an error result in openState=error with the RpcError kept', async () => {
@@ -101,7 +99,7 @@ describe('Session open', () => {
const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => gate.promise
const opening = session.open()
// Three live frames land mid-open; seq 15 overlaps the page tail (page covers 10..15).
// Three live frames land while the opening snapshot is pending; seq 15 overlaps its tail.
const page = plainTurn(10, 0, '早', '安')
const deliveries = [
follow(api, ev.turnStart(15, 1)),
@@ -154,7 +152,7 @@ describe('live event path', () => {
// seq 9 with tail 5 → gap; the event detours to the buffer and one history refetch fires.
await follow(api, ev.assistant(9, 1, 'd'))
await vi.waitFor(() => {
expect(api.callsOf('session.history').length).toBe(2)
expect(api.callsOf('session.history')).toHaveLength(1)
})
await vi.waitFor(() => {
expect(eventSeqs(session)).toEqual(
@@ -175,8 +173,8 @@ describe('paging', () => {
await session.open()
await session.loadOlder()
const snapshot = session.getSnapshot()
expect(api.callsOf('session.follow')).toHaveLength(1)
expect(api.callsOf('session.history')).toMatchObject([
{ sessionId: SID, throughSeq: 11 },
{ sessionId: SID, throughSeq: 11, beforeSeq: 6 },
])
expect(snapshot.hasMore).toBe(false)
@@ -234,7 +232,8 @@ describe('paging', () => {
modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}))
await Promise.all([first, second])
expect(api.callsOf('session.history')).toHaveLength(2) // open + one page, not two
expect(api.callsOf('session.follow')).toHaveLength(1)
expect(api.callsOf('session.history')).toHaveLength(1)
})
})
@@ -251,9 +250,15 @@ describe('prompt and cancel errors', () => {
expect(prompted).toEqual({ ok: true, value: { accepted: true } })
expect(cancelled).toEqual({ ok: true, value: { accepted: true } })
expect(api.callsOf('subagent.history')).toEqual([
{ parentSessionId: PARENT, childSessionId: SID, mode: 'continuable', throughSeq: -1, maxMessages: 50 },
expect(api.callsOf('session.follow')).toEqual([
{
address: {
kind: 'subagent', parentSessionId: PARENT, childSessionId: SID, mode: 'continuable',
},
maxMessages: 50,
},
])
expect(api.callsOf('subagent.history')).toEqual([])
expect(api.callsOf('subagent.prompt')).toEqual([
{
parentSessionId: PARENT, childSessionId: SID, mode: 'continuable',
@@ -303,9 +308,15 @@ describe('prompt and cancel errors', () => {
expect(prompted).toMatchObject({ ok: false, error: { code: 'subagent-not-resumable' } })
expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-delivery-unavailable' } })
expect(api.callsOf('subagent.history')).toEqual([
{ parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot', throughSeq: -1, maxMessages: 50 },
expect(api.callsOf('session.follow')).toEqual([
{
address: {
kind: 'subagent', parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot',
},
maxMessages: 50,
},
])
expect(api.callsOf('subagent.history')).toEqual([])
expect(api.callsOf('subagent.prompt')).toEqual([])
expect(api.callsOf('subagent.interrupt')).toEqual([])
expect(api.callsOf('session.cancel')).toEqual([])
@@ -575,7 +586,7 @@ describe('remaining branches', () => {
const repairPull = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => repairPull.promise
const delivery = follow(api, ev.user(9, '洞'))
await vi.waitFor(() => { expect(api.callsOf('session.history')).toHaveLength(2) })
await vi.waitFor(() => { expect(api.callsOf('session.history')).toHaveLength(1) })
api.onHistory = () => histResponse(plainTurn(6, 1, 'c', 'd'))
const resynced = session.resync() // bumps the generation
repairPull.resolve(ok({
@@ -601,44 +612,35 @@ describe('remaining branches', () => {
await expect(session.dispose()).resolves.toBeUndefined()
})
it('carries history-entry and follow-frame views through the event feed', async () => {
it('carries raw history and follow events through the event feed', async () => {
const { api, session } = makeSession()
const callView = { for: 'call', view: { card: 'generic', title: '历史卡' } }
const historyCall = ev.toolCall(6, 1, 'h1', 'bash', '{"cmd":"pwd"}')
const historyResult = ev.toolResult(7, 1, 'h1', 'done')
api.onHistory = () => Promise.resolve(ok({
events: [
...entries(plainTurn(0, 0, 'a', 'b')),
{ event: ev.toolCall(6, 1, 'h1', 'bash', '{}'), view: callView },
{ event: ev.toolResult(7, 1, 'h1', 'done'), view: { for: 'result', view: { card: 'generic', title: '历史果' } } },
{ event: historyCall },
{ event: historyResult },
] as never[],
hasMore: false,
modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}))
await session.open()
expect(windowEntries(session).slice(-2).map(item => item.view)).toEqual([
callView,
{ for: 'result', view: { card: 'generic', title: '历史果' } },
expect(windowEntries(session).slice(-2)).toEqual([
{ event: historyCall },
{ event: historyResult },
])
await follow(
api,
ev.toolCall(8, 2, 'l1', 'write', '{}'),
{ for: 'call', view: { card: 'generic', title: '直播卡' } },
)
expect(windowEntries(session).at(-1)?.view).toEqual({
for: 'call', view: { card: 'generic', title: '直播卡' },
})
await follow(
api,
ev.toolResult(9, 2, 'l1', 'ok'),
{ for: 'result', view: { card: 'generic', title: '直播果' } },
)
expect(windowEntries(session).at(-1)?.view).toEqual({
for: 'result', view: { card: 'generic', title: '直播果' },
})
const liveCall = ev.toolCall(8, 2, 'l1', 'write', '{"file_path":"a.ts"}')
await follow(api, liveCall)
expect(windowEntries(session).at(-1)).toEqual({ event: liveCall })
const liveResult = ev.toolResult(9, 2, 'l1', 'ok')
await follow(api, liveResult)
expect(windowEntries(session).at(-1)).toEqual({ event: liveResult })
})
})
describe('resync', () => {
it('keeps the old feed until one sorted page-and-live replacement is ready', async () => {
it('keeps the old feed until the reconnect snapshot, then repairs queued live gaps', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, '旧', '窗'))
await session.open()
@@ -652,11 +654,16 @@ describe('resync', () => {
})
const syncing = session.resync()
await vi.waitFor(() => { expect(api.callsOf('session.history')).toHaveLength(2) })
await vi.waitFor(() => { expect(api.callsOf('session.follow')).toHaveLength(2) })
expect(session.eventSource.getSnapshot()).toBe(oldWindow)
expect(publications).toEqual([])
await Promise.all([
api.onHistory = () => histResponse([
...plainTurn(10, 2, '终', '页'),
ev.user(16, '后到低位'),
ev.user(17, '后到高位'),
])
const liveDeliveries = Promise.all([
follow(api, ev.user(17, '后到高位')),
follow(api, ev.user(16, '后到低位')),
])
@@ -666,12 +673,15 @@ describe('resync', () => {
hasMore: false,
modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}))
await syncing
await Promise.all([syncing, liveDeliveries])
await vi.waitFor(() => {
expect(eventSeqs(session)).toEqual([10, 11, 12, 13, 14, 15, 16, 17])
})
expect(publications).toHaveLength(1)
expect(publications[0]?.entries).not.toHaveLength(0)
expect(publications[0]?.change.kind).toBe('replace')
expect(eventSeqs(session)).toEqual([10, 11, 12, 13, 14, 15, 16, 17])
expect(publications).toHaveLength(2)
expect(publications.map(snapshot => snapshot.change.kind)).toEqual(['replace', 'replace'])
expect(publications[0]?.entries.map(entry => entry.event.seq)).toEqual([10, 11, 12, 13, 14, 15])
expect(publications[1]?.entries.map(entry => entry.event.seq)).toEqual([10, 11, 12, 13, 14, 15, 16, 17])
off()
})
@@ -45,7 +45,7 @@ type FeedRow = {
origin?: 'subagent'
running?: boolean
blank?: boolean
agentPreset?: string
projections?: Record<string, unknown>
}
async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
@@ -55,7 +55,9 @@ async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}),
...(r.origin !== undefined ? { origin: r.origin } : {}),
...(r.agentPreset !== undefined ? { agentPreset: r.agentPreset } : {}),
...(r.projections === undefined
? {}
: { projections: { asOfSeq: 0, values: r.projections } }),
})),
}) as never)
await b.svc.refresh()
@@ -81,19 +83,17 @@ describe('list store projection', () => {
expect(state.byId[sid('s2')]?.title).toBeUndefined()
})
it('reprojects a blank session whose composition switched and nothing else moved', async () => {
it('reprojects a blank session from the generic agent-preset projection', async () => {
const b = bench()
await feedList(b, [{ id: 's1', blank: true, agentPreset: 'standard' }])
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('standard')
await feedList(b, [{ id: 's1', blank: true, projections: { agentPreset: 'standard' } }])
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.projectionValues?.agentPreset).toBe('standard')
// A confirmed switch moves the preset alone: the row keeps its updatedAt,
// title, running, and blank bits, so an identity guard blind to the preset
// would serve the old row forever — and every reader (the hero chip's own
// no-op check, the header label) would keep the composition it replaced.
b.svc.noteAgentPreset(sid('s1'), 'minimal')
b.svc.handleControlFrame({
type: 'projection', sessionId: sid('s1'), key: 'agentPreset', value: 'minimal', seq: 1,
})
await Promise.resolve()
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal')
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.projectionValues?.agentPreset).toBe('minimal')
})
it('reflects live increments (host stream via manager) into the store', async () => {
@@ -214,6 +214,7 @@ describe('scope tree', () => {
b.svc.open(sid('s2'))
await vi.waitFor(() => { expect(b.api.activeFollows(sid('s1'))).toBe(0) })
notified.mockClear()
await b.api.pushFollow(sid('s1'), {
type: 'event',
event: { seq: 0, timestamp: 0, type: 'turn/start', data: { turn: 0 } } as never,
@@ -252,7 +253,7 @@ describe('Agent scope disposal lifecycle', () => {
...remote,
session: {
...remote.session,
follow: (_request, signal) => {
follow: (request, signal) => {
if (signal === undefined) throw new Error('fixture requires a signal')
followSignal = signal
let opened = false
@@ -263,7 +264,20 @@ describe('Agent scope disposal lifecycle', () => {
opened = true
return Promise.resolve({
done: false,
value: { type: 'opened', cursor: -1 } as const,
value: {
type: 'snapshot',
header: {
version: 0,
id: request.address.kind === 'session'
? request.address.sessionId
: request.address.childSessionId,
createdAt: 0,
},
cursor: -1,
events: [],
hasMore: false,
projections: { asOfSeq: -1, values: {} },
} as const,
})
}
return new Promise((_resolve, reject) => {
@@ -325,7 +339,14 @@ describe('Agent scope disposal lifecycle', () => {
opened = true
return Promise.resolve({
done: false,
value: { type: 'opened', cursor: -1 } as const,
value: {
type: 'snapshot',
header: { version: 0, id: sessionId, createdAt: 0 },
cursor: -1,
events: [],
hasMore: false,
projections: { asOfSeq: -1, values: {} },
} as const,
})
}
return new Promise<IteratorResult<SessionFollowFrame>>((_resolve, reject) => {
@@ -457,22 +478,22 @@ describe('binding and stage lifecycle', () => {
it('staging (current write) opens the session event window; resolution and re-staging do not re-pull', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }, { id: 's2' }])
const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history')
const followStarts = () => b.api.followStarts.map(String)
// Resolution is addressing, not staging: no window pull.
b.svc.scope(sid('s1'))
b.svc.binding(sid('s1'))
expect(historyCalls()).toHaveLength(0)
expect(followStarts()).toEqual([])
b.svc.open(sid('s1'))
await vi.waitFor(() => {
expect(historyCalls().map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1'])
expect(followStarts()).toEqual(['s1'])
})
// Same current again: no second pull.
b.svc.open(sid('s1'))
expect(historyCalls()).toHaveLength(1)
expect(followStarts()).toHaveLength(1)
// Stage moves: the new occupant opens.
b.svc.open(sid('s2'))
await vi.waitFor(() => {
expect(historyCalls().map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1', 's2'])
expect(followStarts()).toEqual(['s1', 's2'])
})
})
@@ -486,11 +507,10 @@ describe('binding and stage lifecycle', () => {
})
try {
const b = bench()
expect(b.api.calls.filter(c => c.method === 'session.history')).toHaveLength(0)
expect(b.api.followStarts).toEqual([])
await feedList(b, [{ id: 's1' }]) // projection validates the persisted id → current lands → stage follows
await vi.waitFor(() => {
const historyCalls = b.api.calls.filter(c => c.method === 'session.history')
expect(historyCalls.map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1'])
expect(b.api.followStarts.map(String)).toEqual(['s1'])
})
} finally {
vi.unstubAllGlobals()
@@ -827,13 +847,12 @@ describe('coverage tails (branch duals)', () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.open(sid('s1'))
const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history')
await vi.waitFor(() => { expect(historyCalls()).toHaveLength(1) })
await vi.waitFor(() => { expect(b.api.followStarts).toHaveLength(1) })
await feedList(b, []) // removed while staged: current masks to undefined, stage holds → deferred
expect(b.svc.scope(sid('s1'))).toBeDefined()
// Resurfacing re-projects current = s1: same stage occupant, no second pull.
await feedList(b, [{ id: 's1' }])
expect(historyCalls()).toHaveLength(1)
expect(b.api.followStarts).toHaveLength(1)
expect(b.svc.list.getSnapshot().current).toBe('s1')
})
@@ -2,6 +2,16 @@
import type { Context } from '@deepseek-ai/cordis'
import type { ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent'
import type { SessionId } from '@deepseek-ai/dsh-session'
import {
SessionPersistenceCorruptionError,
SessionPersistenceNotFoundError,
SessionPersistenceRevision,
type BorrowedSessionSource,
type SessionInspection,
} from '@deepseek-ai/dsh-session-persistence'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SessionQueryEngine from '@deepseek-ai/dsh-session-query'
import { vi } from 'vitest'
import {
TypertRemoteFailure,
@@ -18,10 +28,10 @@ import type {
SessionCreateValue,
SessionForkRequest,
SessionForkValue,
SessionFollowFrame,
SessionFollowRequest,
SessionListRequest,
SessionListValue,
SessionModels,
SessionModelsRequest,
SessionPage,
SessionPageRequest,
SessionPromptRequest,
@@ -41,7 +51,6 @@ export interface TestSessionRemote {
list(request: SessionListRequest, signal?: AbortSignal): Promise<RemoteResult<SessionListValue>>
search(request: SessionSearchRequest, signal?: AbortSignal): Promise<RemoteResult<SessionSearchValue>>
create(request: SessionCreateRequest): Promise<RemoteResult<SessionCreateValue>>
models(request: SessionModelsRequest): Promise<RemoteResult<SessionModels>>
selectModel(request: SessionSelectModelRequest): Promise<RemoteResult<SessionSelectModelValue>>
rename(request: SessionRenameRequest): Promise<RemoteResult<SessionRenameValue>>
fork(request: SessionForkRequest): Promise<RemoteResult<SessionForkValue>>
@@ -50,6 +59,7 @@ export interface TestSessionRemote {
updateQueue(request: SessionUpdateQueueRequest): Promise<RemoteResult<SessionUpdateQueueValue>>
cancel(request: SessionCancelRequest): Promise<RemoteResult<SessionCancelValue>>
page(request: SessionPageRequest, signal?: AbortSignal): Promise<RemoteResult<SessionPage>>
follow(request: SessionFollowRequest, signal?: AbortSignal): AsyncIterable<SessionFollowFrame>
control(signal?: AbortSignal): AsyncIterable<SessionControlFrame>
}
@@ -63,6 +73,73 @@ export interface TestSessionRemoteDefaults {
const installed = new WeakMap<Context, SessionController>()
type LegacyTestPersistence = Record<string, unknown> & {
readonly inspect?: (
sessionId: SessionId,
signal?: AbortSignal,
) => Promise<SessionInspection | undefined>
readonly borrowSession?: (
sessionId: SessionId,
signal?: AbortSignal,
) => Promise<BorrowedSessionSource>
}
/** Add the preparation-backed point-read contract to compact persistence doubles. */
export function testSessionPersistence(
ctx: Context,
persistence: LegacyTestPersistence,
): LegacyTestPersistence {
if (persistence.borrowSession !== undefined) return persistence
return {
...persistence,
borrowSession: async (sessionId, signal) => {
signal?.throwIfAborted()
const inspection = await persistence.inspect?.(sessionId, signal)
signal?.throwIfAborted()
if (inspection === undefined) throw new SessionPersistenceNotFoundError(sessionId)
try {
const preparedSession = ctx.sessions.prepare(inspection.meta.id, {
seed: [...inspection.events],
meta: inspection.meta,
seedSource: 'persistence',
})
return {
source: 'prepared',
inspection: {
meta: preparedSession.header,
events: Object.freeze([...inspection.events]),
},
revision: SessionPersistenceRevision(`test:${sessionId}:${String(preparedSession.seq)}`),
preparedSession,
[Symbol.dispose]: () => {},
}
} catch (error: unknown) {
throw new SessionPersistenceCorruptionError(
`test session "${sessionId}" failed validation: ${String(error)}`,
{ cause: error },
)
}
},
}
}
/** Concrete point-read query used by Session Controller tests that do not exercise search. */
class TestSessionQuery extends SessionQueryEngine {
override searchSessions(): Promise<never> {
return Promise.reject(new Error('session search is not configured in this test'))
}
override searchEvents(): Promise<never> {
return Promise.reject(new Error('event search is not configured in this test'))
}
}
/** Install the required projection and point-query services for direct controller tests. */
export function installSessionReadTestServices(ctx: Context): void {
if (ctx.get('sessionProjections') === undefined) new SessionProjectionRegistry(ctx)
if (ctx.get('sessionQuery') === undefined) new TestSessionQuery(ctx)
}
function installControllers(
ctx: Context,
defaults: TestSessionRemoteDefaults,
@@ -93,6 +170,7 @@ function installControllers(
},
} as never)
}
installSessionReadTestServices(ctx)
const cwd = vi.spyOn(process, 'cwd').mockReturnValue(defaults.cwd)
let controller: SessionController
try {
@@ -151,7 +229,6 @@ export function createSessionTestRemote(
signal,
),
create: request => remoteResult(() => direct.create(request)),
models: request => remoteResult(() => direct.models(request)),
selectModel: request => remoteResult(() => direct.selectModel(request)),
rename: request => remoteResult(() => direct.rename(request)),
fork: request => remoteResult(() => direct.fork(request)),
@@ -166,6 +243,7 @@ export function createSessionTestRemote(
() => direct.page(request, signal),
signal,
),
follow: (request, signal = new AbortController().signal) => direct.follow(request, signal),
control: (signal = new AbortController().signal) => direct.control(signal),
}
}
@@ -43,6 +43,25 @@ function page(events: readonly SessionEventEntry[], hasMore = false): SessionPag
return { events, hasMore }
}
function snapshot(
cursor: number,
events: readonly SessionEventEntry[],
hasMore = false,
): SessionFollowFrame {
return {
type: 'snapshot',
header: {
version: 0,
id: ADDRESS.kind === 'session' ? ADDRESS.sessionId : ADDRESS.childSessionId,
createdAt: 0,
},
cursor,
events,
hasMore,
projections: { asOfSeq: cursor, values: {} },
}
}
function sessionClient(remote: SessionTransportRemote) {
return {
session: remote as SessionRemote,
@@ -108,14 +127,13 @@ describe('Session Client stream adapters', () => {
const remote = new ScriptedSessionRemote(
[{
frames: [
{ type: 'opened', cursor: 3 },
snapshot(3, [entry(2), entry(3)], true),
{ type: 'event', ...entry(3) },
{ type: 'event', ...entry(4) },
],
hold: true,
}],
[
{ ok: true, value: page([entry(2), entry(3)], true) },
{ ok: true, value: page([entry(0), entry(1)], false) },
],
)
@@ -129,9 +147,8 @@ describe('Session Client stream adapters', () => {
await vi.waitFor(() => { expect(changes).toHaveLength(2) })
await stream.prepend({ beforeSeq: 2, maxMessages: 50 })
expect(remote.followRequests).toEqual([{ address: ADDRESS }])
expect(remote.followRequests).toEqual([{ address: ADDRESS, maxMessages: 50 }])
expect(remote.pageRequests).toEqual([
{ address: ADDRESS, throughSeq: 3, maxMessages: 50 },
{ address: ADDRESS, throughSeq: 4, beforeSeq: 2, maxMessages: 50 },
])
expect(changes).toMatchObject([
@@ -143,20 +160,17 @@ describe('Session Client stream adapters', () => {
expect(remote.signals[0]?.aborted).toBe(true)
})
it('resumes after the applied cursor and repairs through the addressed tail page', async () => {
it('replaces the retained window from each reconnect snapshot', async () => {
const lost = new RemoteStreamCarrierError('lost')
const remote = new ScriptedSessionRemote(
[
{
frames: [{ type: 'opened', cursor: 1 }, { type: 'event', ...entry(2) }],
frames: [snapshot(1, [entry(0), entry(1)]), { type: 'event', ...entry(2) }],
terminal: lost,
},
{ frames: [{ type: 'opened', cursor: 4 }], hold: true },
],
[
{ ok: true, value: page([entry(0), entry(1)]) },
{ ok: true, value: page([entry(0), entry(1), entry(2), entry(3), entry(4)]) },
{ frames: [snapshot(4, [entry(0), entry(1), entry(2), entry(3), entry(4)])], hold: true },
],
[],
)
const changes: SessionJournalChange[] = []
const carrierFailed = vi.fn()
@@ -170,13 +184,10 @@ describe('Session Client stream adapters', () => {
await vi.waitFor(() => { expect(remote.followRequests).toHaveLength(2) })
expect(remote.followRequests).toEqual([
{ address: ADDRESS },
{ address: ADDRESS, afterSeq: 2 },
])
expect(remote.pageRequests).toEqual([
{ address: ADDRESS, throughSeq: 1, maxMessages: 50 },
{ address: ADDRESS, throughSeq: 4, maxMessages: 50 },
{ address: ADDRESS, maxMessages: 50 },
{ address: ADDRESS, maxMessages: 50 },
])
expect(remote.pageRequests).toEqual([])
expect(changes.map(change => change.type)).toEqual(['replace', 'append', 'replace'])
expect(carrierFailed).toHaveBeenCalledWith(lost)
await stream.dispose()
@@ -187,16 +198,13 @@ describe('Session Client stream adapters', () => {
const remote = new ScriptedSessionRemote(
[
{
frames: [{ type: 'opened', cursor: 0 }],
frames: [snapshot(0, [entry(0)])],
waitAfterFrames: finish.promise,
terminal: new RemoteStreamCarrierError('lost'),
},
{ frames: [{ type: 'opened', cursor: 1 }], hold: true },
],
[
{ ok: true, value: page([entry(0)]) },
{ ok: true, value: page([entry(0), entry(1)]) },
{ frames: [snapshot(1, [entry(0), entry(1)])], hold: true },
],
[],
)
const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
publish: vi.fn(),
@@ -205,18 +213,33 @@ describe('Session Client stream adapters', () => {
await stream.open({})
finish.resolve(undefined)
await vi.waitFor(() => { expect(remote.pageRequests).toHaveLength(2) })
expect(remote.pageRequests).toEqual([
{ address: ADDRESS, throughSeq: 0 },
{ address: ADDRESS, throughSeq: 1 },
])
await vi.waitFor(() => { expect(remote.followRequests).toHaveLength(2) })
expect(remote.followRequests).toEqual([{ address: ADDRESS }, { address: ADDRESS }])
expect(remote.pageRequests).toEqual([])
await stream.dispose()
})
it('turns a page failure into a typed stream failure and closes follow', async () => {
it('repairs a live gap without adding an absent message limit', async () => {
const remote = new ScriptedSessionRemote(
[{ frames: [snapshot(0, [entry(0)]), { type: 'event', ...entry(2) }], hold: true }],
[{ ok: true, value: page([entry(0), entry(1), entry(2)]) }],
)
const changes: SessionJournalChange[] = []
const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
publish: (change) => { changes.push(change) },
failed: vi.fn(),
})
await stream.open({})
await vi.waitFor(() => { expect(changes).toHaveLength(2) })
expect(remote.pageRequests).toEqual([{ address: ADDRESS, throughSeq: 2 }])
await stream.dispose()
})
it('turns a pagination failure into a typed stream failure', async () => {
const failure = { code: 'session-not-found', message: 'missing', details: { sessionId: 'session-1' } } as const
const remote = new ScriptedSessionRemote(
[{ frames: [{ type: 'opened', cursor: -1 }], hold: true }],
[{ frames: [snapshot(-1, [])], hold: true }],
[{ ok: false, error: failure }],
)
const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
@@ -224,13 +247,16 @@ describe('Session Client stream adapters', () => {
failed: vi.fn(),
})
await expect(stream.open({})).rejects.toBeInstanceOf(RemoteStreamError)
await stream.open({})
await expect(stream.prepend({})).rejects.toBeInstanceOf(RemoteStreamError)
await expect(stream.open({})).rejects.toThrow('already opened')
expect(sessionStreamFailure(new RemoteStreamError(failure.code, failure.message, failure.details)))
.toEqual(failure)
expect(sessionStreamFailure(new Error('local'))).toBeUndefined()
expect(remote.signals[0]?.aborted).toBe(true)
expect(remote.signals[0]?.aborted).toBe(false)
expect(remote.pageRequests).toEqual([{ address: ADDRESS, throughSeq: -1 }])
await stream.dispose()
expect(remote.signals[0]?.aborted).toBe(true)
})
it('maps the Host-wide control baseline and deltas into one snapshot stream', async () => {
@@ -2,9 +2,12 @@ import { Context } from '@deepseek-ai/cordis'
import { createScope } from '@deepseek-ai/dsh-scope'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts'
import { describe, expect, it, vi } from 'vitest'
import { SessionHistoryController } from '../src/history.ts'
import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
const signal = (): AbortSignal => new AbortController().signal
@@ -22,7 +25,13 @@ function append(
}
function event(type: string, seq: number, data: unknown = {}): SessionEvent {
return { type, seq, time: seq + 1, data } as SessionEvent
return {
type,
seq,
time: seq + 1,
data,
...type.startsWith('fixture/') ? { ignorable: true } : {},
} as SessionEvent
}
function cold(
@@ -30,10 +39,10 @@ function cold(
header: SessionHeader,
events: readonly SessionEvent[],
): void {
ctx.provide('sessionPersistence', {
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
list: () => Promise.resolve([header]),
inspect: () => Promise.resolve({ meta: header, events }),
} as never)
}) as never)
}
interface Deferred<T> {
@@ -50,7 +59,9 @@ function deferred<T>(): Deferred<T> {
async function setup(): Promise<{ ctx: Context; transport: SessionHistoryController }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
const transport = new SessionHistoryController(ctx)
installSessionReadTestServices(ctx)
ctx.sessionProjections.register(subagentIdentityProjectionDefinition)
const transport = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() })
return { ctx, transport }
}
@@ -65,7 +76,7 @@ describe('SessionHistoryController', () => {
abort.signal,
)[Symbol.asyncIterator]()
expect(await iterator.next()).toMatchObject({ done: false, value: { type: 'opened', cursor: 0 } })
expect(await iterator.next()).toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(await iterator.next()).toMatchObject({
done: false,
@@ -85,10 +96,13 @@ describe('SessionHistoryController', () => {
it('ends active followers when the owning Controller unloads', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
installSessionReadTestServices(ctx)
let transport!: SessionHistoryController
const owner = ctx.plugin(Object.assign(
(inner: Context) => { transport = new SessionHistoryController(inner) },
{ inject: ['sessions'] },
(inner: Context) => {
transport = new SessionHistoryController(inner, (observation) => { observation[Symbol.dispose]() })
},
{ inject: ['sessions', 'sessionQuery'] },
))
await owner.await()
const session = ctx.sessions.create(SessionId('controller-unload'), { meta: { cwd: '/workspace' } })
@@ -97,9 +111,9 @@ describe('SessionHistoryController', () => {
new AbortController().signal,
)[Symbol.asyncIterator]()
await expect(iterator.next()).resolves.toEqual({
await expect(iterator.next()).resolves.toMatchObject({
done: false,
value: { type: 'opened', cursor: -1 },
value: { type: 'snapshot', cursor: -1 },
})
const pending = iterator.next()
await owner.dispose()
@@ -107,7 +121,7 @@ describe('SessionHistoryController', () => {
await ctx.fiber.dispose()
})
it('resumes from the last applied seq before delivering later live events', async () => {
it('reconnects with a complete replacement snapshot before later live events', async () => {
const { ctx, transport } = await setup()
const session = ctx.sessions.create(SessionId('resume'), { meta: { cwd: '/workspace' } })
session.append('turn/start', { turn: 1 })
@@ -116,12 +130,16 @@ describe('SessionHistoryController', () => {
const abort = new AbortController()
const iterator = transport.follow({
address: { kind: 'session', sessionId: session.id },
afterSeq: 0,
}, abort.signal)[Symbol.asyncIterator]()
expect(await iterator.next()).toEqual({ done: false, value: { type: 'opened', cursor: 2 } })
expect(await iterator.next()).toMatchObject({ done: false, value: { type: 'event', event: { seq: 1 } } })
expect(await iterator.next()).toMatchObject({ done: false, value: { type: 'event', event: { seq: 2 } } })
expect(await iterator.next()).toMatchObject({
done: false,
value: {
type: 'snapshot',
cursor: 2,
events: [{ event: { seq: 0 } }, { event: { seq: 1 } }, { event: { seq: 2 } }],
},
})
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
expect(await iterator.next()).toMatchObject({ done: false, value: { type: 'event', event: { seq: 3 } } })
@@ -133,34 +151,74 @@ describe('SessionHistoryController', () => {
const { ctx, transport } = await setup()
const sessionId = SessionId('cold-race')
const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
const listed = deferred<readonly SessionHeader[]>()
ctx.provide('sessionPersistence', {
list: () => listed.promise,
inspect: () => Promise.resolve({ meta: header, events: [event('fixture/start', 0)] }),
} as never)
const inspected = deferred<{ meta: SessionHeader; events: readonly SessionEvent[] }>()
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
inspect: () => inspected.promise,
}) as never)
const abort = new AbortController()
const iterator = transport.follow({ address: { kind: 'session', sessionId } }, abort.signal)
[Symbol.asyncIterator]()
const opening = iterator.next()
ctx.emit('session/event', { id: SessionId('unrelated') } as Session, event('fixture/other', 0))
ctx.emit('session/event', { id: sessionId } as Session, event('fixture/start', 0))
listed.resolve([header])
await expect(opening).resolves.toEqual({ done: false, value: { type: 'opened', cursor: 0 } })
ctx.emit('session/event', {
id: SessionId('unrelated'), events: [event('fixture/other', 0)],
} as unknown as Session, event('fixture/other', 0))
ctx.emit('session/event', {
id: sessionId, events: [event('fixture/start', 0)],
} as unknown as Session, event('fixture/start', 0))
inspected.resolve({ meta: header, events: [event('fixture/start', 0)] })
await expect(opening).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } })
const waiting = iterator.next()
abort.abort()
await expect(waiting).resolves.toMatchObject({ done: true })
})
it('buffers creation while the opening observation is unresolved', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const sessionId = SessionId('created-during-observation')
const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
const observed = deferred<SessionObservation>()
ctx.provide('sessionQuery', { observeSession: () => observed.promise } as never)
const transport = new SessionHistoryController(ctx, vi.fn())
const abort = new AbortController()
const iterator = transport.follow({ address: { kind: 'session', sessionId } }, abort.signal)
[Symbol.asyncIterator]()
const opening = iterator.next()
const attached = ctx.sessions.create(sessionId, { meta: header, seed: [event('fixture/seed', 0)] })
observed.resolve({
source: 'live',
header: attached.header,
events: attached.events,
cursor: attached.seq - 1,
projections: { asOfSeq: attached.seq - 1, values: {} },
retain: vi.fn(),
[Symbol.dispose]: vi.fn(),
} as unknown as SessionObservation)
await expect(opening).resolves.toMatchObject({
done: false,
value: {
type: 'snapshot', cursor: 1, events: [{ event: { seq: 0 } }, { event: { seq: 1 } }],
},
})
expect(attached.id).toBe(sessionId)
abort.abort()
await expect(iterator.next()).resolves.toMatchObject({ done: true })
})
it('bridges the unpublished end-seed boundary when a cold source attaches', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
installSessionReadTestServices(ctx)
let transport!: SessionHistoryController
let agentCtx!: Context
await ctx.plugin(Object.assign(
(inner: Context) => { transport = new SessionHistoryController(inner) },
{ inject: ['sessions'] },
(inner: Context) => {
transport = new SessionHistoryController(inner, (observation) => { observation[Symbol.dispose]() })
},
{ inject: ['sessions', 'sessionQuery'] },
))
await ctx.plugin(Object.assign(
(inner: Context) => { agentCtx = createScope(inner, { name: 'agent' }).ctx },
@@ -179,7 +237,7 @@ describe('SessionHistoryController', () => {
const iterator = transport.follow({ address: { kind: 'session', sessionId } }, abort.signal)
[Symbol.asyncIterator]()
await expect(iterator.next()).resolves.toEqual({ done: false, value: { type: 'opened', cursor: 0 } })
await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } })
agentCtx.sessions.create(SessionId('unrelated-created'), { meta: { cwd: '/workspace' } })
const attached = agentCtx.sessions.prepare(sessionId, { meta: header, seed })
agentCtx.sessions.enter(attached)
@@ -212,11 +270,9 @@ describe('SessionHistoryController', () => {
const replayHeader = { version: 0, id: replayId, createdAt: 1, cwd: '/workspace' }
cold(replay.ctx, replayHeader, [event('fixture/start', 0), event('fixture/gap', 2)])
const replayed = replay.transport.follow({
address: { kind: 'session', sessionId: replayId }, afterSeq: -1,
address: { kind: 'session', sessionId: replayId },
}, signal())[Symbol.asyncIterator]()
await expect(replayed.next()).resolves.toEqual({ done: false, value: { type: 'opened', cursor: 2 } })
await expect(replayed.next()).resolves.toMatchObject({ done: false, value: { event: { seq: 0 } } })
await expect(replayed.next()).rejects.toMatchObject({ failure: { code: 'internal' } })
await expect(replayed.next()).rejects.toMatchObject({ code: 'SESSION_QUERY_CORRUPT_SESSION' })
const live = await setup()
const session = live.ctx.sessions.create(SessionId('live-gap'), { meta: { cwd: '/workspace' } })
@@ -225,8 +281,13 @@ describe('SessionHistoryController', () => {
const followed = live.transport.follow({
address: { kind: 'session', sessionId: session.id },
}, signal())[Symbol.asyncIterator]()
await expect(followed.next()).resolves.toEqual({ done: false, value: { type: 'opened', cursor: 0 } })
live.ctx.emit('session/event', session, event('fixture/gap', 2))
await expect(followed.next()).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } })
const skipped = event('fixture/skipped', 1)
const gap = event('fixture/gap', 2)
live.ctx.emit('session/event', {
id: session.id,
events: [event('fixture/start', 0), skipped, gap],
} as unknown as Session, gap)
await expect(followed.next()).rejects.toMatchObject({ failure: { code: 'internal' } })
})
@@ -237,7 +298,7 @@ describe('SessionHistoryController', () => {
const iterator = transport.follow({
address: { kind: 'session', sessionId: session.id },
}, abort.signal)[Symbol.asyncIterator]()
await expect(iterator.next()).resolves.toEqual({ done: false, value: { type: 'opened', cursor: -1 } })
await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: -1 } })
await expect(transport.page({
address: { kind: 'session', sessionId: session.id }, throughSeq: -1,
}, signal())).resolves.toMatchObject({ events: [], hasMore: false })
@@ -245,6 +306,59 @@ describe('SessionHistoryController', () => {
await expect(iterator.next()).resolves.toMatchObject({ done: true })
})
it('publishes an empty projection baseline when the query has no registry', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const sessionId = SessionId('projectionless-follow')
const meta = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
ctx.provide('sessionQuery', {
observeSession: () => Promise.resolve({
source: 'live', header: meta, events: [], cursor: -1,
retain: vi.fn(), [Symbol.dispose]: vi.fn(),
} satisfies SessionObservation),
} as never)
const history = new SessionHistoryController(ctx, vi.fn())
const abort = new AbortController()
const iterator = history.follow({ address: { kind: 'session', sessionId } }, abort.signal)
[Symbol.asyncIterator]()
await expect(iterator.next()).resolves.toMatchObject({
value: { type: 'snapshot', projections: { asOfSeq: -1, values: {} } },
})
abort.abort()
await expect(iterator.next()).resolves.toMatchObject({ done: true })
await ctx.fiber.dispose()
})
it('disposes a retained promotion when background activation rejects synchronously', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const sessionId = SessionId('promotion-failure')
const meta = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
const disposePromotion = vi.fn()
const promotion = {
source: 'prepared', header: meta, events: [], cursor: -1,
projections: { asOfSeq: -1, values: {} },
retain: vi.fn(), [Symbol.dispose]: disposePromotion,
} as unknown as SessionObservation
const source = {
...promotion,
retain: () => promotion,
[Symbol.dispose]: vi.fn(),
} as SessionObservation
ctx.provide('sessionQuery', {
observeSession: () => Promise.resolve(source),
} as never)
const history = new SessionHistoryController(ctx, () => { throw new Error('activation failed') })
const iterator = history.follow({ address: { kind: 'session', sessionId } }, signal())
[Symbol.asyncIterator]()
await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } })
await expect(iterator.next()).rejects.toThrow('activation failed')
expect(disposePromotion).toHaveBeenCalledOnce()
await ctx.fiber.dispose()
})
it('requires the durable parent and mode for a direct subagent address', async () => {
const { ctx, transport } = await setup()
const parentSessionId = SessionId('parent')
@@ -288,15 +402,18 @@ describe('SessionHistoryController', () => {
const sessionId = SessionId('corrupt-cold')
const failure = new Error('cold log is corrupt')
const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
ctx.provide('sessionPersistence', {
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
list: () => Promise.resolve([header]),
inspect: () => Promise.reject(failure),
} as never)
}) as never)
await expect(transport.page({
address: { kind: 'session', sessionId },
throughSeq: -1,
}, new AbortController().signal)).rejects.toBe(failure)
}, new AbortController().signal)).rejects.toMatchObject({
code: 'SESSION_QUERY_PERSISTENCE_FAILED',
cause: failure,
})
})
it('rejects malformed page and follow cursors at the service boundary', async () => {
@@ -325,25 +442,24 @@ describe('SessionHistoryController', () => {
)
await expect(corrupt.transport.page({
address: { kind: 'session', sessionId: corruptId }, throughSeq: 1,
}, signal())).rejects.toMatchObject({ failure: { code: 'internal' } })
for (const afterSeq of [-2, 0.5]) {
const iterator = transport.follow({ address, afterSeq }, signal())[Symbol.asyncIterator]()
}, signal())).rejects.toMatchObject({ code: 'SESSION_QUERY_CORRUPT_SESSION' })
for (const maxMessages of [0, 0.5]) {
const iterator = transport.follow({ address, maxMessages }, signal())[Symbol.asyncIterator]()
await expect(iterator.next()).rejects.toMatchObject({ failure: { code: 'bad-request' } })
}
const past = transport.follow({ address, afterSeq: 0 }, signal())[Symbol.asyncIterator]()
await expect(past.next()).rejects.toMatchObject({ failure: { code: 'bad-request' } })
})
it('reports missing ordinary and subagent sources without fabricating inspection failures', async () => {
const { ctx, transport } = await setup()
const ordinary = { kind: 'session' as const, sessionId: SessionId('missing') }
await expect(transport.page({ address: ordinary, throughSeq: -1 }, signal()))
.rejects.toMatchObject({ failure: { code: 'internal' } })
.rejects.toMatchObject({ failure: { code: 'session-not-found' } })
ctx.provide('sessionPersistence', {
const inspect = vi.fn(() => Promise.resolve(undefined))
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
list: () => Promise.resolve([]),
inspect: () => Promise.reject(new Error('must not inspect')),
} as never)
inspect,
}) as never)
await expect(transport.page({ address: ordinary, throughSeq: -1 }, signal()))
.rejects.toMatchObject({ failure: { code: 'session-not-found' } })
await expect(transport.page({
@@ -355,25 +471,28 @@ describe('SessionHistoryController', () => {
},
throughSeq: -1,
}, signal())).rejects.toMatchObject({ failure: { code: 'subagent-not-found' } })
expect(inspect).toHaveBeenCalledTimes(2)
})
it('rejects incomplete cold metadata before serving a source', async () => {
const first = await setup()
const sessionId = SessionId('incomplete')
const address = { kind: 'session' as const, sessionId }
first.ctx.provide('sessionPersistence', {
list: () => Promise.resolve([{ version: 0, id: sessionId, createdAt: 1 }]),
inspect: () => Promise.reject(new Error('must not inspect')),
} as never)
const firstHeader = { version: 0, id: sessionId, createdAt: 1 }
first.ctx.provide('sessionPersistence', testSessionPersistence(first.ctx, {
list: () => Promise.resolve([firstHeader]),
inspect: () => Promise.resolve({ meta: firstHeader, events: [] }),
}) as never)
await expect(first.transport.page({ address, throughSeq: -1 }, signal()))
.rejects.toMatchObject({ failure: { code: 'session-not-found' } })
const second = await setup()
const listed = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
second.ctx.provide('sessionPersistence', {
const inspected = { version: 0, id: sessionId, createdAt: 1 }
second.ctx.provide('sessionPersistence', testSessionPersistence(second.ctx, {
list: () => Promise.resolve([listed]),
inspect: () => Promise.resolve({ meta: { ...listed, cwd: undefined }, events: [] }),
} as never)
inspect: () => Promise.resolve({ meta: inspected, events: [] }),
}) as never)
await expect(second.transport.page({ address, throughSeq: -1 }, signal()))
.rejects.toMatchObject({ failure: { code: 'session-not-found' } })
})
@@ -407,7 +526,7 @@ describe('SessionHistoryController', () => {
const missing = await setup()
cold(missing.ctx, childHeader, [])
await expect(missing.transport.page({ address: childAddress, throughSeq: -1 }, signal()))
.rejects.toMatchObject({ failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'unsupported' } } })
.rejects.toMatchObject({ failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'corrupt' } } })
const corrupt = await setup()
cold(corrupt.ctx, childHeader, [event('subagent/descriptor', 0, { version: 'bad' })])
@@ -421,44 +540,48 @@ describe('SessionHistoryController', () => {
.rejects.toMatchObject({ failure: { code: 'subagent-unauthorized' } })
})
it('uses attached and detached projection cuts and isolates a child projection failure', async () => {
const attached = await setup()
const session = attached.ctx.sessions.create(SessionId('projected'), { meta: { cwd: '/workspace' } })
it('reports an unavailable descriptor when an observed child has no projection value', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const parentSessionId = SessionId('missing-projection-parent')
const childSessionId = SessionId('missing-projection-child')
const meta: SessionHeader = {
version: 0,
id: childSessionId,
createdAt: 1,
cwd: '/workspace',
origin: 'subagent',
parentSession: parentSessionId,
}
ctx.provide('sessionQuery', {
observeSession: () => Promise.resolve({
source: 'live', header: meta, events: [], cursor: -1,
projections: { asOfSeq: -1, values: {} },
retain: vi.fn(), [Symbol.dispose]: vi.fn(),
} as unknown as SessionObservation),
} as never)
const history = new SessionHistoryController(ctx, vi.fn())
await expect(history.page({
address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'continuable' },
throughSeq: -1,
}, signal())).rejects.toMatchObject({
failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'unsupported' } },
})
await ctx.fiber.dispose()
})
it('keeps pages projection-free and computes projections only for child authorization', async () => {
const ordinary = await setup()
const session = ordinary.ctx.sessions.create(SessionId('projected'), { meta: { cwd: '/workspace' } })
session.append('turn/start', { turn: 1 })
const snapshot = vi.fn(() => ({ asOfSeq: 0, values: { title: 'attached' } }))
attached.ctx.provide('sessionProjections', { snapshot, restore: vi.fn() } as never)
await expect(attached.transport.page({
const ordinarySnapshot = vi.spyOn(ordinary.ctx.sessionProjections, 'snapshot')
const ordinaryPage = await ordinary.transport.page({
address: { kind: 'session', sessionId: session.id },
throughSeq: 0,
}, signal())).resolves.toMatchObject({ projections: { asOfSeq: 0, values: { title: 'attached' } } })
expect(snapshot).toHaveBeenCalledWith(session)
const older = await attached.transport.page({
address: { kind: 'session', sessionId: session.id }, throughSeq: 0, beforeSeq: 1,
}, signal())
expect('projections' in older).toBe(false)
const detached = await setup()
const coldId = SessionId('projected-cold')
const header = { version: 0, id: coldId, createdAt: 1, cwd: '/workspace' }
cold(detached.ctx, header, [event('turn/start', 0, { turn: 1 })])
const restore = vi.fn(() => ({ snapshot: { asOfSeq: 0, values: { title: 'cold' } } }))
detached.ctx.provide('sessionProjections', { snapshot: vi.fn(), restore } as never)
await expect(detached.transport.page({
address: { kind: 'session', sessionId: coldId },
throughSeq: 0,
}, signal())).resolves.toMatchObject({ projections: { values: { title: 'cold' } } })
expect(restore).toHaveBeenCalledWith({}, expect.any(Array), 0)
const failed = await setup()
cold(failed.ctx, header, [event('turn/start', 0, { turn: 1 })])
failed.ctx.provide('sessionProjections', {
snapshot: vi.fn(),
restore: () => { throw new Error('projection failed') },
} as never)
await expect(failed.transport.page({
address: { kind: 'session', sessionId: coldId },
throughSeq: 0,
}, signal())).rejects.toThrow('projection failed')
expect('projections' in ordinaryPage).toBe(false)
expect(ordinarySnapshot).not.toHaveBeenCalled()
const child = await setup()
const parentSessionId = SessionId('projection-parent')
@@ -469,71 +592,13 @@ describe('SessionHistoryController', () => {
childSession.append('subagent/descriptor', snapshotSubagentDescriptor({
mode: 'continuable', provider: 'test', label: 'child',
}))
const warn = vi.spyOn(child.ctx.logger, 'warn').mockImplementation(() => undefined)
child.ctx.provide('sessionProjections', {
snapshot: () => { throw new Error('child projection failed') },
restore: vi.fn(),
} as never)
const childSnapshot = vi.spyOn(child.ctx.sessionProjections, 'snapshot')
const page = await child.transport.page({
address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'continuable' },
throughSeq: 0,
}, signal())
expect('projections' in page).toBe(false)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('child projection failed'))
})
it('resolves presenter scope from a live Agent or the durable preset and tolerates lookup failure', async () => {
const live = await setup()
const liveSession = live.ctx.sessions.create(SessionId('live-scope'), { meta: { cwd: '/workspace' } })
const liveAgent = { id: liveSession.id }
const preset = vi.fn(() => Promise.resolve('preset-scope'))
live.ctx.provide('agents', { get: () => liveAgent } as never)
live.ctx.provide('agentPresets', { standingKeyFor: preset } as never)
await live.transport.page({
address: { kind: 'session', sessionId: liveSession.id }, throughSeq: -1,
}, signal())
expect(preset).not.toHaveBeenCalled()
const attached = await setup()
const attachedSession = attached.ctx.sessions.create(SessionId('preset-scope'), {
meta: { cwd: '/workspace', agentPreset: 'minimal' },
})
const standingKeyFor = vi.fn(() => Promise.resolve('standing-scope'))
attached.ctx.provide('agentPresets', { standingKeyFor } as never)
await attached.transport.page({
address: { kind: 'session', sessionId: attachedSession.id },
throughSeq: -1,
}, signal())
expect(standingKeyFor).toHaveBeenCalledWith('minimal')
const detached = await setup()
const detachedId = SessionId('detached-scope')
const header = {
version: 0, id: detachedId, createdAt: 1, cwd: '/workspace', agentPreset: 'standard',
}
cold(detached.ctx, header, [])
const rejected = vi.fn(() => Promise.reject(new Error('preset unavailable')))
detached.ctx.provide('agentPresets', { standingKeyFor: rejected } as never)
await expect(detached.transport.page({
address: { kind: 'session', sessionId: detachedId },
throughSeq: -1,
}, signal())).resolves.toMatchObject({ events: [] })
expect(rejected).toHaveBeenCalledWith('standard')
const switched = await setup()
const switchedId = SessionId('switched-scope')
const switchedHeader = {
version: 0, id: switchedId, createdAt: 1, cwd: '/workspace', agentPreset: 'standard',
}
cold(switched.ctx, switchedHeader, [
event('agent-preset/selected', 0, { agentPreset: 'minimal' }),
])
const switchedKey = vi.fn(() => Promise.resolve('switched-scope'))
switched.ctx.provide('agentPresets', { standingKeyFor: switchedKey } as never)
await switched.transport.page({
address: { kind: 'session', sessionId: switchedId }, throughSeq: 0,
}, signal())
expect(switchedKey).toHaveBeenCalledWith('minimal')
expect(childSnapshot).toHaveBeenCalledWith(childSession)
})
it('keeps message-aligned pagination contiguous across replacement provenance', async () => {
@@ -576,77 +641,4 @@ describe('SessionHistoryController', () => {
expect(page.hasMore).toBe(false)
})
it('projects tool call and result views and contains malformed presenters', async () => {
const { ctx, transport } = await setup()
const sessionId = SessionId('presenters')
const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
const events = [
event('fixture/start', 0),
event('tool/call', 1, { callId: 'c1', name: 'present', arguments: '{"path":"a.ts"}' }),
event('tool/result', 2, {
message: {
source: { callId: 'c1' },
content: [{ content: [{ type: 'text', text: 'ok' }], isError: true }],
},
meta: { persisted: true },
}),
event('tool/result', 3, {
message: {
source: { callId: 'missing' },
content: [{ content: [{ type: 'text', text: 'missing' }] }],
},
}),
event('tool/call', 4, { callId: 'c2', name: 'present', arguments: '{' }),
event('tool/result', 5, {
message: {
source: { callId: 'c2' },
content: [{ content: [{ type: 'text', text: 'bad args' }] }],
},
}),
event('tool/call', 6, { callId: 'c3', name: 'empty', arguments: '{}' }),
event('tool/result', 7, {
message: {
source: { callId: 'c3' },
content: [{ content: [{ type: 'text', text: 'no presenter' }], isError: false }],
},
}),
event('tool/call', 8, { callId: 'c4', name: 'throw-call', arguments: '{}' }),
event('tool/call', 9, { callId: 'c5', name: 'throw-result', arguments: '{}' }),
event('tool/result', 10, {
message: {
source: { callId: 'c5' },
content: [{ content: [{ type: 'text', text: 'throw' }], isError: false }],
},
}),
]
cold(ctx, header, events)
ctx.provide('tools', {
get: (name: string) => {
if (name === 'present') {
return {
presentCall: (args: unknown) => ({ card: 'generic', title: 'Call', rawInput: args }),
presentResult: (_args: unknown, result: unknown) => ({ card: 'generic', title: 'Result', result }),
}
}
if (name === 'empty') return {}
if (name === 'throw-call') return { presentCall: () => { throw new Error('call presenter failed') } }
if (name === 'throw-result') return { presentResult: () => { throw new Error('result presenter failed') } }
return undefined
},
} as never)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const page = await transport.page({
address: { kind: 'session', sessionId }, throughSeq: 10,
}, signal())
expect(page.events[1]?.view).toEqual({
for: 'call', view: { card: 'generic', title: 'Call', rawInput: { path: 'a.ts' } },
})
expect(page.events[2]?.view).toMatchObject({ for: 'result', view: { card: 'generic', title: 'Result' } })
for (const index of [0, 3, 4, 5, 6, 7, 8, 9, 10]) {
expect(page.events[index]).not.toHaveProperty('view')
}
expect(warn).toHaveBeenCalledWith(expect.stringContaining('call presenter failed'))
expect(warn).toHaveBeenCalledWith(expect.stringContaining('result presenter failed'))
})
})
@@ -21,7 +21,6 @@
{ "path": "../../llm/llm" },
{ "path": "../../session/session-projection" },
{ "path": "../../session/session-title" },
{ "path": "../../core/tools" },
{ "path": "../../util/brand" },
{ "path": "../../util/crypto" },
{ "path": "../../util/workspace-path" },
@@ -15,7 +15,8 @@
"src/commands.ts",
"src/control.ts",
"src/history.ts",
"src/list.ts"
"src/list.ts",
"src/model-selection-projection.ts"
],
"references": [
{ "path": "../../../vendor/cordis" },
@@ -24,7 +25,6 @@
{ "path": "../../core/agent-default-model" },
{ "path": "../../core/scope" },
{ "path": "../../core/session" },
{ "path": "../../core/tools" },
{ "path": "../../attachment/attachment" },
{ "path": "../../interaction/permission-presets" },
{ "path": "../../jobs/jobs" },