fix(session-controller): keep the list summary contract honest

This commit is contained in:
imccyu committed 2026-10-19 10:20:10 +08:00
1 parent af2fa50f8a
commit 6292808bf9
8 files changed
+57 -19

No files matched your search

+3 -2
View File
@@ -16,9 +16,10 @@ import {
SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
} from './types.ts'
import type {
SessionListMetadata, SessionProjectionHints, SessionProjectionValues, SessionSearchItem,
SessionListMetadata, SessionProjectionHints, SessionSearchItem,
SessionSearchValue, SessionSummary,
} from './types.ts'
import type { SessionProjectionSummaryValues } from '@deepseek-ai/dsh-session-projection/types'
const SEARCH_PROVIDER_CALL_LIMIT = 100
const SESSION_SEARCH_QUERY_MAX_CHARS = 500
@@ -332,7 +333,7 @@ function hintsOf(
if (block === undefined || Object.keys(block.values).length === 0) return undefined
// Listing hints contain every summary wire value the source currently holds
// but remain partial: missing cells and cache rows are never materialized here.
return { kind, asOfSeq: block.asOfSeq, values: block.values as SessionProjectionValues }
return { kind, asOfSeq: block.asOfSeq, values: block.values as Partial<SessionProjectionSummaryValues> }
}
function normalizeSearchQuery(query: string): string {
+8 -7
View File
@@ -8,7 +8,7 @@ import type { Branded } from '@deepseek-ai/dsh-brand'
import type { LlmAttemptId, MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { TextBlock } from '@deepseek-ai/dsh-llm'
import type { SessionId, SessionSeqCursor } from '@deepseek-ai/dsh-session/types'
import type { SessionProjectionMap, SessionProjectionSummaryExcluded } from '@deepseek-ai/dsh-session-projection/types'
import type { SessionProjectionMap, SessionProjectionSummaryExcluded, SessionProjectionSummaryValues } from '@deepseek-ai/dsh-session-projection/types'
import type { Principal } from '@deepseek-ai/dsh-typert-protocol'
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
@@ -55,10 +55,11 @@ export interface SessionListMetadata {
}
/**
* Every available wire value a Session-list row carries as partial, possibly
* stale hints. `kind` and `asOfSeq` are independent facts: `kind` says which
* sequence space `asOfSeq` belongs to, and therefore how a client may merge
* the block; `asOfSeq` is the producer's watermark in that space.
* Every available summary wire value a Session-list row carries as partial,
* possibly stale hints. The block omits the keys whose unit declares
* `summary: false`. `kind` and `asOfSeq` are independent facts: `kind` says
* which sequence space `asOfSeq` belongs to, and therefore how a client may
* merge the block; `asOfSeq` is the producer's watermark in that space.
*/
export interface SessionProjectionHints {
/**
@@ -71,8 +72,8 @@ export interface SessionProjectionHints {
readonly kind: 'cached' | 'sequenced'
/** Watermark of the block in the sequence space named by `kind`. */
readonly asOfSeq: number
/** Provider-validated values present in the block; omitted keys remain unknown. */
readonly values: SessionProjectionValues
/** Provider-validated summary values present in the block; omitted keys remain unknown. */
readonly values: Partial<SessionProjectionSummaryValues>
}
/** Complete projection values at an exact Session event cursor. */
@@ -532,7 +532,7 @@ describe('session.list projections column', () => {
expect(row !== undefined && 'projections' in row).toBe(false)
})
it('serves every available cold projection hint from the cache with zero log loads', async () => {
it('serves the summary projection hints 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') }
@@ -541,10 +541,12 @@ describe('session.list projections column', () => {
inspect: load,
open: load,
}) as never)
let seenKeys: unknown
ctx.provide('sessionProjectionCache', {
// The carrier hands the listed header through as the identity witness.
cachedSnapshot: (meta: { id: unknown; createdAt: number }) =>
(meta.id === coldId && meta.createdAt === 5
cachedSnapshot: (meta: { id: unknown; createdAt: number }, keys: unknown) => {
seenKeys = keys
return meta.id === coldId && meta.createdAt === 5
? {
asOfSeq: SessionSeq(7),
values: {
@@ -553,7 +555,8 @@ describe('session.list projections column', () => {
title: 'Cached title',
},
}
: undefined),
: undefined
},
} as never)
const response = await remote(ctx).list(request({}))
if (!response.ok) throw new Error('unreachable')
@@ -568,6 +571,8 @@ describe('session.list projections column', () => {
title: 'Cached title',
},
})
// The cold branch passes the registry's own summary set, not a fresh list.
expect(seenKeys).toBe(ctx.sessionProjections.summaryKeys())
})
it('keeps persisted host-only state out of a cold session.list response', async () => {
@@ -51,7 +51,7 @@ async function opening(
}
/**
* Mount the registry plus the three summary-excluded units and one ordinary
* Mount the registry plus the four summary-excluded units and one ordinary
* wire unit. The tool plugin needs a tool registry; the list path never
* executes a tool, so a registration sink is enough.
*/
@@ -89,7 +89,7 @@ function seedSession(ctx: Context): { id: SessionId } {
}
describe('session.list summary payload', () => {
it('carries every registered wire key except the three summary-excluded units', async () => {
it('carries every registered wire key except the summary-excluded units', async () => {
const { ctx } = await harness()
const { id } = seedSession(ctx)
const session = ctx.sessions.get(id)
@@ -24,13 +24,16 @@ function contract(summary: SessionSummary, full: SessionProjectionSnapshot): voi
void summary.projectionValues?.sessionStats
// @ts-expect-error todos is excluded from Session summary values
void summary.projectionValues?.todos
// @ts-expect-error imageLimits is excluded from Session summary values
void summary.projectionValues?.imageLimits
// Included keys stay readable, and the full snapshot keeps the excluded ones
// (with `todos` still nullable).
const title: string | null | undefined = summary.projectionValues?.title
const outline: readonly unknown[] | undefined = full.values.turnOutline
const todos: readonly TodoItem[] | null | undefined = full.values.todos
void [title, outline, todos]
const limits = full.values.imageLimits
void [title, outline, todos, limits]
}
describe('session summary projection types', () => {
@@ -7372,7 +7372,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionProjectionHints',
declaration: 'export interface SessionProjectionHints {\n readonly kind: \'cached\' | \'sequenced\';\n readonly asOfSeq: number;\n readonly values: SessionProjectionValues;\n}',
declaration: 'export interface SessionProjectionHints {\n readonly kind: \'cached\' | \'sequenced\';\n readonly asOfSeq: number;\n readonly values: Partial<SessionProjectionSummaryValues>;\n}',
},
{
name: 'SessionProjectionMap',
@@ -7390,6 +7390,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionProjectionSummaryExcluded',
declaration: 'export type SessionProjectionSummaryExcluded<T> = T & {\n readonly [sessionProjectionSummaryExcluded]: true;\n};',
},
{
name: 'SessionProjectionSummaryExcludedKey',
declaration: 'export type SessionProjectionSummaryExcludedKey = {\n [K in keyof SessionProjectionMap]: NonNullable<SessionProjectionMap[K]> extends SessionProjectionSummaryExcluded<unknown> ? K : never;\n}[keyof SessionProjectionMap];',
},
{
name: 'SessionProjectionSummaryValues',
declaration: 'export type SessionProjectionSummaryValues = Omit<SessionProjectionViewValues, SessionProjectionSummaryExcludedKey>;',
},
{
name: 'SessionProjectionsValue',
declaration: 'export type SessionProjectionsValue = (SessionProjectionBaseline & {\n readonly kind: \'sequenced\';\n}) | {\n readonly kind: \'migration-required\';\n readonly values: SessionProjectionValues;\n} | null;',
@@ -7410,6 +7418,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionProjectionViewValue',
declaration: 'export type SessionProjectionViewValue<T> = T extends SessionProjectionSummaryExcluded<infer Value> ? Value : T;',
},
{
name: 'SessionProjectionViewValues',
declaration: 'export type SessionProjectionViewValues = {\n [K in keyof SessionProjectionMap]: SessionProjectionViewValue<SessionProjectionMap[K]>;\n};',
},
{
name: 'SessionPromptRequest',
declaration: 'export interface SessionPromptRequest {\n readonly requestId: SessionRequestId;\n readonly sessionId: SessionId;\n readonly mode: \'queue\' | \'steer\';\n readonly content: readonly PromptContentPart[];\n readonly clientTimeZone?: string;\n}',
@@ -304,7 +304,7 @@ export class SessionProjectionRegistry extends Service {
const wire = definition.wire as {
viewSchema: ZodType
view(state: S): unknown
summary?: false
summary?: boolean
} | undefined
const erased: ErasedDefinition = {
key: definition.key,
@@ -333,6 +333,9 @@ export class SessionProjectionRegistry extends Service {
if (existing.def.stateVersion !== erased.stateVersion) {
throw new Error(`session projection key ${JSON.stringify(key)} is already registered at stateVersion ${String(existing.def.stateVersion)}; refusing to share it with stateVersion ${String(erased.stateVersion)}`)
}
if (existing.def.wire?.summary !== erased.wire?.summary) {
throw new Error(`session projection key ${JSON.stringify(key)} is already registered with summary=${String(existing.def.wire?.summary)}; refusing to share it with summary=${String(erased.wire?.summary)}`)
}
existing.refs += 1
}
yield () => {
@@ -88,6 +88,19 @@ describe('session projection summary keys', () => {
}
})
it('refuses a second registration of one key with a different summary membership', async () => {
const { ctx, registry } = await mount()
try {
registry.register(keptUnit())
expect(() => registry.register({
...keptUnit(),
wire: { ...keptUnit().wire, summary: false },
})).toThrow(/test\/summary-kept.*already registered with summary=true/)
} finally {
await ctx.fiber.dispose()
}
})
it('rebuilds the summary set only when a registration or its disposal changes the key table', async () => {
const { ctx, registry } = await mount()
try {
@@ -156,7 +169,7 @@ describe('session projection summary keys', () => {
}
})
it('reports an empty summary set for a registry with only marked wire units', async () => {
it('reports an empty summary set while the complete face still serves only marked units', async () => {
const { ctx, registry } = await mount()
try {
registry.register(excludedUnit())