mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-09 04:02:35 +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:
@@ -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'))
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user