fix(web): hide verified cold blank sessions

This commit is contained in:
_Kerman
2026-08-13 14:04:16 +08:00
parent 2c456a00ea
commit 3c06c66ac4
26 changed files with 548 additions and 125 deletions

View File

@@ -4,7 +4,7 @@
* isolation, and prompt failure mapping.
*/
import { mkdtempSync, writeFileSync, utimesSync } from 'node:fs'
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
@@ -13,7 +13,7 @@ import SessionStore from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import { MessageId } from '@deepseek-ai/dsh-llm'
import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
@@ -39,55 +39,120 @@ function header(id: string, createdAt: number, extra: Partial<SessionHeader> = {
}
describe('sessions.list cold merge', () => {
it('summarizes unattached sessions: log mtime, locate-less and vanished-log createdAt fallbacks, lineage', async () => {
it('verifies only small possibly-blank artifacts and treats every unavailable probe as visible', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(UserQuestionService)
const root = mkdtempSync(join(tmpdir(), 'dsh-cold-'))
const logPath = join(root, 'a.log')
writeFileSync(logPath, 'log-bytes')
utimesSync(logPath, 5000, 5000) // mtime 5_000_000 ms — newer than every createdAt below
const smallPath = join(root, 'small.log')
const largePath = join(root, 'large.log')
writeFileSync(smallPath, 'x'.repeat(1024))
writeFileSync(largePath, 'x'.repeat(1025))
const metas = [
header('session-a', 1000),
header('session-b', 2000, { parentSession: sid('session-parent'), origin: 'subagent' }),
header('session-c', 1500),
header('small-blank', 100),
header('small-conversation', 200),
header('large-unknown', 300),
header('cached-nonblank', 400),
header('locationless', 500, { parentSession: sid('session-parent'), origin: 'subagent' }),
header('vanished', 600),
header('read-failure', 700),
]
// Structural fake of the persistence face list() consumes: list + locate.
// locate: a real per-session file (mtime wins), a backend without one
// (SQLite shape → createdAt), and a path whose file vanished (stat ENOENT
// → createdAt).
const readFrom = vi.fn(async (id: SessionId) => {
if (id === sid('small-blank')) {
return {
meta: metas[0]!,
events: [{ type: 'session/end-seed', seq: 0, time: 700, data: {} }] as SessionEvent[],
}
}
if (id === sid('small-conversation')) {
return {
meta: metas[1]!,
events: [{ type: 'turn/start', seq: 0, time: 800, data: { turn: 1 } }] as SessionEvent[],
}
}
if (id === sid('read-failure')) throw new Error('simulated read failure')
throw new Error(`unexpected cold read: ${id}`)
})
ctx.provide('sessionPersistence', {
list: () => Promise.resolve(metas),
locate: (meta: SessionHeader) => {
if (meta.id === sid('session-a')) return { kind: 'jsonl', path: logPath }
if (meta.id === sid('session-c')) return { kind: 'jsonl', path: join(root, 'vanished.log') }
if (meta.id === sid('large-unknown')) return { kind: 'jsonl', path: largePath }
if (meta.id === sid('locationless')) return undefined
if (meta.id === sid('vanished')) return { kind: 'jsonl', path: join(root, 'vanished.log') }
return { kind: 'jsonl', path: smallPath }
},
readFrom,
} as never)
ctx.provide('sessionProjectionCache', {
cachedSnapshot: (meta: SessionHeader) => {
if (meta.id === sid('small-blank')) {
return { asOfSeq: 0, values: { sessionListMetadata: { blank: true, lastPromptAt: null } } }
}
if (meta.id === sid('small-conversation')) {
return { asOfSeq: 0, values: { sessionListMetadata: { blank: true, lastPromptAt: 900 } } }
}
if (meta.id === sid('cached-nonblank')) {
return { asOfSeq: 1, values: { sessionListMetadata: { blank: false, lastPromptAt: 1000 } } }
}
return undefined
},
})
} as never)
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const response = await api.sessions.list(request({}))
expect(response.result.ok).toBe(true)
if (!response.result.ok) throw new Error('unreachable')
const items = response.result.value.items
expect(items.map(item => item.sessionId)).toEqual(['session-a', 'session-b', 'session-c'])
const [a, b, c] = items
expect(a?.updatedAt).toBeCloseTo(5_000_000, -3)
expect(a?.running).toBe(false)
// Cold summaries are never blank: lazy persistence keeps never-appended
// sessions out of list(), so a listed session necessarily has events.
expect(items.every(item => !item.blank)).toBe(true)
expect(a?.cwd).toBe('/proj')
expect(a?.parentSessionId).toBeUndefined()
expect(b?.updatedAt).toBe(2000)
expect(b?.parentSessionId).toBe('session-parent')
expect(b?.origin).toBe('subagent')
expect(c?.updatedAt).toBe(1500)
const byId = Object.fromEntries(response.result.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: 900 })
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,
updatedAt: 500,
parentSessionId: 'session-parent',
origin: 'subagent',
})
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([
sid('small-blank'),
sid('small-conversation'),
sid('read-failure'),
]))
})
it('can disable bounded blank probes without hiding cold Sessions', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(UserQuestionService)
const meta = header('probe-disabled', 100)
const readFrom = vi.fn()
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
locate: () => ({ kind: 'jsonl', path: '/not-read' }),
readFrom,
} as never)
const api = createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
cwd: '/tmp',
coldBlankProbeMaxBytes: 0,
})
const response = await api.sessions.list(request({}))
if (!response.result.ok) throw new Error('unreachable')
expect(response.result.value.items).toEqual([
expect.objectContaining({ sessionId: meta.id, blank: false, updatedAt: meta.createdAt }),
])
expect(readFrom).not.toHaveBeenCalled()
})
})
describe('attached updatedAt excludes end-seed', () => {
it('reports the last real work, not the pickup, so a resumed-untouched session does not float', async () => {
describe('attached updatedAt tracks human prompts', () => {
it('ignores pickup and non-prompt work after the latest human message', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(UserQuestionService)
@@ -99,7 +164,12 @@ describe('attached updatedAt excludes end-seed', () => {
const resumed = ctx.sessions.create(sid('resumed-untouched'), {
seed: [
{ type: 'turn/start', seq: 0, time: worked, data: { turn: 1 } },
{ type: 'turn/end', seq: 1, time: worked, data: { turn: 1, reason: { kind: 'completed' } } },
{
type: 'user/message', seq: 1, time: worked,
data: createUserMessage({ content: [{ type: 'text', text: 'worked' }], source: { kind: 'user' } }),
surfaceOp: 'append',
},
{ type: 'turn/end', seq: 2, time: worked + 1, data: { turn: 1, reason: { kind: 'completed' } } },
],
meta: { cwd: '/proj', createdAt: 500 },
})
@@ -113,12 +183,21 @@ describe('attached updatedAt excludes end-seed', () => {
const summary = listed.result.value.items.find(item => item.sessionId === 'resumed-untouched')
expect(summary?.updatedAt).toBe(worked)
// Real work appended after end-seed does move it.
// A lifecycle boundary is not a human update.
resumed.append('turn/start', { turn: 2 })
const afterBoundary = await api.sessions.list(request({}))
if (!afterBoundary.result.ok) throw new Error('list failed')
expect(afterBoundary.result.value.items.find(item => item.sessionId === 'resumed-untouched')?.updatedAt)
.toBe(worked)
const prompt = resumed.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'new prompt' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
const after = await api.sessions.list(request({}))
if (!after.result.ok) throw new Error('list failed')
const moved = after.result.value.items.find(item => item.sessionId === 'resumed-untouched')
expect(moved?.updatedAt).toBeGreaterThan(worked)
expect(moved?.updatedAt).toBe(prompt.time)
})
})

View File

@@ -7,7 +7,7 @@
* pushed to mux consumers as a session/projection frame minted here.
*/
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { z } from 'zod'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
@@ -163,10 +163,29 @@ describe('session.history projections block', () => {
dispose()
const after = await proxy.sessions.history(request({ sessionId: session.id }))
if (!after.result.ok) throw new Error('unreachable')
// The registry is still mounted, so the block itself stays (asOfSeq cut
// with zero keys); the disposed key reads as capability absence.
// The registry stays mounted; only the disposed key leaves while the
// gateway-owned Session-list unit remains.
expect(after.result.value.projections?.asOfSeq).toBe(session.seq - 1)
expect(after.result.value.projections?.values).toEqual({})
expect('test/last-user' in (after.result.value.projections?.values ?? {})).toBe(false)
expect(after.result.value.projections?.values.sessionListMetadata).toEqual({
blank: true,
lastPromptAt: session.events.at(-1)?.time,
})
})
it('removes the gateway-owned Session-list unit when the gateway fiber unloads', async () => {
const { ctx, session } = await harness(true)
expect('sessionListMetadata' in ctx.sessionProjections.snapshot(session).values).toBe(false)
const fiber = ctx.plugin(Object.assign((gatewayCtx: Context) => {
createApiProxy(gatewayCtx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
}, { inject: ['sessions', 'agents', 'userQuestions', 'sessionProjections'] }))
await fiber.await()
await vi.waitFor(() => {
expect(ctx.sessionProjections.snapshot(session).values.sessionListMetadata)
.toEqual({ blank: true, lastPromptAt: null })
})
await fiber.dispose()
expect('sessionListMetadata' in ctx.sessionProjections.snapshot(session).values).toBe(false)
})
})
@@ -174,11 +193,18 @@ describe('session.list projections column', () => {
it('serves attached rows from the live registry cut, watermarked for client seeding', async () => {
const { ctx, session } = await harness(true)
ctx.sessionProjections.register(lastUserUnit())
const gateway = api(ctx)
await new Promise(resolve => setTimeout(resolve, 0))
session.append('turn/start', { turn: 1 })
seedMessages(session, 1)
const response = await api(ctx).sessions.list(request({}))
const response = await gateway.sessions.list(request({}))
if (!response.result.ok) throw new Error('unreachable')
const row = response.result.value.items.find(item => item.sessionId === session.id)
expect(row?.projections?.values['test/last-user']).toEqual({ text: 'm0' })
expect(row?.projections?.values.sessionListMetadata).toEqual({
blank: false,
lastPromptAt: session.events.at(-1)?.time,
})
expect(row?.projections?.asOfSeq).toBe(session.seq - 1)
})
@@ -266,21 +292,33 @@ describe('session/projection push frame', () => {
await new Promise(resolve => setTimeout(resolve, 0))
const abort = new AbortController()
const stream = proxy.events.mux({ rpcId: RpcId('t-proj-mux'), payload: {} }, abort.signal)
const collected = collect(stream, 2, abort)
const collected = collect(stream, 5, abort)
const now = vi.spyOn(Date, 'now').mockReturnValue(100)
seedMessages(session, 1)
// Same-reference apply: turn/start does not concern the unit — no frame.
now.mockReturnValue(200)
session.append('turn/start', { turn: 1 })
now.mockReturnValue(300)
seedMessages(session, 1)
now.mockRestore()
const frames = await collected
const pushes = frames.filter(
(f): f is Extract<MuxFrame, { type: 'session/projection' }> => f.type === 'session/projection',
(f): f is Extract<MuxFrame, { type: 'session/projection' }> =>
f.type === 'session/projection' && f.key === 'test/last-user',
)
expect(pushes).toEqual([
{ type: 'session/projection', sessionId: session.id, key: 'test/last-user', value: { text: 'm0' }, seq: 0 },
{ type: 'session/projection', sessionId: session.id, key: 'test/last-user', value: { text: 'm0' }, seq: 2 },
])
expect(frames.filter(
(f): f is Extract<MuxFrame, { type: 'session/projection' }> =>
f.type === 'session/projection' && f.key === 'sessionListMetadata',
)).toEqual([
{ type: 'session/projection', sessionId: session.id, key: 'sessionListMetadata', value: { blank: true, lastPromptAt: 100 }, seq: 0 },
{ type: 'session/projection', sessionId: session.id, key: 'sessionListMetadata', value: { blank: false, lastPromptAt: 100 }, seq: 1 },
{ type: 'session/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 proxy.sessions.history(request({ sessionId: session.id }))
if (!tail.result.ok) throw new Error('unreachable')

View File

@@ -95,7 +95,12 @@ function bench(options: {
})
// The gateway's own projection push feed subscribes at construction; the
// no-op disposer keeps that feed quiet while these tests pin history reads.
ctx.provide('sessionProjections', { snapshot, restore, onChanged: () => () => {} })
ctx.provide('sessionProjections', {
snapshot,
restore,
onChanged: () => () => {},
register: () => () => {},
})
ctx.provide('userQuestions', { registerProvider: () => () => {} })
const api = createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp',

View File

@@ -127,17 +127,32 @@ async function responseBytes(response: Response): Promise<Uint8Array> {
describe('session export compression config', () => {
it('defaults to level 6 and rejects values outside the integer 0-9 range', () => {
expect(ApiProxyService.Config({})).toEqual({ sessionExportCompressionLevel: 6 })
expect(ApiProxyService.Config({})).toEqual({
sessionExportCompressionLevel: 6,
coldBlankProbeMaxBytes: 1024,
})
expect(ApiProxyService.Config({ sessionExportCompressionLevel: 0 }))
.toEqual({ sessionExportCompressionLevel: 0 })
.toEqual({ sessionExportCompressionLevel: 0, coldBlankProbeMaxBytes: 1024 })
expect(ApiProxyService.Config({ sessionExportCompressionLevel: 9 }))
.toEqual({ sessionExportCompressionLevel: 9 })
.toEqual({ sessionExportCompressionLevel: 9, coldBlankProbeMaxBytes: 1024 })
for (const value of [-1, 10, 1.5]) {
expect(() => ApiProxyService.Config({ sessionExportCompressionLevel: value } as never)).toThrow()
}
})
})
describe('cold blank probe config', () => {
it('accepts a per-Session byte bound including zero and rejects invalid bounds', () => {
expect(ApiProxyService.Config({ coldBlankProbeMaxBytes: 0 }))
.toEqual({ sessionExportCompressionLevel: 6, coldBlankProbeMaxBytes: 0 })
expect(ApiProxyService.Config({ coldBlankProbeMaxBytes: 2048 }))
.toEqual({ sessionExportCompressionLevel: 6, coldBlankProbeMaxBytes: 2048 })
for (const value of [-1, 1.5]) {
expect(() => ApiProxyService.Config({ coldBlankProbeMaxBytes: value })).toThrow()
}
})
})
describe('session.export download endpoint', () => {
it('streams a ZIP with the root artifact verbatim under its original filename', async () => {
const api = await buildApi({ 'session-root': artifact('session-root') })