Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification
# Conflicts: # .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md # .agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml # .agents/notes/implemented/feature/2026-07-06-sandbox.md # .agents/notes/implemented/feature/2026-07-06-sandbox.zh.md # .agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml # .agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml # .agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml # .agents/notes/implemented/simplification/2026-07-30-private-agent-send.i18n.yaml # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/compaction.i18n.yaml # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/core.md # docs/core-data-structures/core.zh.md # docs/core-data-structures/llm-streaming.i18n.yaml # docs/core-data-structures/llm-streaming.md # docs/core-data-structures/llm-streaming.zh.md # docs/core-data-structures/session.i18n.yaml # docs/event-producer-consumer.md # docs/module-graph.md # docs/persistence-catalog.md # examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/acp-agent/tests/snapshots/bash-spill/session.jsonl # examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl # examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl # examples/acp-agent/tests/snapshots/cancel/session.jsonl # examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl # examples/acp-agent/tests/snapshots/error-finish/session.jsonl # examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl # examples/acp-agent/tests/snapshots/fs-edit/session.jsonl # examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl # examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl # examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl # examples/acp-agent/tests/snapshots/fs-read/session.jsonl # examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl # examples/acp-agent/tests/snapshots/fs-write/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl # examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl # examples/acp-agent/tests/snapshots/multi-turn/session.jsonl # examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl # examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl # examples/acp-agent/tests/snapshots/pty-tools/session.jsonl # examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl # examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl # examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl # examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl # examples/acp-agent/tests/snapshots/skill-load/session.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl # examples/acp-agent/tests/snapshots/text-turn/session.jsonl # examples/acp-agent/tests/snapshots/todo-write/session.jsonl # examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl # examples/acp-agent/tests/snapshots/web-fetch/session.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.jsonl # examples/acp-agent/tests/snapshots/workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl # examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/pty-tools/session.jsonl # examples/headless-agent/tests/snapshots/pty-tools/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/ralph-loop/stream-json.expected.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/bash-tool/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl # examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl # examples/jsonrpc-agent/tests/snapshots/text-turn/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl # packages/client/runtime/README.i18n.yaml # packages/client/runtime/src/client/sessions/request-inspection.ts # packages/compact/compact-basic/README.i18n.yaml # packages/compact/compact-basic/README.md # packages/compact/compact-basic/README.zh.md # packages/compact/compact-basic/src/index.ts # packages/context/time-context/tests/time-context.spec.ts # packages/context/tmux-context/README.i18n.yaml # packages/context/tmux-context/tests/tmux-context.spec.ts # packages/context/workspace-context/tests/workspace-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-loop/README.i18n.yaml # packages/core/agent-loop/README.md # packages/core/agent-loop/README.zh.md # packages/core/agent-loop/src/agent.ts # packages/core/agent/README.i18n.yaml # packages/core/agent/README.md # packages/core/agent/README.zh.md # packages/core/agent/src/types.ts # packages/core/session/README.i18n.yaml # packages/core/session/README.md # packages/core/session/README.zh.md # packages/fs/tool-str-replace-editor/tests/tools.spec.ts # packages/goal/command-goal/tests/command-goal.spec.ts # packages/goal/goal/tests/goal.spec.ts # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api/index.ts # packages/host/apiproxy/tests/api-proxy-workspace.spec.ts # packages/llm/llm/README.i18n.yaml # packages/llm/llm/README.md # packages/llm/llm/README.zh.md # packages/llm/llm/src/index.ts # packages/pty/pty-local/tests/index.spec.ts # packages/pty/pty-local/tests/local.spec.ts # packages/pty/pty/tests/service.spec.ts # packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts # packages/pty/tool-bash-persistent/tests/tools.spec.ts # packages/pty/tool-pty/tests/loader-composition.spec.ts # packages/pty/tool-pty/tests/tools.spec.ts # packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts # packages/skill/tool-skill/tests/tool-skill.spec.ts # packages/tasks/tasks-local/tests/tasks.spec.ts # packages/ui/tui/README.i18n.yaml # packages/ui/tui/tests/tui.spec.ts # packages/ui/user-approval/src/index.ts # packages/ui/user-approval/tests/approval.spec.ts
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
/**
|
||||
* Tests for the queue-aware `Agent.cancel()` primitive. `cancel()` is the broad verb — it
|
||||
* clears queued + steering work, aborts the active turn, and drops work not yet claimed by the
|
||||
* driver without leaking cancellation into a replacement prompt. The suite covers every landing
|
||||
* window plus signal reset and `whenIdle()` quiescence.
|
||||
* Tests for the queue-aware `Agent.cancel()` primitive. The default clears
|
||||
* queued and steering work, while `keepInbox` preserves pending input and
|
||||
* resumes waking turns after the active turn reaches quiescence. The suite
|
||||
* covers every landing window plus signal reset and `whenIdle()` quiescence.
|
||||
* @module dsh-agent-loop/tests/cancel
|
||||
*/
|
||||
|
||||
@@ -284,6 +284,41 @@ describe('Agent.cancel()', () => {
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('cancel({ keepInbox: true }) aborts the active turn and drains the queued tail in FIFO order', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
'hang',
|
||||
textResponse('second reply'),
|
||||
textResponse('third reply'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('keep-inbox-running'), { provider: 'mock', model: 'mock' })
|
||||
const reasons: TurnEndReason[] = []
|
||||
const discards: unknown[] = []
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason)
|
||||
})
|
||||
ctx.on('agent/inbox/discard', (subject, items) => {
|
||||
if (subject === agent) discards.push(items)
|
||||
})
|
||||
|
||||
send(agent, 'active')
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
send(agent, 'queued second')
|
||||
send(agent, 'queued third')
|
||||
const idle = agent.whenIdle()
|
||||
agent.cancel({ kind: 'user' }, { keepInbox: true })
|
||||
await idle
|
||||
|
||||
expect(discards).toEqual([])
|
||||
expect(userTexts(agent)).toEqual(['active', 'queued second', 'queued third'])
|
||||
expect(reasons).toEqual([
|
||||
{ kind: 'aborted' },
|
||||
{ kind: 'completed' },
|
||||
{ kind: 'completed' },
|
||||
])
|
||||
expect(adapter.requests).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('cancel from an assistant/message observer skips execution but balances replay', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'danger', {}),
|
||||
|
||||
@@ -258,7 +258,7 @@ describe('agent loop', () => {
|
||||
// NO system field at all (not an empty string).
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.on('system-prompt/assemble', async () => ({ sections: [], tools: [], variables: {} }))
|
||||
ctx.on('system-prompt/assemble', async () => ({ sections: [], contexts: [], tools: [], variables: {} }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a-no-system'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'hi')
|
||||
@@ -268,6 +268,178 @@ describe('agent loop', () => {
|
||||
expect('system' in adapter.requests[0]!).toBe(false)
|
||||
})
|
||||
|
||||
it('materializes changed runtime context at the history tail without rewriting the system header', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('one'),
|
||||
textResponse('two'),
|
||||
textResponse('three'),
|
||||
textResponse('four'),
|
||||
textResponse('five'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
let mode = 'read-only'
|
||||
const dispose = ctx.systemPrompt.context({ name: 'policy', order: 0, text: () => `Mode: ${mode}.` })
|
||||
const agent = ctx.agentLoop.create(SessionId('a-runtime-context'), { provider: 'mock', model: 'mock' })
|
||||
const contextEvents = () => agent.session.events.flatMap(event =>
|
||||
event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === '@deepseek-ai/dsh-system-prompt'
|
||||
? [event]
|
||||
: [])
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(contextEvents()).toHaveLength(1)
|
||||
expect(contextEvents()[0]?.data.content).toEqual([{
|
||||
type: 'text',
|
||||
text: 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nMode: read-only.',
|
||||
}])
|
||||
|
||||
send(agent, 'unchanged')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(contextEvents()).toHaveLength(1)
|
||||
|
||||
mode = 'danger-full-access'
|
||||
send(agent, 'changed')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(contextEvents()).toHaveLength(2)
|
||||
const changedBlock = contextEvents()[1]?.data.content[0]
|
||||
expect(changedBlock?.type).toBe('text')
|
||||
if (changedBlock?.type !== 'text') throw new Error('changed runtime context is not text')
|
||||
expect(changedBlock.text).toContain('danger-full-access')
|
||||
|
||||
dispose()
|
||||
send(agent, 'cleared')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(contextEvents()).toHaveLength(3)
|
||||
expect(contextEvents()[2]?.data.content).toEqual([{
|
||||
type: 'text',
|
||||
text: 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.',
|
||||
}])
|
||||
|
||||
send(agent, 'still clear')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(contextEvents()).toHaveLength(3)
|
||||
expect(adapter.requests.map(request => request.system)).toEqual(Array(5).fill(adapter.requests[0]?.system))
|
||||
expect(agent.session.events.filter(event => event.type === 'request/header')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('re-emits unchanged runtime context when a surface replacement removed the retained snapshot', async () => {
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.systemPrompt.context({ name: 'policy', order: 0, text: 'Mode: read-only.' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a-runtime-context-compacted'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
const contextEvent = agent.session.events.find(event =>
|
||||
event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === '@deepseek-ai/dsh-system-prompt')
|
||||
if (contextEvent?.type !== 'user/message') throw new Error('first turn did not materialize runtime context')
|
||||
agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'compacted summary' }],
|
||||
source: { kind: 'plugin', plugin: 'test-compaction' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: contextEvent.seq, end: contextEvent.seq },
|
||||
sourceEventSeqs: [contextEvent.seq],
|
||||
})
|
||||
|
||||
send(agent, 'after compaction')
|
||||
await waitForIdle(ctx, agent)
|
||||
const runtimeContexts = agent.session.events.flatMap(event =>
|
||||
event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === '@deepseek-ai/dsh-system-prompt'
|
||||
? [event]
|
||||
: [])
|
||||
expect(runtimeContexts).toHaveLength(2)
|
||||
expect(adapter.requests[1]?.messages.some(message =>
|
||||
message.source.kind === 'plugin'
|
||||
&& message.source.plugin === '@deepseek-ai/dsh-system-prompt')).toBe(true)
|
||||
})
|
||||
|
||||
it('clears compacted runtime context after the active set becomes empty', async () => {
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||
const ctx = await harness(adapter)
|
||||
const dispose = ctx.systemPrompt.context({ name: 'policy', order: 0, text: 'Mode: read-only.' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a-runtime-context-compacted-clear'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
const contextEvent = agent.session.events.find(event =>
|
||||
event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === '@deepseek-ai/dsh-system-prompt')
|
||||
if (contextEvent?.type !== 'user/message') throw new Error('first turn did not materialize runtime context')
|
||||
agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'summary retaining old mode: read-only' }],
|
||||
source: { kind: 'plugin', plugin: 'test-compaction' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: contextEvent.seq, end: contextEvent.seq },
|
||||
sourceEventSeqs: [contextEvent.seq],
|
||||
})
|
||||
dispose()
|
||||
|
||||
send(agent, 'after compaction')
|
||||
await waitForIdle(ctx, agent)
|
||||
const clearing = adapter.requests[1]?.messages.find(message =>
|
||||
message.source.kind === 'plugin'
|
||||
&& message.source.plugin === '@deepseek-ai/dsh-system-prompt')
|
||||
expect(clearing?.content).toEqual([{
|
||||
type: 'text',
|
||||
text: 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.',
|
||||
}])
|
||||
})
|
||||
|
||||
it('does not clear runtime context after an unrelated replacement', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a-runtime-context-unrelated-compaction'), { provider: 'mock', model: 'mock' })
|
||||
const original = agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'old context' }],
|
||||
source: { kind: 'plugin', plugin: 'test-context' },
|
||||
}), { surfaceOp: 'append' })
|
||||
agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'compacted summary' }],
|
||||
source: { kind: 'plugin', plugin: 'test-compaction' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
|
||||
sourceEventSeqs: [original.seq],
|
||||
})
|
||||
|
||||
send(agent, 'after compaction')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests[0]?.messages.some(message =>
|
||||
message.source.kind === 'plugin'
|
||||
&& message.source.plugin === '@deepseek-ai/dsh-system-prompt')).toBe(false)
|
||||
})
|
||||
|
||||
it('replaces a malformed retained runtime-context message with the current complete snapshot', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.systemPrompt.context({ name: 'policy', order: 0, text: 'Mode: read-only.' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a-runtime-context-malformed'), { provider: 'mock', model: 'mock' })
|
||||
agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'broken' }, { type: 'text', text: 'snapshot' }],
|
||||
source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' },
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
send(agent, 'repair context')
|
||||
await waitForIdle(ctx, agent)
|
||||
const runtimeContexts = agent.session.events.flatMap(event =>
|
||||
event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === '@deepseek-ai/dsh-system-prompt'
|
||||
? [event]
|
||||
: [])
|
||||
expect(runtimeContexts).toHaveLength(2)
|
||||
expect(runtimeContexts[1]?.data.content).toEqual([{
|
||||
type: 'text',
|
||||
text: 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nMode: read-only.',
|
||||
}])
|
||||
})
|
||||
|
||||
it('records raw chunks for replay as assistant/chunk session events', async () => {
|
||||
const adapter = new MockAdapter([textResponse('abc')])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { createUserMessage, LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
@@ -613,3 +613,95 @@ describe('request stability across the loop', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('request/context capacity records', () => {
|
||||
/** Adapter advertising a per-model capacity, keyed by model id. */
|
||||
function capacityAdapter(windows: Record<string, number>, script: StreamChunk[][]): MockAdapter {
|
||||
return new class extends MockAdapter {
|
||||
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||
const contextWindow = windows[model]
|
||||
return Promise.resolve({
|
||||
provider,
|
||||
id: model,
|
||||
name: model,
|
||||
...contextWindow === undefined ? {} : { context: { contextWindow } },
|
||||
})
|
||||
}
|
||||
}(script)
|
||||
}
|
||||
|
||||
it('records capacity once and skips it while the route is unchanged', async () => {
|
||||
const adapter = capacityAdapter({ mock: 128_000 }, [textResponse('a'), textResponse('b')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('capacity-dedup'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
send(agent, 'second')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const records = agent.session.events.filter(event => event.type === 'request/context')
|
||||
expect(records).toHaveLength(1)
|
||||
expect(records[0]?.data).toEqual({ provider: 'mock', model: 'mock', contextWindow: 128_000 })
|
||||
// Log-only: not a SurfaceEventType, so it can never reach a model request
|
||||
// (the type system rejects a surfaceOp here; the session invariant also
|
||||
// requires the record to sit inside its open turn).
|
||||
expect(agent.session.surface.nodes).not.toContain(records[0]?.seq)
|
||||
})
|
||||
|
||||
it('records a second capacity when the route changes mid-session', async () => {
|
||||
const adapter = capacityAdapter(
|
||||
{ small: 64_000, large: 256_000 },
|
||||
[textResponse('a'), textResponse('b')],
|
||||
)
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('capacity-switch'), { provider: 'mock', model: 'small' })
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
ctx.on('agent/request', (subject, _turn, _step, _signal, next) => subject === agent
|
||||
? Promise.resolve({ provider: 'mock', model: 'large' })
|
||||
: next())
|
||||
send(agent, 'second')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(agent.session.events
|
||||
.filter(event => event.type === 'request/context')
|
||||
.map(event => event.data.contextWindow)).toEqual([64_000, 256_000])
|
||||
})
|
||||
|
||||
it('records and deduplicates a route whose adapter advertises no capacity', async () => {
|
||||
const ctx = await harness(new MockAdapter([textResponse('a'), textResponse('b')]))
|
||||
const agent = ctx.agentLoop.create(SessionId('capacity-absent'), { provider: 'mock', model: 'mock' })
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
send(agent, 'second')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(agent.session.events
|
||||
.filter(event => event.type === 'request/context')
|
||||
.map(event => event.data)).toEqual([{ provider: 'mock', model: 'mock' }])
|
||||
})
|
||||
|
||||
it('clears a previous capacity when the next route advertises none', async () => {
|
||||
const adapter = capacityAdapter({ known: 64_000 }, [textResponse('a'), textResponse('b')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('capacity-clear'), { provider: 'mock', model: 'known' })
|
||||
let model = 'known'
|
||||
ctx.on('agent/request', (subject, _turn, _step, _signal, next) => subject === agent
|
||||
? Promise.resolve({ provider: 'mock', model })
|
||||
: next())
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
model = 'unknown'
|
||||
send(agent, 'second')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(agent.session.events
|
||||
.filter(event => event.type === 'request/context')
|
||||
.map(event => event.data)).toEqual([
|
||||
{ provider: 'mock', model: 'known', contextWindow: 64_000 },
|
||||
{ provider: 'mock', model: 'unknown' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
286
packages/core/agent-loop/tests/turn-admission.spec.ts
Normal file
286
packages/core/agent-loop/tests/turn-admission.spec.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry, { type Agent, type InboxItem } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import LlmService, { createUserMessage } 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 { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
async function harness(adapter: MockAdapter): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function prompt(agent: Agent, text: string): void {
|
||||
agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
}
|
||||
|
||||
function itemText(item: InboxItem): string {
|
||||
return item.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')
|
||||
}
|
||||
|
||||
interface InboxRecording {
|
||||
readonly events: string[]
|
||||
readonly enqueued: InboxItem['id'][]
|
||||
readonly dequeued: InboxItem['id'][]
|
||||
readonly discarded: InboxItem['id'][]
|
||||
}
|
||||
|
||||
/** Record the complete inbox lifecycle of one agent for order and identity assertions. */
|
||||
function recordInbox(ctx: Context): InboxRecording {
|
||||
const events: string[] = []
|
||||
const enqueued: InboxItem['id'][] = []
|
||||
const dequeued: InboxItem['id'][] = []
|
||||
const discarded: InboxItem['id'][] = []
|
||||
ctx.on('agent/inbox/enqueue', (_agent, item) => {
|
||||
events.push(`enqueue:${item.placement}:${itemText(item)}`)
|
||||
enqueued.push(item.id)
|
||||
})
|
||||
ctx.on('agent/inbox/dequeue', (_agent, item) => {
|
||||
events.push(`dequeue:${itemText(item)}`)
|
||||
dequeued.push(item.id)
|
||||
})
|
||||
ctx.on('agent/inbox/discard', (_agent, items) => {
|
||||
events.push(`discard:${items.map(itemText).join(',')}`)
|
||||
discarded.push(...items.map(item => item.id))
|
||||
})
|
||||
return { events, enqueued, dequeued, discarded }
|
||||
}
|
||||
|
||||
/** Text of every ordinary prompt the log admitted, in durable order. */
|
||||
function promptTexts(agent: Agent): string[] {
|
||||
return agent.session.events.flatMap(event => event.type === 'user/message'
|
||||
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
|
||||
: [])
|
||||
}
|
||||
|
||||
describe('idle turn admission reservation', () => {
|
||||
it('holds later waking prompts in the FIFO until release', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const inbox = recordInbox(ctx)
|
||||
|
||||
const release = agent.reserveTurnAdmission()
|
||||
expect(release).toBeDefined()
|
||||
|
||||
prompt(agent, 'first prompt')
|
||||
prompt(agent, 'second prompt')
|
||||
expect(agent.acceptsNextStep).toBe(false)
|
||||
await new Promise<void>((resolve) => { setTimeout(resolve, 5) })
|
||||
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(agent.session.events).toHaveLength(0)
|
||||
expect(inbox.events).toEqual([
|
||||
'enqueue:queued:first prompt',
|
||||
'enqueue:queued:second prompt',
|
||||
])
|
||||
|
||||
release?.()
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(promptTexts(agent)).toEqual(['first prompt', 'second prompt'])
|
||||
expect(agent.session.events.flatMap(event =>
|
||||
event.type === 'turn/start' ? [event.data.turn] : [])).toEqual([1, 2])
|
||||
expect(inbox.events).toEqual([
|
||||
'enqueue:queued:first prompt',
|
||||
'enqueue:queued:second prompt',
|
||||
'dequeue:first prompt',
|
||||
'dequeue:second prompt',
|
||||
])
|
||||
expect(inbox.dequeued).toEqual(inbox.enqueued)
|
||||
expect(inbox.discarded).toEqual([])
|
||||
})
|
||||
|
||||
it('refuses acquisition when an accepted waking prompt still owns the next turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
prompt(agent, 'accepted first')
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(agent.reserveTurnAdmission()).toBeUndefined()
|
||||
|
||||
await agent.whenIdle()
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('refuses acquisition while a turn is running', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const reserved: unknown[] = []
|
||||
ctx.on('agent/step', () => {
|
||||
reserved.push(agent.reserveTurnAdmission())
|
||||
})
|
||||
|
||||
prompt(agent, 'running')
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(reserved).toEqual([undefined])
|
||||
})
|
||||
|
||||
it('refuses a second reservation and releases idempotently', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const release = agent.reserveTurnAdmission()
|
||||
expect(agent.reserveTurnAdmission()).toBeUndefined()
|
||||
prompt(agent, 'queued behind the reservation')
|
||||
|
||||
release?.()
|
||||
release?.()
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(promptTexts(agent)).toEqual(['queued behind the reservation'])
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
const second = agent.reserveTurnAdmission()
|
||||
expect(second).toBeDefined()
|
||||
second?.()
|
||||
})
|
||||
|
||||
it('ignores a stale release once a later reservation owns the boundary', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const stale = agent.reserveTurnAdmission()
|
||||
stale?.()
|
||||
const live = agent.reserveTurnAdmission()
|
||||
prompt(agent, 'held by the live reservation')
|
||||
stale?.()
|
||||
await new Promise<void>((resolve) => { setTimeout(resolve, 5) })
|
||||
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
live?.()
|
||||
await agent.whenIdle()
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('acquires beside quiet queued work and leaves it queued', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send(createUserMessage({
|
||||
content: [{ type: 'text', text: 'quiet' }],
|
||||
source: { kind: 'user' },
|
||||
}), {
|
||||
target: 'next-turn',
|
||||
wakeup: false,
|
||||
})
|
||||
const release = agent.reserveTurnAdmission()
|
||||
expect(release).toBeDefined()
|
||||
|
||||
release?.()
|
||||
await agent.whenIdle()
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('makes whenIdle() wait for release without spinning on a settled promise', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const machine = agent as Agent & { done: Promise<void> }
|
||||
let backing = machine.done
|
||||
let reads = 0
|
||||
Object.defineProperty(agent, 'done', {
|
||||
configurable: true,
|
||||
get(): Promise<void> {
|
||||
reads += 1
|
||||
return backing
|
||||
},
|
||||
set(value: Promise<void>) {
|
||||
backing = value
|
||||
},
|
||||
})
|
||||
|
||||
const release = agent.reserveTurnAdmission()
|
||||
prompt(agent, 'waiting for the reservation')
|
||||
let settled = false
|
||||
const idle = agent.whenIdle().then(() => { settled = true })
|
||||
for (let tick = 0; tick < 5; tick += 1) {
|
||||
await new Promise<void>((resolve) => { setTimeout(resolve, 1) })
|
||||
}
|
||||
|
||||
expect(settled).toBe(false)
|
||||
expect(reads).toBeLessThanOrEqual(2)
|
||||
|
||||
release?.()
|
||||
await idle
|
||||
expect(settled).toBe(true)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('resolves whenIdle() after release with nothing queued', async () => {
|
||||
const adapter = new MockAdapter([])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const release = agent.reserveTurnAdmission()
|
||||
let settled = false
|
||||
const idle = agent.whenIdle().then(() => { settled = true })
|
||||
await new Promise<void>((resolve) => { setTimeout(resolve, 5) })
|
||||
expect(settled).toBe(false)
|
||||
|
||||
release?.()
|
||||
await idle
|
||||
expect(agent.status).toBe('idle')
|
||||
})
|
||||
|
||||
it('lets cancellation discard held prompts and keeps the boundary quiet', async () => {
|
||||
const adapter = new MockAdapter([])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const inbox = recordInbox(ctx)
|
||||
|
||||
const release = agent.reserveTurnAdmission()
|
||||
prompt(agent, 'discarded while held')
|
||||
agent.cancel({ kind: 'user' })
|
||||
|
||||
expect(inbox.events).toEqual([
|
||||
'enqueue:queued:discarded while held',
|
||||
'discard:discarded while held',
|
||||
])
|
||||
expect(inbox.discarded).toEqual(inbox.enqueued)
|
||||
expect(inbox.dequeued).toEqual([])
|
||||
|
||||
release?.()
|
||||
await agent.whenIdle()
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(agent.session.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('disposes the agent without waiting for the reservation to be released', async () => {
|
||||
const adapter = new MockAdapter([])
|
||||
const ctx = await harness(adapter)
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('a1'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const { agent } = handle
|
||||
|
||||
const release = agent.reserveTurnAdmission()
|
||||
prompt(agent, 'discarded by disposal')
|
||||
await handle.dispose()
|
||||
|
||||
expect(ctx.agents.list()).toEqual([])
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
release?.()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user