Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions clients/cli/src/commands/chat-writeback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/

Expand Down Expand Up @@ -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[];
};

/**
Expand Down Expand Up @@ -289,16 +292,38 @@ 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<string>();
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 } {
const content = extractContent(payload);
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 };
}

Expand Down Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions clients/cli/tests/chat-writeback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
extractContent,
extractModelId,
extractUsage,
extractMailboxAttachments,
handleChatWriteback,
officialCompleteFailureContent,
officialV0SupportsModel,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<string, unknown> })
.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(
Expand Down
7 changes: 5 additions & 2 deletions skills/acn/references/INTERFAZE.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,14 @@ Content-Type: application/json
"total_tokens": 1540,
"duration_ms": 3711,
"provider": "tencenttokenplan"
}
},
"attachments": ["mbx:<attachment_id>"]
}
```

`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 改默认模型

Expand Down
Loading