Compare commits

...

2 Commits

Author SHA1 Message Date
Kit Langton a7357352e4 refactor(session): filter empty turns at runtime boundaries 2026-06-20 21:35:42 -04:00
Kit Langton 56faca8e58 fix(session): omit empty assistant turns 2026-06-20 20:39:52 -04:00
16 changed files with 282 additions and 91 deletions
+3 -8
View File
@@ -305,14 +305,7 @@ export const layer = Layer.effect(
const llmFailure = failure instanceof LLMError ? failure : undefined
if (llmFailure && !publisher.hasProviderError()) {
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
yield* withPublication(
events.publish(SessionEvent.Step.Failed, {
sessionID: session.id,
timestamp: yield* DateTime.now,
assistantMessageID: yield* publisher.startAssistant(),
error: { type: "unknown", message: llmFailure.reason.message },
}),
)
yield* withPublication(publisher.failAssistant(llmFailure.reason.message))
}
if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers)
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
@@ -327,6 +320,8 @@ export const layer = Layer.effect(
) {
yield* FiberSet.clear(toolFibers)
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
if (publisher.hasAssistantStarted())
yield* withPublication(publisher.failAssistant("Provider turn interrupted"))
}
if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) {
const failure = Cause.squash(settled.cause)
@@ -66,6 +66,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
const timestamp = DateTime.now
let assistantMessageID: SessionMessage.ID | undefined
let providerFailed = false
let assistantSettled = false
const startAssistant = Effect.fnUntraced(function* () {
if (assistantMessageID !== undefined) return assistantMessageID
@@ -156,6 +157,18 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* toolInput.flush()
})
const failAssistant = Effect.fnUntraced(function* (error: string) {
if (assistantSettled) return
yield* flushFragments()
assistantSettled = true
yield* events.publish(SessionEvent.Step.Failed, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID: yield* startAssistant(),
error: { type: "unknown", message: error },
})
})
const startToolInput = Effect.fnUntraced(function* (event: { readonly id: string; readonly name: string }) {
if (tools.has(event.id)) return yield* Effect.die(`Duplicate tool input start: ${event.id}`)
const assistantMessageID = yield* startAssistant()
@@ -375,6 +388,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
}
case "step-finish":
yield* flush()
assistantSettled = true
yield* events.publish(SessionEvent.Step.Ended, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
@@ -388,13 +402,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
return
case "provider-error":
providerFailed = true
yield* flush()
yield* events.publish(SessionEvent.Step.Failed, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID: yield* startAssistant(),
error: { type: "unknown", message: event.message },
})
yield* failAssistant(event.message)
return
}
})
@@ -402,6 +410,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
return {
publish,
flush,
failAssistant,
failUnsettledTools,
hasAssistantStarted: () => assistantMessageID !== undefined,
hasProviderError: () => providerFailed,
@@ -75,7 +75,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => {
if (item.type === "reasoning")
return sameModel
? [{ type: "reasoning", text: item.text, providerMetadata: item.providerMetadata }]
: item.text.length > 0
: item.text.trim().length > 0
? [{ type: "text", text: item.text }]
: []
const call = toolCall(item, sameModel ? item.provider?.metadata : undefined)
@@ -14,6 +14,36 @@ const id = (value: string) => SessionMessage.ID.make(`msg_${value}`)
const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route })
describe("toLLMMessages", () => {
test("preserves projected assistant turns for request compilation", () => {
const assistant = (value: string, content: SessionMessage.Assistant["content"]) =>
new SessionMessage.Assistant({
id: id(value),
type: "assistant",
agent: "build",
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
content,
time: { created, completed: created },
})
const messages = toLLMMessages(
[
assistant("empty", []),
assistant("empty-text", [new SessionMessage.AssistantText({ type: "text", id: "text-empty", text: "" })]),
assistant("text", [new SessionMessage.AssistantText({ type: "text", id: "text", text: "Partial" })]),
assistant("reasoning", [
new SessionMessage.AssistantReasoning({
type: "reasoning",
id: "reasoning",
text: "",
providerMetadata: { anthropic: { signature: "sig_1" } },
}),
]),
],
model,
)
expect(messages.map((message) => message.id)).toEqual([id("empty"), id("empty-text"), id("text"), id("reasoning")])
})
test("maps every top-level V2 Session message type", () => {
const file = new FileAttachment({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" })
const messages = toLLMMessages(
@@ -547,6 +547,8 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
{ type: "user", text: prompt },
{
type: "assistant",
finish: "error",
error: { type: "unknown", message: "Provider turn interrupted" },
content: [
kind === "tool input"
? { type: "tool", id: fragmentID(kind, "interrupted"), state: { status: "error" } }
+37 -1
View File
@@ -16,6 +16,7 @@ import {
HttpOptions,
LLMRequest,
LLMResponse,
Message,
Model,
ModelLimits,
LLMError as LLMErrorClass,
@@ -172,6 +173,41 @@ const resolveRequestOptions = (request: LLMRequest) =>
http: mergeHttpOptions(request.model.route.defaults.http, request.http),
})
const meaningfulAssistantPart = (part: Message["content"][number]) => {
if (part.type === "text") return part.text !== ""
if (part.type !== "reasoning") return true
return (
part.text.trim().length > 0 ||
part.encrypted !== undefined ||
(part.providerMetadata !== undefined && Object.keys(part.providerMetadata).length > 0)
)
}
const omitEmptyAssistantMessages = (request: LLMRequest) => {
let changed = false
const messages = request.messages.flatMap((message) => {
if (message.role !== "assistant") return [message]
const content = message.content.filter(meaningfulAssistantPart)
if (content.length === 0 && (!message.native || Object.keys(message.native).length === 0)) {
changed = true
return []
}
if (content.length === message.content.length) return [message]
changed = true
return [
Message.make({
id: message.id,
role: message.role,
content,
metadata: message.metadata,
native: message.native,
}),
]
})
if (!changed) return request
return LLMRequest.update(request, { messages })
}
export interface MakeInput<Body, Frame, Event, State> {
/** Route id used in diagnostics and prepared request metadata. */
readonly id: string
@@ -335,7 +371,7 @@ export function make<Body, Prepared, Frame, Event, State>(
// validated provider body plus transport-private prepared data, but does not
// execute transport.
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
const resolved = applyCachePolicy(resolveRequestOptions(request))
const resolved = applyCachePolicy(omitEmptyAssistantMessages(resolveRequestOptions(request)))
const route = resolved.model.route
const body = yield* route.body
@@ -57,6 +57,30 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("removes empty assistant parts while preserving tool calls", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({
model,
messages: [
Message.assistant([
{ type: "text", text: "" },
ToolCallPart.make({ id: "call_1", name: "read", input: { path: "README.md" } }),
]),
],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: [{ type: "tool_use", id: "call_1", name: "read", input: { path: "README.md" } }],
},
])
}),
)
it.effect("lowers chronological system updates natively for Claude Opus 4.8 with cache hints", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
@@ -92,6 +92,48 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("omits empty assistant turns before protocol serialization", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
messages: [Message.user("Before"), Message.assistant(""), Message.user("After")],
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: "Before" },
{ role: "user", content: "After" },
])
}),
)
it.effect("preserves opaque OpenAI-compatible reasoning continuation", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
messages: [
Message.make({
role: "assistant",
content: [],
native: { openaiCompatible: { reasoning_content: "opaque reasoning" } },
}),
],
}),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: null,
reasoning_content: "opaque reasoning",
tool_calls: undefined,
},
])
}),
)
it.effect("maps OpenAI provider options to Chat options", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
+18 -55
View File
@@ -129,62 +129,25 @@ function normalizeMessages(
}
})
// Anthropic rejects messages with empty content - filter out empty string messages
// and remove empty text/reasoning parts from array content
if (model.api.npm === "@ai-sdk/anthropic") {
msgs = msgs
.map((msg) => {
if (typeof msg.content === "string") {
if (msg.content === "") return undefined
return msg
}
if (!Array.isArray(msg.content)) return msg
const filtered = msg.content.filter((part) => {
if (part.type === "text") {
return part.text !== ""
}
if (part.type === "reasoning") {
return (
part.text.trim().length > 0 ||
part.providerOptions?.anthropic?.signature != null ||
part.providerOptions?.anthropic?.redactedData != null
)
}
return true
})
if (filtered.length === 0) return undefined
return { ...msg, content: filtered }
// Empty assistant turns carry no history and are rejected by several provider APIs.
msgs = msgs
.map((msg) => {
if (msg.role !== "assistant") return msg
if (typeof msg.content === "string") return msg.content !== "" ? msg : undefined
if (!Array.isArray(msg.content)) return msg
const content = msg.content.filter((part) => {
if (part.type === "text") return part.text !== ""
if (part.type !== "reasoning") return true
return (
part.text.trim().length > 0 ||
(part.providerOptions !== undefined && Object.keys(part.providerOptions).length > 0)
)
})
.filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "")
}
// Bedrock specific transforms
if (model.api.npm === "@ai-sdk/amazon-bedrock") {
msgs = msgs
.map((msg) => {
if (typeof msg.content === "string") {
if (msg.content === "") return undefined
return msg
}
if (!Array.isArray(msg.content)) return msg
const filtered = msg.content.filter((part) => {
if (part.type === "text") {
return part.text !== ""
}
if (part.type === "reasoning") {
return (
part.text.trim().length > 0 ||
part.providerOptions?.bedrock?.signature != null ||
part.providerOptions?.bedrock?.redactedData != null
)
}
return true
})
if (filtered.length === 0) return undefined
return { ...msg, content: filtered }
})
.filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "")
}
if (content.length === 0) return undefined
if (content.length === msg.content.length) return msg
return { ...msg, content }
})
.filter((msg): msg is ModelMessage => msg !== undefined)
if (model.api.id.includes("claude")) {
const scrub = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_")
+5 -13
View File
@@ -255,16 +255,12 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
if (msg.info.role === "assistant") {
const differentModel = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}`
const media: Array<{ mime: string; url: string; filename?: string }> = []
const hasSignedReasoning = msg.parts.some((part) => {
if (part.type !== "reasoning") return false
return part.metadata?.anthropic?.signature != null
})
if (
msg.info.error &&
!(
AbortedError.isInstance(msg.info.error) &&
msg.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning")
)
) {
continue
}
if (msg.info.error && !AbortedError.isInstance(msg.info.error)) continue
const assistantMessage: UIMessage = {
id: msg.info.id,
role: "assistant",
@@ -281,10 +277,6 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
// here is the only safe replay point we have.
// Use a single space so the separator survives replay without changing
// the neighboring signed reasoning blocks.
const hasSignedReasoning = msg.parts.some((part) => {
if (part.type !== "reasoning") return false
return part.metadata?.anthropic?.signature != null
})
for (const part of msg.parts) {
if (part.type === "text") {
const text = part.text === "" && hasSignedReasoning ? " " : part.text
@@ -950,6 +950,7 @@ export const layer = Layer.effect(
}
}
ctx.assistantMessage.error = error
ctx.assistantMessage.finish ??= "error"
yield* events.publish(Session.Event.Error, {
sessionID: ctx.assistantMessage.sessionID,
error: ctx.assistantMessage.error,
+1
View File
@@ -1259,6 +1259,7 @@ export const layer = Layer.effect(
providerID: msg.providerID,
aborted: true,
})
msg.finish = "error"
msg.time.completed = Date.now()
yield* sessions.updateMessage(msg)
})
@@ -2018,7 +2018,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
expect(result[1].content[0]).toEqual({ type: "text", text: "Answer" })
})
test("does not filter for non-anthropic providers", () => {
test("filters empty assistant content for non-anthropic providers", () => {
const openaiModel = {
...anthropicModel,
providerID: "openai",
@@ -2039,9 +2039,32 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
const result = ProviderTransform.message(msgs, openaiModel, {})
expect(result).toHaveLength(2)
expect(result[0].content).toBe("")
expect(result[1].content).toHaveLength(1)
expect(result).toStrictEqual([])
})
test("preserves metadata-only reasoning for non-anthropic providers", () => {
const openaiModel = {
...anthropicModel,
providerID: "openai",
api: { id: "gpt-5", url: "https://api.openai.com", npm: "@ai-sdk/openai" },
}
const reasoning = {
type: "reasoning",
text: "",
providerOptions: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted" } },
} as const
expect(
ProviderTransform.message([{ role: "assistant", content: [reasoning] }], openaiModel, {})[0]?.content,
).toContainEqual(
expect.objectContaining({
type: "reasoning",
text: "",
providerOptions: expect.objectContaining({
openai: expect.objectContaining({ reasoningEncryptedContent: "encrypted" }),
}),
}),
)
})
test("leaves valid anthropic assistant tool ordering unchanged", () => {
@@ -988,7 +988,7 @@ describe("session.message-v2.toModelMessage", () => {
expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([])
})
test("includes aborted assistant messages only when they have non-step-start/reasoning content", async () => {
test("preserves aborted assistant partial output", async () => {
const assistantID1 = "m-assistant-1"
const assistantID2 = "m-assistant-2"
@@ -1038,6 +1038,70 @@ describe("session.message-v2.toModelMessage", () => {
{ type: "text", text: "partial answer" },
],
},
{
role: "assistant",
content: [{ type: "reasoning", text: "thinking", providerOptions: undefined }],
},
])
})
test("preserves empty aborted assistant turns until the provider boundary", async () => {
const assistantID = "m-assistant"
const input: SessionV1.WithParts[] = [
{
info: assistantInfo(
assistantID,
"m-parent",
new SessionV1.AbortedError({ message: "aborted" }).toObject() as SessionV1.Assistant["error"],
),
parts: [
{
...basePart(assistantID, "a1"),
type: "text",
text: "",
},
] as SessionV1.Part[],
},
]
const messages = await MessageV2.toModelMessages(input, model)
expect(messages).toStrictEqual([{ role: "assistant", content: [{ type: "text", text: "" }] }])
expect(ProviderTransform.message(messages, model, {})).toStrictEqual([])
})
test("preserves aborted assistant messages with signed reasoning", async () => {
const assistantID = "m-assistant"
const input: SessionV1.WithParts[] = [
{
info: assistantInfo(
assistantID,
"m-parent",
new SessionV1.AbortedError({ message: "aborted" }).toObject() as SessionV1.Assistant["error"],
),
parts: [
{
...basePart(assistantID, "a1"),
type: "reasoning",
text: "",
time: { start: 0 },
metadata: { anthropic: { signature: "sig_1" } },
},
] as SessionV1.Part[],
},
]
expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([
{
role: "assistant",
content: [
{
type: "reasoning",
text: "",
providerOptions: { anthropic: { signature: "sig_1" } },
},
],
},
])
})
@@ -1292,6 +1356,7 @@ describe("session.message-v2.toModelMessage", () => {
expect(result).toHaveLength(2)
expect((result[0].content as any[]).find((p) => p.type === "text").text).toBe(" ")
expect((result[1].content as any[]).find((p) => p.type === "text").text).toBe("the answer")
expect(ProviderTransform.message(result, model, {})).toEqual(result)
})
test("leaves empty text alone when reasoning signature is under 'bedrock' namespace", async () => {
@@ -899,9 +899,13 @@ it.live("session.processor effect tests record aborted errors and idle state", (
expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
}
expect(handle.message.error?.name).toBe("MessageAbortedError")
expect(handle.message.finish).toBe("end_turn")
expect(handle.message.time.completed).toBeNumber()
expect(stored.info.role).toBe("assistant")
if (stored.info.role === "assistant") {
expect(stored.info.error?.name).toBe("MessageAbortedError")
expect(stored.info.finish).toBe("end_turn")
expect(stored.info.time.completed).toBeNumber()
}
expect(state).toMatchObject({ type: "idle" })
expect(errs).toContain("MessageAbortedError")
@@ -957,9 +961,13 @@ it.live("session.processor effect tests mark interruptions aborted without manua
expect(Exit.isFailure(exit)).toBe(true)
expect(handle.message.error?.name).toBe("MessageAbortedError")
expect(handle.message.finish).toBe("end_turn")
expect(handle.message.time.completed).toBeNumber()
expect(stored.info.role).toBe("assistant")
if (stored.info.role === "assistant") {
expect(stored.info.error?.name).toBe("MessageAbortedError")
expect(stored.info.finish).toBe("end_turn")
expect(stored.info.time.completed).toBeNumber()
}
expect(state).toMatchObject({ type: "idle" })
}),
@@ -1082,7 +1082,7 @@ raceNoLLMServer.instance(
expect(firstInterrupted?.info.role).toBe("assistant")
expect(firstInterrupted?.parts).toHaveLength(0)
if (firstInterrupted?.info.role === "assistant") {
expect(firstInterrupted.info.finish).toBeUndefined()
expect(firstInterrupted.info.finish).toBe("error")
expect(firstInterrupted.info.time.completed).toBeNumber()
expect(firstInterrupted.info.error?.name).toBe("MessageAbortedError")
}