Compare commits

...

1 Commits

Author SHA1 Message Date
opencode-agent[bot] 7afe537c69 fix(ai): align chat assistant content (#41210)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-08-07 23:59:46 -05:00
2 changed files with 64 additions and 38 deletions
+16 -12
View File
@@ -371,12 +371,13 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
return text
})()
const cached = message.content.findLast((part) => "cache" in part && part.cache !== undefined)
const cacheControl = options.cacheControl?.(cached && "cache" in cached ? cached.cache : undefined)
const result = {
role: "assistant" as const,
content: content.length === 0 ? null : ProviderShared.joinText(content),
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
reasoning_details: details,
cache_control: options.cacheControl?.(cached && "cache" in cached ? cached.cache : undefined),
content: content.length > 0 ? content.map((part) => part.text).join("") : toolCalls.length > 0 ? null : "",
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
...(details !== undefined ? { reasoning_details: details } : {}),
...(cacheControl !== undefined ? { cache_control: cacheControl } : {}),
}
if (field === undefined || reasoningText === undefined) return result
return { ...result, [field]: reasoningText }
@@ -716,14 +717,13 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
return [{ ...state, usage }, events] as const
}
const reasoningField = state.reasoningField ?? (!state.lifecycle.text.has("text-0") ? reasoning?.field : undefined)
const reasoningField = state.reasoningField ?? reasoning?.field
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
const reasoningDetailsObserved = state.reasoningDetailsObserved || detailDelta !== undefined
const deltaMetadata = reasoningMetadata(reasoningField)
const text = detailDelta?.length ? (detailText(detailDelta) ?? reasoning?.text) : reasoning?.text
if (!state.lifecycle.text.has("text-0") && text !== undefined)
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata)
if (text !== undefined) lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata)
else if (
reasoningDetailsObserved &&
!lifecycle.reasoning.has("reasoning-0") &&
@@ -812,14 +812,18 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
const events: LLMEvent[] = []
const hasToolCalls = state.toolCallEvents.length > 0
const toolCallEvents =
state.finishReason === undefined && Object.keys(state.tools).length > 0
? Effect.runSync(ToolStream.finishAll(ADAPTER, state.tools)).events
: state.toolCallEvents
const hasToolCalls = toolCallEvents.length > 0
const reason = state.finishReason
? {
...state.finishReason,
normalized:
state.finishReason.normalized === "stop" && hasToolCalls ? "tool-calls" : state.finishReason.normalized,
}
: undefined
: { normalized: hasToolCalls ? ("tool-calls" as const) : ("unknown" as const) }
const metadata = reasoningMetadata(
state.reasoningField,
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
@@ -829,9 +833,9 @@ const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
? Lifecycle.reasoningStart(state.lifecycle, events, "reasoning-0", reasoningMetadata(state.reasoningField))
: state.lifecycle
const ended = Lifecycle.reasoningEnd(started, events, "reasoning-0", metadata)
const lifecycle = state.toolCallEvents.length ? Lifecycle.stepStart(ended, events) : ended
events.push(...state.toolCallEvents)
if (reason) Lifecycle.finish(lifecycle, events, { reason, usage: state.usage })
const lifecycle = toolCallEvents.length ? Lifecycle.stepStart(ended, events) : ended
events.push(...toolCallEvents)
Lifecycle.finish(lifecycle, events, { reason, usage: state.usage })
return events
}
+48 -26
View File
@@ -102,6 +102,24 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("concatenates assistant text parts without adding separators", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
{ type: "text", text: "Hello" },
{ type: "text", text: " world" },
]),
],
}),
)
expect(prepared.body.messages).toEqual([{ role: "assistant", content: "Hello world" }])
}),
)
it.effect("writes reasoning to a configured custom field on every assistant message", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -578,7 +596,7 @@ describe("OpenAI Chat route", () => {
}),
)
expect(prepared.body.messages).toEqual([{ role: "assistant", content: null, reasoning_content: "hidden" }])
expect(prepared.body.messages).toEqual([{ role: "assistant", content: "", reasoning_content: "hidden" }])
}),
)
@@ -827,7 +845,7 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("ignores scalar reasoning after content starts", () =>
it.effect("preserves scalar reasoning after content starts", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.text", text: "detail", format: "unknown", index: 0 }]
const response = yield* LLMClient.generate(request).pipe(
@@ -843,11 +861,11 @@ describe("OpenAI Chat route", () => {
),
)
expect(response.reasoning).toBe("detail")
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
expect(response.reasoning).toBe("detailscalar")
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(2)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(2)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningDetails: details },
openai: { reasoningField: "reasoning", reasoningDetails: details },
})
}),
)
@@ -947,7 +965,7 @@ describe("OpenAI Chat route", () => {
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([{ role: "assistant", content: null, reasoning_details: details }])
expect(replay.body.messages).toEqual([{ role: "assistant", content: "", reasoning_details: details }])
}),
)
@@ -997,7 +1015,7 @@ describe("OpenAI Chat route", () => {
)
expect(replay.body.messages).toEqual([
{ role: "assistant", content: null, reasoning: "firstsecond", reasoning_details: [first, second] },
{ role: "assistant", content: "", reasoning: "firstsecond", reasoning_details: [first, second] },
])
}),
)
@@ -1022,7 +1040,7 @@ describe("OpenAI Chat route", () => {
)
expect(replay.body.messages).toEqual([
{ role: "assistant", content: null, reasoning_content: "AB", reasoning_details: [detail] },
{ role: "assistant", content: "", reasoning_content: "AB", reasoning_details: [detail] },
])
}),
)
@@ -1044,7 +1062,7 @@ describe("OpenAI Chat route", () => {
)
expect(replay.body.messages).toEqual([
{ role: "assistant", content: null, reasoning_content: "thinking", reasoning_details: details },
{ role: "assistant", content: "", reasoning_content: "thinking", reasoning_details: details },
])
}),
)
@@ -1152,7 +1170,7 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("fails a streamed tool call when the provider ends without a finish reason", () =>
it.effect("finalizes a streamed tool call when the provider ends without a finish reason", () =>
Effect.gen(function* () {
const body = sseEvents(
deltaChunk({
@@ -1164,27 +1182,31 @@ describe("OpenAI Chat route", () => {
const input = LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
})
const events: LLMEvent[] = []
const streamError = yield* LLMClient.stream(input).pipe(
Stream.runForEach((event) => Effect.sync(() => events.push(event))),
Effect.flip,
Effect.provide(fixedResponse(body)),
)
const error = yield* LLMClient.generate(input).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
const response = yield* LLMClient.generate(input).pipe(Effect.provide(fixedResponse(body)))
expect(events).toEqual([
expect(response.events).toEqual([
{ type: "step-start", index: 0 },
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
{
type: "tool-call",
id: "call_1",
name: "lookup",
input: { query: "weather" },
providerExecuted: undefined,
providerMetadata: undefined,
},
{
type: "step-finish",
index: 0,
reason: { normalized: "tool-calls" },
usage: undefined,
providerMetadata: undefined,
},
{ type: "finish", reason: { normalized: "tool-calls" }, usage: undefined },
])
expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
expect(streamError.reason).toMatchObject({
_tag: "InvalidProviderOutput",
classification: "incomplete-stream",
})
expect(streamError.message).toContain("The provider response ended unexpectedly.")
expect(error.message).toContain("The provider response ended unexpectedly.")
}),
)