mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 17:52:31 +03:00
fix(conversation): show a placeholder for truncated bodies with no known message count
Follow-up to the earlier truncated-request-body transcript fix, found by re-checking the live dashboard: a specific /v1/responses request still showed nothing for its own turn even though its body genuinely was truncated by logTruncation.ts's truncateForLog(). Root cause (two parts): 1. truncateForLog() only counted messages[] (Chat Completions) and contents[] (Gemini) — never input[] (Responses API) — so a truncated Responses API request's summary carried NO count field at all. 2. buildMultiRowConversation()'s earlier fix defaulted an unknown count to 0, which silently produced "0 new turns" instead of surfacing that the count was simply unavailable — same end symptom as the original bug (nothing shown) despite the row being genuinely truncated. Fixes: - logTruncation.ts now also sets messageCount for input[] bodies (root fix, only helps requests logged from here forward). - multiRowConversation.ts now distinguishes "known count" (existing specific "N messages not shown" placeholder + correct bookkeeping) from "unknown count" (a generic placeholder, since we can't safely diff against previousTotal without a real number) — needed for the already-persisted historical data on omniroute-dev that will never retroactively get a messageCount. Test plan: - New TDD tests for both gaps (Responses API count capture in logTruncation, unknown-count placeholder in multiRowConversation), confirmed failing before each fix and passing after - npm run typecheck:core / npm run lint / npm run check:file-size — clean - npm run test:unit — 27223 tests, same 4 pre-existing/unrelated failures as the last confirmed-clean run (no new regressions) - npm run test:vitest — 291/291 passed - Rebuilt and redeployed to omniroute-dev
This commit is contained in:
@@ -86,6 +86,10 @@ export function truncateForLog(value: unknown): Record<string, unknown> | null |
|
||||
if (typeof obj.model === "string") summary.model = obj.model;
|
||||
if (typeof obj.provider === "string") summary.provider = obj.provider;
|
||||
if (Array.isArray(obj.messages)) summary.messageCount = obj.messages.length;
|
||||
// Responses API (`input`, not `messages`) — same count semantics, needed so
|
||||
// the dashboard's multi-row conversation transcript can still render a
|
||||
// "N messages not shown" placeholder for a truncated /v1/responses request.
|
||||
else if (Array.isArray(obj.input)) summary.messageCount = obj.input.length;
|
||||
if (Array.isArray(obj.contents)) summary.contentCount = obj.contents.length;
|
||||
if (typeof obj.stream === "boolean") summary.stream = obj.stream;
|
||||
if (Array.isArray(obj.tools)) summary.tools = cloneBoundedChatLogPayload(obj.tools);
|
||||
|
||||
@@ -46,12 +46,27 @@ export interface LoadedCallLogRow {
|
||||
* SUBSEQUENT row's delta slicing would be computed against the wrong
|
||||
* previousTotal (0 instead of the row's real turn count), corrupting the
|
||||
* rest of the reconstruction too.
|
||||
*
|
||||
* `knownCount` is null when the summary carries no count at all — either
|
||||
* older data logged before truncateForLog() learned to count Responses API
|
||||
* `input[]` bodies, or some other body shape it doesn't recognize. In that
|
||||
* case we can't safely diff against previousTotal, so the caller falls back
|
||||
* to a single generic placeholder instead of a specific "N messages" one.
|
||||
*/
|
||||
function truncatedMessageCount(body: unknown): number | null {
|
||||
function getTruncationInfo(body: unknown): { knownCount: number | null } | null {
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) return null;
|
||||
const record = body as Record<string, unknown>;
|
||||
if (record._truncated !== true) return null;
|
||||
return typeof record.messageCount === "number" ? record.messageCount : 0;
|
||||
return { knownCount: typeof record.messageCount === "number" ? record.messageCount : null };
|
||||
}
|
||||
|
||||
function placeholderTurn(text: string, row: LoadedCallLogRow): ConversationTurn {
|
||||
return {
|
||||
role: "system",
|
||||
blocks: [{ type: "text", text }],
|
||||
sourceCallLogId: row.id,
|
||||
timestamp: row.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
function rowAsInterceptedRequest(row: LoadedCallLogRow): InterceptedRequest {
|
||||
@@ -82,39 +97,45 @@ export function buildMultiRowConversation(rows: LoadedCallLogRow[]): Conversatio
|
||||
const turns: ConversationTurn[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const truncatedCount = truncatedMessageCount(row.requestBody);
|
||||
const reqTurns = truncatedCount === null ? (buildRequestTurns(row.requestBody) ?? []) : [];
|
||||
const effectiveReqTurnCount = truncatedCount ?? reqTurns.length;
|
||||
const sliceStart = Math.max(0, Math.min(previousTotal, effectiveReqTurnCount));
|
||||
const truncation = getTruncationInfo(row.requestBody);
|
||||
const respTurns = buildResponseTurns(rowAsInterceptedRequest(row));
|
||||
|
||||
if (truncatedCount !== null) {
|
||||
const newCount = Math.max(0, effectiveReqTurnCount - sliceStart);
|
||||
if (newCount > 0) {
|
||||
turns.push({
|
||||
role: "system",
|
||||
blocks: [
|
||||
{
|
||||
type: "text",
|
||||
text: `${newCount} message${newCount === 1 ? "" : "s"} not shown — the request body was too large to log.`,
|
||||
},
|
||||
],
|
||||
sourceCallLogId: row.id,
|
||||
timestamp: row.timestamp,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const newRequestTurns = reqTurns.slice(sliceStart);
|
||||
for (const turn of newRequestTurns) {
|
||||
if (truncation === null) {
|
||||
const reqTurns = buildRequestTurns(row.requestBody) ?? [];
|
||||
const sliceStart = Math.max(0, Math.min(previousTotal, reqTurns.length));
|
||||
for (const turn of reqTurns.slice(sliceStart)) {
|
||||
turns.push({ ...turn, sourceCallLogId: row.id, timestamp: row.timestamp });
|
||||
}
|
||||
previousTotal = reqTurns.length + respTurns.length;
|
||||
} else if (truncation.knownCount !== null) {
|
||||
const effectiveReqTurnCount = truncation.knownCount;
|
||||
const sliceStart = Math.max(0, Math.min(previousTotal, effectiveReqTurnCount));
|
||||
const newCount = Math.max(0, effectiveReqTurnCount - sliceStart);
|
||||
if (newCount > 0) {
|
||||
turns.push(
|
||||
placeholderTurn(
|
||||
`${newCount} message${newCount === 1 ? "" : "s"} not shown — the request body was too large to log.`,
|
||||
row
|
||||
)
|
||||
);
|
||||
}
|
||||
previousTotal = effectiveReqTurnCount + respTurns.length;
|
||||
} else {
|
||||
// Count unknown (older data, or a body shape truncateForLog() doesn't
|
||||
// recognize) — can't tell how many of this row's turns are genuinely
|
||||
// new, so surface one generic placeholder rather than silently
|
||||
// showing nothing. previousTotal is left as-is: we have no reliable
|
||||
// new figure to add to it, and understating a later row's "new" count
|
||||
// is a safer failure mode here than overstating it.
|
||||
turns.push(
|
||||
placeholderTurn("Earlier messages not shown — the request body was too large to log.", row)
|
||||
);
|
||||
previousTotal = previousTotal + respTurns.length;
|
||||
}
|
||||
|
||||
for (const turn of respTurns) {
|
||||
turns.push({ ...turn, sourceCallLogId: row.id, timestamp: row.timestamp });
|
||||
}
|
||||
|
||||
previousTotal = effectiveReqTurnCount + respTurns.length;
|
||||
}
|
||||
|
||||
return turns;
|
||||
|
||||
@@ -150,6 +150,22 @@ test("truncateForLog summarizes oversized payloads instead of cloning", () => {
|
||||
assert.notEqual(summary, huge);
|
||||
});
|
||||
|
||||
test("truncateForLog captures a message count for Responses API bodies too (input[], not messages[])", () => {
|
||||
// Live bug: a large /v1/responses request got summarized with NO count at
|
||||
// all (messages/contents are OpenAI-chat/Gemini-only field names), so the
|
||||
// "Full Conversation" dashboard panel had nothing to base its "N messages
|
||||
// not shown" placeholder on for any Responses-API conversation, even
|
||||
// though the exact same 8KB summarization applies to it.
|
||||
const huge = {
|
||||
model: "gpt-5",
|
||||
stream: true,
|
||||
input: Array.from({ length: 400 }, () => ({ role: "user", content: "x".repeat(64) })),
|
||||
};
|
||||
const summary = truncateForLog(huge) as Record<string, unknown>;
|
||||
assert.equal(summary._truncated, true);
|
||||
assert.equal(summary.messageCount, 400);
|
||||
});
|
||||
|
||||
test("truncateForLog keeps a bounded `tools` field alive when the request is summarized", () => {
|
||||
// A request whose message history alone blows well past the 8KB summary
|
||||
// threshold, but which also carries `tools` — a field that used to be
|
||||
@@ -198,14 +214,8 @@ test("truncateForLog keeps a bounded `tools` field alive when the request is sum
|
||||
assert.ok(summary.tools, "expected the summary to retain a `tools` field");
|
||||
const clonedTools = summary.tools as Array<Record<string, unknown>>;
|
||||
assert.equal(clonedTools.length, tools.length);
|
||||
assert.equal(
|
||||
(clonedTools[0].function as Record<string, unknown>).name,
|
||||
"get_weather"
|
||||
);
|
||||
assert.equal(
|
||||
(clonedTools[1].function as Record<string, unknown>).name,
|
||||
"search_web"
|
||||
);
|
||||
assert.equal((clonedTools[0].function as Record<string, unknown>).name, "get_weather");
|
||||
assert.equal((clonedTools[1].function as Record<string, unknown>).name, "search_web");
|
||||
});
|
||||
|
||||
test("truncateForLog bounds an oversized `tools` array to the configured tail-item cap", () => {
|
||||
|
||||
@@ -205,3 +205,36 @@ test("buildMultiRowConversation: a truncated row's messageCount keeps a LATER re
|
||||
);
|
||||
assert.equal(nextRowTurns[1].role, "assistant");
|
||||
});
|
||||
|
||||
test("buildMultiRowConversation: a truncated body with NO messageCount at all (older data, or a body shape truncateForLog() doesn't count) still shows a generic placeholder", () => {
|
||||
// Live bug: a /v1/responses request's truncated summary had no count field
|
||||
// at all (truncateForLog() only counted `messages[]`, not Responses API's
|
||||
// `input[]`, until a companion fix) — the previous version of this
|
||||
// function silently treated an unknown count as 0 new turns, so the
|
||||
// transcript rendered NOTHING for the request side, same end symptom as
|
||||
// the original bug report despite the row genuinely being truncated.
|
||||
const rows = [
|
||||
{
|
||||
id: "responses-big",
|
||||
timestamp: "2026-01-01T10:00:00.000Z",
|
||||
requestBody: {
|
||||
_truncated: true,
|
||||
_originalBytes: 300000,
|
||||
model: "gpt-5",
|
||||
stream: true,
|
||||
// no messageCount field at all
|
||||
},
|
||||
responseBody: { choices: [{ message: { role: "assistant", content: "final reply" } }] },
|
||||
},
|
||||
];
|
||||
|
||||
const turns = buildMultiRowConversation(rows);
|
||||
assert.equal(turns.length, 2, "expected a generic placeholder plus the response turn");
|
||||
assert.equal(turns[0].role, "system");
|
||||
assert.equal(turns[0].sourceCallLogId, "responses-big");
|
||||
assert.equal(
|
||||
turns[0].blocks[0].type === "text" ? turns[0].blocks[0].text : "",
|
||||
"Earlier messages not shown — the request body was too large to log."
|
||||
);
|
||||
assert.equal(turns[1].role, "assistant");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user