feat(acp): 补充 promptConversion 模块 + 修复 build
- 从上流 c2ac9a74 拉取缺失的 promptConversion.ts 及测试
- build 586 文件通过,test 3551 (5 fail/1 error 基线一致)
- acp-link manager 以上游 CCB 最新版为准
This commit is contained in:
parent
082f91aa80
commit
3a542d4f50
189
docs/features/acp-zed.md
Normal file
189
docs/features/acp-zed.md
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
# ACP (Agent Client Protocol) — Zed / IDE 集成
|
||||
|
||||
> Feature Flag: `FEATURE_ACP=1`(build 和 dev 模式默认启用)
|
||||
> 实现状态:可用(支持 Zed、Cursor 等 ACP 客户端)
|
||||
> 源码目录:`src/services/acp/`
|
||||
|
||||
## 一、功能概述
|
||||
|
||||
ACP (Agent Client Protocol) 是一种标准化的 stdio 协议,允许 IDE 和编辑器通过 stdin/stdout 的 NDJSON 流驱动 AI Agent。CCB 实现了完整的 ACP agent 端,可以被 Zed、Cursor 等支持 ACP 的客户端直接调用。
|
||||
|
||||
### 核心特性
|
||||
|
||||
- **会话管理**:新建 / 恢复 / 加载 / 分叉 / 关闭会话
|
||||
- **历史回放**:恢复会话时自动加载并回放对话历史
|
||||
- **权限桥接**:ACP 客户端的权限决策映射到 CCB 的工具权限系统
|
||||
- **斜杠命令 & Skills**:加载真实命令列表,支持 `/commit`、`/review` 等 prompt 型 skill
|
||||
- **Context Window 跟踪**:精确的 usage_update,含 model prefix matching
|
||||
- **Prompt 排队**:支持连续发送多条 prompt,自动排队处理
|
||||
- **模式切换**:auto / default / acceptEdits / plan / dontAsk / bypassPermissions
|
||||
- **模型切换**:运行时切换 AI 模型
|
||||
|
||||
## 二、架构
|
||||
|
||||
```
|
||||
┌──────────────┐ NDJSON/stdio ┌──────────────────┐
|
||||
│ Zed / IDE │ ◄────────────────► │ CCB ACP Agent │
|
||||
│ (Client) │ stdin / stdout │ (Agent) │
|
||||
└──────────────┘ │ │
|
||||
│ entry.ts │ ← stdio → NDJSON stream
|
||||
│ agent.ts │ ← ACP protocol handler
|
||||
│ bridge.ts │ ← SDKMessage → ACP SessionUpdate
|
||||
│ permissions.ts │ ← 权限桥接
|
||||
│ utils.ts │ ← 通用工具
|
||||
│ │
|
||||
│ QueryEngine │ ← 内部查询引擎
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
### 文件职责
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `entry.ts` | 入口,创建 stdio → NDJSON stream,启动 `AgentSideConnection` |
|
||||
| `agent.ts` | 实现 ACP `Agent` 接口:会话 CRUD、prompt、cancel、模式/模型切换 |
|
||||
| `bridge.ts` | `SDKMessage` → ACP `SessionUpdate` 转换:文本/思考/工具/用量/编辑 diff |
|
||||
| `permissions.ts` | ACP `requestPermission()` → CCB `CanUseToolFn` 桥接 |
|
||||
| `utils.ts` | Pushable、流转换、权限模式解析、session fingerprint、路径显示 |
|
||||
|
||||
## 三、配置 Zed 编辑器
|
||||
|
||||
### 3.1 Zed settings.json 配置
|
||||
|
||||
打开 Zed 的 `settings.json`(`Cmd+,` → Open Settings),添加 `agent_servers` 配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_servers": {
|
||||
"ccb": {
|
||||
"type": "custom",
|
||||
"command": "ccb",
|
||||
"args": ["--acp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 API 认证配置
|
||||
|
||||
CCB 的 ACP agent 在启动时会自动加载 `settings.json` 中的环境变量(`ANTHROPIC_BASE_URL`、`ANTHROPIC_AUTH_TOKEN` 等)。确保已通过 `/login` 配置好 API 供应商。
|
||||
|
||||
也可通过环境变量传入:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_servers": {
|
||||
"claude-code": {
|
||||
"command": "ccb",
|
||||
"args": ["--acp"],
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.example.com/v1",
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-xxx"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 在 Zed 中使用
|
||||
|
||||
1. 配置完成后重启 Zed
|
||||
2. 打开任意项目目录
|
||||
3. 按 `Cmd+'`(macOS)或 `Ctrl+'`(Linux)打开 Agent Panel
|
||||
4. 在 Agent Panel 顶部的下拉菜单中选择 **claude-code**
|
||||
5. 开始对话
|
||||
|
||||
### 3.5 功能说明
|
||||
|
||||
| 功能 | 操作 |
|
||||
|------|------|
|
||||
| 对话 | 在 Agent Panel 中直接输入消息 |
|
||||
| 斜杠命令 | 输入 `/` 查看可用 skills 列表(如 `/commit`、`/review`) |
|
||||
| 工具权限 | 弹出权限请求时选择 Allow / Reject / Always Allow |
|
||||
| 模式切换 | 通过 Agent Panel 的设置菜单切换 auto/default/plan 等模式 |
|
||||
| 模型切换 | 通过 Agent Panel 的设置菜单切换 AI 模型 |
|
||||
| 会话恢复 | 关闭重开 Zed 后,之前的会话可自动恢复(含历史消息) |
|
||||
|
||||
## 四、配置其他 ACP 客户端
|
||||
|
||||
ACP 是开放协议,任何支持 ACP 的客户端都可以连接 CCB。通用配置模式:
|
||||
|
||||
```
|
||||
命令: ccb --acp
|
||||
参数: ["--acp"]
|
||||
通信: stdin/stdout NDJSON
|
||||
协议版本: ACP v1
|
||||
```
|
||||
|
||||
### 4.1 Cursor
|
||||
|
||||
在 Cursor 的设置中配置 MCP / Agent Server,使用同样的 `ccb --acp` 命令。
|
||||
|
||||
### 4.2 自定义客户端
|
||||
|
||||
使用 `@agentclientprotocol/sdk` 可以快速构建 ACP 客户端:
|
||||
|
||||
```typescript
|
||||
import { ClientSideConnection, ndJsonStream } from '@agentclientprotocol/sdk'
|
||||
|
||||
// 创建连接(将 ccb --acp 作为子进程启动)
|
||||
const child = spawn('ccb', ['--acp'])
|
||||
const stream = ndJsonStream(
|
||||
Writable.toWeb(child.stdin),
|
||||
Readable.toWeb(child.stdout),
|
||||
)
|
||||
|
||||
const client = new ClientSideConnection(stream)
|
||||
|
||||
// 初始化
|
||||
await client.initialize({ clientCapabilities: {} })
|
||||
|
||||
// 创建会话
|
||||
const { sessionId } = await client.newSession({
|
||||
cwd: '/path/to/project',
|
||||
})
|
||||
|
||||
// 发送 prompt
|
||||
const response = await client.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'Hello, explain this project' }],
|
||||
})
|
||||
|
||||
// 监听 session 更新
|
||||
client.on('sessionUpdate', (update) => {
|
||||
console.log('Update:', update)
|
||||
})
|
||||
```
|
||||
|
||||
## 五、ACP 协议支持矩阵
|
||||
|
||||
| 方法 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| `initialize` | ✅ | 返回 agent 信息和能力 |
|
||||
| `authenticate` | ✅ | 无需认证(自托管) |
|
||||
| `newSession` | ✅ | 创建新会话 |
|
||||
| `resumeSession` | ✅ | 恢复已有会话(含历史回放) |
|
||||
| `loadSession` | ✅ | 加载指定会话(含历史回放) |
|
||||
| `listSessions` | ✅ | 列出可用会话 |
|
||||
| `forkSession` | ✅ | 分叉会话 |
|
||||
| `closeSession` | ✅ | 关闭会话 |
|
||||
| `prompt` | ✅ | 发送消息,支持排队 |
|
||||
| `cancel` | ✅ | 取消当前/排队的 prompt |
|
||||
| `setSessionMode` | ✅ | 切换权限模式 |
|
||||
| `setSessionModel` | ✅ | 切换 AI 模型 |
|
||||
| `setSessionConfigOption` | ✅ | 动态修改配置 |
|
||||
|
||||
### SessionUpdate 类型
|
||||
|
||||
| 类型 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| `agent_message_chunk` | ✅ | 助手文本消息 |
|
||||
| `agent_thought_chunk` | ✅ | 思考/推理内容 |
|
||||
| `user_message_chunk` | ✅ | 用户消息(历史回放) |
|
||||
| `tool_call` | ✅ | 工具调用开始 |
|
||||
| `tool_call_update` | ✅ | 工具调用结果/状态更新 |
|
||||
| `usage_update` | ✅ | token 用量 + context window |
|
||||
| `plan` | ✅ | TodoWrite → plan entries |
|
||||
| `available_commands_update` | ✅ | 斜杠命令 & skills 列表 |
|
||||
| `current_mode_update` | ✅ | 模式切换通知 |
|
||||
| `config_option_update` | ✅ | 配置更新通知 |
|
||||
|
|
@ -19,33 +19,33 @@ export const command = buildCommand({
|
|||
kind: "parsed",
|
||||
parse: numberParser,
|
||||
brief: "Port to listen on",
|
||||
default: "9315",
|
||||
optional: true,
|
||||
},
|
||||
host: {
|
||||
kind: "parsed",
|
||||
parse: String,
|
||||
brief: "Host to bind to (use 0.0.0.0 for remote access)",
|
||||
default: "localhost",
|
||||
optional: true,
|
||||
},
|
||||
debug: {
|
||||
kind: "boolean",
|
||||
brief: "Enable debug logging to file",
|
||||
default: false,
|
||||
optional: true,
|
||||
},
|
||||
"no-auth": {
|
||||
kind: "boolean",
|
||||
brief: "DANGEROUS: Disable authentication (not recommended)",
|
||||
default: false,
|
||||
optional: true,
|
||||
},
|
||||
https: {
|
||||
kind: "boolean",
|
||||
brief: "Enable HTTPS with auto-generated self-signed certificate",
|
||||
default: false,
|
||||
optional: true,
|
||||
},
|
||||
manager: {
|
||||
kind: "boolean",
|
||||
brief: "Start Manager Web UI (no proxy)",
|
||||
default: false,
|
||||
optional: true,
|
||||
},
|
||||
group: {
|
||||
kind: "parsed",
|
||||
|
|
@ -65,21 +65,22 @@ export const command = buildCommand({
|
|||
brief: "Agent command and arguments (use -- before agent flags)",
|
||||
parse: String,
|
||||
placeholder: "command",
|
||||
optional: true,
|
||||
},
|
||||
minimum: 0,
|
||||
},
|
||||
},
|
||||
func: async function (
|
||||
this: LocalContext,
|
||||
flags: { port: number; host: string; debug: boolean; "no-auth": boolean; https: boolean; manager: boolean; group: string | undefined },
|
||||
flags: { port?: number; host?: string; debug?: boolean; "no-auth"?: boolean; https?: boolean; manager?: boolean; group?: string },
|
||||
...args: readonly string[]
|
||||
) {
|
||||
const port = flags.port;
|
||||
const host = flags.host;
|
||||
const debug = flags.debug;
|
||||
const noAuth = flags["no-auth"];
|
||||
const https = flags.https;
|
||||
const manager = flags.manager;
|
||||
const port = flags.port ?? 9315;
|
||||
const host = flags.host ?? "localhost";
|
||||
const debug = flags.debug ?? false;
|
||||
const noAuth = flags["no-auth"] ?? false;
|
||||
const https = flags.https ?? false;
|
||||
const manager = flags.manager ?? false;
|
||||
const group = flags.group;
|
||||
|
||||
// Manager mode: start web UI only, no proxy
|
||||
|
|
|
|||
|
|
@ -1282,16 +1282,6 @@ export async function startServer(config: ServerConfig): Promise<void> {
|
|||
process.on('SIGINT', shutdown)
|
||||
process.on('SIGTERM', shutdown)
|
||||
|
||||
// Graceful shutdown — close RCS upstream
|
||||
const shutdown = async () => {
|
||||
if (rcsUpstream) {
|
||||
await rcsUpstream.close();
|
||||
}
|
||||
process.exit(0);
|
||||
};
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
|
||||
// Keep the server running
|
||||
await new Promise(() => {})
|
||||
}
|
||||
|
|
|
|||
28
src/services/acp/__tests__/promptConversion.test.ts
Normal file
28
src/services/acp/__tests__/promptConversion.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
import { promptToQueryInput } from '../promptConversion.js'
|
||||
|
||||
describe('promptToQueryInput', () => {
|
||||
test('converts text and embedded text resources', () => {
|
||||
expect(
|
||||
promptToQueryInput([
|
||||
{ type: 'text', text: 'hello' },
|
||||
{
|
||||
type: 'resource',
|
||||
resource: { text: 'resource body' },
|
||||
} as any,
|
||||
]),
|
||||
).toBe('hello\nresource body')
|
||||
})
|
||||
|
||||
test('renders resource_link as plain metadata instead of markdown link', () => {
|
||||
expect(
|
||||
promptToQueryInput([
|
||||
{
|
||||
type: 'resource_link',
|
||||
name: 'Spec',
|
||||
uri: 'file:///tmp/spec.md',
|
||||
} as any,
|
||||
]),
|
||||
).toBe('Resource link: name=Spec, uri=file:///tmp/spec.md')
|
||||
})
|
||||
})
|
||||
1346
src/services/acp/bridge.ts
Normal file
1346
src/services/acp/bridge.ts
Normal file
File diff suppressed because it is too large
Load Diff
248
src/services/acp/permissions.ts
Normal file
248
src/services/acp/permissions.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
/**
|
||||
* Permission bridge: maps Claude Code's canUseTool / PermissionDecision
|
||||
* system to ACP's requestPermission() flow.
|
||||
*
|
||||
* Supports:
|
||||
* - bypassPermissions mode (auto-allow all tools)
|
||||
* - ExitPlanMode special handling (multi-option: Yes+auto/acceptEdits/default/No)
|
||||
* - Always Allow
|
||||
* - Standard allow_once/allow_always/reject_once
|
||||
*/
|
||||
import type {
|
||||
AgentSideConnection,
|
||||
PermissionOption,
|
||||
ToolCallUpdate,
|
||||
ClientCapabilities,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import type { CanUseToolFn } from '../../hooks/useCanUseTool.js'
|
||||
import type {
|
||||
PermissionAllowDecision,
|
||||
PermissionAskDecision,
|
||||
PermissionDenyDecision,
|
||||
} from '../../types/permissions.js'
|
||||
import type { Tool as ToolType, ToolUseContext } from '../../Tool.js'
|
||||
import type { AssistantMessage } from '../../types/message.js'
|
||||
import { toolInfoFromToolUse } from './bridge.js'
|
||||
|
||||
const IS_ROOT =
|
||||
typeof process.geteuid === 'function'
|
||||
? process.geteuid() === 0
|
||||
: typeof process.getuid === 'function'
|
||||
? process.getuid() === 0
|
||||
: false
|
||||
const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX
|
||||
|
||||
/**
|
||||
* Creates a CanUseToolFn that delegates permission decisions to the
|
||||
* ACP client via requestPermission().
|
||||
*/
|
||||
export function createAcpCanUseTool(
|
||||
conn: AgentSideConnection,
|
||||
sessionId: string,
|
||||
getCurrentMode: () => string,
|
||||
clientCapabilities?: ClientCapabilities,
|
||||
cwd?: string,
|
||||
): CanUseToolFn {
|
||||
return async (
|
||||
tool: ToolType,
|
||||
input: Record<string, unknown>,
|
||||
_context: ToolUseContext,
|
||||
_assistantMessage: AssistantMessage,
|
||||
toolUseID: string,
|
||||
_forceDecision?:
|
||||
| PermissionAllowDecision
|
||||
| PermissionAskDecision
|
||||
| PermissionDenyDecision,
|
||||
): Promise<
|
||||
PermissionAllowDecision | PermissionAskDecision | PermissionDenyDecision
|
||||
> => {
|
||||
const supportsTerminalOutput = checkTerminalOutput(clientCapabilities)
|
||||
|
||||
// ── ExitPlanMode special handling ────────────────────────────
|
||||
if (tool.name === 'ExitPlanMode') {
|
||||
return handleExitPlanMode(
|
||||
conn,
|
||||
sessionId,
|
||||
toolUseID,
|
||||
input,
|
||||
supportsTerminalOutput,
|
||||
cwd,
|
||||
)
|
||||
}
|
||||
|
||||
// ── bypassPermissions mode ───────────────────────────────────
|
||||
if (getCurrentMode() === 'bypassPermissions') {
|
||||
return {
|
||||
behavior: 'allow',
|
||||
updatedInput: input,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Standard tool permission ─────────────────────────────────
|
||||
const info = toolInfoFromToolUse(
|
||||
{ name: tool.name, id: toolUseID, input },
|
||||
supportsTerminalOutput,
|
||||
cwd,
|
||||
)
|
||||
|
||||
const toolCall: ToolCallUpdate = {
|
||||
toolCallId: toolUseID,
|
||||
title: info.title,
|
||||
kind: info.kind,
|
||||
status: 'pending',
|
||||
rawInput: input,
|
||||
}
|
||||
|
||||
const options: Array<PermissionOption> = [
|
||||
{ kind: 'allow_always', name: 'Always Allow', optionId: 'allow_always' },
|
||||
{ kind: 'allow_once', name: 'Allow', optionId: 'allow' },
|
||||
{ kind: 'reject_once', name: 'Reject', optionId: 'reject' },
|
||||
]
|
||||
|
||||
try {
|
||||
const response = await conn.requestPermission({
|
||||
sessionId,
|
||||
toolCall,
|
||||
options,
|
||||
})
|
||||
|
||||
if (response.outcome.outcome === 'cancelled') {
|
||||
return {
|
||||
behavior: 'deny',
|
||||
message: 'Permission request cancelled by client',
|
||||
decisionReason: { type: 'mode', mode: 'default' },
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
response.outcome.outcome === 'selected' &&
|
||||
'optionId' in response.outcome &&
|
||||
response.outcome.optionId !== undefined
|
||||
) {
|
||||
const optionId = response.outcome.optionId
|
||||
if (optionId === 'allow' || optionId === 'allow_always') {
|
||||
return {
|
||||
behavior: 'allow',
|
||||
updatedInput: input,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default: deny
|
||||
return {
|
||||
behavior: 'deny',
|
||||
message: 'Permission denied by client',
|
||||
decisionReason: { type: 'mode', mode: 'default' },
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
behavior: 'deny',
|
||||
message: 'Permission request failed',
|
||||
decisionReason: { type: 'mode', mode: 'default' },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExitPlanMode(
|
||||
conn: AgentSideConnection,
|
||||
sessionId: string,
|
||||
toolUseID: string,
|
||||
input: Record<string, unknown>,
|
||||
supportsTerminalOutput: boolean,
|
||||
cwd?: string,
|
||||
): Promise<PermissionAllowDecision | PermissionDenyDecision> {
|
||||
const options: Array<PermissionOption> = [
|
||||
{
|
||||
kind: 'allow_always',
|
||||
name: 'Yes, and use "auto" mode',
|
||||
optionId: 'auto',
|
||||
},
|
||||
{
|
||||
kind: 'allow_always',
|
||||
name: 'Yes, and auto-accept edits',
|
||||
optionId: 'acceptEdits',
|
||||
},
|
||||
{
|
||||
kind: 'allow_once',
|
||||
name: 'Yes, and manually approve edits',
|
||||
optionId: 'default',
|
||||
},
|
||||
{ kind: 'reject_once', name: 'No, keep planning', optionId: 'plan' },
|
||||
]
|
||||
if (ALLOW_BYPASS) {
|
||||
options.unshift({
|
||||
kind: 'allow_always',
|
||||
name: 'Yes, and bypass permissions',
|
||||
optionId: 'bypassPermissions',
|
||||
})
|
||||
}
|
||||
|
||||
const info = toolInfoFromToolUse(
|
||||
{ name: 'ExitPlanMode', id: toolUseID, input },
|
||||
supportsTerminalOutput,
|
||||
cwd,
|
||||
)
|
||||
|
||||
const toolCall: ToolCallUpdate = {
|
||||
toolCallId: toolUseID,
|
||||
title: info.title,
|
||||
kind: info.kind,
|
||||
status: 'pending',
|
||||
rawInput: input,
|
||||
}
|
||||
|
||||
const response = await conn.requestPermission({
|
||||
sessionId,
|
||||
toolCall,
|
||||
options,
|
||||
})
|
||||
|
||||
if (response.outcome.outcome === 'cancelled') {
|
||||
return {
|
||||
behavior: 'deny',
|
||||
message: 'Tool use aborted',
|
||||
decisionReason: { type: 'mode', mode: 'default' },
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
response.outcome.outcome === 'selected' &&
|
||||
'optionId' in response.outcome &&
|
||||
response.outcome.optionId !== undefined
|
||||
) {
|
||||
const selectedOption = response.outcome.optionId
|
||||
if (
|
||||
selectedOption === 'default' ||
|
||||
selectedOption === 'acceptEdits' ||
|
||||
selectedOption === 'auto' ||
|
||||
selectedOption === 'bypassPermissions'
|
||||
) {
|
||||
await conn.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'current_mode_update',
|
||||
currentModeId: selectedOption,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
behavior: 'allow',
|
||||
updatedInput: input,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
behavior: 'deny',
|
||||
message: 'User rejected request to exit plan mode.',
|
||||
decisionReason: { type: 'mode', mode: 'plan' },
|
||||
}
|
||||
}
|
||||
|
||||
function checkTerminalOutput(clientCapabilities?: ClientCapabilities): boolean {
|
||||
if (!clientCapabilities) return false
|
||||
const meta = (clientCapabilities as unknown as Record<string, unknown>)._meta
|
||||
if (!meta || typeof meta !== 'object') return false
|
||||
return (meta as Record<string, unknown>)['terminal_output'] === true
|
||||
}
|
||||
40
src/services/acp/promptConversion.ts
Normal file
40
src/services/acp/promptConversion.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import type { ContentBlock } from '@agentclientprotocol/sdk'
|
||||
|
||||
export function promptToQueryInput(
|
||||
prompt: Array<ContentBlock> | undefined,
|
||||
): string {
|
||||
if (!prompt || prompt.length === 0) return ''
|
||||
|
||||
const parts: string[] = []
|
||||
for (const block of prompt) {
|
||||
const b = block as Record<string, unknown>
|
||||
if (b.type === 'text') {
|
||||
parts.push(String(b.text ?? ''))
|
||||
} else if (b.type === 'resource_link') {
|
||||
const name = typeof b.name === 'string' ? b.name : undefined
|
||||
const uri = typeof b.uri === 'string' ? b.uri : undefined
|
||||
// Keep resource links as metadata, not markdown links, so models do not
|
||||
// infer user-visible click targets or silently rewrite URI semantics.
|
||||
parts.push(formatResourceLink(name, uri))
|
||||
} else if (b.type === 'resource') {
|
||||
const resource = b.resource as Record<string, unknown> | undefined
|
||||
if (resource && typeof resource.text === 'string') {
|
||||
parts.push(resource.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts.filter(part => part.length > 0).join('\n')
|
||||
}
|
||||
|
||||
function formatResourceLink(
|
||||
name: string | undefined,
|
||||
uri: string | undefined,
|
||||
): string {
|
||||
const details: string[] = []
|
||||
if (name && name.length > 0) details.push(`name=${name}`)
|
||||
if (uri && uri.length > 0) details.push(`uri=${uri}`)
|
||||
return details.length > 0
|
||||
? `Resource link: ${details.join(', ')}`
|
||||
: 'Resource link'
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user