From 6292808bf91ddb9e080f6cd333493e936861e3a2 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:20:10 +0800 Subject: [PATCH] fix(session-controller): keep the list summary contract honest --- packages/api/session-controller/src/list.ts | 5 +++-- packages/api/session-controller/src/types.ts | 15 ++++++++------- .../tests/session-projections.host.spec.ts | 13 +++++++++---- .../tests/summary-list.host.spec.ts | 4 ++-- .../tests/summary-types.client.spec.ts | 5 ++++- .../extensions/tool-cordis/src/api-catalog.ts | 14 +++++++++++++- packages/session/session-projection/src/index.ts | 5 ++++- .../session-projection/tests/summary.spec.ts | 15 ++++++++++++++- 8 files changed, 57 insertions(+), 19 deletions(-) diff --git a/packages/api/session-controller/src/list.ts b/packages/api/session-controller/src/list.ts index 095f1d583f..e8ac842d23 100644 --- a/packages/api/session-controller/src/list.ts +++ b/packages/api/session-controller/src/list.ts @@ -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 } } function normalizeSearchQuery(query: string): string { diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 89581c51d7..dcde6460ab 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -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 } /** Complete projection values at an exact Session event cursor. */ diff --git a/packages/api/session-controller/tests/session-projections.host.spec.ts b/packages/api/session-controller/tests/session-projections.host.spec.ts index ed4f4a6b0c..1c216cbcd5 100644 --- a/packages/api/session-controller/tests/session-projections.host.spec.ts +++ b/packages/api/session-controller/tests/session-projections.host.spec.ts @@ -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 () => { diff --git a/packages/api/session-controller/tests/summary-list.host.spec.ts b/packages/api/session-controller/tests/summary-list.host.spec.ts index f1891989c8..041d79e094 100644 --- a/packages/api/session-controller/tests/summary-list.host.spec.ts +++ b/packages/api/session-controller/tests/summary-list.host.spec.ts @@ -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) diff --git a/packages/api/session-controller/tests/summary-types.client.spec.ts b/packages/api/session-controller/tests/summary-types.client.spec.ts index b9c745a121..3900aebfd4 100644 --- a/packages/api/session-controller/tests/summary-types.client.spec.ts +++ b/packages/api/session-controller/tests/summary-types.client.spec.ts @@ -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', () => { diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 7f16217ce0..a5d48d2eca 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -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;\n}', }, { name: 'SessionProjectionMap', @@ -7390,6 +7390,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionProjectionSummaryExcluded', declaration: 'export type SessionProjectionSummaryExcluded = T & {\n readonly [sessionProjectionSummaryExcluded]: true;\n};', }, + { + name: 'SessionProjectionSummaryExcludedKey', + declaration: 'export type SessionProjectionSummaryExcludedKey = {\n [K in keyof SessionProjectionMap]: NonNullable extends SessionProjectionSummaryExcluded ? K : never;\n}[keyof SessionProjectionMap];', + }, + { + name: 'SessionProjectionSummaryValues', + declaration: 'export type SessionProjectionSummaryValues = Omit;', + }, { 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 extends SessionProjectionSummaryExcluded ? Value : T;', }, + { + name: 'SessionProjectionViewValues', + declaration: 'export type SessionProjectionViewValues = {\n [K in keyof SessionProjectionMap]: SessionProjectionViewValue;\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}', diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index a442baee16..b196567ad5 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -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 () => { diff --git a/packages/session/session-projection/tests/summary.spec.ts b/packages/session/session-projection/tests/summary.spec.ts index 541b447727..af65cb58ab 100644 --- a/packages/session/session-projection/tests/summary.spec.ts +++ b/packages/session/session-projection/tests/summary.spec.ts @@ -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())