Files
deepseek-harness/packages/api/session-controller/tests/fake-api.client.ts
T
Tianyi Cui 5f773a0ded feat(workspace): restore archived sessions from a settings page
Sessions could be archived from the sidebar row menu but never restored:
the registry-global archive set was a one-way display filter with no
inverse RPC and no surface listing its members.

WorkspaceRegistry.unarchiveSession drops one id from archivedSessionIds in
one durable write on the same operation chain as archive, with no
session-existence probe because a removal cannot introduce an unknown
referent. The Workspace controller exposes it as workspace.unarchiveSession
returning the complete archive set, which the existing archived follow
increment already propagates, so no frame type and no persisted field
changes.

The new client package dsh-client-ui-settings-unarchive-sessions owns the
restore surface: one localized settings.section page listing the archive
set joined with loaded session summaries, newest archive first, searchable
by title or workspace, with one Unarchive action per row.
2026-09-13 16:45:45 +08:00

477 lines
19 KiB
TypeScript

// Test-local programmable Remote fake (NOT the fixture: fixture is a demo
// data source on a real clock; behavior tests need per-case responses and
// deferred-controlled timing). Session streams are hand pumps: pushFollow/pushControl.
import type {
MessageId,
SessionId, SessionSearchItem,
SubagentCatalog, SubagentInterruptReceipt, SubagentPromptReceipt,
WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-api-remotes/client'
import type {
SessionAddress,
SessionAssistantStreamBaseline,
SessionControlBaseline,
SessionControlFrame,
SessionFollowFrame,
SessionFollowRequest,
SessionPage,
SessionPageRequest,
SessionProjectionBaseline,
SessionSelectModelRequest,
SessionSelectModelValue,
} from '@deepseek-ai/dsh-api-session-controller/types'
import type { WorkspaceRemote } from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { WorkspaceFollowFrame } from '@deepseek-ai/dsh-api-workspace-controller/types'
import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import {
RemoteStream,
type RemoteStreamOptions,
} from '@deepseek-ai/dsh-api-gateway/client'
import type { SessionRemotes } from '../src/client/sessions/remotes.ts'
import { followSnapshot, pageThrough } from './remote/history.client.ts'
const AVAILABLE_STREAM_CONNECTION = {
generation: {
getSnapshot: () => ({ id: 1, host: { home: '/h' } }),
subscribe: () => () => {},
},
}
/** Programmable-default workspace row (branded id, ISO-ish times). */
function fakeWorkspace(id: string, over: Partial<WorkspaceView> = {}): WorkspaceView {
return {
workspaceId: id as WorkspaceId,
path: '/f/ws',
title: 'ws',
sessionIds: [],
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...over,
}
}
function addressSessionId(address: SessionAddress): SessionId {
return address.kind === 'session' ? address.sessionId : address.childSessionId
}
export interface Deferred<T> {
promise: Promise<T>
resolve(value: T): void
reject(error: unknown): void
}
/** Test-held settlement: the case decides when an RPC lands (history-pending injections etc.). */
export function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
/**
* Successful generated Remote result for programmable domain fakes.
* @param value - the value the Host answers with.
* @returns the success branch of a Remote result.
*/
export function ok<T>(value: T): RemoteResult<T> {
return { ok: true, value }
}
/**
* Failed generated Remote result carrying the owner's declared failure.
* @param error - the owner-declared failure.
* @returns the failure branch of a Remote result.
*/
export function err<T>(error: RemoteFailure): RemoteResult<T> {
return { ok: false, error }
}
type ValueStreamItem<F> =
| { kind: 'frame'; value: F; delivered?: () => void }
| { kind: 'end' }
| { kind: 'fail'; error: unknown }
interface ValueStreamConn<F> {
feed(item: ValueStreamItem<F>): void
}
interface OpenValueStream<F> {
readonly values: AsyncGenerator<F>
dispose(): void
}
/**
* Commands Remote double: the generated face delivers the carrier's outcome, so
* a test that programs nothing sees an empty catalog and an unmatched line.
* @returns the Remote namespaces the session cluster calls.
*/
export type RuntimeRemotes = SessionRemotes & { readonly workspace: WorkspaceRemote }
export function fakeRemote(api = new FakeApiClient()): RuntimeRemotes {
return api.sessionRemotes()
}
export class FakeApiClient {
/** Chronological call record: [method, payload]. */
readonly calls: { method: string; payload: unknown }[] = []
/** Session ids in physical follow-generation opening order. */
readonly followStarts: SessionId[] = []
// Programmable slots (defaults answer OK-empty); reassign per case.
onList: (payload: unknown) => Promise<RemoteResult<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onSearch: (payload: unknown) => Promise<RemoteResult<{ items: SessionSearchItem[]; hasMore: boolean }>> =
() => Promise.resolve(ok({ items: [], hasMore: false }))
onCreate: (payload: unknown) => Promise<RemoteResult<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
onSelectModel: (payload: SessionSelectModelRequest) => Promise<RemoteResult<SessionSelectModelValue>> =
payload => Promise.resolve(ok({
selected: {
provider: payload.provider,
model: payload.model,
...(payload.reasoningEffort === undefined
? {}
: { reasoningEffort: payload.reasoningEffort }),
},
}))
onRename: (payload: unknown) => Promise<RemoteResult<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
onFork: (payload: unknown) => Promise<RemoteResult<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
onHistory: (payload: { sessionId: SessionId; throughSeq?: number; beforeSeq?: number; maxMessages?: number })
=> Promise<RemoteResult<SessionPage & { readonly projections?: SessionProjectionBaseline }>> =
() => Promise.resolve(ok({ records: [], hasMore: false }))
onPrompt: (payload: unknown) => Promise<RemoteResult<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onAttachment: (payload: unknown) => Promise<RemoteResult<{ attachment: { attachmentId: never; mediaType: 'image/png'; bytes: number; width: number; height: number }; data: string }>> =
() => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' }))
onUpdateQueue: (payload: unknown) => Promise<RemoteResult<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RemoteResult<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onOpenWorkspacePath: (payload: unknown) => Promise<RemoteResult<{ opened: true }>> =
() => Promise.resolve(ok({ opened: true as const }))
private readonly followConns = new Map<SessionId, ValueStreamConn<SessionFollowFrame>[]>()
private readonly controlConns: ValueStreamConn<SessionControlFrame>[] = []
private readonly workspaceConns: ValueStreamConn<WorkspaceFollowFrame>[] = []
/** Optional Host opening cursor override for stale-page and reconnect tests. */
followCursor: number | undefined
controlBaseline: SessionControlBaseline = {
queues: {},
jobs: {},
projections: {},
}
assistantStreamBaseline: SessionAssistantStreamBaseline = {
revision: 0,
}
workspaceBaseline: Extract<WorkspaceFollowFrame, { type: 'baseline' }>['value'] = {
items: [],
archivedSessionIds: [],
}
lastSearchSignal: AbortSignal | undefined
onSubagentList: (payload: unknown) => Promise<RemoteResult<SubagentCatalog>>
= () => Promise.resolve(ok({ entries: [], parentAvailable: true }))
onSubagentPrompt: (payload: unknown) => Promise<RemoteResult<SubagentPromptReceipt>>
= () => Promise.resolve(ok({ messageId: 'fake-message' as MessageId }))
onSubagentInterrupt: (payload: unknown) => Promise<RemoteResult<SubagentInterruptReceipt>>
= () => Promise.resolve(ok({ accepted: true as const }))
onWorkspaceCreate: (payload: unknown) => Promise<RemoteResult<{ workspace: WorkspaceView; created: boolean }>> =
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws'), created: true }))
onWorkspaceRename: (payload: unknown) => Promise<RemoteResult<{ workspace: WorkspaceView }>> =
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
onWorkspaceDelete: (payload: unknown) => Promise<RemoteResult<{ deleted: true }>> =
() => Promise.resolve(ok({ deleted: true }))
onWorkspaceInsertBefore: (payload: unknown) => Promise<RemoteResult<{ workspaceIds: WorkspaceId[] }>> =
() => Promise.resolve(ok({ workspaceIds: [] }))
onWorkspaceInsertSessionBefore: (payload: unknown) => Promise<RemoteResult<{ workspace: WorkspaceView }>> =
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
onWorkspaceArchiveSession: (payload: unknown) => Promise<RemoteResult<{ archivedSessionIds: SessionId[] }>> =
payload => Promise.resolve(ok({ archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId] }))
onWorkspaceUnarchiveSession: (payload: unknown) => Promise<RemoteResult<{ archivedSessionIds: SessionId[] }>> =
() => Promise.resolve(ok({ archivedSessionIds: [] }))
/** Remote namespaces bound to this fake's programmable unary slots and stream pumps. */
sessionRemotes(): RuntimeRemotes {
return {
$stream: <Item>(options: RemoteStreamOptions<Item>) => (
new RemoteStream(AVAILABLE_STREAM_CONNECTION, options)
),
commands: {
execute: () => Promise.resolve({ ok: true, value: undefined }),
},
session: {
canOpenWorkspacePath: () => Promise.resolve(ok(true)),
list: payload => this.record('session.list', payload, this.onList(payload)),
modelCatalog: () => Promise.resolve({
ok: true,
value: {
default: { provider: 'fixture', model: 'fixture' },
routableProviders: [],
groups: [],
failures: [],
},
}),
search: (payload, signal) => {
this.lastSearchSignal = signal
return this.record('session.search', payload, this.onSearch(payload))
},
create: payload => this.record('session.create', payload, this.onCreate(payload)),
selectModel: payload => this.record(
'session.selectModel',
payload,
this.onSelectModel(payload),
),
rename: payload => this.record('session.rename', payload, this.onRename(payload)),
fork: payload => this.record('session.fork', payload, this.onFork(payload)),
prompt: payload => this.record('session.prompt', payload, this.onPrompt(payload)),
attachment: payload => this.record('session.attachment', payload, this.onAttachment(payload)),
updateQueue: payload => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
cancel: payload => this.record('session.cancel', payload, this.onCancel(payload)),
openWorkspacePath: payload => this.record(
'session.openWorkspacePath',
payload,
this.onOpenWorkspacePath(payload),
),
page: request => this.page(request),
follow: (request, signal) => this.openFollow(request, signal),
control: signal => this.openControl(signal),
},
subagents: {
list: parentSessionId => this.record(
'subagents.list',
parentSessionId,
this.onSubagentList(parentSessionId),
),
prompt: request => this.record('subagents.prompt', request, this.onSubagentPrompt(request)),
interruptByParent: (childSessionId, parentSessionId, mode) => this.record(
'subagents.interruptByParent',
{ childSessionId, parentSessionId, mode },
this.onSubagentInterrupt({ childSessionId, parentSessionId, mode }),
),
},
workspace: {
create: payload => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)),
rename: payload => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)),
delete: payload => this.record('workspace.delete', payload, this.onWorkspaceDelete(payload)),
insertBefore: payload => this.record(
'workspace.insertBefore',
payload,
this.onWorkspaceInsertBefore(payload),
),
insertSessionBefore: payload => this.record(
'workspace.insertSessionBefore',
payload,
this.onWorkspaceInsertSessionBefore(payload),
),
archiveSession: payload => this.record(
'workspace.archiveSession',
payload,
this.onWorkspaceArchiveSession(payload),
),
unarchiveSession: payload => this.record(
'workspace.unarchiveSession',
payload,
this.onWorkspaceUnarchiveSession(payload),
),
follow: signal => this.openWorkspace(signal),
},
}
}
/** Push one live Session event to every follower of that Session. */
async pushFollow(
sessionId: SessionId,
frame: Exclude<SessionFollowFrame, { type: 'snapshot' }>,
): Promise<void> {
await Promise.all([...(this.followConns.get(sessionId) ?? [])].map(conn => new Promise<void>((resolve) => {
conn.feed({ kind: 'frame', value: frame, delivered: resolve })
})))
}
/** Push one Host-wide control update. */
pushControl(frame: Exclude<SessionControlFrame, { type: 'baseline' }>): void {
for (const conn of [...this.controlConns]) conn.feed({ kind: 'frame', value: frame })
}
/** Push one Workspace projection increment. */
pushWorkspace(frame: Exclude<WorkspaceFollowFrame, { type: 'baseline' }>): void {
for (const conn of [...this.workspaceConns]) conn.feed({ kind: 'frame', value: frame })
}
/** End (clean close) or fail (throw) every open stream — reconnect-path material. */
endStreams(): void {
for (const conns of this.followConns.values()) {
for (const conn of [...conns]) conn.feed({ kind: 'end' })
}
for (const conn of [...this.controlConns]) conn.feed({ kind: 'end' })
for (const conn of [...this.workspaceConns]) conn.feed({ kind: 'end' })
}
failStreams(error: unknown): void {
for (const conns of this.followConns.values()) {
for (const conn of [...conns]) conn.feed({ kind: 'fail', error })
}
for (const conn of [...this.controlConns]) conn.feed({ kind: 'fail', error })
for (const conn of [...this.workspaceConns]) conn.feed({ kind: 'fail', error })
}
callsOf(method: string): unknown[] {
return this.calls.filter(c => c.method === method).map(c => c.payload)
}
/** Number of currently attached journal generations for one Session. */
activeFollows(sessionId: SessionId): number {
return this.followConns.get(sessionId)?.length ?? 0
}
private record<T>(method: string, payload: unknown, response: Promise<T>): Promise<T> {
this.calls.push({ method, payload })
return response
}
private page(request: SessionPageRequest): Promise<RemoteResult<SessionPage>> {
return this.fetchPage(request)
}
private async fetchPage(
request: SessionPageRequest,
response?: Promise<RemoteResult<SessionPage>>,
): Promise<RemoteResult<SessionPage>> {
const sessionId = addressSessionId(request.address)
const payload = request.address.kind === 'session'
? {
sessionId,
throughSeq: request.throughSeq,
...request.beforeSeq === undefined ? {} : { beforeSeq: request.beforeSeq },
...request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages },
}
: {
parentSessionId: request.address.parentSessionId,
childSessionId: request.address.childSessionId,
mode: request.address.mode,
throughSeq: request.throughSeq,
...request.beforeSeq === undefined ? {} : { beforeSeq: request.beforeSeq },
...request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages },
}
const method = request.address.kind === 'session' ? 'session.history' : 'subagent.history'
const result = await this.record(method, payload, response ?? this.onHistory({
sessionId,
throughSeq: request.throughSeq,
...request.beforeSeq === undefined ? {} : { beforeSeq: request.beforeSeq },
...request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages },
}))
if (!result.ok) return result
return {
ok: true,
value: pageThrough(result.value, request.throughSeq),
}
}
private async *openFollow(
request: SessionFollowRequest,
signal: AbortSignal = new AbortController().signal,
): AsyncGenerator<SessionFollowFrame> {
const sessionId = addressSessionId(request.address)
this.followStarts.push(sessionId)
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 response = await this.onHistory({
sessionId,
maxMessages: request.maxMessages ?? 50,
})
if (!response.ok) throw response.error
const page = response.value
yield followSnapshot(page, request, this.followCursor, this.assistantStreamBaseline)
yield* stream.values
} finally {
stream.dispose()
}
}
private async *openControl(
signal: AbortSignal = new AbortController().signal,
): AsyncGenerator<SessionControlFrame> {
const stream = this.openValueStream(this.controlConns, signal)
try {
yield { type: 'baseline', value: this.controlBaseline }
yield* stream.values
} finally {
stream.dispose()
}
}
private async *openWorkspace(
signal: AbortSignal = new AbortController().signal,
): AsyncGenerator<WorkspaceFollowFrame> {
const stream = this.openValueStream(this.workspaceConns, signal)
try {
yield { type: 'baseline', value: this.workspaceBaseline }
yield* stream.values
} finally {
stream.dispose()
}
}
private openValueStream<F>(
registry: ValueStreamConn<F>[],
signal: AbortSignal,
): OpenValueStream<F> {
const inbox: ValueStreamItem<F>[] = []
let wake: (() => void) | null = null
let inFlightDelivered: (() => void) | undefined
let disposed = false
const conn: ValueStreamConn<F> = {
feed: (item) => {
inbox.push(item)
wake?.()
},
}
registry.push(conn)
const dispose = (): void => {
if (disposed) return
disposed = true
inFlightDelivered?.()
for (const item of inbox) {
if (item.kind === 'frame') item.delivered?.()
}
const index = registry.indexOf(conn)
if (index >= 0) registry.splice(index, 1)
wake?.()
}
const values = (async function* (): AsyncGenerator<F> {
try {
while (!signal.aborted && !disposed) {
while (inbox.length > 0) {
const item = inbox.shift() as ValueStreamItem<F>
if (item.kind === 'end') return
if (item.kind === 'fail') throw item.error
inFlightDelivered = item.delivered
yield item.value
inFlightDelivered?.()
inFlightDelivered = undefined
}
await new Promise<void>((resolve) => {
wake = resolve
signal.addEventListener('abort', () => { resolve() }, { once: true })
})
wake = null
}
} finally {
dispose()
}
})()
return { values, dispose }
}
}