fix(llm): replay complete response messages

This commit is contained in:
Aiden Cline
2026-07-23 13:57:54 -05:00
parent 1114137eb6
commit 6b13d117d3
4 changed files with 103 additions and 38 deletions
+51 -14
View File
@@ -46,6 +46,7 @@ type OpenAIResponsesInputContent = Schema.Schema.Type<typeof OpenAIResponsesInpu
const OpenAIResponsesOutputText = Schema.Struct({
type: Schema.tag("output_text"),
text: Schema.String,
annotations: Schema.Array(Schema.Unknown),
})
const OpenAIResponsesMessagePhase = Schema.Literals(["commentary", "final_answer"])
@@ -82,6 +83,14 @@ const OpenAIResponsesInputItem = Schema.Union([
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenAIResponsesInputContent) }),
Schema.Struct({
role: Schema.tag("assistant"),
content: Schema.String,
phase: optionalNull(OpenAIResponsesMessagePhase),
}),
Schema.Struct({
type: Schema.tag("message"),
id: Schema.String,
status: Schema.Literals(["in_progress", "completed", "incomplete"]),
role: Schema.tag("assistant"),
content: Schema.Array(OpenAIResponsesOutputText),
phase: optionalNull(OpenAIResponsesMessagePhase),
@@ -326,6 +335,11 @@ const messageItemID = (part: TextPart) => {
return typeof itemID === "string" && itemID.length > 0 ? itemID : undefined
}
const messageStatus = (part: TextPart) => {
const status = part.providerMetadata?.openai?.status
return status === "in_progress" || status === "completed" || status === "incomplete" ? status : undefined
}
const hostedToolItemID = (part: ToolResultPart) => {
const openai = part.providerMetadata?.openai
return ProviderShared.isRecord(openai) && typeof openai.itemId === "string" && openai.itemId.length > 0
@@ -399,19 +413,32 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
const content: TextPart[] = []
let phase: OpenAIResponsesMessagePhase | null | undefined
let itemID: string | undefined
let status: "in_progress" | "completed" | "incomplete" | undefined
const reasoningItems: Record<string, OpenAIResponsesReasoningReplay> = {}
const reasoningReferences = new Set<string>()
const hostedToolReferences = new Set<string>()
const flushText = () => {
if (content.length === 0) return
input.push({
role: "assistant",
content: content.map((part) => ({ type: "output_text", text: part.text })),
...(phase !== undefined ? { phase } : {}),
})
input.push(
itemID
? {
type: "message",
id: itemID,
status: status ?? "completed",
role: "assistant",
content: content.map((part) => ({ type: "output_text", text: part.text, annotations: [] })),
...(phase !== undefined ? { phase } : {}),
}
: {
role: "assistant",
content: ProviderShared.joinText(content),
...(phase !== undefined ? { phase } : {}),
},
)
content.splice(0, content.length)
phase = undefined
itemID = undefined
status = undefined
}
for (const part of message.content) {
if (part.type === "text") {
@@ -420,6 +447,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
if (content.length > 0 && (phase !== nextPhase || itemID !== nextItemID)) flushText()
phase = nextPhase
itemID = nextItemID
status = messageStatus(part) ?? status
content.push(part)
continue
}
@@ -653,8 +681,19 @@ const NO_EVENTS: StepResult["1"] = []
// the protocol's `terminal` predicate stay in sync.
const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"])
const messageMetadata = (item: OpenAIResponsesStreamItem, id: string) =>
openaiMetadata({ itemId: id, ...(item.phase !== undefined ? { phase: item.phase } : {}) })
const messageMetadata = (item: OpenAIResponsesStreamItem, id: string, previous?: ProviderMetadata) => {
const openai = previous?.openai
const phase = item.phase !== undefined ? item.phase : openai?.phase
const status =
item.status === "in_progress" || item.status === "completed" || item.status === "incomplete"
? item.status
: openai?.status
return openaiMetadata({
itemId: id,
...(phase === "commentary" || phase === "final_answer" || phase === null ? { phase } : {}),
...(status === "in_progress" || status === "completed" || status === "incomplete" ? { status } : {}),
})
}
const ensureMessageContent = (state: ParserState, event: OpenAIResponsesEvent) => {
const itemID = event.item_id ?? "text-0"
@@ -787,15 +826,16 @@ const reasoningMetadata = (item: OpenAIResponsesStreamItem & { id: string }) =>
const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
const item = event.item
if (item?.type === "message" && item.id) {
const existing = state.messageItems[item.id]
return [
{
...state,
messageItems: {
...state.messageItems,
[item.id]: {
...state.messageItems[item.id],
providerMetadata: messageMetadata(item, item.id),
content: state.messageItems[item.id]?.content ?? {},
...existing,
providerMetadata: messageMetadata(item, item.id, existing?.providerMetadata),
content: existing?.content ?? {},
},
},
},
@@ -964,10 +1004,7 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
const itemID = item.id
const messageItem = state.messageItems[itemID]
const { [itemID]: _finished, ...messageItems } = state.messageItems
const providerMetadata =
item.phase !== undefined
? messageMetadata(item, itemID)
: (messageItem?.providerMetadata ?? messageMetadata(item, itemID))
const providerMetadata = messageMetadata(item, itemID, messageItem?.providerMetadata)
const lifecycle = Object.values(messageItem?.content ?? {}).reduce(
(lifecycle, content) => Lifecycle.textEnd(lifecycle, events, content.id, providerMetadata),
state.lifecycle,
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -153,7 +153,7 @@ describe("OpenAI Responses route", () => {
{ type: "input_text", text: "<system-update>\nTreat &lt;/system-update&gt; literally.\n</system-update>" },
],
},
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
{ role: "assistant", content: "After." },
])
}),
)
@@ -529,11 +529,11 @@ describe("OpenAI Responses route", () => {
encrypted_content: "encrypted-continuation-state",
summary: [{ type: "summary_text", text: "I inspected the previous turn." }],
},
{ role: "assistant", content: [{ type: "output_text", text: "It shows a small test image." }] },
{ role: "assistant", content: "It shows a small test image." },
{ role: "user", content: [{ type: "input_text", text: "Check the weather in Paris before continuing." }] },
{ type: "function_call", call_id: "call_weather_1", name: "get_weather", arguments: '{"city":"Paris"}' },
{ type: "function_call_output", call_id: "call_weather_1", output: '{"temperature":22}' },
{ role: "assistant", content: [{ type: "output_text", text: "Paris is 22 degrees." }] },
{ role: "assistant", content: "Paris is 22 degrees." },
{
role: "user",
content: [{ type: "input_text", text: "Continue from this conversation in one short sentence." }],
@@ -841,6 +841,10 @@ describe("OpenAI Responses route", () => {
content_index: 0,
delta: "First.",
},
{
type: "response.output_item.added",
item: { type: "message", id: "msg_commentary" },
},
{
type: "response.output_text.done",
item_id: "msg_commentary",
@@ -963,31 +967,46 @@ describe("OpenAI Responses route", () => {
)
expect(prepared.body.input).toEqual([
{
type: "message",
id: "msg_commentary",
status: "completed",
role: "assistant",
phase: "commentary",
content: [
{ type: "output_text", text: "First." },
{ type: "output_text", text: "Second." },
{ type: "output_text", text: "First.", annotations: [] },
{ type: "output_text", text: "Second.", annotations: [] },
],
},
{
type: "message",
id: "msg_commentary_2",
status: "completed",
role: "assistant",
phase: "commentary",
content: [{ type: "output_text", text: "Third." }],
content: [{ type: "output_text", text: "Third.", annotations: [] }],
},
{
type: "message",
id: "openai-text-0",
status: "completed",
role: "assistant",
phase: "final_answer",
content: [{ type: "output_text", text: "Final." }],
content: [{ type: "output_text", text: "Final.", annotations: [] }],
},
{
type: "message",
id: "msg_null",
status: "completed",
role: "assistant",
phase: null,
content: [{ type: "output_text", text: "Nullable." }],
content: [{ type: "output_text", text: "Nullable.", annotations: [] }],
},
{
type: "message",
id: "msg_unphased",
status: "completed",
role: "assistant",
content: [{ type: "output_text", text: "Unphased." }],
content: [{ type: "output_text", text: "Unphased.", annotations: [] }],
},
])
}),
@@ -1186,7 +1205,7 @@ describe("OpenAI Responses route", () => {
encrypted_content: "encrypted-state",
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
},
{ role: "assistant", content: [{ type: "output_text", text: "The parser changed." }] },
{ role: "assistant", content: "The parser changed." },
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
],
})
@@ -1234,13 +1253,13 @@ describe("OpenAI Responses route", () => {
)
expect(prepared.body.input).toEqual([
{ role: "assistant", content: [{ type: "output_text", text: "Before." }] },
{ role: "assistant", content: "Before." },
{
type: "reasoning",
encrypted_content: "encrypted-state",
summary: [{ type: "summary_text", text: "Checked order." }],
},
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
{ role: "assistant", content: "After." },
])
}),
)
@@ -1274,19 +1293,28 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.input).toEqual([
{
type: "message",
id: "msg_commentary",
status: "completed",
role: "assistant",
phase: "commentary",
content: [{ type: "output_text", text: "Checking first." }],
content: [{ type: "output_text", text: "Checking first.", annotations: [] }],
},
{
type: "message",
id: "msg_commentary_2",
status: "completed",
role: "assistant",
phase: "commentary",
content: [{ type: "output_text", text: "Still checking." }],
content: [{ type: "output_text", text: "Still checking.", annotations: [] }],
},
{
type: "message",
id: "msg_final",
status: "completed",
role: "assistant",
phase: "final_answer",
content: [{ type: "output_text", text: "Finished." }],
content: [{ type: "output_text", text: "Finished.", annotations: [] }],
},
])
}),
@@ -1417,7 +1445,7 @@ describe("OpenAI Responses route", () => {
expect(prepared.body).toMatchObject({
input: [
{ role: "user", content: [{ type: "input_text", text: "What changed?" }] },
{ role: "assistant", content: [{ type: "output_text", text: "The parser changed." }] },
{ role: "assistant", content: "The parser changed." },
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
],
store: false,