From the repository root, save this as repro_api_content_object_stringification.mts and run:
import { Readable } from "node:stream";
import { ApiServerChannel } from "./src/adapters/channel/api-server/ApiServerChannel.js";
import { ApiServerSessionMapper } from "./src/adapters/channel/api-server/ApiServerSessionMapper.js";
import type { Gateway, GatewayEvent, GatewaySubmitTurnInput } from "./src/gateway/index.js";
type CaptureResponse = {
statusCode: number;
headers: Map<string, string>;
ended: boolean;
body: string;
setHeader(name: string, value: string): void;
flushHeaders(): void;
write(chunk: string | Buffer): boolean;
end(chunk?: string | Buffer): void;
};
function makeResponse(): CaptureResponse {
const chunks: string[] = [];
return {
statusCode: 0,
headers: new Map<string, string>(),
ended: false,
get body() { return chunks.join(""); },
setHeader(name, value) { this.headers.set(name.toLowerCase(), String(value)); },
flushHeaders() {},
write(chunk) { chunks.push(String(chunk)); return true; },
end(chunk) {
if (chunk != null) chunks.push(String(chunk));
this.ended = true;
},
};
}
function makeRequest(content: unknown, stream: boolean): any {
const body = JSON.stringify({
model: "object-stringification-model",
messages: [{ role: "user", content }],
stream,
});
const req = Readable.from([Buffer.from(body)]) as any;
req.method = "POST";
req.url = "/v1/chat/completions";
req.headers = {
host: "fixture.invalid",
"content-type": "application/json",
"x-hermes-session-id": "object-stringification-session",
};
return req;
}
function makeGateway(calls: Array<Record<string, unknown>>): Gateway {
return {
async *submitTurn(input: GatewaySubmitTurnInput): AsyncGenerator<GatewayEvent> {
calls.push({
keys: Object.keys(input).sort(),
sessionKey: input.sessionKey,
channelKey: input.channelKey,
message: input.message,
attachments: input.attachments ?? null,
});
yield { type: "assistant_text_delta", text: "fixture-reply" };
yield {
type: "turn_completed",
usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
finishReason: "completed",
};
},
} as unknown as Gateway;
}
function summarizeBody(response: CaptureResponse): Record<string, unknown> {
let parsed: any = null;
try { parsed = JSON.parse(response.body); } catch { /* streaming body */ }
return {
responseObject: parsed?.object ?? null,
assistantContent: parsed?.choices?.[0]?.message?.content ?? null,
hasSseData: response.body.includes("data: "),
hasDone: response.body.includes("data: [DONE]"),
};
}
async function runCase(name: string, content: unknown, stream: boolean): Promise<Record<string, unknown>> {
const calls: Array<Record<string, unknown>> = [];
const mapper = new ApiServerSessionMapper({ activeByChatId: {} }, () => "object-stringification-uuid");
const channel = new ApiServerChannel({ mapper, modelName: "object-stringification-model" });
(channel as any).gateway = makeGateway(calls);
const response = makeResponse();
await (channel as any).handleRequest(makeRequest(content, stream), response);
return {
name,
stream,
status: response.statusCode,
contentType: response.headers.get("content-type") ?? null,
response: summarizeBody(response),
gatewayCalls: calls,
mapper: mapper.snapshot(),
};
}
const cases = [
{ name: "arbitrary-object", content: { kind: "fixture-object", value: 7 } },
{ name: "object-text-shape", content: { type: "text", text: "fixture-object-text" } },
];
const results: Array<Record<string, unknown>> = [];
for (const item of cases) {
results.push(await runCase(item.name, item.content, false));
results.push(await runCase(item.name, item.content, true));
}
console.log(JSON.stringify({
fixture: "api-server-content-object-stringification-witness",
transport: "in-memory IncomingMessage/ServerResponse equivalent",
endpoint: "POST /v1/chat/completions",
gatewayOracle: "capture exact normalized message and input keys",
results,
}, null, 2));
Summary
Arbitrary objects and objects shaped like {type:text,text:...} return HTTP 200 in buffered and streaming modes. Each sends one Gateway call with message=[object Object], with no attachment or recoverable object structure.
Expected behavior
An object that is not a documented content representation should be rejected or decoded by schema. It must not be converted to a lossy JavaScript string.
Actual behavior
Arbitrary objects and objects shaped like {type:text,text:...} return HTTP 200 in buffered and streaming modes. Each sends one Gateway call with message=[object Object], with no attachment or recoverable object structure.
Impact
The API reports success while discarding structured input and sending literal [object Object] to the downstream model path.
Reproduction
POST /v1/chat/completions with a message whose content is an arbitrary JSON object, and repeat with an object shaped like {type:"text",text:"..."}; test buffered and streaming responses. The API should reject or decode the object according to its schema. The observed result is HTTP 200 while the downstream Gateway receives the literal string [object Object].
Minimal reproduction script
From the repository root, save this as repro_api_content_object_stringification.mts and run:
pnpm install --frozen-lockfile pnpm exec tsx repro_api_content_object_stringification.mtsRelevant source locations
src/adapters/channel/api-server/ApiServerChannel.ts:225-235src/adapters/channel/api-server/ApiServerChannel.ts:317-321src/adapters/channel/api-server/ApiServerChannel.ts:374-378src/adapters/channel/api-server/ApiServerChannel.ts:461-475src/gateway/protocol/types.ts:86-94src/gateway/client/InProcessGateway.ts:430-438Suggested direction
Make the external-input path establish one durable, identity-bound state/receipt before returning success; propagate explicit terminal outcomes to every channel and client; and add a regression test for the reproduced boundary.
This report is about functional behavior, not security. The reproduction uses deterministic in-memory or isolated fixtures and contains no credentials or private data.