mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 17:19:49 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce1203ce83 | |||
| fc8bbb153f |
@@ -62,6 +62,9 @@ const OpenAIResponsesOutputText = Schema.Struct({
|
|||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const OpenAIResponsesMessagePhase = Schema.Literals(["commentary", "final_answer"])
|
||||||
|
type OpenAIResponsesMessagePhase = Schema.Schema.Type<typeof OpenAIResponsesMessagePhase>
|
||||||
|
|
||||||
const OpenAIResponsesReasoningSummaryText = Schema.Struct({
|
const OpenAIResponsesReasoningSummaryText = Schema.Struct({
|
||||||
type: Schema.tag("summary_text"),
|
type: Schema.tag("summary_text"),
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
@@ -96,7 +99,11 @@ const OpenAIResponsesFunctionCallOutput = Schema.Union([
|
|||||||
const OpenAIResponsesInputItem = Schema.Union([
|
const OpenAIResponsesInputItem = Schema.Union([
|
||||||
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
|
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
|
||||||
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenAIResponsesInputContent) }),
|
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenAIResponsesInputContent) }),
|
||||||
Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenAIResponsesOutputText) }),
|
Schema.Struct({
|
||||||
|
role: Schema.tag("assistant"),
|
||||||
|
content: Schema.Array(OpenAIResponsesOutputText),
|
||||||
|
phase: optionalNull(OpenAIResponsesMessagePhase),
|
||||||
|
}),
|
||||||
OpenAIResponsesReasoningItem,
|
OpenAIResponsesReasoningItem,
|
||||||
OpenAIResponsesItemReference,
|
OpenAIResponsesItemReference,
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
@@ -216,6 +223,7 @@ const OpenAIResponsesStreamItem = Schema.Struct({
|
|||||||
// call's typed input portion and round-trip the full result payload without
|
// call's typed input portion and round-trip the full result payload without
|
||||||
// hand-rolling a per-tool schema.
|
// hand-rolling a per-tool schema.
|
||||||
status: Schema.optional(Schema.String),
|
status: Schema.optional(Schema.String),
|
||||||
|
phase: optionalNull(OpenAIResponsesMessagePhase),
|
||||||
action: Schema.optional(Schema.Unknown),
|
action: Schema.optional(Schema.Unknown),
|
||||||
queries: Schema.optional(Schema.Unknown),
|
queries: Schema.optional(Schema.Unknown),
|
||||||
results: Schema.optional(Schema.Unknown),
|
results: Schema.optional(Schema.Unknown),
|
||||||
@@ -271,6 +279,7 @@ interface ParserState {
|
|||||||
readonly tools: ToolStream.State<string>
|
readonly tools: ToolStream.State<string>
|
||||||
readonly hasFunctionCall: boolean
|
readonly hasFunctionCall: boolean
|
||||||
readonly lifecycle: Lifecycle.State
|
readonly lifecycle: Lifecycle.State
|
||||||
|
readonly textItems: Readonly<Record<string, ProviderMetadata>>
|
||||||
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
||||||
readonly store: boolean | undefined
|
readonly store: boolean | undefined
|
||||||
}
|
}
|
||||||
@@ -395,10 +404,7 @@ const lowerToolResultContentItem = Effect.fn("OpenAIResponses.lowerToolResultCon
|
|||||||
provider: string,
|
provider: string,
|
||||||
) {
|
) {
|
||||||
if (item.type === "text") return { type: "input_text" as const, text: item.text }
|
if (item.type === "text") return { type: "input_text" as const, text: item.text }
|
||||||
return yield* lowerMedia(
|
return yield* lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name }, provider)
|
||||||
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
|
|
||||||
provider,
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const lowerToolResultOutput = Effect.fn("OpenAIResponses.lowerToolResultOutput")(function* (
|
const lowerToolResultOutput = Effect.fn("OpenAIResponses.lowerToolResultOutput")(function* (
|
||||||
@@ -442,16 +448,29 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
|||||||
|
|
||||||
if (message.role === "assistant") {
|
if (message.role === "assistant") {
|
||||||
const content: TextPart[] = []
|
const content: TextPart[] = []
|
||||||
|
let phase: OpenAIResponsesMessagePhase | undefined
|
||||||
const reasoningItems: Record<string, OpenAIResponsesReasoningReplay> = {}
|
const reasoningItems: Record<string, OpenAIResponsesReasoningReplay> = {}
|
||||||
const reasoningReferences = new Set<string>()
|
const reasoningReferences = new Set<string>()
|
||||||
const hostedToolReferences = new Set<string>()
|
const hostedToolReferences = new Set<string>()
|
||||||
const flushText = () => {
|
const flushText = () => {
|
||||||
if (content.length === 0) return
|
if (content.length === 0) return
|
||||||
input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) })
|
input.push({
|
||||||
|
role: "assistant",
|
||||||
|
content: content.map((part) => ({ type: "output_text", text: part.text })),
|
||||||
|
...(phase === undefined ? {} : { phase }),
|
||||||
|
})
|
||||||
content.splice(0, content.length)
|
content.splice(0, content.length)
|
||||||
|
phase = undefined
|
||||||
}
|
}
|
||||||
for (const part of message.content) {
|
for (const part of message.content) {
|
||||||
if (part.type === "text") {
|
if (part.type === "text") {
|
||||||
|
const openai = part.providerMetadata?.openai
|
||||||
|
const nextPhase =
|
||||||
|
ProviderShared.isRecord(openai) && (openai.phase === "commentary" || openai.phase === "final_answer")
|
||||||
|
? openai.phase
|
||||||
|
: undefined
|
||||||
|
if (content.length > 0 && phase !== nextPhase) flushText()
|
||||||
|
phase = nextPhase
|
||||||
content.push(part)
|
content.push(part)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -709,15 +728,28 @@ const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "re
|
|||||||
const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||||
if (!event.delta) return [state, NO_EVENTS]
|
if (!event.delta) return [state, NO_EVENTS]
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
|
const itemID = event.item_id ?? "text-0"
|
||||||
return [
|
return [
|
||||||
{ ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, event.item_id ?? "text-0", event.delta) },
|
{
|
||||||
|
...state,
|
||||||
|
lifecycle: Lifecycle.textDelta(state.lifecycle, events, itemID, event.delta, state.textItems[itemID]),
|
||||||
|
},
|
||||||
events,
|
events,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
const onOutputTextDone = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
const onOutputTextDone = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, event.item_id ?? "text-0") }, events]
|
const itemID = event.item_id ?? "text-0"
|
||||||
|
const { [itemID]: _completed, ...textItems } = state.textItems
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...state,
|
||||||
|
lifecycle: Lifecycle.textEnd(state.lifecycle, events, itemID, state.textItems[itemID]),
|
||||||
|
textItems,
|
||||||
|
},
|
||||||
|
events,
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
const onReasoningDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
const onReasoningDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||||
@@ -754,6 +786,21 @@ const reasoningMetadata = (item: OpenAIResponsesStreamItem & { id: string }) =>
|
|||||||
// best-effort, not guaranteed.
|
// best-effort, not guaranteed.
|
||||||
const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||||
const item = event.item
|
const item = event.item
|
||||||
|
if (item?.type === "message" && item.id) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...state,
|
||||||
|
textItems: {
|
||||||
|
...state.textItems,
|
||||||
|
[item.id]: openaiMetadata({
|
||||||
|
itemId: item.id,
|
||||||
|
...(item.phase === undefined || item.phase === null ? {} : { phase: item.phase }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
NO_EVENTS,
|
||||||
|
]
|
||||||
|
}
|
||||||
if (item && isReasoningItem(item)) {
|
if (item && isReasoningItem(item)) {
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
return [
|
return [
|
||||||
@@ -1063,6 +1110,7 @@ export const protocol = Protocol.make({
|
|||||||
hasFunctionCall: false,
|
hasFunctionCall: false,
|
||||||
tools: ToolStream.empty<string>(),
|
tools: ToolStream.empty<string>(),
|
||||||
lifecycle: Lifecycle.initial(),
|
lifecycle: Lifecycle.initial(),
|
||||||
|
textItems: {},
|
||||||
reasoningItems: {},
|
reasoningItems: {},
|
||||||
store: OpenAIOptions.store(request),
|
store: OpenAIOptions.store(request),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -14,16 +14,25 @@ export const stepStart = (state: State, events: LLMEvent[]): State => {
|
|||||||
return { ...state, stepStarted: true }
|
return { ...state, stepStarted: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
|
export const textStart = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
|
||||||
|
if (state.text.has(id)) return state
|
||||||
const stepped = stepStart(state, events)
|
const stepped = stepStart(state, events)
|
||||||
if (stepped.text.has(id)) {
|
events.push(LLMEvent.textStart({ id, providerMetadata }))
|
||||||
events.push(LLMEvent.textDelta({ id, text }))
|
|
||||||
return stepped
|
|
||||||
}
|
|
||||||
events.push(LLMEvent.textStart({ id }), LLMEvent.textDelta({ id, text }))
|
|
||||||
return { ...stepped, text: new Set([...stepped.text, id]) }
|
return { ...stepped, text: new Set([...stepped.text, id]) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const textDelta = (
|
||||||
|
state: State,
|
||||||
|
events: LLMEvent[],
|
||||||
|
id: string,
|
||||||
|
text: string,
|
||||||
|
providerMetadata?: ProviderMetadata,
|
||||||
|
): State => {
|
||||||
|
const started = textStart(state, events, id, providerMetadata)
|
||||||
|
events.push(LLMEvent.textDelta({ id, text, providerMetadata }))
|
||||||
|
return started
|
||||||
|
}
|
||||||
|
|
||||||
export const reasoningStart = (
|
export const reasoningStart = (
|
||||||
state: State,
|
state: State,
|
||||||
events: LLMEvent[],
|
events: LLMEvent[],
|
||||||
|
|||||||
@@ -899,6 +899,66 @@ describe("OpenAI Responses route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("preserves output message phases in follow-up requests", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
item: { type: "message", id: "msg_commentary", phase: "commentary" },
|
||||||
|
},
|
||||||
|
{ type: "response.output_text.delta", item_id: "msg_commentary", delta: "Checking." },
|
||||||
|
{ type: "response.output_text.done", item_id: "msg_commentary" },
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
item: { type: "message", id: "msg_final", phase: "final_answer" },
|
||||||
|
},
|
||||||
|
{ type: "response.output_text.delta", item_id: "msg_final", delta: "Done." },
|
||||||
|
{ type: "response.output_text.done", item_id: "msg_final" },
|
||||||
|
{ type: "response.completed", response: { id: "resp_1" } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.message.content).toEqual([
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Checking.",
|
||||||
|
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Done.",
|
||||||
|
providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const followUp = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
messages: [Message.user("Start"), response.message, Message.user("Continue")],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(followUp.body.input).toEqual([
|
||||||
|
{ role: "user", content: [{ type: "input_text", text: "Start" }] },
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
phase: "commentary",
|
||||||
|
content: [{ type: "output_text", text: "Checking." }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
phase: "final_answer",
|
||||||
|
content: [{ type: "output_text", text: "Done." }],
|
||||||
|
},
|
||||||
|
{ role: "user", content: [{ type: "input_text", text: "Continue" }] },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("parses reasoning summary stream fixtures", () =>
|
it.effect("parses reasoning summary stream fixtures", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const body = sseEvents(
|
const body = sseEvents(
|
||||||
|
|||||||
@@ -98,8 +98,6 @@ export type SessionMessageShell = {
|
|||||||
output?: { output: string; cursor: number; size: number; truncated: boolean }
|
output?: { output: string; cursor: number; size: number; truncated: boolean }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionMessageAssistantText = { type: "text"; text: string }
|
|
||||||
|
|
||||||
export type SessionMessageProviderState = { [x: string]: JsonValue }
|
export type SessionMessageProviderState = { [x: string]: JsonValue }
|
||||||
|
|
||||||
export type SessionMessageToolStateStreaming = { status: "streaming"; input: string }
|
export type SessionMessageToolStateStreaming = { status: "streaming"; input: string }
|
||||||
@@ -151,8 +149,6 @@ export type ShellInfo = {
|
|||||||
time: { started: number; completed?: number }
|
time: { started: number; completed?: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionMessageProviderState3 = { [x: string]: any }
|
|
||||||
|
|
||||||
export type SessionMessageProviderState4 = { [x: string]: any }
|
export type SessionMessageProviderState4 = { [x: string]: any }
|
||||||
|
|
||||||
export type SessionMessageProviderState5 = { [x: string]: any }
|
export type SessionMessageProviderState5 = { [x: string]: any }
|
||||||
@@ -161,6 +157,12 @@ export type SessionMessageProviderState6 = { [x: string]: any }
|
|||||||
|
|
||||||
export type SessionMessageProviderState7 = { [x: string]: any }
|
export type SessionMessageProviderState7 = { [x: string]: any }
|
||||||
|
|
||||||
|
export type SessionMessageProviderState8 = { [x: string]: any }
|
||||||
|
|
||||||
|
export type SessionMessageProviderState9 = { [x: string]: any }
|
||||||
|
|
||||||
|
export type SessionMessageProviderState10 = { [x: string]: any }
|
||||||
|
|
||||||
export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: number }
|
export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: number }
|
||||||
|
|
||||||
export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning_text" | (string & {})
|
export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning_text" | (string & {})
|
||||||
@@ -717,26 +719,6 @@ export type SessionStepEnded = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionTextStarted = {
|
|
||||||
id: string
|
|
||||||
created: number
|
|
||||||
metadata?: { [x: string]: any }
|
|
||||||
type: "session.text.started"
|
|
||||||
durable: { aggregateID: string; seq: number; version: 1 }
|
|
||||||
location?: LocationRef
|
|
||||||
data: { sessionID: string; assistantMessageID: string; ordinal: number }
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SessionTextEnded = {
|
|
||||||
id: string
|
|
||||||
created: number
|
|
||||||
metadata?: { [x: string]: any }
|
|
||||||
type: "session.text.ended"
|
|
||||||
durable: { aggregateID: string; seq: number; version: 1 }
|
|
||||||
location?: LocationRef
|
|
||||||
data: { sessionID: string; assistantMessageID: string; ordinal: number; text: string }
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SessionToolInputStarted = {
|
export type SessionToolInputStarted = {
|
||||||
id: string
|
id: string
|
||||||
created: number
|
created: number
|
||||||
@@ -1234,6 +1216,8 @@ export type SessionPendingSynthetic = {
|
|||||||
delivery: "steer" | "queue"
|
delivery: "steer" | "queue"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SessionMessageAssistantText = { type: "text"; text: string; state?: SessionMessageProviderState }
|
||||||
|
|
||||||
export type SessionMessageAssistantReasoning = {
|
export type SessionMessageAssistantReasoning = {
|
||||||
type: "reasoning"
|
type: "reasoning"
|
||||||
text: string
|
text: string
|
||||||
@@ -1344,6 +1328,32 @@ export type ShellCreated = {
|
|||||||
data: { info: ShellInfo }
|
data: { info: ShellInfo }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SessionTextStarted = {
|
||||||
|
id: string
|
||||||
|
created: number
|
||||||
|
metadata?: { [x: string]: any }
|
||||||
|
type: "session.text.started"
|
||||||
|
durable: { aggregateID: string; seq: number; version: 1 }
|
||||||
|
location?: LocationRef
|
||||||
|
data: { sessionID: string; assistantMessageID: string; ordinal: number; state?: SessionMessageProviderState4 }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionTextEnded = {
|
||||||
|
id: string
|
||||||
|
created: number
|
||||||
|
metadata?: { [x: string]: any }
|
||||||
|
type: "session.text.ended"
|
||||||
|
durable: { aggregateID: string; seq: number; version: 1 }
|
||||||
|
location?: LocationRef
|
||||||
|
data: {
|
||||||
|
sessionID: string
|
||||||
|
assistantMessageID: string
|
||||||
|
ordinal: number
|
||||||
|
text: string
|
||||||
|
state?: SessionMessageProviderState5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type SessionReasoningStarted = {
|
export type SessionReasoningStarted = {
|
||||||
id: string
|
id: string
|
||||||
created: number
|
created: number
|
||||||
@@ -1351,7 +1361,7 @@ export type SessionReasoningStarted = {
|
|||||||
type: "session.reasoning.started"
|
type: "session.reasoning.started"
|
||||||
durable: { aggregateID: string; seq: number; version: 1 }
|
durable: { aggregateID: string; seq: number; version: 1 }
|
||||||
location?: LocationRef
|
location?: LocationRef
|
||||||
data: { sessionID: string; assistantMessageID: string; ordinal: number; state?: SessionMessageProviderState3 }
|
data: { sessionID: string; assistantMessageID: string; ordinal: number; state?: SessionMessageProviderState6 }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionReasoningEnded = {
|
export type SessionReasoningEnded = {
|
||||||
@@ -1366,7 +1376,7 @@ export type SessionReasoningEnded = {
|
|||||||
assistantMessageID: string
|
assistantMessageID: string
|
||||||
ordinal: number
|
ordinal: number
|
||||||
text: string
|
text: string
|
||||||
state?: SessionMessageProviderState4
|
state?: SessionMessageProviderState7
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1383,7 +1393,7 @@ export type SessionToolCalled = {
|
|||||||
callID: string
|
callID: string
|
||||||
input: { [x: string]: any }
|
input: { [x: string]: any }
|
||||||
executed: boolean
|
executed: boolean
|
||||||
state?: SessionMessageProviderState5
|
state?: SessionMessageProviderState8
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1848,7 +1858,7 @@ export type SessionToolSuccess = {
|
|||||||
content: Array<LLMToolContent>
|
content: Array<LLMToolContent>
|
||||||
result?: any
|
result?: any
|
||||||
executed: boolean
|
executed: boolean
|
||||||
resultState?: SessionMessageProviderState6
|
resultState?: SessionMessageProviderState9
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1868,7 +1878,7 @@ export type SessionToolFailed = {
|
|||||||
metadata?: { [x: string]: any }
|
metadata?: { [x: string]: any }
|
||||||
result?: any
|
result?: any
|
||||||
executed: boolean
|
executed: boolean
|
||||||
resultState?: SessionMessageProviderState7
|
resultState?: SessionMessageProviderState10
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -307,7 +307,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||||||
},
|
},
|
||||||
"session.text.started": (event) => {
|
"session.text.started": (event) => {
|
||||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||||
draft.content.push(castDraft(SessionMessage.AssistantText.make({ type: "text", text: "" })))
|
draft.content.push(
|
||||||
|
castDraft(SessionMessage.AssistantText.make({ type: "text", text: "", state: event.data.state })),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
"session.text.delta": (event) => {
|
"session.text.delta": (event) => {
|
||||||
@@ -319,7 +321,10 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||||||
"session.text.ended": (event) => {
|
"session.text.ended": (event) => {
|
||||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||||
const match = latestText(draft)
|
const match = latestText(draft)
|
||||||
if (match) match.text = event.data.text
|
if (match) {
|
||||||
|
match.text = event.data.text
|
||||||
|
if (event.data.state !== undefined) match.state = event.data.state
|
||||||
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
"session.tool.input.started": (event) => {
|
"session.tool.input.started": (event) => {
|
||||||
|
|||||||
@@ -142,13 +142,14 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||||||
|
|
||||||
const text = fragments(
|
const text = fragments(
|
||||||
"text",
|
"text",
|
||||||
(_textID, value, ordinal) =>
|
(_textID, value, ordinal, state) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* events.publish(SessionEvent.Text.Ended, {
|
yield* events.publish(SessionEvent.Text.Ended, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
assistantMessageID: yield* currentAssistantMessageID(),
|
assistantMessageID: yield* currentAssistantMessageID(),
|
||||||
ordinal,
|
ordinal,
|
||||||
text: value,
|
text: value,
|
||||||
|
state,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
true,
|
true,
|
||||||
@@ -317,15 +318,16 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||||||
return
|
return
|
||||||
case "text-start":
|
case "text-start":
|
||||||
retryEvidence = true
|
retryEvidence = true
|
||||||
const startedTextOrdinal = yield* text.start(event.id)
|
const startedTextOrdinal = yield* text.start(event.id, providerState(event.providerMetadata))
|
||||||
yield* events.publish(SessionEvent.Text.Started, {
|
yield* events.publish(SessionEvent.Text.Started, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
assistantMessageID: yield* startAssistant(),
|
assistantMessageID: yield* startAssistant(),
|
||||||
ordinal: startedTextOrdinal,
|
ordinal: startedTextOrdinal,
|
||||||
|
state: providerState(event.providerMetadata),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
case "text-delta":
|
case "text-delta":
|
||||||
const deltaTextOrdinal = yield* text.append(event.id, event.text)
|
const deltaTextOrdinal = yield* text.append(event.id, event.text, providerState(event.providerMetadata))
|
||||||
yield* events.publish(SessionEvent.Text.Delta, {
|
yield* events.publish(SessionEvent.Text.Delta, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
assistantMessageID: yield* currentAssistantMessageID(),
|
assistantMessageID: yield* currentAssistantMessageID(),
|
||||||
@@ -334,7 +336,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
case "text-end":
|
case "text-end":
|
||||||
yield* text.end(event.id)
|
yield* text.end(event.id, providerState(event.providerMetadata))
|
||||||
return
|
return
|
||||||
case "reasoning-start":
|
case "reasoning-start":
|
||||||
retryEvidence = true
|
retryEvidence = true
|
||||||
|
|||||||
@@ -123,7 +123,14 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, provid
|
|||||||
String(message.model.providerID) === String(model.providerID) && String(message.model.id) === String(model.id)
|
String(message.model.providerID) === String(model.providerID) && String(message.model.id) === String(model.id)
|
||||||
const reuseProviderMetadata = sameModel && message.error === undefined
|
const reuseProviderMetadata = sameModel && message.error === undefined
|
||||||
const content = message.content.flatMap((item): ContentPart[] => {
|
const content = message.content.flatMap((item): ContentPart[] => {
|
||||||
if (item.type === "text") return [{ type: "text", text: item.text }]
|
if (item.type === "text")
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: item.text,
|
||||||
|
providerMetadata: reuseProviderMetadata ? providerMetadata(providerMetadataKey, item.state) : undefined,
|
||||||
|
},
|
||||||
|
]
|
||||||
if (item.type === "reasoning")
|
if (item.type === "reasoning")
|
||||||
return reuseProviderMetadata
|
return reuseProviderMetadata
|
||||||
? [
|
? [
|
||||||
|
|||||||
@@ -15,6 +15,36 @@ const model = ModelV2.Ref.make({ id: ModelV2.ID.make("model"), providerID: Provi
|
|||||||
const build = AgentV2.defaultID
|
const build = AgentV2.defaultID
|
||||||
|
|
||||||
describe("toLLMMessages", () => {
|
describe("toLLMMessages", () => {
|
||||||
|
test("restores same-model provider state on assistant text", () => {
|
||||||
|
const messages = toLLMMessages(
|
||||||
|
[
|
||||||
|
SessionMessage.Assistant.make({
|
||||||
|
id: id("assistant-text-state"),
|
||||||
|
type: "assistant",
|
||||||
|
agent: build,
|
||||||
|
model,
|
||||||
|
content: [
|
||||||
|
SessionMessage.AssistantText.make({
|
||||||
|
type: "text",
|
||||||
|
text: "Checking.",
|
||||||
|
state: { itemId: "msg_commentary", phase: "commentary" },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
time: { created, completed: created },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
model,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(messages[0]?.content).toEqual([
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Checking.",
|
||||||
|
providerMetadata: { provider: { itemId: "msg_commentary", phase: "commentary" } },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
test("omits empty assistant turns", () => {
|
test("omits empty assistant turns", () => {
|
||||||
const assistant = (value: string, content: SessionMessage.Assistant["content"]) =>
|
const assistant = (value: string, content: SessionMessage.Assistant["content"]) =>
|
||||||
SessionMessage.Assistant.make({
|
SessionMessage.Assistant.make({
|
||||||
@@ -661,6 +691,11 @@ Recent work
|
|||||||
agent: build,
|
agent: build,
|
||||||
model: { id: ModelV2.ID.make("old-model"), providerID: ProviderV2.ID.make("provider") },
|
model: { id: ModelV2.ID.make("old-model"), providerID: ProviderV2.ID.make("provider") },
|
||||||
content: [
|
content: [
|
||||||
|
SessionMessage.AssistantText.make({
|
||||||
|
type: "text",
|
||||||
|
text: "Checking",
|
||||||
|
state: { itemId: "msg_old", phase: "commentary" },
|
||||||
|
}),
|
||||||
SessionMessage.AssistantReasoning.make({
|
SessionMessage.AssistantReasoning.make({
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
text: "Visible thought",
|
text: "Visible thought",
|
||||||
@@ -705,6 +740,7 @@ Recent work
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(messages[0]?.content).toEqual([
|
expect(messages[0]?.content).toEqual([
|
||||||
|
{ type: "text", text: "Checking", providerMetadata: undefined },
|
||||||
{ type: "text", text: "Visible thought" },
|
{ type: "text", text: "Visible thought" },
|
||||||
{
|
{
|
||||||
type: "tool-call",
|
type: "tool-call",
|
||||||
|
|||||||
@@ -2434,6 +2434,51 @@ describe("SessionRunnerLLM", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("preserves assistant text provider state across durable continuation", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const session = yield* setup
|
||||||
|
yield* admit(session, "Check this")
|
||||||
|
|
||||||
|
const providerMetadata = { openai: { itemId: "msg_commentary", phase: "commentary" } }
|
||||||
|
responses = [
|
||||||
|
[
|
||||||
|
LLMEvent.stepStart({ index: 0 }),
|
||||||
|
LLMEvent.textStart({ id: "msg_commentary", providerMetadata }),
|
||||||
|
LLMEvent.textDelta({ id: "msg_commentary", text: "Checking.", providerMetadata }),
|
||||||
|
LLMEvent.textEnd({ id: "msg_commentary", providerMetadata }),
|
||||||
|
LLMEvent.toolCall({ id: "call-echo", name: "echo", input: { text: "hello" } }),
|
||||||
|
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||||
|
LLMEvent.finish({ reason: "tool-calls" }),
|
||||||
|
],
|
||||||
|
reply.text("Done", "text-final"),
|
||||||
|
]
|
||||||
|
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
|
||||||
|
expect(requests).toHaveLength(2)
|
||||||
|
expect(requests[1]?.messages[1]?.content[0]).toEqual({
|
||||||
|
type: "text",
|
||||||
|
text: "Checking.",
|
||||||
|
providerMetadata,
|
||||||
|
})
|
||||||
|
expect(yield* session.context(sessionID)).toMatchObject([
|
||||||
|
{ type: "user", text: "Check this" },
|
||||||
|
{
|
||||||
|
type: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Checking.",
|
||||||
|
state: { itemId: "msg_commentary", phase: "commentary" },
|
||||||
|
},
|
||||||
|
{ type: "tool", id: "call-echo" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: "assistant", content: [{ type: "text", text: "Done" }] },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("reloads a model switch before a tool-driven continuation step", () =>
|
it.effect("reloads a model switch before a tool-driven continuation step", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
|
|||||||
@@ -290,6 +290,7 @@ export namespace Text {
|
|||||||
...Base,
|
...Base,
|
||||||
assistantMessageID: SessionMessage.ID,
|
assistantMessageID: SessionMessage.ID,
|
||||||
ordinal: NonNegativeInt,
|
ordinal: NonNegativeInt,
|
||||||
|
state: SessionMessage.ProviderState.pipe(optional),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export type Started = typeof Started.Type
|
export type Started = typeof Started.Type
|
||||||
@@ -314,6 +315,7 @@ export namespace Text {
|
|||||||
assistantMessageID: SessionMessage.ID,
|
assistantMessageID: SessionMessage.ID,
|
||||||
ordinal: NonNegativeInt,
|
ordinal: NonNegativeInt,
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
|
state: SessionMessage.ProviderState.pipe(optional),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export type Ended = typeof Ended.Type
|
export type Ended = typeof Ended.Type
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ export interface AssistantText extends Schema.Schema.Type<typeof AssistantText>
|
|||||||
export const AssistantText = Schema.Struct({
|
export const AssistantText = Schema.Struct({
|
||||||
type: Schema.tag("text"),
|
type: Schema.tag("text"),
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
|
state: ProviderState.pipe(optional),
|
||||||
}).annotate({ identifier: "Session.Message.Assistant.Text" })
|
}).annotate({ identifier: "Session.Message.Assistant.Text" })
|
||||||
|
|
||||||
export interface AssistantReasoning extends Schema.Schema.Type<typeof AssistantReasoning> {}
|
export interface AssistantReasoning extends Schema.Schema.Type<typeof AssistantReasoning> {}
|
||||||
|
|||||||
Reference in New Issue
Block a user