diff --git a/clients/cli/src/commands/chat-writeback.ts b/clients/cli/src/commands/chat-writeback.ts index 91dedf1..3dc80c9 100644 --- a/clients/cli/src/commands/chat-writeback.ts +++ b/clients/cli/src/commands/chat-writeback.ts @@ -8,11 +8,12 @@ * official, no exec → POST Host /chat/completions (CLI-owned; omit usage) * byo → --chat-complete-url | --chat-complete-exec * 2) mints a short-lived ACN agent JWT via POST /oauth/token (acn_* API key) - * 3) POSTs { content, reply_to_id?, usage? } to Chat Gateway agent-messages - * with Bearer JWT + * 3) POSTs { content, reply_to_id?, usage?, attachments? } to Chat Gateway + * agent-messages with Bearer JWT * * Hosts return {"content":"..."} and optionally usage (in/out billed; - * extras stored). See skills/acn/references/INTERFAZE.md. + * extras stored) and mailbox ``attachments`` (``mbx:{id}`` only). + * See skills/acn/references/INTERFAZE.md. * They do not call Gateway themselves. */ @@ -156,6 +157,8 @@ export type ChatCompleteResult = { usage?: ChatTokenUsage; /** Top-level complete.model_id when no token usage is present. */ modelId?: string; + /** Mailbox refs only (``mbx:{id}``). Hotlinks are dropped. */ + attachments?: string[]; }; /** @@ -289,6 +292,26 @@ export function extractUsage(payload: unknown): ChatTokenUsage | undefined { return out; } +const MAILBOX_REF = /^mbx:[A-Za-z0-9._-]+$/; + +/** Complete JSON ``attachments`` — only mailbox ids; never forward http(s). */ +export function extractMailboxAttachments(payload: unknown): string[] | undefined { + const rec = asRecord(payload); + if (!rec || !Array.isArray(rec.attachments) || rec.attachments.length === 0) { + return undefined; + } + const out: string[] = []; + const seen = new Set(); + for (const item of rec.attachments) { + if (typeof item !== 'string') continue; + const t = item.trim(); + if (!MAILBOX_REF.test(t) || seen.has(t)) continue; + seen.add(t); + out.push(t); + } + return out.length ? out : undefined; +} + function parseCompletePayload( payload: unknown ): { ok: true; result: ChatCompleteResult } | { ok: false; reason: string } { @@ -296,9 +319,11 @@ function parseCompletePayload( if (!content) return { ok: false, reason: 'complete_missing_content' }; const usage = extractUsage(payload); const modelId = extractModelId(payload); + const attachments = extractMailboxAttachments(payload); const result: ChatCompleteResult = { content }; if (usage) result.usage = usage; else if (modelId) result.modelId = modelId; + if (attachments) result.attachments = attachments; return { ok: true, result }; } @@ -811,6 +836,9 @@ async function postWriteback( // model_id only — do not invent zero token counts; Host defaults tokens to 0. body.usage = { model_id: complete.modelId }; } + if (complete.attachments?.length) { + body.attachments = complete.attachments; + } const postOnce = async ( token: string diff --git a/clients/cli/tests/chat-writeback.test.ts b/clients/cli/tests/chat-writeback.test.ts index 3209542..67a502b 100644 --- a/clients/cli/tests/chat-writeback.test.ts +++ b/clients/cli/tests/chat-writeback.test.ts @@ -13,6 +13,7 @@ import { extractContent, extractModelId, extractUsage, + extractMailboxAttachments, handleChatWriteback, officialCompleteFailureContent, officialV0SupportsModel, @@ -321,6 +322,25 @@ describe('extractUsage', () => { }); }); +describe('extractMailboxAttachments', () => { + it('keeps mbx refs and drops hotlinks', () => { + expect( + extractMailboxAttachments({ + content: 'duck', + attachments: [ + 'mbx:att-1', + 'https://cdn.example/x.png', + 'mbx:att-1', + 'mbx:att-2', + ], + }) + ).toEqual(['mbx:att-1', 'mbx:att-2']); + expect( + extractMailboxAttachments({ content: 'hi', attachments: ['https://x'] }) + ).toBeUndefined(); + }); +}); + describe('validateChatWritebackOptions', () => { it('allows disabled', () => { expect(validateChatWritebackOptions({})).toBeNull(); @@ -431,6 +451,54 @@ describe('handleChatWriteback', () => { expect(hdrs['X-Internal-Token']).toBeUndefined(); }); + it('forwards mailbox attachments from complete JSON', async () => { + clearAgentJwtCache(); + const calls: Array<{ url: string; body: string }> = []; + const fetchFn = vi.fn(async (url: string | URL, init?: RequestInit) => { + const u = String(url); + calls.push({ url: u, body: String(init?.body ?? '') }); + if (u.includes('/complete')) { + return mockOkResponse( + JSON.stringify({ + content: 'here is a duck', + attachments: ['mbx:att-1', 'https://cdn.example/x.png'], + }) + ); + } + if (u.includes('/oauth/token')) { + return mockOkResponse( + JSON.stringify({ access_token: 'jwt-from-acn', expires_in: 1800 }) + ); + } + return mockOkResponse(JSON.stringify({ id: 'm1' }), 201); + }); + + const event = normalizeEvent( + (parseJsonRpcBody(chatMessageBody()) as { ok: true; body: Record }) + .body + ); + const opts = buildChatWritebackOptions({ + chatWriteback: true, + chatApiBase: 'http://gw:8000', + acnBaseUrl: 'https://api.acnlabs.dev', + apiKey: 'acn_secret', + chatCompleteUrl: 'http://127.0.0.1:9/complete', + agentId: 'agent-1', + })!; + + const result = await handleChatWriteback(event, opts, { + fetchFn: fetchFn as unknown as typeof fetch, + logFn: () => {}, + }); + expect(result).toEqual({ ok: true, httpStatus: 201 }); + const writeback = calls.find((c) => c.url.includes('/agent-messages')); + expect(JSON.parse(writeback?.body ?? '{}')).toEqual({ + content: 'here is a duck', + reply_to_id: 'user-msg-1', + attachments: ['mbx:att-1'], + }); + }); + it('injects official hop env for complete-exec and headers for complete-url', async () => { clearAgentJwtCache(); const event = normalizeEvent( diff --git a/skills/acn/references/INTERFAZE.md b/skills/acn/references/INTERFAZE.md index 08c3239..2a3ea5b 100644 --- a/skills/acn/references/INTERFAZE.md +++ b/skills/acn/references/INTERFAZE.md @@ -119,11 +119,14 @@ Content-Type: application/json "total_tokens": 1540, "duration_ms": 3711, "provider": "tencenttokenplan" - } + }, + "attachments": ["mbx:"] } ``` -`acn listen --chat-writeback`:complete 返回 `{"content"}` 即可;若附带 `usage`,CLI **1.0.3+** 会一并 POST(并自动填 `reply_to_id`)。Host 开了 `CHAT_BILLING_ENABLED` 且要求 usage 时,缺 usage 则本跳不扣费。 +有图或视频:先 `POST /api/chats/{chat_id}/files`(multipart `file`:图或 mp4/webm)拿到 `ref`,再写进 `attachments`。只认本会话 `mbx:`;http(s) 热链会被 Host 拒。没有 `metadata.agentplanet.chat_id` 不要打 `/api/chats`。另一场开聊看不见这场的件。 + +`acn listen --chat-writeback`:complete 返回 `{"content"}` 即可;若附带 `usage`,CLI **1.0.3+** 会一并 POST(并自动填 `reply_to_id`)。`attachments` 里的 `mbx:` 会转发,外链会被丢掉。Host 开了 `CHAT_BILLING_ENABLED` 且要求 usage 时,缺 usage 则本跳不扣费。 CLI 不会代传文件,宿主必须自己先 POST files。 #### Owner 改默认模型