diff --git a/open-sse/handlers/chatCore/logTruncation.ts b/open-sse/handlers/chatCore/logTruncation.ts index e2a4b51c96..666e483fef 100644 --- a/open-sse/handlers/chatCore/logTruncation.ts +++ b/open-sse/handlers/chatCore/logTruncation.ts @@ -86,6 +86,10 @@ export function truncateForLog(value: unknown): Record | 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); diff --git a/src/mitm/inspector/multiRowConversation.ts b/src/mitm/inspector/multiRowConversation.ts index c24c5d22b6..3493b0943c 100644 --- a/src/mitm/inspector/multiRowConversation.ts +++ b/src/mitm/inspector/multiRowConversation.ts @@ -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; 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; diff --git a/tests/unit/chatcore-log-truncation.test.ts b/tests/unit/chatcore-log-truncation.test.ts index d050b4f90a..635ce34d23 100644 --- a/tests/unit/chatcore-log-truncation.test.ts +++ b/tests/unit/chatcore-log-truncation.test.ts @@ -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; + 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>; assert.equal(clonedTools.length, tools.length); - assert.equal( - (clonedTools[0].function as Record).name, - "get_weather" - ); - assert.equal( - (clonedTools[1].function as Record).name, - "search_web" - ); + assert.equal((clonedTools[0].function as Record).name, "get_weather"); + assert.equal((clonedTools[1].function as Record).name, "search_web"); }); test("truncateForLog bounds an oversized `tools` array to the configured tail-item cap", () => { diff --git a/tests/unit/multiRowConversation.test.ts b/tests/unit/multiRowConversation.test.ts index 404c7e0e4d..b66c295cb8 100644 --- a/tests/unit/multiRowConversation.test.ts +++ b/tests/unit/multiRowConversation.test.ts @@ -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"); +});