fix(apiproxy): serve history events and projections from one log position

Review found two defects in the previous commit's ordering fix.

@pku-xht: `historyStateFor` copied the attached session's events, the handler
then awaited `presenterScopeFor`, and only then read the projection baseline
off the still-live Session. An append during that await served events cut at N
beside a baseline folded to N+1 — one response describing two moments. The
same restructure had also moved the baseline read outside the `try`, so a
failing snapshot escaped the structured `internal` error.

Both awaits now happen before the cut: `historySourceFor` resolves which
session serves the read, `presenterScopeFor` ensures the recorded composition,
and `historyCutOf` then reads events and baseline adjacently with nothing
between them. The whole sequence is back inside the try.

The invariant judged any scoped assembly with a chain of one as an unjoined
agent, which rejects a legitimate assembly in a standing preset key (that key
has no parent of its own). It now gates on `context.agent` — a scope-only read
is not an agent and is out of range by construction rather than by a premise
about who else calls `assemble` — and asks the roster's own
`composedPreset()` instead of introspecting chain length. The advisory warning
uses the same relation.

Also from review: the `2026-08-05-per-agent-tool-presentation` note still
described `presentAs` as per-agent, which standing mounts made false and this
branch's own rewording contradicts; the duplicated "process-wide unit table"
argument collapses to the Agent Note with pointers from the five copies; a
dead `.sort()` before `arrayContaining`; and change-history narration in the
tool-cordis README.
This commit is contained in:
Yichen Jiang
2026-08-11 10:45:15 +08:00
parent 19ea7ae573
commit 61d5cb9e41
15 changed files with 146 additions and 123 deletions
@@ -7,7 +7,7 @@ import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentRegistry, { assembleContextFor } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import InvariantService from '@deepseek-ai/dsh-invariants'
import { describe, expect, it } from 'vitest'
@@ -87,25 +87,31 @@ describe('agent-presets invariants', () => {
it('rejects an agent that addresses a model without joining any preset', async () => {
const ctx = await harness()
// The delegation shape: an agent composed outside the roster keeps a
// scope chain of length one, so every registry view it reads is the empty
// global layer. Publication alone stays legal — `recompose` binds exactly
// such an agent — so nothing fires until that empty world reaches a prompt.
// The delegation shape: an agent composed outside the roster joined no
// standing mount, so every registry view it reads is the empty global
// layer. Publication alone stays legal — `recompose` binds exactly such an
// agent — so nothing fires until that empty world reaches a prompt.
const handle = await ctx.agents.create({ sessionId: SessionId('inv-unjoined') })
await expect(ctx.systemPrompt.assemble({ scope: handle.agent }))
await expect(ctx.systemPrompt.assemble(assembleContextFor(handle.agent)))
.rejects.toThrow(/without joining any agent preset/)
})
it('admits a joined agent and a host assembly that names no scope', async () => {
it('admits a joined agent, a scopeless read, and a standing-key read', async () => {
const ctx = await harness()
const handle = await ctx.agents.create({
sessionId: SessionId('inv-joined'),
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'),
})
await expect(ctx.systemPrompt.assemble({ scope: handle.agent })).resolves.toBeDefined()
await expect(ctx.systemPrompt.assemble(assembleContextFor(handle.agent))).resolves.toBeDefined()
// A scopeless assembly belongs to no agent, so it cannot be an unjoined one.
await expect(ctx.systemPrompt.assemble({})).resolves.toBeDefined()
// Neither can a scope that is not an agent at all: a standing preset key
// has no parent of its own, so a chain-length rule would reject the cold
// read that resolves presenters in it. `context.agent` is what keeps this
// check to agent assemblies.
const standing = await ctx.agentPresets.standingKeyFor('standard')
await expect(ctx.systemPrompt.assemble({ scope: standing })).resolves.toBeDefined()
})
})