mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
fix(session-controller): keep the list summary contract honest
This commit is contained in:
1 parent
af2fa50f8a
commit
6292808bf9
8 files changed
+57
-19
No files matched your search
@@ -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 +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())
|
||||
|
||||
Reference in new issue
Block a user