mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
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:
@@ -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 的情况下检查 persistence;queue 变更和取消要求对应 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。
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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) }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user