fix(web): hide verified cold blank sessions
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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') })
|
||||
|
||||
Reference in New Issue
Block a user