Skip to content

Commit 1bd85d9

Browse files
authored
fix(ai): mark replayed assistant messages completed (#48221)
1 parent c45e425 commit 1bd85d9

13 files changed

Lines changed: 172 additions & 35 deletions

packages/ai/src/protocols/open-responses.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -655,7 +655,8 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
655655
type: "message" as const,
656656
...(group.id === undefined ? {} : { id: group.id }),
657657
role: "assistant" as const,
658-
status: metadata?.status,
658+
// Replayed text is a finished input item, even if generation was cut short.
659+
status: "completed",
659660
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
660661
...(group.phase === undefined ? {} : { phase: group.phase }),
661662
})),

packages/ai/test/fixtures/recordings/alibaba-responses/qwen-3-8-max-replays-reasoning-through-a-tool-loop-and-follow-up.json

Lines changed: 6 additions & 6 deletions
Large diffs are not rendered by default.

packages/ai/test/fixtures/recordings/meta-responses/replays-encrypted-reasoning-through-a-tool-loop.json

Lines changed: 4 additions & 4 deletions
Large diffs are not rendered by default.

packages/ai/test/fixtures/recordings/meta-search/searches-and-replays-grounded-responses.json

Lines changed: 4 additions & 4 deletions
Large diffs are not rendered by default.

packages/ai/test/fixtures/recordings/moonshot-responses/kimi-k3-preserves-reasoning-through-a-tool-loop-and-follow-up.json

Lines changed: 6 additions & 6 deletions
Large diffs are not rendered by default.

packages/ai/test/fixtures/recordings/openai-responses-websocket/reconstructs-full-context-after-reconnect.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@
8181
{
8282
"direction": "client",
8383
"kind": "text",
84-
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
84+
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
8585
},
8686
{
8787
"direction": "server",

packages/ai/test/fixtures/recordings/openai-responses-websocket/recovers-from-explicit-continuation-rejection.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@
9191
{
9292
"direction": "client",
9393
"kind": "text",
94-
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
94+
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
9595
},
9696
{
9797
"direction": "server",

packages/ai/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json

Lines changed: 5 additions & 5 deletions
Large diffs are not rendered by default.

packages/ai/test/provider/conversation-lowering.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ it.effect("conversation lowering excludes generation settings and tool definitio
2323
instructions: "Keep the context",
2424
input: [
2525
{ role: "user", content: [{ type: "input_text", text: "hello" }] },
26-
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "hi" }] },
26+
{ type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: "hi" }] },
2727
],
2828
})
2929
}),
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { describe, expect } from "bun:test"
2+
import { Effect } from "effect"
3+
import { LLM, LLMEvent, Message } from "../../src/index.js"
4+
import { OpenAI } from "../../src/providers.js"
5+
import { configure } from "../../src/providers/openai-compatible-responses.js"
6+
import { compileRequest, LLMClient } from "../../src/route/client.js"
7+
import { it } from "../lib/effect.js"
8+
import { fixedResponse } from "../lib/http.js"
9+
import { sseEvents } from "../lib/sse.js"
10+
11+
for (const model of [
12+
OpenAI.configure({ apiKey: "test-key" }).responses("example-model"),
13+
configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model("example-model"),
14+
]) {
15+
describe(`${model.route.protocol} message replay`, () => {
16+
const key = model.route.providerMetadataKey ?? "openresponses"
17+
18+
it.effect("marks assistant text completed regardless of stored status", () =>
19+
Effect.gen(function* () {
20+
const prepared = yield* compileRequest(
21+
LLM.request({
22+
model,
23+
messages: [
24+
...[undefined, "in_progress", "incomplete", "completed"].map((status, index) =>
25+
Message.make({
26+
role: "assistant",
27+
providerMetadata: { [key]: { status } },
28+
content: [
29+
{
30+
type: "text",
31+
text: `Saved ${index}`,
32+
providerMetadata: { [key]: { itemId: `msg_${index}`, phase: "commentary", status } },
33+
},
34+
{
35+
type: "text",
36+
text: `Final ${index}`,
37+
providerMetadata: { [key]: { itemId: `msg_final_${index}`, phase: "final_answer", status } },
38+
},
39+
],
40+
}),
41+
),
42+
Message.make({
43+
role: "user",
44+
content: [{ type: "text", text: "Continue" }],
45+
providerMetadata: { [key]: { status: "incomplete" } },
46+
}),
47+
],
48+
}),
49+
)
50+
expect(prepared.body.input).toEqual([
51+
...[0, 1, 2, 3].flatMap((index) => [
52+
{
53+
type: "message",
54+
role: "assistant",
55+
id: `msg_${index}`,
56+
phase: "commentary",
57+
status: "completed",
58+
content: [{ type: "output_text", text: `Saved ${index}` }],
59+
},
60+
{
61+
type: "message",
62+
role: "assistant",
63+
id: `msg_final_${index}`,
64+
phase: "final_answer",
65+
status: "completed",
66+
content: [{ type: "output_text", text: `Final ${index}` }],
67+
},
68+
]),
69+
{ role: "user", status: "incomplete", content: [{ type: "input_text", text: "Continue" }] },
70+
])
71+
}),
72+
)
73+
74+
it.effect("replays truncated text as completed while retaining the response finish reason", () =>
75+
Effect.gen(function* () {
76+
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Respond" })).pipe(
77+
Effect.provide(
78+
fixedResponse(
79+
sseEvents(
80+
{
81+
type: "response.output_item.added",
82+
item: { type: "message", id: "msg_partial", status: "in_progress" },
83+
},
84+
{ type: "response.output_text.delta", item_id: "msg_partial", delta: "The next step is" },
85+
{
86+
type: "response.output_item.done",
87+
item: {
88+
type: "message",
89+
id: "msg_partial",
90+
status: "incomplete",
91+
content: [{ type: "output_text", text: "The next step is" }],
92+
},
93+
},
94+
{
95+
type: "response.incomplete",
96+
response: { status: "incomplete", incomplete_details: { reason: "max_output_tokens" } },
97+
},
98+
),
99+
),
100+
),
101+
)
102+
expect(response.finishReason.normalized).toBe("length")
103+
expect(response.events.filter(LLMEvent.is.textEnd)).toHaveLength(1)
104+
const prepared = yield* compileRequest(
105+
LLM.request({ model, messages: [response.message, Message.user("Continue")] }),
106+
)
107+
expect(prepared.body.input).toEqual([
108+
{
109+
type: "message",
110+
role: "assistant",
111+
id: "msg_partial",
112+
status: "completed",
113+
content: [{ type: "output_text", text: "The next step is" }],
114+
},
115+
{ role: "user", content: [{ type: "input_text", text: "Continue" }] },
116+
])
117+
}),
118+
)
119+
})
120+
}

0 commit comments

Comments
 (0)