fix(acp): align prompt and workspace contracts
This commit is contained in:
@@ -105,19 +105,20 @@ describe('acp bridge', () => {
|
||||
})).rejects.toThrow(/text/)
|
||||
})
|
||||
|
||||
it('rejects a prompt carrying a non-text block alongside text (no silent context loss)', async () => {
|
||||
// A text + resource_link prompt must be rejected, not run text-only with the
|
||||
// resource silently dropped — that would feed the model an incomplete prompt.
|
||||
harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
it('accepts a resource_link prompt by rendering the link into the text sent to the agent', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await expect(harness.client.prompt({
|
||||
const result = await harness.client.prompt({
|
||||
sessionId,
|
||||
prompt: [
|
||||
{ type: 'text', text: 'fix the bug in' },
|
||||
{ type: 'resource_link', uri: 'file:///x.ts', name: 'x.ts' },
|
||||
],
|
||||
})).rejects.toThrow(/text/)
|
||||
})
|
||||
expect(result.stopReason).toBe('end_turn')
|
||||
const user = harness.ctx.agents.get(sessionId)!.session.events.find(event => event.type === 'user/message')
|
||||
expect(JSON.stringify(user)).toContain('resource_link')
|
||||
})
|
||||
|
||||
it('rejects a prompt for an unknown session', async () => {
|
||||
|
||||
@@ -39,27 +39,29 @@ describe('harnessBlockToAcpContent', () => {
|
||||
})
|
||||
|
||||
describe('acpPromptToText', () => {
|
||||
it('concatenates text blocks and ignores non-text', () => {
|
||||
it('concatenates text blocks and renders resource links explicitly', () => {
|
||||
const prompt: AcpContentBlock[] = [
|
||||
{ type: 'text', text: 'hello ' },
|
||||
{ type: 'resource_link', uri: 'file:///x', name: 'x' },
|
||||
{ type: 'text', text: 'world' },
|
||||
]
|
||||
expect(acpPromptToText(prompt)).toBe('hello world')
|
||||
expect(acpPromptToText(prompt)).toBe('hello \n[resource_link name="x" uri="file:///x"]\nworld')
|
||||
})
|
||||
|
||||
it('returns empty string for a prompt with no text blocks', () => {
|
||||
expect(acpPromptToText([{ type: 'resource_link', uri: 'file:///x', name: 'x' }])).toBe('')
|
||||
expect(acpPromptToText([{ type: 'image', mimeType: 'image/png', data: 'AA==' }])).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('promptHasUnsupportedContent', () => {
|
||||
it('detects image and audio blocks', () => {
|
||||
it('detects image, audio, and embedded resource blocks', () => {
|
||||
expect(promptHasUnsupportedContent([{ type: 'image', mimeType: 'image/png', data: 'AA==' }])).toBe(true)
|
||||
expect(promptHasUnsupportedContent([{ type: 'audio', mimeType: 'audio/wav', data: 'AA==' }])).toBe(true)
|
||||
expect(promptHasUnsupportedContent([{ type: 'resource', resource: { uri: 'file:///x', text: 'x' } }])).toBe(true)
|
||||
})
|
||||
|
||||
it('passes a text-only prompt', () => {
|
||||
it('passes baseline text and resource_link prompt blocks', () => {
|
||||
expect(promptHasUnsupportedContent([{ type: 'text', text: 'hi' }])).toBe(false)
|
||||
expect(promptHasUnsupportedContent([{ type: 'resource_link', uri: 'file:///x', name: 'x' }])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -52,4 +52,13 @@ describe('acp bridge — demux & config edges', () => {
|
||||
const a = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [], additionalDirectories: [] })
|
||||
expect(a.sessionId).toBeTruthy()
|
||||
})
|
||||
|
||||
it('rejects non-empty mcpServers until MCP wiring is implemented', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await expect(harness.client.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [{ name: 'fs', command: 'npx', args: ['server'], env: [] }],
|
||||
})).rejects.toThrow(/mcpServers/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -85,7 +85,7 @@ describe('acp bridge — session/load replay', () => {
|
||||
expect(loader.ctx.agents.get(sessionId)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('loads a session whose persisted cwd differs from the launch dir (honors per-session cwd)', async () => {
|
||||
it('rejects load when the requested cwd does not match the persisted session cwd', async () => {
|
||||
// Seed a session on disk whose header.cwd is a DIFFERENT absolute path than
|
||||
// the server's launch dir. The bridge must LOAD it (per-session cwd is
|
||||
// honored — the resumed session keeps header.cwd, and bash routes there), no
|
||||
@@ -101,10 +101,12 @@ describe('acp bridge — session/load replay', () => {
|
||||
])
|
||||
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
// Load succeeds even though the requested cwd is the launch dir, not otherCwd.
|
||||
const res = await loader.client.loadSession({ sessionId: 'elsewhere', cwd: process.cwd(), mcpServers: [] })
|
||||
await expect(loader.client.loadSession({ sessionId: 'elsewhere', cwd: process.cwd(), mcpServers: [] }))
|
||||
.rejects.toThrow(/cwd mismatch/)
|
||||
expect(loader.ctx.agents.get('elsewhere')).toBeUndefined()
|
||||
|
||||
const res = await loader.client.loadSession({ sessionId: 'elsewhere', cwd: otherCwd, mcpServers: [] })
|
||||
expect(res).toBeDefined()
|
||||
// The resumed session retains its ORIGINAL workspace cwd (so bash runs there).
|
||||
expect(loader.ctx.agents.get('elsewhere')!.session.header.cwd).toBe(otherCwd)
|
||||
})
|
||||
|
||||
|
||||
@@ -11,6 +11,12 @@ function updatesFor(event: SessionEvent): SessionNotification['update'][] {
|
||||
return out
|
||||
}
|
||||
|
||||
function liveUpdatesFor(event: SessionEvent): SessionNotification['update'][] {
|
||||
const out: SessionNotification['update'][] = []
|
||||
streamSessionEventUpdate('s1', event, n => out.push(n.update), { includeUserMessages: false })
|
||||
return out
|
||||
}
|
||||
|
||||
function evt<T extends SessionEvent['type']>(type: T, data: Extract<SessionEvent, { type: T }>['data']): SessionEvent {
|
||||
return { type, seq: 0, time: 0, data } as SessionEvent
|
||||
}
|
||||
@@ -92,6 +98,13 @@ describe('streamSessionEventUpdate', () => {
|
||||
expect(updatesFor(evt('user/message', { content: [], source: { kind: 'user' } }))).toEqual([])
|
||||
})
|
||||
|
||||
it('can suppress user/message chunks for live prompt turns', () => {
|
||||
expect(liveUpdatesFor(evt('user/message', {
|
||||
content: [{ type: 'text', text: 'hi' }],
|
||||
source: { kind: 'user' },
|
||||
}))).toEqual([])
|
||||
})
|
||||
|
||||
it('produces no update for boundary/other event types', () => {
|
||||
expect(updatesFor(evt('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }))).toEqual([])
|
||||
expect(updatesFor(evt('turn/end', { turn: 1, reason: { kind: 'completed' } }))).toEqual([])
|
||||
|
||||
Reference in New Issue
Block a user