Skip to content

Commit 938ef3e

Browse files
committed
feat(acp): harden v1 protocol support
1 parent 590e0f0 commit 938ef3e

41 files changed

Lines changed: 1727 additions & 160 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/main/presenter/llmProviderPresenter/acp/acpCapabilities.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,50 @@ import type * as schema from '@agentclientprotocol/sdk/dist/schema/index.js'
33
export interface AcpCapabilityOptions {
44
enableFs?: boolean
55
enableTerminal?: boolean
6+
enableTerminalAuth?: boolean
7+
}
8+
9+
export interface AcpCapabilitySupport {
10+
loadSession: boolean
11+
sessionList: boolean
12+
sessionResume: boolean
13+
sessionClose: boolean
14+
sessionFork: boolean
15+
}
16+
17+
export interface AcpCapabilitySnapshot {
18+
protocolVersion?: schema.ProtocolVersion
19+
agentInfo?: schema.Implementation | null
20+
agentCapabilities?: schema.AgentCapabilities
21+
sessionCapabilities?: schema.SessionCapabilities
22+
promptCapabilities?: schema.PromptCapabilities
23+
authMethods: schema.AuthMethod[]
24+
mcpCapabilities?: schema.McpCapabilities
25+
supports: AcpCapabilitySupport
26+
}
27+
28+
export function buildCapabilitySnapshot(
29+
initializeResult: schema.InitializeResponse
30+
): AcpCapabilitySnapshot {
31+
const agentCapabilities = initializeResult.agentCapabilities
32+
const sessionCapabilities = agentCapabilities?.sessionCapabilities
33+
34+
return {
35+
protocolVersion: initializeResult.protocolVersion,
36+
agentInfo: initializeResult.agentInfo,
37+
agentCapabilities,
38+
sessionCapabilities,
39+
promptCapabilities: agentCapabilities?.promptCapabilities,
40+
authMethods: initializeResult.authMethods ?? [],
41+
mcpCapabilities: agentCapabilities?.mcpCapabilities,
42+
supports: {
43+
loadSession: Boolean(agentCapabilities?.loadSession),
44+
sessionList: Boolean(sessionCapabilities?.list),
45+
sessionResume: Boolean(sessionCapabilities?.resume),
46+
sessionClose: Boolean(sessionCapabilities?.close),
47+
sessionFork: Boolean(sessionCapabilities?.fork)
48+
}
49+
}
650
}
751

852
/**
@@ -27,5 +71,11 @@ export function buildClientCapabilities(
2771
caps.terminal = true
2872
}
2973

74+
if (options.enableTerminalAuth) {
75+
caps.auth = {
76+
terminal: true
77+
}
78+
}
79+
3080
return caps
3181
}

src/main/presenter/llmProviderPresenter/acp/acpContentMapper.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,19 @@ export interface MappedContent {
2525
}>
2626
/** Unified ACP session config state */
2727
configState?: AcpConfigState
28+
/** ACP session metadata update */
29+
sessionInfo?: {
30+
title?: string | null
31+
updatedAt?: string | null
32+
meta?: Record<string, unknown> | null
33+
}
34+
/** ACP session usage/context update */
35+
usage?: {
36+
used: number
37+
size: number
38+
cost?: schema.Cost | null
39+
meta?: Record<string, unknown> | null
40+
}
2841
}
2942

3043
interface ToolCallState {
@@ -76,8 +89,10 @@ export class AcpContentMapper {
7689
this.handleConfigOptionUpdate(update, payload)
7790
break
7891
case 'session_info_update':
92+
this.handleSessionInfoUpdate(update, payload)
93+
break
7994
case 'usage_update':
80-
// These updates are useful for stateful clients but do not affect chat rendering.
95+
this.handleUsageUpdate(update, payload)
8196
break
8297
case 'user_message_chunk':
8398
// ignore echo
@@ -291,6 +306,29 @@ export class AcpContentMapper {
291306
})
292307
}
293308

