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:
@@ -9,6 +9,7 @@ import {
|
||||
import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { CompactAgentContext } from '@deepseek-ai/dsh-compact'
|
||||
import type { ManualCompactAgentContext } from '@deepseek-ai/dsh-compact'
|
||||
|
||||
/**
|
||||
* A trivial concrete CompactService implementing the abstract contract. The
|
||||
@@ -29,6 +30,14 @@ class StubCompactService extends CompactService {
|
||||
return null
|
||||
}
|
||||
|
||||
override async compactNow(
|
||||
_agent: ManualCompactAgentContext,
|
||||
signal: AbortSignal,
|
||||
): Promise<CompactionResult | null> {
|
||||
this.lastSignal = signal
|
||||
return null
|
||||
}
|
||||
|
||||
override async compactRegion(
|
||||
start: number,
|
||||
end: number,
|
||||
@@ -98,6 +107,12 @@ describe('CompactService seam', () => {
|
||||
const svc = new StubCompactService(ctx)
|
||||
const session = new Session(SessionId('s'))
|
||||
expect(await svc.compactIfNeeded(stubAgent(session), 'pressure', new AbortController().signal)).toBeNull()
|
||||
const signal = new AbortController().signal
|
||||
expect(await svc.compactNow({
|
||||
...stubAgent(session),
|
||||
reserveTurnAdmission: () => () => undefined,
|
||||
}, signal)).toBeNull()
|
||||
expect(svc.lastSignal).toBe(signal)
|
||||
})
|
||||
|
||||
it('compact/* events merge into SessionEventMap and are log-only', async () => {
|
||||
|
||||
@@ -41,6 +41,103 @@ describe('compaction invariants', () => {
|
||||
failed.append('compact/end', { turn: 2, error: 'provider failed' })
|
||||
})
|
||||
|
||||
it('accepts standalone successful and failed compaction lifecycles between turns', async () => {
|
||||
const ctx = await setup()
|
||||
const success = ctx.sessions.create()
|
||||
success.append('compact/start', { turn: null })
|
||||
success.append('compact/summary', summary())
|
||||
success.append('compact/end', { turn: null })
|
||||
|
||||
const failed = ctx.sessions.create()
|
||||
failed.append('compact/start', { turn: null })
|
||||
failed.append('compact/end', { turn: null, error: 'provider failed' })
|
||||
})
|
||||
|
||||
it('clears an inherited open compaction trace at end-seed during replay', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const source = new Session(SessionId('stale-compaction-source'))
|
||||
source.append('compact/start', { turn: null })
|
||||
const replayed = ctx.sessions.create(SessionId('stale-compaction-replay'), {
|
||||
seed: source.events,
|
||||
})
|
||||
expect(replayed.events.map(event => event.type))
|
||||
.toEqual(['compact/start', 'session/end-seed'])
|
||||
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(CompactInvariant)
|
||||
|
||||
expect(() => {
|
||||
replayed.append('compact/start', { turn: null })
|
||||
replayed.append('compact/end', { turn: null, error: 'new attempt failed' })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('allows repair turn boundaries after end-seed clears a seeded numbered orphan', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const source = new Session(SessionId('stale-numbered-compaction-source'))
|
||||
startTurn(source)
|
||||
source.append('compact/start', { turn: 1 })
|
||||
const replayed = ctx.sessions.create(SessionId('stale-numbered-compaction-replay'), {
|
||||
seed: source.events,
|
||||
})
|
||||
expect(replayed.events.map(event => event.type))
|
||||
.toEqual(['turn/start', 'compact/start', 'session/end-seed'])
|
||||
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(CompactInvariant)
|
||||
|
||||
expect(() => replayed.append(
|
||||
'turn/end',
|
||||
{ turn: 1, reason: { kind: 'interrupted' } },
|
||||
)).not.toThrow()
|
||||
})
|
||||
|
||||
it('accepts inherited repair boundaries before the end-seed that clears a standalone orphan', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const source = new Session(SessionId('stale-repaired-compaction-source'))
|
||||
source.append('compact/start', { turn: null })
|
||||
startTurn(source)
|
||||
source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
|
||||
const replayed = ctx.sessions.create(SessionId('stale-repaired-compaction-replay'), {
|
||||
seed: source.events,
|
||||
})
|
||||
expect(replayed.events.map(event => event.type)).toEqual([
|
||||
'compact/start',
|
||||
'turn/start',
|
||||
'turn/end',
|
||||
'session/end-seed',
|
||||
])
|
||||
|
||||
await ctx.plugin(InvariantService)
|
||||
await expect(ctx.plugin(CompactInvariant).then(() => undefined)).resolves.toBeUndefined()
|
||||
|
||||
expect(() => {
|
||||
startTurn(replayed, 2)
|
||||
replayed.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a closed standalone bracket that contains a turn before end-seed', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const source = new Session(SessionId('closed-nested-compaction-source'))
|
||||
source.append('compact/start', { turn: null })
|
||||
startTurn(source)
|
||||
source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
|
||||
source.append('compact/end', { turn: null, error: 'failed after crossing turn' })
|
||||
const replayed = ctx.sessions.create(SessionId('closed-nested-compaction-replay'), {
|
||||
seed: source.events,
|
||||
})
|
||||
expect(replayed.events.at(-1)?.type).toBe('session/end-seed')
|
||||
|
||||
await ctx.plugin(InvariantService)
|
||||
await expect(ctx.plugin(CompactInvariant).then(() => undefined))
|
||||
.rejects.toThrow(/turn\/start cannot cross an open standalone compaction/)
|
||||
})
|
||||
|
||||
it('rebuilds an open trace when the companion loads after the session', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -78,6 +175,26 @@ describe('compaction invariants', () => {
|
||||
expect(() => session.append('compact/start', { turn: 2 })).toThrow(/but open turn is 1/)
|
||||
})
|
||||
|
||||
it('rejects a standalone bracket while a turn is open and a numbered bracket between turns', async () => {
|
||||
const ctx = await setup()
|
||||
const open = ctx.sessions.create()
|
||||
startTurn(open)
|
||||
expect(() => open.append('compact/start', { turn: null }))
|
||||
.toThrow(/standalone but turn 1 is open/)
|
||||
|
||||
const idle = ctx.sessions.create()
|
||||
expect(() => idle.append('compact/start', { turn: 1 }))
|
||||
.toThrow(/outside any open turn/)
|
||||
})
|
||||
|
||||
it('attributes a nested standalone start to the standalone owner', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('compact/start', { turn: null })
|
||||
expect(() => session.append('compact/start', { turn: null }))
|
||||
.toThrow(/standalone compaction is still compacting/)
|
||||
})
|
||||
|
||||
it('rejects an unenclosed compaction event when replaying an existing session', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -89,23 +206,30 @@ describe('compaction invariants', () => {
|
||||
await expect(ctx.plugin(CompactInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/)
|
||||
})
|
||||
|
||||
it('rejects an open compaction that crosses into another turn', async () => {
|
||||
it('rejects turn boundaries that cross live standalone or numbered compaction brackets', async () => {
|
||||
const ctx = await setup()
|
||||
const summarySession = ctx.sessions.create()
|
||||
startTurn(summarySession)
|
||||
summarySession.append('compact/start', { turn: 1 })
|
||||
summarySession.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
startTurn(summarySession, 2)
|
||||
expect(() => summarySession.append('compact/summary', summary()))
|
||||
.toThrow(/belongs to turn 1 but open turn is 2/)
|
||||
const standalone = ctx.sessions.create()
|
||||
standalone.append('compact/start', { turn: null })
|
||||
expect(() => { startTurn(standalone) })
|
||||
.toThrow(/turn\/start cannot cross an open standalone compaction/)
|
||||
standalone.append('compact/end', { turn: null, error: 'cancelled' })
|
||||
expect(() => {
|
||||
startTurn(standalone)
|
||||
standalone.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}).not.toThrow()
|
||||
|
||||
const endSession = ctx.sessions.create()
|
||||
startTurn(endSession)
|
||||
endSession.append('compact/start', { turn: 1 })
|
||||
endSession.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
startTurn(endSession, 2)
|
||||
expect(() => endSession.append('compact/end', { turn: 1, error: 'late' }))
|
||||
.toThrow(/names turn 1 but open turn is 2/)
|
||||
const numbered = ctx.sessions.create()
|
||||
startTurn(numbered)
|
||||
numbered.append('compact/start', { turn: 1 })
|
||||
expect(() => numbered.append(
|
||||
'turn/end',
|
||||
{ turn: 1, reason: { kind: 'completed' } },
|
||||
)).toThrow(/turn\/end cannot cross an open compaction for turn 1/)
|
||||
numbered.append('compact/end', { turn: 1, error: 'cancelled' })
|
||||
expect(() => numbered.append(
|
||||
'turn/end',
|
||||
{ turn: 1, reason: { kind: 'completed' } },
|
||||
)).not.toThrow()
|
||||
})
|
||||
|
||||
it.each([
|
||||
|
||||
Reference in New Issue
Block a user