Merge branch 'codex/fix-compact-agents-reinjection' into codex/fix-resume-baseline-dedup

# Conflicts:
#	examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl
#	examples/acp-agent/tests/snapshots/workspace-context/session.jsonl
This commit is contained in:
fz
2026-08-06 20:17:41 +08:00
637 changed files with 18032 additions and 3511 deletions

View File

@@ -199,6 +199,7 @@ export class SessionReferenceService extends Service {
const prompt = renderPrompt(rendered.map(source => source.data))
const source: SessionReferenceSource = {
kind: 'session-reference',
form: 'recall',
version: 1,
references: rendered.map((source, index) => ({
sessionId: source.data.sessionId,

View File

@@ -6,6 +6,8 @@ import type { SessionId, UserMessage } from '@deepseek-ai/dsh-session'
/** Durable provenance for one prepared cross-session context. */
export interface SessionReferenceSource {
kind: 'session-reference'
/** Material lifted out of another session's log (`recall` context form). */
form: 'recall'
version: 1
references: {
sessionId: string

View File

@@ -157,9 +157,7 @@ export function apply(ctx: Context, config: Config): void {
const resolvedTimeZone = formatter.resolvedOptions().timeZone
ctx.on('agent/pre-step', async (
agent: Agent,
_messages,
{ turn, step, signal },
{ agent, turn, step, signal },
next,
): Promise<PreStepDecision> => {
const decision = await next()
@@ -174,13 +172,14 @@ export function apply(ctx: Context, config: Config): void {
const previous = step === 1
? precedingMessageTime(agent)
: precedingStepContextTime(agent, turn)
const text = renderText(now, turn, step, previous, formatter, resolvedTimeZone)
return {
kind: 'enter',
messages: [
...decision.messages,
createUserMessage({
content: [{ type: 'text', text: renderText(now, turn, step, previous, formatter, resolvedTimeZone) }],
source: { kind: 'plugin', plugin: name },
content: [{ type: 'text', text }],
source: { kind: 'plugin', plugin: name, form: 'snapshot', sections: [{ name, text }] },
}),
],
}

View File

@@ -56,7 +56,14 @@ describe('time-context through a real headless cordis.yml', () => {
for (let index = 0; index < contexts.length; index += 1) {
expect(contexts[index]!.seq).toBeGreaterThan(starts[index]!.seq)
expect(contexts[index]!.surfaceOp).toBe('append')
expect(contexts[index]!.data.source).toEqual({ kind: 'plugin', plugin: 'time-context' })
// `snapshot` form: one named contribution whose text is exactly what the
// model read, so a consumer attributes it without re-splitting prose.
expect(contexts[index]!.data.source).toMatchObject({
kind: 'plugin',
plugin: 'time-context',
form: 'snapshot',
sections: [{ name: 'time-context' }],
})
}
const contextText = contexts.map(event => event.data.content
.filter(block => block.type === 'text')

View File

@@ -82,8 +82,7 @@ async function fire(
): Promise<void> {
const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step',
[],
{ turn, step, signal },
{ messages: [], turn, step, signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
)
if (decision.kind === 'enter') {
@@ -161,7 +160,19 @@ describe('durable step context', () => {
const event = session.events.at(-1)
expect(event?.type).toBe('user/message')
if (event?.type !== 'user/message') throw new Error('missing time context')
expect(event.data.source).toEqual({ kind: 'plugin', plugin: 'time-context' })
// The reading is a `snapshot`-form context: one named contribution whose
// text is exactly what the model read, so a consumer attributes it without
// re-splitting prose.
expect(event.data.source).toEqual({
kind: 'plugin',
plugin: 'time-context',
form: 'snapshot',
sections: [{
name: 'time-context',
text: 'Time sampled while preparing turn 1, step 1: 2026-07-15T09:01:01+08:00[Asia/Shanghai]\n'
+ 'Elapsed since the preceding model-visible message: 1d 1h 1m 1s.',
}],
})
expect(event.surfaceOp).toBe('append')
})
@@ -366,7 +377,7 @@ describe('real agent-loop request history', () => {
] as const)('does not commit a preparation reading when a downstream pre-step listener %s', async (mode) => {
const adapter = new ScriptedAdapter([textResponse('unused')])
const ctx = await loopHarness(adapter)
ctx.on('agent/pre-step', (subject, _messages, _context, next) => {
ctx.on('agent/pre-step', ({ agent: subject }, next) => {
if (mode === 'throws') throw new Error('later pre-step failure')
subject.cancel({ kind: 'user' })
return next()

View File

@@ -216,9 +216,7 @@ export function apply(ctx: Context, config: Config): void {
validateRefreshInterval(refreshIntervalMs)
ctx.on('agent/pre-step', async (
agent: Agent,
_messages,
{ turn, step, signal },
{ agent, turn, step, signal },
next,
): Promise<PreStepDecision> => {
const decision = await next()
@@ -234,12 +232,13 @@ export function apply(ctx: Context, config: Config): void {
if (location === undefined) return decision
const state = renderState(location)
if (previous !== undefined && previous.state === state) return decision
const text = renderReading(location, turn)
return {
kind: 'enter',
messages: [
createUserMessage({
content: [{ type: 'text', text: renderReading(location, turn) }],
source: { kind: 'plugin', plugin: name },
content: [{ type: 'text', text }],
source: { kind: 'plugin', plugin: name, form: 'snapshot', sections: [{ name, text }] },
}),
...decision.messages,
],

View File

@@ -138,8 +138,7 @@ async function fire(
): Promise<void> {
const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step',
[],
{ turn, step, signal },
{ messages: [], turn, step, signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
)
if (decision.kind === 'enter') {
@@ -170,7 +169,14 @@ describe('tmux-context injection', () => {
])
const event = session.events.at(-1)
if (event?.type !== 'user/message') throw new Error('missing tmux context')
expect(event.data.source).toEqual({ kind: 'plugin', plugin: 'tmux-context' })
// `snapshot` form: one named contribution carrying exactly the reading the
// model saw, so a consumer attributes it without re-splitting prose.
expect(event.data.source).toMatchObject({
kind: 'plugin',
plugin: 'tmux-context',
form: 'snapshot',
sections: [{ name: 'tmux-context' }],
})
expect(event.surfaceOp).toBe('append')
})

View File

@@ -168,6 +168,7 @@ export function apply(ctx: Context, config: Config): void {
content: baselineContent,
source: {
kind: 'workspace-instructions',
form: 'instructions',
baseline: true,
baselineIdentity: identity,
changes: baselineChanges,
@@ -205,6 +206,7 @@ export function apply(ctx: Context, config: Config): void {
content,
source: {
kind: 'workspace-instructions',
form: 'instructions',
...desiredBaseline ? { baseline: true } : {},
...desiredBaseline ? { baselineIdentity: identity } : {},
changes,
@@ -271,9 +273,7 @@ export function apply(ctx: Context, config: Config): void {
}
ctx.on('agent/pre-step', async (
agent: Agent,
messages,
{ step, signal },
{ agent, messages, step, signal },
next,
): Promise<PreStepDecision> => {
const decision = await next()

View File

@@ -36,6 +36,8 @@ export const name = 'workspace-context'
/** Durable provenance and reconciliation facts for one workspace context. */
export interface WorkspaceInstructionSource {
kind: 'workspace-instructions'
/** Every workspace context carries instructions read out of a file (the `instructions` context form). */
form: 'instructions'
/** Marks the complete startup/resume baseline rather than a later delta. */
baseline?: true
/** Discovery, precedence, and budget identity used to validate a resumed baseline. */
@@ -79,7 +81,7 @@ export interface ReconciledInstructionContext {
function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[]): UserMessage {
return createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'workspace-instructions', changes },
source: { kind: 'workspace-instructions', form: 'instructions', changes },
})
}

View File

@@ -57,7 +57,7 @@ async function harness(): Promise<{ ctx: Context; agent: Agent }> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent && status === 'idle') {
dispose()
resolve()

View File

@@ -209,8 +209,7 @@ async function workspaceContextOf(agent: Agent): Promise<UserMessage> {
async function syncWorkspaceContext(ctx: Context, agent: Agent): Promise<void> {
await agentEvents(ctx, agent).waterfall(
'agent/pre-step', [],
{ turn: 1, step: 1, signal: testToolSignal },
'agent/pre-step', { messages: [], turn: 1, step: 1, signal: testToolSignal },
async () => ({ kind: 'enter' as const, messages: [] }),
)
}
@@ -245,15 +244,13 @@ async function composeBaselinePrefix(ctx: Context, agent: Agent): Promise<Messag
const signal = AbortSignal.timeout(1000)
await agentEvents(ctx, agent).waterfall(
'agent/pre-step',
[],
{ turn: 1, step: 1, signal },
{ messages: [], turn: 1, step: 1, signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
)
const claimed = agent.inbox.claim('next-step', 1)
const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step',
claimed,
{ turn: 1, step: 2, signal },
{ messages: claimed, turn: 1, step: 2, signal },
() => Promise.resolve({ kind: 'enter' as const, messages: claimed }),
)
const entered = decision.kind === 'enter' ? decision.messages : []
@@ -916,6 +913,7 @@ describe('workspace context request injection', () => {
role: 'user',
source: {
kind: 'workspace-instructions',
form: 'instructions',
baseline: true,
changes: [{ action: 'set', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
},
@@ -1229,8 +1227,7 @@ describe('workspace context request injection', () => {
const original = stubAgent(root)
await agentEvents(ctx, original).waterfall(
'agent/pre-step',
[],
{ turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
{ messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
)
const inserted = original.inbox.nextStep[0]
@@ -1239,12 +1236,11 @@ describe('workspace context request injection', () => {
await fiber.dispose()
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const resumed = stubAgent(root, [...original.session.events])
agentEvents(ctx, resumed).emit('agent/session-start', 'resume')
agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' })
const claimed = resumed.inbox.claim('next-step', 1)
const decision = await agentEvents(ctx, resumed).waterfall(
'agent/pre-step',
claimed,
{ turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
{ messages: claimed, turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
() => Promise.resolve({ kind: 'enter' as const, messages: claimed }),
)
if (decision.kind !== 'enter') throw new Error('recovered baseline was rejected')
@@ -1276,8 +1272,7 @@ describe('workspace context request injection', () => {
const original = stubAgent(root)
await agentEvents(ctx, original).waterfall(
'agent/pre-step',
[],
{ turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
{ messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
)
const stale = original.inbox.nextStep[0]
@@ -1287,12 +1282,11 @@ describe('workspace context request injection', () => {
await fiber.dispose()
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const resumed = stubAgent(root, [...original.session.events])
agentEvents(ctx, resumed).emit('agent/session-start', 'resume')
agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' })
const staleClaim = resumed.inbox.claim('next-step', 1)
const staleDecision = await agentEvents(ctx, resumed).waterfall(
'agent/pre-step',
staleClaim,
{ turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
{ messages: staleClaim, turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
() => Promise.resolve({ kind: 'enter' as const, messages: staleClaim }),
)
@@ -1331,8 +1325,7 @@ describe('workspace context request injection', () => {
const original = stubAgent(root)
await agentEvents(originalCtx, original).waterfall(
'agent/pre-step',
[],
{ turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
{ messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
)
const stale = original.inbox.nextStep[0]
@@ -1342,12 +1335,11 @@ describe('workspace context request injection', () => {
if (provideFs) await resumedCtx.plugin(LocalFileSystem, { cwd: '/' })
await resumedCtx.plugin(workspaceContext, { dshHome: home, maxBytes })
const resumed = stubAgent(root, [...original.session.events])
agentEvents(resumedCtx, resumed).emit('agent/session-start', 'resume')
agentEvents(resumedCtx, resumed).emit('agent/session-start', { source: 'resume' })
const claimed = resumed.inbox.claim('next-step', 1)
const decision = await agentEvents(resumedCtx, resumed).waterfall(
'agent/pre-step',
claimed,
{ turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
{ messages: claimed, turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
() => Promise.resolve({ kind: 'enter' as const, messages: claimed }),
)
@@ -1374,6 +1366,7 @@ describe('workspace context request injection', () => {
content: [{ type: 'text', text: 'stale nested instructions' }],
source: {
kind: 'workspace-instructions',
form: 'instructions',
changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md'), digest: 'stale' }],
},
}), {
@@ -1407,6 +1400,7 @@ describe('workspace context request injection', () => {
content: [{ type: 'text', text: 'stale nested instructions' }],
source: {
kind: 'workspace-instructions',
form: 'instructions',
changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md'), digest: 'stale' }],
},
}), {
@@ -1449,8 +1443,7 @@ describe('workspace context request injection', () => {
const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step',
[prompt],
{ turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
{ messages: [prompt], turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
() => Promise.resolve(downstream),
)
@@ -1507,8 +1500,7 @@ describe('workspace context request injection', () => {
const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step',
[],
{ turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
{ messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
() => Promise.resolve(downstream),
)
@@ -1619,8 +1611,7 @@ describe('workspace context request injection', () => {
const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step',
[prompt],
{ turn: 2, step: 1, signal: AbortSignal.timeout(1000) },
{ messages: [prompt], turn: 2, step: 1, signal: AbortSignal.timeout(1000) },
() => Promise.resolve({ kind: 'enter' as const, messages: [prompt] }),
)
@@ -1653,7 +1644,7 @@ describe('workspace context request injection', () => {
const resumed = stubAgent(root, [...original.session.events])
// Resume announces its lifecycle start before the first step.
agentEvents(ctx, resumed).emit('agent/session-start', 'resume')
agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' })
await composeBaselinePrefix(ctx, resumed)
const baselines = baselineEvents(resumed)
@@ -1704,7 +1695,7 @@ describe('workspace context request injection', () => {
await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context()
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
ctx.on('agent/pre-step', async (_agent, _messages, _context, next) => {
ctx.on('agent/pre-step', async (_payload, next) => {
const decision = await next()
if (decision.kind === 'reject') return decision
return {
@@ -1978,8 +1969,7 @@ describe('workspace context request injection', () => {
const reason = new Error('cancel prefix')
const pending = agentEvents(ctx, stubAgent(root)).waterfall(
'agent/pre-step',
[],
{ turn: 1, step: 1, signal: controller.signal },
{ messages: [], turn: 1, step: 1, signal: controller.signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
)
@@ -2521,7 +2511,7 @@ describe('dynamic nested workspace context injection', () => {
expect(result.isError).toBe(false)
expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({ kind: 'workspace-instructions' })
const queuedSource = ((await syncedWorkspaceContext(ctx, agent))).source
expect(queuedSource).toMatchObject({ kind: 'workspace-instructions' })
expect(queuedSource).toMatchObject({ kind: 'workspace-instructions', form: 'instructions' })
expect(queuedSource.kind === 'workspace-instructions' && queuedSource.changes.some(change =>
change.action === 'set'
&& change.scope === sk('pkg', 'AGENTS.md')
@@ -2864,6 +2854,7 @@ describe('dynamic nested workspace context injection', () => {
expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({
kind: 'workspace-instructions',
form: 'instructions',
changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toBe([
@@ -2973,7 +2964,7 @@ describe('dynamic nested workspace context injection', () => {
if (previous === undefined) throw new Error('missing AGENTS.md baseline state')
const authoritative = createUserMessage({
content: [{ type: 'text', text: 'nested rule' }],
source: { kind: 'workspace-instructions', changes: [previous] },
source: { kind: 'workspace-instructions', form: 'instructions', changes: [previous] },
})
if (authority === 'visible') {
agent.session.append('user/message', authoritative, { surfaceOp: 'append' })
@@ -2999,6 +2990,7 @@ describe('dynamic nested workspace context injection', () => {
content: [{ type: 'text', text: 'pending baseline duplicate' }],
source: {
kind: 'workspace-instructions',
form: 'instructions',
changes: [{ action: 'set', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
},
})],
@@ -3042,7 +3034,7 @@ describe('dynamic nested workspace context injection', () => {
if (previous === undefined) throw new Error('missing AGENTS.md baseline state')
const authoritative = createUserMessage({
content: [{ type: 'text', text: 'repo rule' }],
source: { kind: 'workspace-instructions', changes: [previous] },
source: { kind: 'workspace-instructions', form: 'instructions', changes: [previous] },
})
agent.session.append('user/message', authoritative, { surfaceOp: 'append' })
const resolved = resolveConfig({ dshHome: home, maxBytes: 65536, localInstructionFileCandidates: [] })
@@ -3239,6 +3231,7 @@ describe('dynamic nested workspace context injection', () => {
expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({
kind: 'workspace-instructions',
form: 'instructions',
changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toBe([
@@ -3641,6 +3634,7 @@ describe('dynamic nested workspace context injection', () => {
],
source: {
kind: 'workspace-instructions',
form: 'instructions',
changes: [
null,
{ action: 'unknown', scope: 'pkg', path: join('pkg', 'AGENTS.md') },
@@ -3794,6 +3788,7 @@ describe('dynamic nested workspace context injection', () => {
expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({ kind: 'workspace-instructions' })
expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({
kind: 'workspace-instructions',
form: 'instructions',
changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toContain('nested package rule')
@@ -4236,8 +4231,7 @@ describe('workspace context inbox synchronization', () => {
controller.abort(new Error('abort pre-step reconciliation'))
await expect(agentEvents(ctx, agent).waterfall(
'agent/pre-step', [],
{ turn: 1, step: 1, signal: controller.signal },
'agent/pre-step', { messages: [], turn: 1, step: 1, signal: controller.signal },
async () => ({ kind: 'enter' as const, messages: [] }),
)).rejects.toThrow('abort pre-step reconciliation')
@@ -4347,8 +4341,7 @@ describe('workspace context inbox synchronization', () => {
const downstream = { kind: 'enter' as const, messages: claimed }
const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step', claimed,
{ turn: 1, step: 1, signal: testToolSignal },
'agent/pre-step', { messages: claimed, turn: 1, step: 1, signal: testToolSignal },
async () => downstream,
)