Skip to content

Commit 58b8d22

Browse files
authored
Merge pull request #105 from teng-lin/fix/close-protocol-gaps-v2
fix: close remaining unified message protocol gaps
2 parents 0d1d3d3 + 34600bb commit 58b8d22

11 files changed

+460
-4
lines changed

src/adapters/claude/message-translator.test.ts

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,17 +225,32 @@ describe("message-translator", () => {
225225
expect(typeof (result.content[0] as { content: string }).content).toBe("string");
226226
});
227227

228-
it("converts thinking blocks to text content", () => {
228+
it("passes through thinking blocks", () => {
229229
const msg = makeAssistantMsg({
230230
message: {
231231
...makeAssistantMsg().message,
232-
content: [{ type: "thinking", thinking: "Let me think..." }],
232+
content: [{ type: "thinking", thinking: "Let me think...", budget_tokens: 2048 }],
233233
},
234234
});
235235
const result = translate(msg)!;
236236
expect(result.content[0]).toEqual({
237237
type: "thinking",
238238
thinking: "Let me think...",
239+
budget_tokens: 2048,
240+
});
241+
});
242+
243+
it("thinking block budget_tokens is undefined when absent", () => {
244+
const msg = makeAssistantMsg({
245+
message: {
246+
...makeAssistantMsg().message,
247+
content: [{ type: "thinking", thinking: "No budget" }],
248+
},
249+
});
250+
const result = translate(msg)!;
251+
expect(result.content[0]).toEqual({
252+
type: "thinking",
253+
thinking: "No budget",
239254
budget_tokens: undefined,
240255
});
241256
});
@@ -258,6 +273,69 @@ describe("message-translator", () => {
258273
const result = translate(msg)!;
259274
expect(result.metadata.parent_tool_use_id).toBe("ptu-1");
260275
});
276+
277+
it("passes through image content blocks", () => {
278+
const msg = makeAssistantMsg({
279+
message: {
280+
...makeAssistantMsg().message,
281+
content: [
282+
{
283+
type: "image",
284+
source: { type: "base64", media_type: "image/png", data: "abc123" },
285+
},
286+
],
287+
},
288+
});
289+
const result = translate(msg)!;
290+
expect(result.content[0]).toEqual({
291+
type: "image",
292+
source: { type: "base64", media_type: "image/png", data: "abc123" },
293+
});
294+
expect(result.metadata.dropped_content_block_types).toBeUndefined();
295+
});
296+
297+
it("passes through code content blocks", () => {
298+
const msg = makeAssistantMsg({
299+
message: {
300+
...makeAssistantMsg().message,
301+
content: [{ type: "code", language: "python", code: "print('hi')" }],
302+
},
303+
});
304+
const result = translate(msg)!;
305+
expect(result.content[0]).toEqual({
306+
type: "code",
307+
language: "python",
308+
code: "print('hi')",
309+
});
310+
expect(result.metadata.dropped_content_block_types).toBeUndefined();
311+
});
312+
313+
it("passes through refusal content blocks", () => {
314+
const msg = makeAssistantMsg({
315+
message: {
316+
...makeAssistantMsg().message,
317+
content: [{ type: "refusal", refusal: "I cannot do that" }],
318+
},
319+
});
320+
const result = translate(msg)!;
321+
expect(result.content[0]).toEqual({
322+
type: "refusal",
323+
refusal: "I cannot do that",
324+
});
325+
expect(result.metadata.dropped_content_block_types).toBeUndefined();
326+
});
327+
328+
it("drops truly unknown content blocks with metadata tracking", () => {
329+
const msg = makeAssistantMsg({
330+
message: {
331+
...makeAssistantMsg().message,
332+
content: [{ type: "experimental_widget" } as any],
333+
},
334+
});
335+
const result = translate(msg)!;
336+
expect(result.content[0]).toEqual({ type: "text", text: "" });
337+
expect(result.metadata.dropped_content_block_types).toEqual(["experimental_widget"]);
338+
});
261339
});
262340

