fix(session-title): respect request and config boundaries

This commit is contained in:
Tianyi Cui
2026-07-21 14:09:17 +08:00
parent 63cb16a540
commit b3ba4345f5
30 changed files with 208 additions and 73 deletions

View File

@@ -39,7 +39,7 @@ Agent status (per agent):
Model requests (on `llm/stream`):
- **a loop-built request is exactly what the log reconstructs** — a frozen request with a live `sessionId` (the loop-built marker; hand-built one-shots like compaction's summarize are unfrozen and skipped) must carry frozen `messages` deep-equal to the derivation over the log prefix strictly before the in-flight step's `step/start` (rebuilt through a FRESH `Session`, so the live cache cannot vouch for itself — and boundary-correct: content logged after `step/start` legitimately belongs to the next request), and every non-content field must equal the latest logged `request/header` (see [the reconstructability Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)). Registered with `prepend: true` so a short-circuiting `llm/stream` listener (the replay adapter) cannot silence it; prepend orders it against append-registered listeners only — correctness rests on the seq-bounded rebuild, never listener timing.
- **a loop-built request is exactly what the log reconstructs** — a request carrying dsh-agent-loop's process-local identity must have a frozen envelope, live `sessionId`, frozen `messages` deep-equal to the derivation over the log prefix strictly before the in-flight step's `step/start` (rebuilt through a FRESH `Session`, so the live cache cannot vouch for itself — and boundary-correct: content logged after `step/start` legitimately belongs to the next request), and every non-content field must equal the latest logged `request/header` (see [the reconstructability Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)). Frozen auxiliary calls remain outside this loop-only equation. Registered with `prepend: true` so a short-circuiting `llm/stream` listener (the replay adapter) cannot silence it; prepend orders it against append-registered listeners only — correctness rests on the seq-bounded rebuild, never listener timing.
On any violation it throws `InvariantError` (`code: 'INVARIANT'`).
@@ -61,5 +61,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **The request-reconstructability assertion covers loop-built requests only** — hand-built one-shots (e.g. compaction's summarize call) carry no live `sessionId` marker and are skipped.
- **The request-reconstructability assertion covers loop-built requests only** — hand-built and auxiliary calls carry no process-local loop marker and are skipped even when they are immutable or session-associated.
- **Merge-extended event families get no family-specific assertions** — `compact/*` lock pairing and `hook/*` invoked/result pairing are not checked here; only the core turn/step/chunk/tool-result contract is.

View File

@@ -10,7 +10,7 @@
import type { Context } from 'cordis'
import { carrierKeyOf, isScopeCarrier } from '@deepseek-ai/dsh-scope'
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
import { assertNever, HarnessError, isAgentLoopRequest } from '@deepseek-ai/dsh-llm'
import type { CallId, GenerateOptions } from '@deepseek-ai/dsh-llm'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
@@ -341,8 +341,8 @@ export function apply(ctx: Context): void {
}, { global: true })
// Request-reconstruction cross-check (the reconstructability Agent Note): a
// loop-built request — frozen envelope + live sessionId is the marker; a
// hand-built one-shot (compaction summarize) is unfrozen and skipped — must
// loop-built request — identified by dsh-agent-loop's process-local marker;
// frozen auxiliary calls remain outside this loop-only equation — must
// be EXACTLY what the session log reconstructs:
//
// - messages: the folded header's session prefix (messagePrefix — the
@@ -366,7 +366,13 @@ export function apply(ctx: Context): void {
// (cordis unshift) — which is fine: correctness rests on the seq-bounded
// fold below, never on listener timing.
ctx.on('llm/stream', (options: GenerateOptions, next) => {
if (options.sessionId === undefined || !Object.isFrozen(options)) return next()
if (!isAgentLoopRequest(options)) return next()
if (!Object.isFrozen(options)) {
throw new InvariantError('a loop-built request must carry a frozen envelope')
}
if (options.sessionId === undefined) {
throw new InvariantError('a loop-built request must carry a sessionId')
}
// GenerateOptions types sessionId as Branded<'SessionId'>, which IS
// SessionId (dsh-llm cannot import it without a cycle) — no cast needed.
const session = ctx.sessions.get(options.sessionId)

View File

@@ -1,7 +1,8 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { createScope, scopeTarget } from '@deepseek-ai/dsh-scope'
import { CallId } from '@deepseek-ai/dsh-llm'
import { CallId, markAgentLoopRequest } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import * as Invariants from '@deepseek-ai/dsh-invariants'
@@ -798,9 +799,14 @@ describe('request-reconstruction cross-check (llm/stream)', () => {
void ctx.waterfall('llm/stream', options as never, () => (async function* () {})() as never)
}
/** Attach the same process-local identity as dsh-agent-loop. */
function loopRequest(options: unknown): GenerateOptions {
return markAgentLoopRequest(options as GenerateOptions)
}
it('passes a frozen request that equals the boundary derivation + the folded header', async () => {
const { ctx, session, boundary } = await requestSetup()
const options = Object.freeze({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id })
const options = loopRequest(Object.freeze({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id }))
expect(() => { dispatch(ctx, options) }).not.toThrow()
})
@@ -810,7 +816,7 @@ describe('request-reconstruction cross-check (llm/stream)', () => {
// belongs to the NEXT request. A current-surface comparison would
// false-fire here; the seq-bounded rebuild must not.
session.append('context/message', { content: [{ type: 'text', text: '[late]' }], source: { kind: 'plugin', plugin: 'x' } }, { surfaceOp: 'append' })
const options = Object.freeze({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id })
const options = loopRequest(Object.freeze({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id }))
expect(() => { dispatch(ctx, options) }).not.toThrow()
})
@@ -819,26 +825,26 @@ describe('request-reconstruction cross-check (llm/stream)', () => {
const prefix = { role: 'user' as const, content: [{ type: 'text' as const, text: '<system-reminder>catalog</system-reminder>' }] }
session.append('request/header', { header: { config: { provider: 'mock', model: 'm' }, messagePrefix: [prefix] }, reason: 'change' })
// The prefixed request matches the fold…
const prefixed = Object.freeze({ model: 'm', messages: Object.freeze([prefix, ...boundary]), sessionId: session.id })
const prefixed = loopRequest(Object.freeze({ model: 'm', messages: Object.freeze([prefix, ...boundary]), sessionId: session.id }))
expect(() => { dispatch(ctx, prefixed) }).not.toThrow()
// …a request that DROPPED the logged prefix diverges…
const bare = Object.freeze({ model: 'm', messages: Object.freeze([...boundary]), sessionId: session.id })
const bare = loopRequest(Object.freeze({ model: 'm', messages: Object.freeze([...boundary]), sessionId: session.id }))
expect(() => { dispatch(ctx, bare) }).toThrow(/diverges from the boundary derivation/)
// …and so does one that misplaced it (prefix sent after the history).
const misplaced = Object.freeze({ model: 'm', messages: Object.freeze([...boundary, prefix]), sessionId: session.id })
const misplaced = loopRequest(Object.freeze({ model: 'm', messages: Object.freeze([...boundary, prefix]), sessionId: session.id }))
expect(() => { dispatch(ctx, misplaced) }).toThrow(/diverges from the boundary derivation/)
})
it('rejects a frozen request whose messages diverge from the boundary derivation', async () => {
const { ctx, session, boundary } = await requestSetup()
const messages = [...boundary, { role: 'user', content: [{ type: 'text', text: 'phantom' }] }]
const options = Object.freeze({ model: 'm', messages: Object.freeze(messages), sessionId: session.id })
const options = loopRequest(Object.freeze({ model: 'm', messages: Object.freeze(messages), sessionId: session.id }))
expect(() => { dispatch(ctx, options) }).toThrow(/diverges from the boundary derivation/)
})
it('rejects a frozen request whose fields diverge from the folded header', async () => {
const { ctx, session, boundary } = await requestSetup()
const options = Object.freeze({ model: 'other', messages: Object.freeze(boundary), sessionId: session.id })
const options = loopRequest(Object.freeze({ model: 'other', messages: Object.freeze(boundary), sessionId: session.id }))
expect(() => { dispatch(ctx, options) }).toThrow(/diverges from the folded request header/)
})
@@ -846,7 +852,7 @@ describe('request-reconstruction cross-check (llm/stream)', () => {
const { ctx } = await setup()
const session = ctx.sessions.create(SessionId('req-bare'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const bare = Object.freeze({ model: 'm', messages: Object.freeze([]), sessionId: session.id })
const bare = loopRequest(Object.freeze({ model: 'm', messages: Object.freeze([]), sessionId: session.id }))
expect(() => { dispatch(ctx, bare) }).toThrow(/no step\/start/)
session.append('step/start', { turn: 1, step: 1 })
@@ -855,7 +861,7 @@ describe('request-reconstruction cross-check (llm/stream)', () => {
it('rejects a frozen request carrying an unfrozen messages array', async () => {
const { ctx, session, boundary } = await requestSetup()
const options = Object.freeze({ model: 'm', messages: [...boundary], sessionId: session.id })
const options = loopRequest(Object.freeze({ model: 'm', messages: [...boundary], sessionId: session.id }))
expect(() => { dispatch(ctx, options) }).toThrow(/frozen messages array/)
})
@@ -866,10 +872,32 @@ describe('request-reconstruction cross-check (llm/stream)', () => {
expect(() => { dispatch(ctx, options) }).not.toThrow()
})
it('skips frozen auxiliary requests that were not built by the loop', async () => {
const { ctx, session } = await requestSetup()
const options = Object.freeze({
model: 'title-model',
messages: Object.freeze([{ role: 'user', content: [{ type: 'text', text: 'title this session' }] }]),
sessionId: session.id,
})
expect(() => { dispatch(ctx, options) }).not.toThrow()
})
it('rejects a marked loop request without its frozen envelope or session identity', async () => {
const { ctx } = await requestSetup()
expect(() => {
dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([]) }))
}).toThrow(/frozen envelope/)
expect(() => {
dispatch(ctx, loopRequest(Object.freeze({ model: 'm', messages: Object.freeze([]) })))
}).toThrow(/sessionId/)
})
it('skips requests without a sessionId or with an unknown session', async () => {
const { ctx } = await requestSetup()
expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([]) })) }).not.toThrow()
expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([]), sessionId: SessionId('ghost') })) }).not.toThrow()
expect(() => {
dispatch(ctx, loopRequest(Object.freeze({ model: 'm', messages: Object.freeze([]), sessionId: SessionId('ghost') })))
}).not.toThrow()
})
})
@@ -888,11 +916,11 @@ describe('request cross-check ordering (prepend)', () => {
session.append('step/start', { turn: 1, step: 1 })
session.append('request/header', { header: { config: { provider: 'mock', model: 'm' } }, reason: 'initial' })
const divergent = Object.freeze({
const divergent = markAgentLoopRequest(Object.freeze({
model: 'm',
messages: Object.freeze([{ role: 'user', content: [{ type: 'text', text: 'phantom' }] }]),
sessionId: session.id,
})
}) as unknown as GenerateOptions)
expect(() => {
void ctx.waterfall('llm/stream', divergent as never, () => (async function* () {})() as never)
}).toThrow(/diverges from the boundary derivation/)