deepseek-harness/packages/core/agent-loop/tests/request-cache.e2e.ts

104 lines
4.6 KiB
TypeScript
Raw Normal View History

loop: every request is built from the log — boundary snapshot, header events, config-only waterfall The loop is now transmission-stateless; a request is a pure function of (session log, this step's rendered assembly, current AgentOptions): - The reconstruction boundary is step/start: the messages snapshot is taken in the same synchronous frame immediately before the step/start append, so the request's messages are exactly the derivation over events[0..stepStartSeq) — an inject() from an agent/request listener (or any concurrent task) lands after the boundary and joins the NEXT request. This changes behavior for a synchronous step/start session/event listener that appends content (master derived after the append, so such a listener could reach the current request): agent/pre-step is the sanctioned seam for current-request content. - agent/request is re-typed to config-only: (agent, turn, step, config: LlmCallConfig, next) → LlmCallConfig. The frozen seed comes from AgentOptions on a loop instance's first request (explicit options beat the logged baseline — fork overrides and resume reconfiguration stay correct) and from the log's folded header afterwards; listeners return a replacement to switch. Content shaping through the request is no longer expressible — model-visible content flows through the log channels. - recordRequestHeader appends whatever header event the request owes the log before dispatch: an 'initial'/'resume' snapshot anchoring each loop instance, a round-trip-verified delta on change, a 'fallback' snapshot when the encoding cannot express it. Session.requestHeader() is the log's incrementally-folded baseline. - Requests are deep-frozen before dispatch (deepFreeze exempts the AbortSignal — freezing one breaks AbortController.abort() outright); frozen + sessionId is the loop-built marker the dev invariant keys on. Ported from #162 and re-anchored on the log: the append-extension / frozen-end-to-end / compaction-resend / prompt-change property tests, plus new specs for the boundary semantics, resume anchoring, and the end-to-end theorem (every recorded request rebuilds byte-equal from the log alone). Live cache-hit e2e (request-cache.e2e.ts) verified against the real DeepSeek API. Snapshot goldens intentionally stale until the single re-record after the compact/summary envelope lands.
2026-07-06 03:07:34 +08:00
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
loop: every request is built from the log — boundary snapshot, header events, config-only waterfall The loop is now transmission-stateless; a request is a pure function of (session log, this step's rendered assembly, current AgentOptions): - The reconstruction boundary is step/start: the messages snapshot is taken in the same synchronous frame immediately before the step/start append, so the request's messages are exactly the derivation over events[0..stepStartSeq) — an inject() from an agent/request listener (or any concurrent task) lands after the boundary and joins the NEXT request. This changes behavior for a synchronous step/start session/event listener that appends content (master derived after the append, so such a listener could reach the current request): agent/pre-step is the sanctioned seam for current-request content. - agent/request is re-typed to config-only: (agent, turn, step, config: LlmCallConfig, next) → LlmCallConfig. The frozen seed comes from AgentOptions on a loop instance's first request (explicit options beat the logged baseline — fork overrides and resume reconfiguration stay correct) and from the log's folded header afterwards; listeners return a replacement to switch. Content shaping through the request is no longer expressible — model-visible content flows through the log channels. - recordRequestHeader appends whatever header event the request owes the log before dispatch: an 'initial'/'resume' snapshot anchoring each loop instance, a round-trip-verified delta on change, a 'fallback' snapshot when the encoding cannot express it. Session.requestHeader() is the log's incrementally-folded baseline. - Requests are deep-frozen before dispatch (deepFreeze exempts the AbortSignal — freezing one breaks AbortController.abort() outright); frozen + sessionId is the loop-built marker the dev invariant keys on. Ported from #162 and re-anchored on the log: the append-extension / frozen-end-to-end / compaction-resend / prompt-change property tests, plus new specs for the boundary semantics, resume anchoring, and the end-to-end theorem (every recorded request rebuilds byte-equal from the log alone). Live cache-hit e2e (request-cache.e2e.ts) verified against the real DeepSeek API. Snapshot goldens intentionally stale until the single re-record after the compact/summary envelope lands.
2026-07-06 03:07:34 +08:00
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
loop: every request is built from the log — boundary snapshot, header events, config-only waterfall The loop is now transmission-stateless; a request is a pure function of (session log, this step's rendered assembly, current AgentOptions): - The reconstruction boundary is step/start: the messages snapshot is taken in the same synchronous frame immediately before the step/start append, so the request's messages are exactly the derivation over events[0..stepStartSeq) — an inject() from an agent/request listener (or any concurrent task) lands after the boundary and joins the NEXT request. This changes behavior for a synchronous step/start session/event listener that appends content (master derived after the append, so such a listener could reach the current request): agent/pre-step is the sanctioned seam for current-request content. - agent/request is re-typed to config-only: (agent, turn, step, config: LlmCallConfig, next) → LlmCallConfig. The frozen seed comes from AgentOptions on a loop instance's first request (explicit options beat the logged baseline — fork overrides and resume reconfiguration stay correct) and from the log's folded header afterwards; listeners return a replacement to switch. Content shaping through the request is no longer expressible — model-visible content flows through the log channels. - recordRequestHeader appends whatever header event the request owes the log before dispatch: an 'initial'/'resume' snapshot anchoring each loop instance, a round-trip-verified delta on change, a 'fallback' snapshot when the encoding cannot express it. Session.requestHeader() is the log's incrementally-folded baseline. - Requests are deep-frozen before dispatch (deepFreeze exempts the AbortSignal — freezing one breaks AbortController.abort() outright); frozen + sessionId is the loop-built marker the dev invariant keys on. Ported from #162 and re-anchored on the log: the append-extension / frozen-end-to-end / compaction-resend / prompt-change property tests, plus new specs for the boundary semantics, resume anchoring, and the end-to-end theorem (every recorded request rebuilds byte-equal from the log alone). Live cache-hit e2e (request-cache.e2e.ts) verified against the real DeepSeek API. Snapshot goldens intentionally stale until the single re-record after the compact/summary envelope lands.
2026-07-06 03:07:34 +08:00
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
/**
2026-07-12 03:36:43 +08:00
* With-key proof that log-derived requests translate into real provider cache hits: a
* multi-step tool turn (plus a follow-up turn) against the live DeepSeek API must report
* `cacheReadTokens > 0` on every request after the first — the adapter maps the provider's
* `prompt_cache_hit_tokens`, and the per-step usage recorded on `assistant/message` events is
2026-07-19 22:50:49 +08:00
* the production observable for cache behavior (the reconstructability Agent Note's measurement
* layer: prefix stability is corollary #1). Mocks establish append-extension;
* this key-gated test establishes a real provider cache hit.
loop: every request is built from the log — boundary snapshot, header events, config-only waterfall The loop is now transmission-stateless; a request is a pure function of (session log, this step's rendered assembly, current AgentOptions): - The reconstruction boundary is step/start: the messages snapshot is taken in the same synchronous frame immediately before the step/start append, so the request's messages are exactly the derivation over events[0..stepStartSeq) — an inject() from an agent/request listener (or any concurrent task) lands after the boundary and joins the NEXT request. This changes behavior for a synchronous step/start session/event listener that appends content (master derived after the append, so such a listener could reach the current request): agent/pre-step is the sanctioned seam for current-request content. - agent/request is re-typed to config-only: (agent, turn, step, config: LlmCallConfig, next) → LlmCallConfig. The frozen seed comes from AgentOptions on a loop instance's first request (explicit options beat the logged baseline — fork overrides and resume reconfiguration stay correct) and from the log's folded header afterwards; listeners return a replacement to switch. Content shaping through the request is no longer expressible — model-visible content flows through the log channels. - recordRequestHeader appends whatever header event the request owes the log before dispatch: an 'initial'/'resume' snapshot anchoring each loop instance, a round-trip-verified delta on change, a 'fallback' snapshot when the encoding cannot express it. Session.requestHeader() is the log's incrementally-folded baseline. - Requests are deep-frozen before dispatch (deepFreeze exempts the AbortSignal — freezing one breaks AbortController.abort() outright); frozen + sessionId is the loop-built marker the dev invariant keys on. Ported from #162 and re-anchored on the log: the append-extension / frozen-end-to-end / compaction-resend / prompt-change property tests, plus new specs for the boundary semantics, resume anchoring, and the end-to-end theorem (every recorded request rebuilds byte-equal from the log alone). Live cache-hit e2e (request-cache.e2e.ts) verified against the real DeepSeek API. Snapshot goldens intentionally stale until the single re-record after the compact/summary envelope lands.
2026-07-06 03:07:34 +08:00
*/
// Long enough that the shared request prefix comfortably spans the provider's
// cache-block granularity (64 tokens) from the very first request.
const SYSTEM = 'You are a terse coding assistant used in an automated cache test. '
+ 'Always follow instructions literally and exactly. When the user asks you to look '
+ 'something up, call the lookup tool with the requested key and wait for its result '
+ 'before answering. Never invent a value the tool has not returned. After the tool '
+ 'returns, answer with a single short sentence that repeats the returned value '
+ 'verbatim. Do not add explanations, do not use markdown, do not ask follow-up '
+ 'questions. If the user asks anything else, answer in one short sentence.'
let ctx: Context | undefined
afterEach(async () => {
await ctx?.fiber.dispose()
ctx = undefined
})
async function loopHarness(): Promise<Context> {
const created = new Context()
await created.plugin(LlmService)
await created.plugin(SessionStore)
await created.plugin(SystemPrompt, { persona: SYSTEM })
await created.plugin(ToolRegistry)
await created.plugin(AgentRegistry)
await created.plugin(AgentLoop, { agents: [] })
2026-07-14 21:57:52 +08:00
await created.plugin(LlmDeepSeek)
loop: every request is built from the log — boundary snapshot, header events, config-only waterfall The loop is now transmission-stateless; a request is a pure function of (session log, this step's rendered assembly, current AgentOptions): - The reconstruction boundary is step/start: the messages snapshot is taken in the same synchronous frame immediately before the step/start append, so the request's messages are exactly the derivation over events[0..stepStartSeq) — an inject() from an agent/request listener (or any concurrent task) lands after the boundary and joins the NEXT request. This changes behavior for a synchronous step/start session/event listener that appends content (master derived after the append, so such a listener could reach the current request): agent/pre-step is the sanctioned seam for current-request content. - agent/request is re-typed to config-only: (agent, turn, step, config: LlmCallConfig, next) → LlmCallConfig. The frozen seed comes from AgentOptions on a loop instance's first request (explicit options beat the logged baseline — fork overrides and resume reconfiguration stay correct) and from the log's folded header afterwards; listeners return a replacement to switch. Content shaping through the request is no longer expressible — model-visible content flows through the log channels. - recordRequestHeader appends whatever header event the request owes the log before dispatch: an 'initial'/'resume' snapshot anchoring each loop instance, a round-trip-verified delta on change, a 'fallback' snapshot when the encoding cannot express it. Session.requestHeader() is the log's incrementally-folded baseline. - Requests are deep-frozen before dispatch (deepFreeze exempts the AbortSignal — freezing one breaks AbortController.abort() outright); frozen + sessionId is the loop-built marker the dev invariant keys on. Ported from #162 and re-anchored on the log: the append-extension / frozen-end-to-end / compaction-resend / prompt-change property tests, plus new specs for the boundary semantics, resume anchoring, and the end-to-end theorem (every recorded request rebuilds byte-equal from the log alone). Live cache-hit e2e (request-cache.e2e.ts) verified against the real DeepSeek API. Snapshot goldens intentionally stale until the single re-record after the compact/summary envelope lands.
2026-07-06 03:07:34 +08:00
created.tools.register(defineTool({
name: 'lookup',
description: 'Look up the stored value for a key.',
parameters: { key: { type: 'string', description: 'The key to look up.' } },
async execute(args) {
return [{ type: 'text', text: `value(${String(args.key)}) = azure-falcon-42` }]
},
}))
return created
}
function waitForIdle(context: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = context.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
dispose()
resolve()
}
})
})
}
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('log-derived request cache hits (real API)', () => {
it('every request after the first hits the provider prefix cache', async () => {
ctx = await loopHarness()
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
const agent = ctx.agentLoop.create(SessionId('cache-e2e'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
loop: every request is built from the log — boundary snapshot, header events, config-only waterfall The loop is now transmission-stateless; a request is a pure function of (session log, this step's rendered assembly, current AgentOptions): - The reconstruction boundary is step/start: the messages snapshot is taken in the same synchronous frame immediately before the step/start append, so the request's messages are exactly the derivation over events[0..stepStartSeq) — an inject() from an agent/request listener (or any concurrent task) lands after the boundary and joins the NEXT request. This changes behavior for a synchronous step/start session/event listener that appends content (master derived after the append, so such a listener could reach the current request): agent/pre-step is the sanctioned seam for current-request content. - agent/request is re-typed to config-only: (agent, turn, step, config: LlmCallConfig, next) → LlmCallConfig. The frozen seed comes from AgentOptions on a loop instance's first request (explicit options beat the logged baseline — fork overrides and resume reconfiguration stay correct) and from the log's folded header afterwards; listeners return a replacement to switch. Content shaping through the request is no longer expressible — model-visible content flows through the log channels. - recordRequestHeader appends whatever header event the request owes the log before dispatch: an 'initial'/'resume' snapshot anchoring each loop instance, a round-trip-verified delta on change, a 'fallback' snapshot when the encoding cannot express it. Session.requestHeader() is the log's incrementally-folded baseline. - Requests are deep-frozen before dispatch (deepFreeze exempts the AbortSignal — freezing one breaks AbortController.abort() outright); frozen + sessionId is the loop-built marker the dev invariant keys on. Ported from #162 and re-anchored on the log: the append-extension / frozen-end-to-end / compaction-resend / prompt-change property tests, plus new specs for the boundary semantics, resume anchoring, and the end-to-end theorem (every recorded request rebuilds byte-equal from the log alone). Live cache-hit e2e (request-cache.e2e.ts) verified against the real DeepSeek API. Snapshot goldens intentionally stale until the single re-record after the compact/summary envelope lands.
2026-07-06 03:07:34 +08:00
// Turn 1: forces a tool call → at least two steps (two model requests).
agent.send([{ type: 'text', text: 'Look up the key "deploy-color" with the lookup tool and tell me the value.' }])
await waitForIdle(ctx, agent)
// Turn 2: a follow-up over the same (longer) prefix.
agent.send([{ type: 'text', text: 'Thanks. Repeat that value one more time.' }])
await waitForIdle(ctx, agent)
const usages = [...agent.session.events]
.filter(e => e.type === 'assistant/message')
.map(e => e.data.usage)
expect(usages.length).toBeGreaterThanOrEqual(3) // 2 steps in turn 1 + ≥1 in turn 2
for (const usage of usages) expect(usage).toBeDefined()
// The first request has nothing to hit; every later one shares its
// predecessor as a byte-identical prefix, so the provider must report
// cached prompt tokens (prompt_cache_hit_tokens → cacheReadTokens).
for (const usage of usages.slice(1)) {
expect(usage!.cacheReadTokens ?? 0).toBeGreaterThan(0)
}
// World-verification of the conversation itself: the tool value made it
// through the loop into the final answer.
const finalText = agent.session.deriveMessages().at(-1)!.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
expect(finalText).toContain('azure-falcon-42')
}, 180_000)
})