263341
describe("result → result", () => {

src/adapters/claude/message-translator.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,23 @@ function translateAssistant(msg: CLIAssistantMessage): UnifiedMessage {
117117
return {
118118
type: "thinking" as const,
119119
thinking: block.thinking,
120-
budget_tokens: (block as { budget_tokens?: number }).budget_tokens,
120+
budget_tokens: block.budget_tokens,
121+
};
122+
case "image":
123+
return {
124+
type: "image" as const,
125+
source: block.source,
126+
};
127+
case "code":
128+
return {
129+
type: "code" as const,
130+
language: block.language,
131+
code: block.code,
132+
};
133+
case "refusal":
134+
return {
135+
type: "refusal" as const,
136+
refusal: block.refusal,
121137
};
122138
default:
123139
droppedContentBlockTypes.push(

src/adapters/codex/codex-message-translator.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ describe("codex-message-translator", () => {
159159
expect(result.metadata.name).toBe("shell");
160160
expect(result.metadata.arguments).toBe('{"command":"ls"}');
161161
expect(result.metadata.tool_use_id).toBe("call-1");
162+
expect(result.metadata.tool_name).toBe("shell");
162163
expect(result.metadata.item_id).toBe("fc-1");
163164
});
164165

@@ -203,6 +204,7 @@ describe("codex-message-translator", () => {
203204
expect(result.type).toBe("tool_progress");
204205
expect(result.metadata.done).toBe(true);
205206
expect(result.metadata.name).toBe("read_file");
207+
expect(result.metadata.tool_name).toBe("read_file");
206208
});
207209
});
208210

src/adapters/codex/codex-message-translator.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ function translateItemAdded(event: CodexTurnEvent): UnifiedMessage | null {
212212
role: "tool",
213213
metadata: {
214214
name: item.name,
215+
tool_name: item.name,
215216
arguments: item.arguments,
216217
tool_use_id: item.call_id,
217218
item_id: item.id,
@@ -248,6 +249,7 @@ function translateItemDone(event: CodexTurnEvent): UnifiedMessage | null {
248249
role: "tool",
249250
metadata: {
250251
name: item.name,
252+
tool_name: item.name,
251253
arguments: item.arguments,
252254
tool_use_id: item.call_id,
253255
item_id: item.id,

src/adapters/opencode/opencode-message-translator.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,7 @@ describe("translateEvent: message.updated with assistant role", () => {
330330
expect(msg!.type).toBe("assistant");
331331
expect(msg!.role).toBe("assistant");
332332
expect(msg!.metadata.model_id).toBe("claude-3-5-sonnet");
333+
expect(msg!.metadata.model).toBe("claude-3-5-sonnet");
333334
expect(msg!.metadata.provider_id).toBe("anthropic");
334335
expect(msg!.metadata.cost).toBe(0.002);
335336
expect(msg!.metadata.tokens).toEqual({

src/adapters/opencode/opencode-message-translator.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,7 @@ function translateMessageUpdated(
302302
message_id: info.id,
303303
session_id: info.sessionID,
304304
model_id: info.modelID,
305+
model: info.modelID,
305306
provider_id: info.providerID,
306307
cost: info.cost,
307308
tokens: info.tokens,

src/core/adapter-bridge-consumer.integration.test.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
tick,
1010
} from "../testing/adapter-test-helpers.js";
1111
import type { SessionBridge } from "./session-bridge.js";
12+
import { createUnifiedMessage } from "./types/unified-message.js";
1213

1314
// ── Mock WebSocket ───────────────────────────────────────────────────────────
1415

@@ -477,6 +478,123 @@ describe("Adapter → SessionBridge → Consumer Integration", () => {
477478
});
478479
});
479480

481+
// ── 7. Content type round-trip ───────────────────────────────────────────
482+
483+
describe("content type round-trip through adapter path", () => {
484+
it("image content block reaches consumer with flattened source", async () => {
485+
const socket = createMockSocket();
486+
bridge.handleConsumerOpen(socket, { sessionId, transport: {} });
487+
await bridge.connectBackend(sessionId);
488+
const backendSession = adapter.getSession(sessionId)!;
489+
490+
socket.sentMessages.length = 0;
491+
492+
backendSession.pushMessage(
493+
createUnifiedMessage({
494+
type: "assistant",
495+
role: "assistant",
496+
content: [
497+
{
498+
type: "image",
499+
source: { type: "base64", media_type: "image/png", data: "iVBOR..." },
500+
},
501+
],
502+
metadata: {
503+
message_id: "msg-img-rt",
504+
model: "claude-sonnet-4-5-20250929",
505+
stop_reason: "end_turn",
506+
parent_tool_use_id: null,
507+
usage: {
508+
input_tokens: 10,
509+
output_tokens: 20,
510+
cache_creation_input_tokens: 0,
511+
cache_read_input_tokens: 0,
512+
},
513+
},
514+
}),
515+
);
516+
await tick();
517+
518+
const assistantMsgs = sentOfType(socket, "assistant") as any[];
519+
expect(assistantMsgs).toHaveLength(1);
520+
expect(assistantMsgs[0].message.content).toEqual([
521+
{ type: "image", media_type: "image/png", data: "iVBOR..." },
522+
]);
523+
});
524+
525+
it("code content block reaches consumer", async () => {
526+
const socket = createMockSocket();
527+
bridge.handleConsumerOpen(socket, { sessionId, transport: {} });
528+
await bridge.connectBackend(sessionId);
529+
const backendSession = adapter.getSession(sessionId)!;
530+
531+
socket.sentMessages.length = 0;
532+
533+
backendSession.pushMessage(
534+
createUnifiedMessage({
535+
type: "assistant",
536+
role: "assistant",
537+
content: [{ type: "code", language: "typescript", code: "const x = 1;" }],
538+
metadata: {
539+
message_id: "msg-code-rt",
540+
model: "claude-sonnet-4-5-20250929",
541+
stop_reason: "end_turn",
542+
parent_tool_use_id: null,
543+
usage: {
544+
input_tokens: 10,
545+
output_tokens: 20,
546+
cache_creation_input_tokens: 0,
547+
cache_read_input_tokens: 0,
548+
},
549+
},
550+
}),
551+
);
552+
await tick();
553+
554+
const assistantMsgs = sentOfType(socket, "assistant") as any[];
555+
expect(assistantMsgs).toHaveLength(1);
556+
expect(assistantMsgs[0].message.content).toEqual([
557+
{ type: "code", language: "typescript", code: "const x = 1;" },
558+
]);
559+
});
560+
561+
it("refusal content block reaches consumer", async () => {
562+
const socket = createMockSocket();
563+
bridge.handleConsumerOpen(socket, { sessionId, transport: {} });
564+
await bridge.connectBackend(sessionId);
565+
const backendSession = adapter.getSession(sessionId)!;
566+
567+
socket.sentMessages.length = 0;
568+
569+
backendSession.pushMessage(
570+
createUnifiedMessage({
571+
type: "assistant",
572+
role: "assistant",
573+
content: [{ type: "refusal", refusal: "I cannot assist with that." }],
574+
metadata: {
575+
message_id: "msg-ref-rt",
576+
model: "claude-sonnet-4-5-20250929",
577+
stop_reason: "end_turn",
578+
parent_tool_use_id: null,
579+
usage: {
580+
input_tokens: 10,
581+
output_tokens: 20,
582+
cache_creation_input_tokens: 0,
583+
cache_read_input_tokens: 0,
584+
},
585+
},
586+
}),
587+
);
588+
await tick();
589+
590+
const assistantMsgs = sentOfType(socket, "assistant") as any[];
591+
expect(assistantMsgs).toHaveLength(1);
592+
expect(assistantMsgs[0].message.content).toEqual([
593+
{ type: "refusal", refusal: "I cannot assist with that." },
594+
]);
595+
});
596+
});
597+
480598
// ── Full round-trip flow ──────────────────────────────────────────────────
481599

482600
describe("full round-trip: consumer → backend → consumer", () => {

src/core/consumer-message-mapper.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,29 @@ import {
1010
mapToolProgress,
1111
mapToolUseSummary,
1212
} from "./consumer-message-mapper.js";
13+
import type { UnifiedContent } from "./types/unified-message.js";
1314
import { createUnifiedMessage } from "./types/unified-message.js";
1415

16+
function makeAssistantMsg(content: UnifiedContent[], messageId: string) {
17+
return createUnifiedMessage({
18+
type: "assistant",
19+
role: "assistant",
20+
content,
21+
metadata: {
22+
message_id: messageId,
23+
model: "claude-sonnet-4-5-20250929",
24+
stop_reason: null,
25+
usage: {
26+
input_tokens: 0,
27+
output_tokens: 0,
28+
cache_creation_input_tokens: 0,
29+
cache_read_input_tokens: 0,
30+
},
31+
parent_tool_use_id: null,
32+
},
33+
});
34+
}
35+
1536
// ─── mapAssistantMessage ────────────────────────────────────────────────────
1637

1738
describe("mapAssistantMessage", () => {
@@ -208,6 +229,54 @@ describe("mapAssistantMessage", () => {
208229
const assistant = result as Extract<typeof result, { type: "assistant" }>;
209230
expect(assistant.message.content).toEqual([{ type: "text", text: "" }]);
210231
});
232+
233+
it("maps thinking content blocks", () => {
234+
const msg = makeAssistantMsg(
235+
[{ type: "thinking", thinking: "Let me analyze...", budget_tokens: 5000 }],
236+
"msg-007",
237+
);
238+
const result = mapAssistantMessage(msg);
239+
const assistant = result as Extract<typeof result, { type: "assistant" }>;
240+
expect(assistant.message.content).toEqual([
241+
{ type: "thinking", thinking: "Let me analyze...", budget_tokens: 5000 },
242+
]);
243+
});
244+
245+
it("maps code content blocks", () => {
246+
const msg = makeAssistantMsg(
247+
[{ type: "code", language: "typescript", code: "const x = 1;" }],
248+
"msg-008",
249+
);
250+
const result = mapAssistantMessage(msg);
251+
const assistant = result as Extract<typeof result, { type: "assistant" }>;
252+
expect(assistant.message.content).toEqual([
253+
{ type: "code", language: "typescript", code: "const x = 1;" },
254+
]);
255+
});
256+
257+
it("maps image content blocks with flattened source", () => {
258+
const msg = makeAssistantMsg(
259+
[{ type: "image", source: { type: "base64", media_type: "image/png", data: "iVBOR..." } }],
260+
"msg-009",
261+
);
262+
const result = mapAssistantMessage(msg);
263+
const assistant = result as Extract<typeof result, { type: "assistant" }>;
264+
expect(assistant.message.content).toEqual([
265+
{ type: "image", media_type: "image/png", data: "iVBOR..." },
266+
]);
267+
});
268+
269+
it("maps refusal content blocks", () => {
270+
const msg = makeAssistantMsg(
271+
[{ type: "refusal", refusal: "I cannot assist with that." }],
272+
"msg-010",
273+
);
274+
const result = mapAssistantMessage(msg);
275+
const assistant = result as Extract<typeof result, { type: "assistant" }>;
276+
expect(assistant.message.content).toEqual([
277+
{ type: "refusal", refusal: "I cannot assist with that." },
278+
]);
279+
});
211280
});
212281

213282
// ─── mapResultMessage ───────────────────────────────────────────────────────

0 commit comments

Comments
 (0)