fix(acp): preserve command reference arguments

This commit is contained in:
Yichen Jiang
2026-07-21 18:19:42 +08:00
parent ebb62c482c
commit 70b67bd559
6 changed files with 43 additions and 19 deletions

View File

@@ -73,6 +73,7 @@ import {
type AskUserQuestionRequest,
} from '@deepseek-ai/dsh-user-interaction'
import {
acpPromptToText,
acpPromptToReferencedPrompt,
harnessBlockToAcpContent,
promptHasUnsupportedContent,
@@ -896,23 +897,16 @@ export function apply(ctx: Context, config: AcpConfig): void {
if (promptHasUnsupportedContent(params.prompt)) {
throw invalidParams('only text and resource_link prompt content is supported; image/audio/embedded resource blocks are rejected rather than silently dropped')
}
let referencedPrompt: ReturnType<typeof acpPromptToReferencedPrompt>
try {
referencedPrompt = acpPromptToReferencedPrompt(params.prompt)
} catch (error: unknown) {
throw invalidParams(`invalid session reference: ${renderThrown(error)}`)
}
const { text } = referencedPrompt
if (text.trim().length === 0) {
const flattenedText = acpPromptToText(params.prompt)
if (flattenedText.trim().length === 0) {
// Reject up front rather than calling send(): an empty prompt would
// queue no work, no turn would start, and the RPC would hang forever
// waiting for a settle that never comes.
throw invalidParams('empty prompt')
}
// ACP command prompts may carry additional supported content blocks.
// The same lossless flattening used for model prompts supplies their
// unstructured command input; unsupported kinds were rejected above.
const commandLine = text.startsWith('/') ? text : undefined
// Direct commands consume ordinary ACP flattening before reference
// extraction, so URI-shaped arguments remain opaque to the bridge.
const commandLine = flattenedText.startsWith('/') ? flattenedText : undefined
if (commandLine !== undefined) {
const controller = new AbortController()
rec.commandAbort = controller
@@ -955,6 +949,13 @@ export function apply(ctx: Context, config: AcpConfig): void {
rec.commandAbort = undefined
}
}
let referencedPrompt: ReturnType<typeof acpPromptToReferencedPrompt>
try {
referencedPrompt = acpPromptToReferencedPrompt(params.prompt)
} catch (error: unknown) {
throw invalidParams(`invalid session reference: ${renderThrown(error)}`)
}
const { text } = referencedPrompt
let preparedContent: ContentBlock[] = [{ type: 'text', text }]
let preparedContexts: NonNullable<Parameters<Agent['send']>[1]>['contexts'] = []
if (referencedPrompt.references.length > 0) {

View File

@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { SessionId } from '@deepseek-ai/dsh-session'
import { encodeSessionReferenceUri } from '@deepseek-ai/dsh-session-reference'
import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
function commandUpdates(harness: BridgeHarness, sessionId: string) {
@@ -195,6 +196,28 @@ describe('ACP plugin commands', () => {
expect(harness.adapter.requests).toHaveLength(0)
})
it('keeps session-reference syntax opaque in direct command arguments', async () => {
harness = await makeBridgeHarness({ storageDir })
const command = vi.fn(() => ({ kind: 'success' as const }))
harness.ctx.commands.register({ name: 'direct', description: 'Direct', handler: command })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const sourceUri = encodeSessionReferenceUri(SessionId('source'))
await expect(harness.client.prompt({
sessionId,
prompt: [
{ type: 'text', text: `/direct valid=${sourceUri} malformed=dsh-session:IiJ` },
{ type: 'resource_link', name: 'source', uri: sourceUri },
],
})).resolves.toEqual({ stopReason: 'end_turn' })
expect(command).toHaveBeenCalledWith(expect.objectContaining({
rawInput: ` valid=${sourceUri} malformed=dsh-session:IiJ\n[resource_link name="source" uri=${JSON.stringify(sourceUri)}]\n`,
}))
expect(harness.adapter.requests).toHaveLength(0)
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0)
})
it('maps session cancellation to the in-flight command signal and isolates other sessions', async () => {
harness = await makeBridgeHarness({ storageDir })
let started!: () => void