309+
private handleSessionInfoUpdate(
310+
update: Extract<schema.SessionNotification['update'], { sessionUpdate: 'session_info_update' }>,
311+
payload: MappedContent
312+
) {
313+
payload.sessionInfo = {
314+
title: update.title,
315+
updatedAt: update.updatedAt,
316+
meta: update._meta ?? null
317+
}
318+
}
319+
320+
private handleUsageUpdate(
321+
update: Extract<schema.SessionNotification['update'], { sessionUpdate: 'usage_update' }>,
322+
payload: MappedContent
323+
) {
324+
payload.usage = {
325+
used: update.used,
326+
size: update.size,
327+
cost: update.cost ?? null,
328+
meta: update._meta ?? null
329+
}
330+
}
331+
294332
private formatToolCallContent(
295333
contents?: schema.ToolCallContent[] | null,
296334
joiner: string = '\n'
Lines changed: 166 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,73 @@
11
import type * as schema from '@agentclientprotocol/sdk/dist/schema/index.js'
2-
import type { ChatMessage, ModelConfig } from '@shared/presenter'
2+
import type { ChatMessage } from '@shared/presenter'
33

4-
interface NormalizedContent {
5-
type: 'text' | 'resource_link'
6-
value: string
4+
interface FormatOptions {
5+
promptCapabilities?: schema.PromptCapabilities
6+
includeSystemPrompt?: boolean
77
}
88

9+
interface FormatResult {
10+
blocks: schema.ContentBlock[]
11+
includedSystemPrompt: boolean
12+
}
13+
14+
type NormalizedContent =
15+
| { type: 'text'; text: string }
16+
| { type: 'image'; data: string; mimeType: string; uri?: string }
17+
| { type: 'audio'; data: string; mimeType: string }
18+
| { type: 'resource_link'; uri: string; name?: string; mimeType?: string }
19+
| { type: 'resource'; uri: string; text: string; mimeType?: string }
20+
21+
const DATA_URL_PATTERN = /^data:([^;,]+);base64,(.*)$/s
22+
923
export class AcpMessageFormatter {
10-
format(messages: ChatMessage[], modelConfig: ModelConfig): schema.ContentBlock[] {
24+
format(messages: ChatMessage[], options: FormatOptions = {}): FormatResult {
1125
const blocks: schema.ContentBlock[] = []
12-
const configLine = this.buildConfigLine(modelConfig)
13-
if (configLine) {
14-
blocks.push({ type: 'text', text: configLine })
26+
const systemPrompt = options.includeSystemPrompt ? this.extractSystemPrompt(messages) : null
27+
if (systemPrompt) {
28+
blocks.push({
29+
type: 'text',
30+
text: `System instructions:\n${systemPrompt}`
31+
})
1532
}
1633

17-
messages.forEach((message) => {
18-
const prefix = (message.role || 'unknown').toUpperCase()
19-
const normalized = this.normalizeContent(message)
20-
if (normalized.length === 0) {
21-
blocks.push({ type: 'text', text: `${prefix}:` })
22-
return
23-
}
24-
25-
normalized.forEach((item, index) => {
26-
if (item.type === 'text') {
27-
const label = index === 0 ? `${prefix}: ` : ''
28-
blocks.push({ type: 'text', text: `${label}${item.value}` })
29-
} else if (item.type === 'resource_link') {
30-
blocks.push({ type: 'resource_link', uri: item.value, name: prefix })
31-
}
34+
const userMessage = this.findLastUserMessage(messages)
35+
if (userMessage) {
36+
this.normalizeContent(userMessage).forEach((item) => {
37+
blocks.push(this.toContentBlock(item, options.promptCapabilities))
3238
})
39+
}
3340

34-
if (message.tool_calls && message.tool_calls.length > 0) {
35-
message.tool_calls.forEach((toolCall) => {
36-
blocks.push({
37-
type: 'text',
38-
text: `${prefix} TOOL CALL ${toolCall.id || ''}: ${toolCall.function?.name || 'unknown'} ${toolCall.function?.arguments || ''}`
39-
})
40-
})
41-
}
41+
if (!blocks.length) {
42+
blocks.push({ type: 'text', text: '' })
43+
}
4244

43-
if (message.role === 'tool' && typeof message.content === 'string') {
44-
blocks.push({
45-
type: 'text',
46-
text: `TOOL RESPONSE${message.tool_call_id ? ` (${message.tool_call_id})` : ''}: ${message.content}`
47-
})
48-
}
49-
})
45+
return {
46+
blocks,
47+
includedSystemPrompt: Boolean(systemPrompt)
48+
}
49+
}
5050

51-
return blocks
51+
private findLastUserMessage(messages: ChatMessage[]): ChatMessage | null {
52+
for (let index = messages.length - 1; index >= 0; index -= 1) {
53+
if (messages[index]?.role === 'user') {
54+
return messages[index]
55+
}
56+
}
57+
return null
5258
}
5359

54-
private buildConfigLine(modelConfig: ModelConfig): string {
55-
const temperature = modelConfig.temperature ?? 0.6
56-
const maxTokens = modelConfig.maxTokens ?? modelConfig.maxCompletionTokens ?? 4096
57-
return `temperature=${temperature}, maxTokens=${maxTokens}`
60+
private extractSystemPrompt(messages: ChatMessage[]): string | null {
61+
const systemMessage = messages.find((message) => message.role === 'system')
62+
if (!systemMessage) return null
63+
64+
const text = this.normalizeContent(systemMessage)
65+
.filter((item): item is Extract<NormalizedContent, { type: 'text' }> => item.type === 'text')
66+
.map((item) => item.text.trim())
67+
.filter(Boolean)
68+
.join('\n')
69+
70+
return text || null
5871
}
5972

6073
private normalizeContent(message: ChatMessage): NormalizedContent[] {
@@ -63,31 +76,121 @@ export class AcpMessageFormatter {
6376

6477
if (typeof content === 'string') {
6578
if (content.trim().length > 0) {
66-
normalized.push({ type: 'text', value: content })
79+
normalized.push({ type: 'text', text: content })
6780
}
68-
} else if (Array.isArray(content)) {
69-
content.forEach((rawPart) => {
70-
const part = rawPart as Record<string, unknown>
71-
const type = typeof part.type === 'string' ? part.type : undefined
72-
73-
if ((type === 'text' || type === 'input_text') && typeof part.text === 'string') {
74-
normalized.push({ type: 'text', value: part.text })
75-
} else if (type === 'image_url') {
76-
const imageUrl = part['image_url'] as { url?: string } | undefined
77-
if (imageUrl?.url) {
78-
normalized.push({ type: 'resource_link', value: imageUrl.url })
81+
return normalized
82+
}
83+
84+
if (!Array.isArray(content)) {
85+
return normalized
86+
}
87+
88+
content.forEach((rawPart) => {
89+
const part = rawPart as Record<string, unknown>
90+
const type = typeof part.type === 'string' ? part.type : undefined
91+
92+
if ((type === 'text' || type === 'input_text') && typeof part.text === 'string') {
93+
normalized.push({ type: 'text', text: part.text })
94+
return
95+
}
96+
97+
if (type === 'image_url' || type === 'input_image') {
98+
const imageUrl = part.image_url as { url?: string } | undefined
99+
const url = imageUrl?.url
100+
if (!url) return
101+
const image = this.parseDataUrl(url)
102+
if (image) {
103+
normalized.push({ type: 'image', data: image.data, mimeType: image.mimeType, uri: url })
104+
} else {
105+
normalized.push({ type: 'resource_link', uri: url, name: 'image' })
106+
}
107+
return
108+
}
109+
110+
if (type === 'input_audio' || type === 'audio') {
111+
const data = typeof part.data === 'string' ? part.data : undefined
112+
const mimeType =
113+
typeof part.mimeType === 'string'
114+
? part.mimeType
115+
: typeof part.mime_type === 'string'
116+
? part.mime_type
117+
: 'audio/mpeg'
118+
if (data) {
119+
normalized.push({ type: 'audio', data, mimeType })
120+
}
121+
return
122+
}
123+
124+
if (type === 'resource_link' && typeof part.uri === 'string') {
125+
normalized.push({
126+
type: 'resource_link',
127+
uri: part.uri,
128+
name: typeof part.name === 'string' ? part.name : undefined,
129+
mimeType: typeof part.mimeType === 'string' ? part.mimeType : undefined
130+
})
131+
return
132+
}
133+
134+
if (typeof part.text === 'string') {
135+
normalized.push({ type: 'text', text: part.text })
136+
}
137+
})
138+
139+
return normalized
140+
}
141+
142+
private toContentBlock(
143+
item: NormalizedContent,
144+
capabilities?: schema.PromptCapabilities
145+
): schema.ContentBlock {
146+
switch (item.type) {
147+
case 'text':
148+
return { type: 'text', text: item.text }
149+
case 'image':
150+
if (capabilities?.image) {
151+
return {
152+
type: 'image',
153+
data: item.data,
154+
mimeType: item.mimeType,
155+
...(item.uri ? { uri: item.uri } : {})
79156
}
80-
} else if (type === 'input_image') {
81-
const imageUrl = part['image_url'] as { url?: string } | undefined
82-
if (imageUrl?.url) {
83-
normalized.push({ type: 'resource_link', value: imageUrl.url })
157+
}
158+
return item.uri
159+
? { type: 'resource_link', uri: item.uri, name: 'image', mimeType: item.mimeType }
160+
: { type: 'text', text: `[image ${item.mimeType}]` }
161+
case 'audio':
162+
if (capabilities?.audio) {
163+
return { type: 'audio', data: item.data, mimeType: item.mimeType }
164+
}
165+
return { type: 'text', text: `[audio ${item.mimeType}]` }
166+
case 'resource':
167+
if (capabilities?.embeddedContext) {
168+
return {
169+
type: 'resource',
170+
resource: {
171+
uri: item.uri,
172+
text: item.text,
173+
...(item.mimeType ? { mimeType: item.mimeType } : {})
174+
}
84175
}
85-
} else if (typeof part.text === 'string') {
86-
normalized.push({ type: 'text', value: part.text })
87176
}
88-
})
177+
return { type: 'text', text: item.text }
178+
case 'resource_link':
179+
return {
180+
type: 'resource_link',
181+
uri: item.uri,
182+
name: item.name ?? item.uri,
183+
...(item.mimeType ? { mimeType: item.mimeType } : {})
184+
}
89185
}
186+
}
90187

91-
return normalized
188+
private parseDataUrl(value: string): { mimeType: string; data: string } | null {
189+
const match = DATA_URL_PATTERN.exec(value)
190+
if (!match) return null
191+
return {
192+
mimeType: match[1],
193+
data: match[2]
194+
}
92195
}
93196
}

0 commit comments

Comments
 (0)