fix(session-title): respect request and config boundaries
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/)
|
||||
|
||||
Reference in New Issue
Block a user