import { Readable } from "node:stream";
import { ApiServerChannel } from "./src/adapters/channel/api-server/ApiServerChannel.js";
import type { Gateway, GatewayEvent, GatewaySubmitTurnInput } from "./src/gateway/index.js";
type Call = Pick<GatewaySubmitTurnInput, "sessionKey" | "channelKey" | "message">;
const calls: Call[] = [];
const gateway = {
submitTurn(input: GatewaySubmitTurnInput): AsyncIterable<GatewayEvent> {
calls.push({
sessionKey: input.sessionKey,
channelKey: input.channelKey,
message: input.message,
});
return (async function* (): AsyncGenerator<GatewayEvent> {
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 redact(value: string): string {
return value
.replace(/api-[0-9a-f-]{36}/g, "api-<uuid>")
.replace(/s_[0-9a-f-]{36}/g, "s_<uuid>");
}
class FakeResponse {
statusCode = 200;
readonly headers = new Map<string, string>();
private readonly chunks: string[] = [];
setHeader(name: string, value: string): void {
this.headers.set(name.toLowerCase(), String(value));
}
write(chunk: string): boolean {
this.chunks.push(String(chunk));
return true;
}
flushHeaders(): void {}
end(chunk?: string): void {
if (chunk !== undefined) this.chunks.push(String(chunk));
}
get body(): string {
return this.chunks.join("");
}
}
function summarizeJsonCompletion(body: string): Record<string, unknown> {
const parsed = JSON.parse(body) as {
object?: unknown;
model?: unknown;
choices?: Array<{ message?: { role?: unknown; content?: unknown }; finish_reason?: unknown }>;
};
return {
object: parsed.object,
model: parsed.model,
assistantRole: parsed.choices?.[0]?.message?.role,
assistantContent: parsed.choices?.[0]?.message?.content,
finishReason: parsed.choices?.[0]?.finish_reason,
};
}
const channel = new ApiServerChannel({ host: "127.0.0.1", port: 0, modelName: "fixture-model" });
(channel as unknown as { gateway: Gateway }).gateway = gateway;
async function post(content: string, sessionId?: string, streaming = false) {
const headers: Record<string, string> = {
host: "fixture.invalid",
"content-type": "application/json",
};
if (sessionId) headers["x-hermes-session-id"] = sessionId;
// Socket binding is unavailable in the sandbox (listen returns EPERM), so this
// replays the exact /v1/chat/completions route with stream-compatible fakes.
const request = Readable.from([Buffer.from(JSON.stringify({
model: "fixture-model",
messages: [{ role: "user", content }],
stream: streaming,
}))]) as Readable & { method?: string; url?: string; headers: Record<string, string> };
request.method = "POST";
request.url = "/v1/chat/completions";
request.headers = headers;
const response = new FakeResponse();
await (channel as unknown as {
handleRequest(req: unknown, res: FakeResponse): Promise<void>;
}).handleRequest(request, response);
return {
status: response.statusCode,
sessionHeader: response.headers.get("x-hermes-session-id") ?? null,
body: response.body,
};
}
const newAck = await post("/new");
const stateAfterNew = (channel as unknown as {
mapper: { snapshot(): { activeByChatId: Record<string, string> } };
}).mapper.snapshot();
const continuation = await post("continuation without returned session header");
const stateAfterContinuation = (channel as unknown as {
mapper: { snapshot(): { activeByChatId: Record<string, string> } };
}).mapper.snapshot();
const streamingNewAck = await post("/new", undefined, true);
const stateAfterStreamingNew = (channel as unknown as {
mapper: { snapshot(): { activeByChatId: Record<string, string> } };
}).mapper.snapshot();
const mappedEntries = Object.entries(stateAfterNew.activeByChatId);
const continuationCall = calls.find((call) => call.message === "continuation without returned session header");
const mappedSessionKey = mappedEntries[0]?.[1] ?? "";
const continuationSessionKey = continuationCall?.sessionKey ?? "";
console.log(JSON.stringify({
fixture: "api-server-route-handler-new-session-header",
transport: "in-memory IncomingMessage/ServerResponse equivalent",
endpoint: "POST /v1/chat/completions",
input: {
firstMessage: "/new",
secondMessage: "continuation without returned session header",
firstRequestSessionHeader: null,
secondRequestSessionHeader: null,
},
newAck: {
status: newAck.status,
hasSessionHeader: newAck.sessionHeader !== null,
bodySummary: summarizeJsonCompletion(newAck.body),
},
streamingNewAck: {
status: streamingNewAck.status,
hasSessionHeader: streamingNewAck.sessionHeader !== null,
containsAck: streamingNewAck.body.includes("已创建新会话。"),
containsDone: streamingNewAck.body.includes("data: [DONE]"),
},
continuation: {
status: continuation.status,
hasSessionHeader: continuation.sessionHeader !== null,
bodySummary: summarizeJsonCompletion(continuation.body),
},
mapper: {
entriesAfterNew: mappedEntries.length,
entriesAfterContinuation: Object.keys(stateAfterContinuation.activeByChatId).length,
entriesAfterStreamingNew: Object.keys(stateAfterStreamingNew.activeByChatId).length,
newSessionKeyCreated: /^api_server:chat=api-.*:s_/.test(mappedSessionKey),
continuationUsedMappedSession: continuationSessionKey === mappedSessionKey,
continuationSessionKey: redact(continuationSessionKey),
mappedSessionKey: redact(mappedSessionKey),
},
gatewayCalls: calls.map((call) => ({
...call,
sessionKey: redact(call.sessionKey),
})),
}));
Summary
Both transports create an s_ binding but return no X-Hermes-Session-Id value. Exact and wildcard CORS expose the header name but not a value; a headerless continuation generates a different chat ID and uses :general.
Expected behavior
A successful headerless buffered or streaming /new acknowledgement should expose the chat ID used for the new mapper binding so a continuation can reach that session.
Actual behavior
Both transports create an s_ binding but return no X-Hermes-Session-Id value. Exact and wildcard CORS expose the header name but not a value; a headerless continuation generates a different chat ID and uses :general.
Impact
Callers cannot continue the session that the successful /new response created.
Reproduction
POST a bare /new command without X-Hermes-Session-Id in both buffered and streaming modes. Record the newly allocated mapper chat ID, response headers, and the chat ID used by a headerless continuation. A successful reset must expose the allocated identifier; the observed result is an s_ binding with no header value, followed by a continuation on a different chat ID and :general session.
Minimal reproduction script
From the repository root, save this as repro_api_new_session_header.mts and run:
pnpm install --frozen-lockfile pnpm exec tsx repro_api_new_session_header.mtsRelevant source locations
src/adapters/channel/api-server/ApiServerChannel.ts:238-264src/adapters/channel/api-server/ApiServerChannel.ts:303-307src/adapters/channel/api-server/ApiServerChannel.ts:404-405src/adapters/channel/api-server/ApiServerSessionMapper.ts:13-33Suggested 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.