refactor(agent-loop): separate injected context from turns

This commit is contained in:
_Kerman
2026-07-24 16:05:52 +08:00
parent 712448a2d2
commit 45fc7fda3d
45 changed files with 470 additions and 873 deletions

View File

@@ -54,13 +54,14 @@ import { assertNever, CallId } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import {
installAgentLlmTarget,
type AdditionalContext,
type Agent,
type AgentLlmTarget as LlmTarget,
type AgentLlmTargetRef as LlmTargetRef,
} from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-commands'
import { encodeSessionReferenceUri } from '@deepseek-ai/dsh-session-reference'
import { displayPromptContent, SessionId, type JsonValue } from '@deepseek-ai/dsh-session'
import { SessionId, type JsonValue } from '@deepseek-ai/dsh-session'
// Side-effect type import: resolves `ctx.get('permission')` to the service.
import type {} from '@deepseek-ai/dsh-permission'
import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session'
@@ -1056,7 +1057,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
}
const { text } = referencedPrompt
let preparedContent: ContentBlock[] = [{ type: 'text', text }]
let preparedContexts: NonNullable<Parameters<Agent['send']>[1]>['contexts'] = []
let additionalContext: AdditionalContext | undefined
if (referencedPrompt.references.length > 0) {
const sessionReferences = ctx.get('sessionReferences')
if (sessionReferences === undefined) {
@@ -1072,7 +1073,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
controller.signal,
)
preparedContent = prepared.content
preparedContexts = prepared.contexts
additionalContext = prepared.additionalContext
} catch (error: unknown) {
if (controller.signal.aborted) return { stopReason: 'cancelled' }
throw invalidParams(`session reference preparation failed: ${renderThrown(error)}`)
@@ -1088,7 +1089,10 @@ export function apply(ctx: Context, config: AcpConfig): void {
// produces an error stop reason).
const stopReason = await new Promise<StopReason>((resolve, reject) => {
rec.inflight = { resolve, reject, turn: undefined }
rec.agent.send(preparedContent, { source: { kind: 'user' }, contexts: preparedContexts })
if (additionalContext !== undefined) {
rec.agent.inject(additionalContext.content, { source: additionalContext.source })
}
rec.agent.send(preparedContent, { source: { kind: 'user' } })
})
return { stopReason }
},
@@ -1380,7 +1384,7 @@ export function streamSessionEventUpdate(
// Replay the user's prompt so a loaded session shows both sides of each
// turn. Live prompt turns suppress this path to avoid duplicating what
// the client just sent.
for (const block of displayPromptContent(event.data)) {
for (const block of event.data.content) {
const content = harnessBlockToAcpContent(block)
if (content !== undefined) {
notify({ sessionId, update: { sessionUpdate: 'user_message_chunk', content } })

View File

@@ -352,7 +352,7 @@ describe('acp bridge', () => {
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0)
})
it('prepares ACP session resource links and inline mentions before one atomic send', async () => {
it('injects ACP session references before sending the direct prompt', async () => {
harness = await makeBridgeHarness({ storageDir, withSessionReferences: true, script: [textResponse('ok')] })
const source = harness.ctx.sessions.create(SessionId('source'), { meta: { cwd: '/source' } })
source.append('user/message', {
@@ -372,23 +372,21 @@ describe('acp bridge', () => {
expect(result.stopReason).toBe('end_turn')
const target = harness.ctx.agents.get(SessionId(sessionId))!.session
const user = target.events.find(event => event.type === 'user/message')
expect(user?.type === 'user/message' && user.data.envelope).toMatchObject({
displayContent: [{ type: 'text', text: 'use @source-inline and @source-link' }],
prefixContexts: [{
source: { kind: 'plugin', plugin: 'session-reference' },
meta: {
kind: 'session-reference',
references: [{ sessionId: 'source', label: 'source-inline' }],
},
}],
const context = target.events.find(event =>
event.type === 'user/message' && event.data.source.kind === 'session-reference')
expect(context?.type === 'user/message' && context.data.source).toMatchObject({
kind: 'session-reference',
references: [{ sessionId: 'source', label: 'source-inline' }],
})
expect(target.events.some(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toBe(false)
const user = target.events.find(event =>
event.type === 'user/message' && event.data.source.kind === 'user')
expect(user?.type === 'user/message' && user.data.content).toEqual([
{ type: 'text', text: 'use @source-inline and @source-link' },
])
const request = JSON.stringify(harness.adapter.requests[0]?.messages)
expect(request).toContain('untrusted, read-only snapshot')
expect(request).toContain('source background')
expect(request.indexOf('source background')).toBeLessThan(request.indexOf('## My request:'))
expect(request.indexOf('## My request:')).toBeLessThan(request.indexOf('use @source-inline and @source-link'))
expect(request.indexOf('source background')).toBeLessThan(request.indexOf('use @source-inline and @source-link'))
})
it('rejects a failed referenced-session read before starting a turn', async () => {

View File

@@ -209,22 +209,11 @@ describe('streamSessionEventUpdate', () => {
expect(updatesFor(evt('user/message', { content: [], source: { kind: 'user' } }))).toEqual([])
})
it('replays only the direct prompt from a prefixed user message', () => {
it('does not replay injected context as a direct user prompt', () => {
expect(updatesFor(evt('user/message', {
content: [
{ type: 'text', text: 'internal prefix' },
{ type: 'text', text: '\n\n## My request:\n' },
{ type: 'text', text: 'visible request' },
],
source: { kind: 'user' },
envelope: {
displayContent: [{ type: 'text', text: 'visible request' }],
prefixContexts: [{ source: { kind: 'plugin', plugin: 'reference' } }],
},
}))).toEqual([{
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text: 'visible request' },
}])
content: [{ type: 'text', text: 'internal context' }],
source: { kind: 'plugin', plugin: 'reference' },
}))).toEqual([])
})
it('can suppress user/message chunks for live prompt turns', () => {