Compare commits

..

5 Commits

Author SHA1 Message Date
Aiden Cline 5483cd6e2b Merge remote-tracking branch 'origin/v2' into responses-item-replay 2026-08-06 20:41:22 -05:00
opencode-agent[bot] d7651519f3 fix(tui): use tab layout setting (#40952)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-08-07 01:19:28 +00:00
Kit Langton 1eb3a43add fix(tui): keep model selection session scoped (#40913) 2026-08-06 20:34:57 -04:00
Aiden Cline 50f76827bf fix(ai): harden Responses item replay 2026-08-06 17:45:37 -05:00
Aiden Cline a619814c79 fix(ai): preserve Responses item IDs 2026-08-06 17:41:39 -05:00
23 changed files with 699 additions and 319 deletions
+136 -88
View File
@@ -70,10 +70,14 @@ const OpenResponsesReasoningItem = Schema.Struct({
encrypted_content: optionalNull(Schema.String),
})
const OpenResponsesItemReference = Schema.Struct({
type: Schema.tag("item_reference"),
id: Schema.String,
})
export const ProviderItem = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
id: Schema.String,
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
type OpenResponsesProviderItem = Schema.Schema.Type<typeof ProviderItem>
// `function_call_output.output` accepts either a plain string or an ordered
// array of content items so tools can return images and files in addition to text.
@@ -91,29 +95,42 @@ const OpenResponsesFunctionCallOutput = Schema.Union([
export const InputItem = Schema.Union([
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
Schema.Struct({
type: Schema.optionalKey(Schema.tag("message")),
id: Schema.optionalKey(Schema.String),
role: Schema.tag("user"),
content: Schema.Array(OpenResponsesInputContent),
status: Schema.optionalKey(Schema.Literals(["in_progress", "completed", "incomplete"])),
}),
Schema.Struct({
type: Schema.optionalKey(Schema.tag("message")),
id: Schema.optionalKey(Schema.String),
role: Schema.tag("assistant"),
content: Schema.Array(OpenResponsesOutputText),
status: Schema.optionalKey(Schema.Literals(["in_progress", "completed", "incomplete"])),
phase: Schema.optionalKey(MessagePhase),
}),
OpenResponsesReasoningItem,
OpenResponsesItemReference,
Schema.Struct({
type: Schema.tag("function_call"),
id: Schema.optionalKey(Schema.String),
call_id: Schema.String,
name: Schema.String,
arguments: Schema.String,
status: Schema.optionalKey(Schema.Literals(["in_progress", "completed", "incomplete"])),
}),
Schema.Struct({
type: Schema.tag("function_call_output"),
id: Schema.optionalKey(Schema.String),
call_id: Schema.String,
output: OpenResponsesFunctionCallOutput,
status: Schema.optionalKey(Schema.Literals(["in_progress", "completed", "incomplete"])),
}),
])
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
type LoweredInputItem =
| OpenResponsesInputItem
| OpenResponsesProviderItem
| {
readonly role: "assistant"
readonly content: ReadonlyArray<{ readonly type: "output_text"; readonly text: string }>
@@ -128,7 +145,7 @@ type OpenResponsesReasoningInput = {
summary: Array<{ type: "summary_text"; text: string }>
encrypted_content?: string | null
}
type OpenResponsesReasoningReplay = Omit<OpenResponsesReasoningInput, "id">
type OpenResponsesReasoningReplay = OpenResponsesReasoningInput
export const Tool = Schema.Struct({
type: Schema.tag("function"),
@@ -269,10 +286,9 @@ export interface ParserState {
readonly messagePhase: (value: unknown) => MessagePhase | null | undefined
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
readonly store: boolean | undefined
}
type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
type ReasoningSummaryStatus = "active" | "can-conclude"
interface ReasoningStreamItem {
readonly encryptedContent: string | null | undefined
@@ -310,34 +326,82 @@ export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LL
tool: (toolName) => ({ type: "function" as const, name: toolName }),
})
const lowerToolCall = (part: ToolCallPart): OpenResponsesInputItem => ({
type: "function_call",
call_id: part.id,
name: part.name,
arguments: ProviderShared.encodeJson(part.input),
})
const responseItemID = (prefix: string, id: string) => {
const value = id.startsWith("call_") ? id.slice(5) : id
const sanitized = value.replace(/[^a-zA-Z0-9_-]/g, "_").replace(/^_+|_+$/g, "") || "item"
const direct = `${prefix}_${sanitized}`
if (value === sanitized && direct.length <= 64) return direct
const hash = Array.from(id).reduce(
(hash, character) => BigInt.asUintN(64, (hash ^ BigInt(character.codePointAt(0) ?? 0)) * 1099511628211n),
14695981039346656037n,
)
const suffix = hash.toString(36)
return `${prefix}_${sanitized.slice(0, 62 - prefix.length - suffix.length)}_${suffix}`
}
const validResponseItemID = (value: unknown): value is string =>
typeof value === "string" && value.length <= 64 && /^[a-zA-Z0-9]+_.+$/.test(value)
const responseItemMetadata = (part: { readonly providerMetadata?: ProviderMetadata }, providerMetadataKey: string) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
return ProviderShared.isRecord(metadata) ? metadata : undefined
}
const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => {
const metadata = responseItemMetadata(part, providerMetadataKey)
return {
type: "function_call",
id: validResponseItemID(metadata?.itemId) ? metadata.itemId : responseItemID("fc", part.id),
call_id: part.id,
name: part.name,
arguments: ProviderShared.encodeJson(part.input),
status: "completed",
}
}
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
const metadata = part.providerMetadata?.[providerMetadataKey]
if (!ProviderShared.isRecord(metadata) || typeof metadata.itemId !== "string" || metadata.itemId.length === 0)
return undefined
if (!ProviderShared.isRecord(metadata) || typeof metadata.itemId !== "string") return undefined
const encryptedContent =
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
? metadata.reasoningEncryptedContent
: undefined
return {
type: "reasoning",
id: metadata.itemId,
id: validResponseItemID(metadata.itemId) ? metadata.itemId : responseItemID("rs", metadata.itemId),
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
encrypted_content: encryptedContent,
}
}
const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
? metadata.itemId
: undefined
const hostedToolItem = (part: ToolResultPart, providerMetadataKey: string): OpenResponsesProviderItem | undefined => {
const metadata = responseItemMetadata(part, providerMetadataKey)
if (
ProviderShared.isRecord(metadata?.responseItem) &&
typeof metadata.responseItem.id === "string" &&
typeof metadata.responseItem.type === "string"
)
return {
...metadata.responseItem,
type: metadata.responseItem.type,
id: validResponseItemID(metadata.responseItem.id)
? metadata.responseItem.id
: responseItemID("item", metadata.responseItem.id),
}
if (
part.result.type === "json" &&
ProviderShared.isRecord(part.result.value) &&
typeof part.result.value.id === "string" &&
typeof part.result.value.type === "string"
)
return {
...part.result.value,
type: part.result.value.type,
id: validResponseItemID(part.result.value.id)
? part.result.value.id
: responseItemID("item", part.result.value.id),
}
return undefined
}
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
@@ -400,14 +464,15 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
const system: LoweredInputItem[] =
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
const input: LoweredInputItem[] = [...system]
const store = OpenResponsesOptions.resolve(request).store
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
// `store` controls server persistence, not client-managed history. Replay the
// same stable item identities for stored and stateless requests.
for (const message of request.messages) {
if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate(extension.name, message)
const previous = input.at(-1)
if (previous && "role" in previous && previous.role === "user")
if (previous && "role" in previous && previous.role === "user" && Array.isArray(previous.content))
input[input.length - 1] = {
role: "user",
content: [...previous.content, { type: "input_text", text: part.text }],
@@ -427,8 +492,8 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (message.role === "assistant") {
const content: TextPart[] = []
const reasoningItems: Record<string, OpenResponsesReasoningReplay> = {}
const reasoningReferences = new Set<string>()
const hostedToolReferences = new Set<string>()
const hostedToolItems = new Set<string>()
let textItemIndex = 0
const flushText = () => {
if (content.length === 0) return
const groups = content.reduce<Array<{ phase: MessagePhase | null | undefined; parts: TextPart[] }>>(
@@ -443,11 +508,26 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
[],
)
input.push(
...groups.map((group) => ({
role: "assistant" as const,
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
...(group.phase === undefined ? {} : { phase: group.phase }),
})),
...groups.map((group) => {
const index = textItemIndex++
const first = group.parts[0]
const metadata = first ? responseItemMetadata(first, providerMetadataKey) : undefined
const id = validResponseItemID(metadata?.itemId)
? metadata.itemId
: message.id === undefined && typeof metadata?.itemId !== "string"
? undefined
: index === 0 && validResponseItemID(message.id)
? message.id
: responseItemID("msg", `${message.id ?? metadata?.itemId}_${index}`)
return {
type: "message" as const,
...(id === undefined ? {} : { id }),
role: "assistant" as const,
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
status: "completed" as const,
...(group.phase === undefined ? {} : { phase: group.phase }),
}
}),
)
content.splice(0, content.length)
}
@@ -460,11 +540,6 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
flushText()
const reasoning = lowerReasoning(part, providerMetadataKey)
if (!reasoning) continue
if (store !== false) {
if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
reasoningReferences.add(reasoning.id)
continue
}
const existing = reasoningItems[reasoning.id]
if (existing) {
existing.summary.push(...reasoning.summary)
@@ -472,11 +547,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
existing.encrypted_content = reasoning.encrypted_content
continue
}
const replay = {
type: reasoning.type,
summary: reasoning.summary,
encrypted_content: reasoning.encrypted_content,
}
const replay = { ...reasoning }
reasoningItems[reasoning.id] = replay
input.push(replay)
continue
@@ -484,22 +555,21 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (part.type === "tool-call") {
flushText()
if (part.providerExecuted === true) continue
input.push(lowerToolCall(part))
input.push(lowerToolCall(part, providerMetadataKey))
continue
}
if (part.type === "tool-result" && part.providerExecuted === true) {
flushText()
const itemID = hostedToolItemID(part, providerMetadataKey)
if (store !== false && itemID && !hostedToolReferences.has(itemID))
input.push({ type: "item_reference", id: itemID })
if (store === false && part.result.type === "content") {
const item = hostedToolItem(part, providerMetadataKey)
if (item && !hostedToolItems.has(item.id)) input.push(item)
if (!item && part.result.type === "content") {
const content: ReadonlyArray<Content> = part.result.value
input.push({
role: "user",
content: yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension)),
})
}
if (itemID) hostedToolReferences.add(itemID)
if (item) hostedToolItems.add(item.id)
continue
}
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
@@ -518,20 +588,15 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
return yield* ProviderShared.unsupportedContent(extension.name, "tool", ["tool-result"])
input.push({
type: "function_call_output",
id: responseItemID("fco", part.id),
call_id: part.id,
output: yield* lowerToolResultOutput(part, request, extension),
status: "completed",
})
}
}
// With store:false, Responses APIs only accept previous reasoning items when the
// complete item has encrypted state. Summary blocks for one item may carry
// that state only on the last block, so filter after they have been joined.
return store === false
? input.filter(
(item) => !("type" in item) || item.type !== "reasoning" || typeof item.encrypted_content === "string",
)
: input
return input
})
const lowerOptions = (request: LLMRequest) => {
@@ -641,7 +706,7 @@ const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepRe
if (!event.delta) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const phase = state.messagePhases[id]
const metadata = phase === undefined ? undefined : providerMetadata(state, { phase })
const metadata = providerMetadata(state, { itemId: id, ...(phase === undefined ? {} : { phase }) })
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) }, events]
}
@@ -652,7 +717,13 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
return onOutputTextDelta(state, { ...event, delta: event.text }, id)
}
const events: LLMEvent[] = []
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
return [
{
...state,
lifecycle: Lifecycle.textEnd(state.lifecycle, events, id, providerMetadata(state, { itemId: id })),
},
events,
]
}
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
@@ -761,23 +832,11 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
}
const events: LLMEvent[] = []
const closed = Object.entries(item.summaryParts)
.filter((entry) => entry[1] === "can-conclude")
.reduce(
(lifecycle, entry) =>
Lifecycle.reasoningEnd(
lifecycle,
events,
`${event.item_id}:${entry[0]}`,
providerMetadata(state, { itemId: event.item_id }),
),
state.lifecycle,
)
return [
{
...state,
lifecycle: Lifecycle.reasoningStart(
closed,
state.lifecycle,
events,
`${event.item_id}:${event.summary_index}`,
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }),
@@ -787,11 +846,7 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
[event.item_id]: {
...item,
summaryParts: {
...Object.fromEntries(
Object.entries(item.summaryParts).map((entry) =>
entry[1] === "can-conclude" ? [entry[0], "concluded" as const] : entry,
),
),
...item.summaryParts,
[event.summary_index]: "active",
},
},
@@ -809,22 +864,13 @@ const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResul
return [
{
...state,
lifecycle:
state.store !== false
? Lifecycle.reasoningEnd(
state.lifecycle,
events,
`${event.item_id}:${event.summary_index}`,
providerMetadata(state, { itemId: event.item_id }),
)
: state.lifecycle,
reasoningItems: {
...state.reasoningItems,
[event.item_id]: {
...item,
summaryParts: {
...item.summaryParts,
[event.summary_index]: state.store !== false ? "concluded" : "can-conclude",
[event.summary_index]: "can-conclude",
},
},
},
@@ -870,7 +916,10 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
state.lifecycle,
events,
item.id,
phase === undefined ? undefined : providerMetadata(state, { phase }),
providerMetadata(state, {
itemId: item.id,
...(phase === undefined ? {} : { phase }),
}),
),
messageItems,
messagePhases,
@@ -1037,7 +1086,6 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
messagePhase: (value) => messagePhase(value, extension),
messagePhases: {},
reasoningItems: {},
store: OpenResponsesOptions.resolve(request).store,
})
const messagePhase = (value: unknown, extension: Extension): MessagePhase | null | undefined => {
@@ -17,6 +17,7 @@ export const route = Route.make({
protocol: OpenResponses.protocol,
endpoint: Endpoint.path(OpenResponses.PATH),
transport: OpenResponses.httpTransport,
defaults: { providerOptions: { openresponses: { store: false } } },
})
export * as OpenAICompatibleResponses from "./openai-compatible-responses"
@@ -37,10 +37,14 @@ const OpenAIResponsesToolChoice = Schema.Union([
const OpenAIResponsesInputItem = Schema.Union([
Schema.Struct({
type: Schema.optionalKey(Schema.tag("message")),
id: Schema.optionalKey(Schema.String),
role: Schema.tag("assistant"),
content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })),
status: Schema.optionalKey(Schema.Literals(["in_progress", "completed", "incomplete"])),
phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)),
}),
OpenResponses.ProviderItem,
OpenResponses.InputItem,
])
@@ -195,7 +199,8 @@ const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function*
item: HostedToolItem,
) {
const tool = HOSTED_TOOLS[item.type]
const providerMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
const callMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
const resultMetadata = OpenResponses.providerMetadata(state, { itemId: item.id, responseItem: item })
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(
@@ -204,14 +209,14 @@ const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function*
name: tool.name,
input: tool.input(item),
providerExecuted: true,
providerMetadata,
providerMetadata: callMetadata,
}),
LLMEvent.toolResult({
id: item.id,
name: tool.name,
result: yield* hostedToolResult(item),
providerExecuted: true,
providerMetadata,
providerMetadata: resultMetadata,
}),
)
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
+22 -12
View File
@@ -2,11 +2,11 @@ import { LLMEvent, type FinishReasonDetails, type ProviderMetadata, type Usage }
export interface State {
readonly stepStarted: boolean
readonly text: ReadonlySet<string>
readonly reasoning: ReadonlySet<string>
readonly text: ReadonlyMap<string, ProviderMetadata | undefined>
readonly reasoning: ReadonlyMap<string, ProviderMetadata | undefined>
}
export const initial = (): State => ({ stepStarted: false, text: new Set(), reasoning: new Set() })
export const initial = (): State => ({ stepStarted: false, text: new Map(), reasoning: new Map() })
export const stepStart = (state: State, events: LLMEvent[]): State => {
if (state.stepStarted) return state
@@ -18,7 +18,7 @@ export const textStart = (state: State, events: LLMEvent[], id: string, provider
if (state.text.has(id)) return state
const stepped = stepStart(state, events)
events.push(LLMEvent.textStart({ id, providerMetadata }))
return { ...stepped, text: new Set([...stepped.text, id]) }
return { ...stepped, text: new Map([...stepped.text, [id, providerMetadata]]) }
}
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
@@ -36,7 +36,7 @@ export const reasoningStart = (
if (state.reasoning.has(id)) return state
const stepped = stepStart(state, events)
events.push(LLMEvent.reasoningStart({ id, providerMetadata }))
return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) }
return { ...stepped, reasoning: new Map([...stepped.reasoning, [id, providerMetadata]]) }
}
export const reasoningDelta = (
@@ -59,8 +59,10 @@ export const reasoningEnd = (
): State => {
if (!state.reasoning.has(id)) return state
const stepped = stepStart(state, events)
events.push(LLMEvent.reasoningEnd({ id, providerMetadata }))
const reasoning = new Set(stepped.reasoning)
events.push(
LLMEvent.reasoningEnd({ id, providerMetadata: mergeMetadata(stepped.reasoning.get(id), providerMetadata) }),
)
const reasoning = new Map(stepped.reasoning)
reasoning.delete(id)
return { ...stepped, reasoning }
}
@@ -68,16 +70,24 @@ export const reasoningEnd = (
export const textEnd = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
if (!state.text.has(id)) return state
const stepped = stepStart(state, events)
events.push(LLMEvent.textEnd({ id, providerMetadata }))
const text = new Set(stepped.text)
events.push(LLMEvent.textEnd({ id, providerMetadata: mergeMetadata(stepped.text.get(id), providerMetadata) }))
const text = new Map(stepped.text)
text.delete(id)
return { ...stepped, text }
}
const mergeMetadata = (left: ProviderMetadata | undefined, right: ProviderMetadata | undefined) => {
if (left === undefined) return right
if (right === undefined) return left
return Object.fromEntries(
Array.from(new Set([...Object.keys(left), ...Object.keys(right)]), (key) => [key, { ...left[key], ...right[key] }]),
)
}
const closeOpenBlocks = (state: State, events: LLMEvent[]): State => {
for (const id of state.reasoning) events.push(LLMEvent.reasoningEnd({ id }))
for (const id of state.text) events.push(LLMEvent.textEnd({ id }))
return { ...state, text: new Set(), reasoning: new Set() }
for (const [id, providerMetadata] of state.reasoning) events.push(LLMEvent.reasoningEnd({ id, providerMetadata }))
for (const [id, providerMetadata] of state.text) events.push(LLMEvent.textEnd({ id, providerMetadata }))
return { ...state, text: new Map(), reasoning: new Map() }
}
export const finish = (
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -44,7 +44,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEjXoGMCw3WDXpoD9151PEr2Lt8raW7KBKefQhZJGWx5f8jy152bApO6oE-Mr1BhUtfZNq3OPBVfSL4ioQ9bHREfujIBXgk9LUDBAz2Sle7KjOr9HaUV16A4HBiaFIRFjsHPS9G8yEySp1m6F1CD_WR6apyUGgugRh_y39EcOJmxPOzmiac5DVM6fraA1VpcGbqrZ1x2ANHFDOfnYTycPtPNTgzE7LjkYjDDWbT03uN1YxfP4pqjDVRzY14pA8bSZ8ys-pDv5kUFCAsw-OlU4jYKUXp-M8_6KTaRQP71LPwppt__zG_NJPfy-qUil4pOU8_NoxtxerHgLLXbfExZdzfpoGinoEjn7nj7BJDEtl-LNeNEb5c-1ZymNfVMp-Cs3fLEPkAV8rtHFtZ0MhE_07GKbGo7hTrOmkM4DydxmHsdWGNbXAG35cprslEA5P7p3GHFKnRs5hGs2eq-XcZ3yki64ZBOU_Tv6UR7nUH09gF1rdrJo3dpre6M00COwwdZ02zUP5KxCuI8FKu2jsZu9zgMVXDALsdtM5orTCVLXsn4rddWd111zE-vMjNmMMmktW2cHMjH7j1ooA-9P083koNVYiLi4UhMA64gTqgyl8MxkZekl7eFSMa7qk295NaHOKtFxzYYcZ9jdioCwSPSZ0ZZWLoNgrK7SWfRh0uaTHNcMZ3wq8ae6CguktIeVTCPTQAqJLQqd7AU0oOCKCJ7BWnC-L8UC6m7Pm9ZS958uUVeWBhgKHzMAGq9UeQB7IEeAcbMn3EDgOSfd8qCb8iwU9iG9dcu9axQwWU7pd7kd-T-He61W7z5wWgpx1KehWCxrN6kuKSo6p-uUfwVnJukreOn8BJNAzADQgz68bhmN9VGih7YcKVnLgwDwKditrjSd6-tfE0Baarj3jWENvT6ohY17R9FDrKS-2v8IIX6tGjoKJw8SRhaWLNv4vWlmxRgR0gdac3qumd0GKqsWSveNz01naA==\"},{\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}"
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEjXoGMCw3WDXpoD9151PEr2Lt8raW7KBKefQhZJGWx5f8jy152bApO6oE-Mr1BhUtfZNq3OPBVfSL4ioQ9bHREfujIBXgk9LUDBAz2Sle7KjOr9HaUV16A4HBiaFIRFjsHPS9G8yEySp1m6F1CD_WR6apyUGgugRh_y39EcOJmxPOzmiac5DVM6fraA1VpcGbqrZ1x2ANHFDOfnYTycPtPNTgzE7LjkYjDDWbT03uN1YxfP4pqjDVRzY14pA8bSZ8ys-pDv5kUFCAsw-OlU4jYKUXp-M8_6KTaRQP71LPwppt__zG_NJPfy-qUil4pOU8_NoxtxerHgLLXbfExZdzfpoGinoEjn7nj7BJDEtl-LNeNEb5c-1ZymNfVMp-Cs3fLEPkAV8rtHFtZ0MhE_07GKbGo7hTrOmkM4DydxmHsdWGNbXAG35cprslEA5P7p3GHFKnRs5hGs2eq-XcZ3yki64ZBOU_Tv6UR7nUH09gF1rdrJo3dpre6M00COwwdZ02zUP5KxCuI8FKu2jsZu9zgMVXDALsdtM5orTCVLXsn4rddWd111zE-vMjNmMMmktW2cHMjH7j1ooA-9P083koNVYiLi4UhMA64gTqgyl8MxkZekl7eFSMa7qk295NaHOKtFxzYYcZ9jdioCwSPSZ0ZZWLoNgrK7SWfRh0uaTHNcMZ3wq8ae6CguktIeVTCPTQAqJLQqd7AU0oOCKCJ7BWnC-L8UC6m7Pm9ZS958uUVeWBhgKHzMAGq9UeQB7IEeAcbMn3EDgOSfd8qCb8iwU9iG9dcu9axQwWU7pd7kd-T-He61W7z5wWgpx1KehWCxrN6kuKSo6p-uUfwVnJukreOn8BJNAzADQgz68bhmN9VGih7YcKVnLgwDwKditrjSd6-tfE0Baarj3jWENvT6ohY17R9FDrKS-2v8IIX6tGjoKJw8SRhaWLNv4vWlmxRgR0gdac3qumd0GKqsWSveNz01naA==\",\"id\":\"rs_0a0794dab3b8ec7d016a1235e7ce3881958a5eca32a36a14c5\"},{\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\"}],\"type\":\"message\",\"status\":\"completed\",\"id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"phase\":\"final_answer\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}"
},
"response": {
"status": 200,
@@ -21,7 +21,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-4o-mini\",\"input\":[{\"role\":\"system\",\"content\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_pdf_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"PDF read successfully\"},{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}"
"body": "{\"model\":\"gpt-4o-mini\",\"input\":[{\"role\":\"system\",\"content\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"arguments\":\"{}\",\"id\":\"fc_pdf_1\",\"status\":\"completed\"},{\"type\":\"function_call_output\",\"call_id\":\"call_pdf_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"PDF read successfully\"},{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}],\"id\":\"fco_pdf_1\",\"status\":\"completed\"}],\"tools\":[{\"type\":\"function\",\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}"
},
"response": {
"status": 200,
@@ -21,7 +21,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"grok-4.5\",\"input\":[{\"role\":\"system\",\"content\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_pdf_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"PDF read successfully\"},{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\",\"mime_type\":\"application/pdf\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}"
"body": "{\"model\":\"grok-4.5\",\"input\":[{\"role\":\"system\",\"content\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"arguments\":\"{}\",\"id\":\"fc_pdf_1\",\"status\":\"completed\"},{\"type\":\"function_call_output\",\"call_id\":\"call_pdf_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"PDF read successfully\"},{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\",\"mime_type\":\"application/pdf\"}],\"id\":\"fco_pdf_1\",\"status\":\"completed\"}],\"tools\":[{\"type\":\"function\",\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}"
},
"response": {
"status": 200,
@@ -51,7 +51,13 @@ describe("Open Responses-compatible route", () => {
{ role: "system", content: "You are concise." },
{ role: "user", content: [{ type: "input_text", text: "Say hello." }] },
],
store: false,
stream: true,
max_output_tokens: undefined,
temperature: undefined,
tool_choice: undefined,
tools: undefined,
top_p: undefined,
})
}),
)
@@ -112,6 +118,40 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("keeps response item replay independent of store", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const messages = [
Message.assistant({
type: "reasoning",
text: "Checked the previous diff.",
providerMetadata: {
openresponses: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
},
}),
]
const stored = yield* compileRequest(
LLM.request({ model, messages, providerOptions: { openresponses: { store: true } } }),
)
const stateless = yield* compileRequest(
LLM.request({ model, messages, providerOptions: { openresponses: { store: false } } }),
)
expect(stored.body.input).toEqual(stateless.body.input)
expect(stored.body.input).toEqual([
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
encrypted_content: "encrypted-state",
},
])
}),
)
it.effect("does not interpret OpenAI hosted-tool items", () =>
Effect.gen(function* () {
const model = configure({
@@ -211,7 +211,12 @@ 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." }] },
{
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "After." }],
status: "completed",
},
])
}),
)
@@ -329,7 +334,7 @@ describe("OpenAI Responses route", () => {
yield* LLMClient.generate(
LLMRequest.update(request, {
model: Azure.configure({
baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
resourceName: "opencode-test",
apiKey: "azure-key",
headers: { authorization: "Bearer stale" },
}).responses("gpt-4.1-mini"),
@@ -414,8 +419,21 @@ describe("OpenAI Responses route", () => {
model: "gpt-4.1-mini",
input: [
{ role: "user", content: [{ type: "input_text", text: "What is the weather?" }] },
{ type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
{ type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' },
{
type: "function_call",
id: "fc_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
status: "completed",
},
{
type: "function_call_output",
id: "fco_1",
call_id: "call_1",
output: '{"forecast":"sunny"}',
status: "completed",
},
],
store: false,
stream: true,
@@ -864,10 +882,10 @@ describe("OpenAI Responses route", () => {
expect(response.text).toBe("Hello!")
expect(response.events).toEqual([
{ type: "step-start", index: 0 },
{ type: "text-start", id: "msg_1" },
{ type: "text-start", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-delta", id: "msg_1", text: "Hello" },
{ type: "text-delta", id: "msg_1", text: "!" },
{ type: "text-end", id: "msg_1" },
{ type: "text-end", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{
type: "step-finish",
index: 0,
@@ -923,35 +941,44 @@ describe("OpenAI Responses route", () => {
{
type: "text",
text: "Checking.",
providerMetadata: { openai: { phase: "commentary" } },
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
},
{
type: "text",
text: "Finished.",
providerMetadata: { openai: { phase: "final_answer" } },
providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } },
},
{
type: "text",
text: "Unclassified.",
providerMetadata: { openai: { phase: null } },
providerMetadata: { openai: { itemId: "msg_null", phase: null } },
},
])
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(prepared.body.input).toEqual([
{
type: "message",
id: "msg_commentary",
role: "assistant",
content: [{ type: "output_text", text: "Checking." }],
status: "completed",
phase: "commentary",
},
{
type: "message",
id: "msg_final",
role: "assistant",
content: [{ type: "output_text", text: "Finished." }],
status: "completed",
phase: "final_answer",
},
{
type: "message",
id: "msg_null",
role: "assistant",
content: [{ type: "output_text", text: "Unclassified." }],
status: "completed",
phase: null,
},
])
@@ -1043,12 +1070,12 @@ describe("OpenAI Responses route", () => {
)
expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
{ type: "text-start", id: "msg_1" },
{ type: "text-start", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-delta", id: "msg_1", text: "First" },
{ type: "text-end", id: "msg_1" },
{ type: "text-start", id: "msg_2" },
{ type: "text-end", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-start", id: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
{ type: "text-delta", id: "msg_2", text: "Second" },
{ type: "text-end", id: "msg_2" },
{ type: "text-end", id: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
])
}),
)
@@ -1070,7 +1097,7 @@ describe("OpenAI Responses route", () => {
{ type: "step-start", index: 0 },
{ type: "reasoning-start", id: "rs_1" },
{ type: "reasoning-delta", id: "rs_1", text: "thinking" },
{ type: "text-start", id: "msg_1" },
{ type: "text-start", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-delta", id: "msg_1", text: "Hello" },
{ type: "reasoning-end", id: "rs_1" },
{ type: "text-end", id: "msg_1" },
@@ -1080,7 +1107,7 @@ describe("OpenAI Responses route", () => {
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
expect(response.message.content).toEqual([
{ type: "reasoning", text: "thinking" },
{ type: "text", text: "Hello" },
{ type: "text", text: "Hello", providerMetadata: { openai: { itemId: "msg_1" } } },
])
}),
)
@@ -1146,33 +1173,34 @@ describe("OpenAI Responses route", () => {
)
expect(response.reasoning).toBe("FirstSecond")
expect(response.events).toMatchObject([
{ type: "step-start", index: 0 },
expect(response.events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
{
type: "reasoning-start",
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:0", text: "First" },
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
{ type: "reasoning-delta", id: "rs_1:0", text: "First", providerMetadata: undefined },
{
type: "reasoning-start",
id: "rs_1:1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:1", text: "Second" },
{ type: "reasoning-delta", id: "rs_1:1", text: "Second", providerMetadata: undefined },
{
type: "reasoning-end",
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
{
type: "reasoning-end",
id: "rs_1:1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
{ type: "finish", reason: { normalized: "stop", raw: undefined } },
])
}),
)
it.effect("closes reasoning summary parts when storage is not disabled", () =>
it.effect("preserves complete reasoning metadata when storage is enabled", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, { providerOptions: { openai: { store: true } } }),
@@ -1192,7 +1220,7 @@ describe("OpenAI Responses route", () => {
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 },
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
@@ -1201,8 +1229,16 @@ describe("OpenAI Responses route", () => {
)
expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
{ type: "reasoning-end", id: "rs_1:1", providerMetadata: { openai: { itemId: "rs_1" } } },
{
type: "reasoning-end",
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
{
type: "reasoning-end",
id: "rs_1:1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
}),
)
@@ -1250,7 +1286,7 @@ describe("OpenAI Responses route", () => {
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
],
})
expect(body.input[1]).not.toHaveProperty("id")
expect(body.input[1]).toHaveProperty("id", "rs_1")
return input.respond(
sseEvents(
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." },
@@ -1267,6 +1303,98 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("keeps OpenAI and Azure response item replay independent of store", () =>
Effect.gen(function* () {
const models = [
model,
Azure.configure({ resourceName: "opencode-test", apiKey: "azure-key" }).responses("gpt-4.1-mini"),
]
const messages = [
Message.assistant([
{
type: "reasoning" as const,
text: "Checked the previous diff.",
providerMetadata: {
openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
},
},
ToolCallPart.make({
id: "call_1",
name: "lookup",
input: { query: "weather" },
providerMetadata: { openai: { itemId: "fc_1" } },
}),
]),
Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }),
]
for (const current of models) {
const stored = yield* compileRequest(
LLM.request({ model: current, messages, providerOptions: { openai: { store: true } } }),
)
const stateless = yield* compileRequest(
LLM.request({ model: current, messages, providerOptions: { openai: { store: false } } }),
)
expect(stored.body.input).toEqual(stateless.body.input)
expect(stored.body.input).toEqual([
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
encrypted_content: "encrypted-state",
},
{
type: "function_call",
id: "fc_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
status: "completed",
},
{
type: "function_call_output",
id: "fco_1",
call_id: "call_1",
output: '{"forecast":"sunny"}',
status: "completed",
},
])
}
}),
)
it.effect("replaces invalid item ids without creating collisions", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.make({
id: "msg_text",
role: "assistant",
content: [
{ type: "text", text: "Ready.", providerMetadata: { openai: { itemId: "" } } },
{
type: "reasoning",
text: "Think.",
providerMetadata: { openai: { itemId: "", reasoningEncryptedContent: "encrypted" } },
},
ToolCallPart.make({ id: "call_a/b", name: "one", input: {} }),
ToolCallPart.make({ id: "call_a?b", name: "two", input: {} }),
],
}),
],
}),
)
const ids = prepared.body.input.flatMap((item) => ("id" in item && typeof item.id === "string" ? [item.id] : []))
expect(ids).toHaveLength(4)
expect(new Set(ids).size).toBe(ids.length)
expect(ids.every((id) => /^[a-zA-Z0-9]+_.+$/.test(id) && id.length <= 64)).toBe(true)
}),
)
it.effect("preserves assistant content order around reasoning items", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -1274,38 +1402,55 @@ describe("OpenAI Responses route", () => {
id: "req_reasoning_order",
model,
messages: [
Message.assistant([
{ type: "text", text: "Before." },
{
type: "reasoning",
text: "Checked order.",
providerMetadata: {
openai: {
itemId: "rs_1",
reasoningEncryptedContent: "encrypted-state",
Message.make({
id: "msg_assistant",
role: "assistant",
content: [
{ type: "text", text: "Before." },
{
type: "reasoning",
text: "Checked order.",
providerMetadata: {
openai: {
itemId: "rs_1",
reasoningEncryptedContent: "encrypted-state",
},
},
},
},
{ type: "text", text: "After." },
]),
{ type: "text", text: "After." },
],
}),
],
providerOptions: { openai: { store: false } },
}),
)
expect(prepared.body.input).toEqual([
{ role: "assistant", content: [{ type: "output_text", text: "Before." }] },
{
type: "message",
id: "msg_assistant",
role: "assistant",
content: [{ type: "output_text", text: "Before." }],
status: "completed",
},
{
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [{ type: "summary_text", text: "Checked order." }],
},
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
{
type: "message",
id: "msg_msg_assistant_1",
role: "assistant",
content: [{ type: "output_text", text: "After." }],
status: "completed",
},
])
}),
)
it.effect("references stored reasoning items by id", () =>
it.effect("replays stored reasoning items with their id", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
@@ -1323,11 +1468,18 @@ describe("OpenAI Responses route", () => {
}),
)
expect(prepared.body.input).toEqual([{ type: "item_reference", id: "rs_1" }])
expect(prepared.body.input).toEqual([
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
encrypted_content: undefined,
},
])
}),
)
it.effect("references stored provider-executed hosted tool results by id", () =>
it.effect("replays stored provider-executed hosted tool results", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
@@ -1357,7 +1509,7 @@ describe("OpenAI Responses route", () => {
)
expect(prepared.body.input).toEqual([
{ type: "item_reference", id: "ws_1" },
{ type: "web_search_call", id: "ws_1", status: "completed" },
{ role: "user", content: [{ type: "input_text", text: "Continue." }] },
])
}),
@@ -1432,6 +1584,7 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.input).toEqual([
{
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [
{ type: "summary_text", text: "First" },
@@ -1442,7 +1595,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("skips non-persisted reasoning ids without encrypted state", () =>
it.effect("replays reasoning ids without encrypted state", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
@@ -1472,6 +1625,12 @@ describe("OpenAI Responses route", () => {
expect(prepared.body).toMatchObject({
input: [
{ role: "user", content: [{ type: "input_text", text: "What changed?" }] },
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
encrypted_content: null,
},
{ role: "assistant", content: [{ type: "output_text", text: "The parser changed." }] },
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
],
@@ -1663,7 +1822,8 @@ describe("OpenAI Responses route", () => {
name: "web_search",
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1" } },
output: undefined,
providerMetadata: { openai: { itemId: "ws_1", responseItem: item } },
},
])
}),
@@ -1754,7 +1914,8 @@ describe("OpenAI Responses route", () => {
name: "code_interpreter",
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ci_1" } },
output: undefined,
providerMetadata: { openai: { itemId: "ci_1", responseItem: item } },
})
}),
)
+1 -1
View File
@@ -261,7 +261,7 @@ const assistantMessageFromResponse = (response: LLMResponse, step: AssistantStep
content.push({ type: "reasoning", text: response.reasoning, providerMetadata: reasoning.providerMetadata })
}
if (response.text.length > 0) content.push({ type: "text", text: response.text })
content.push(...response.message.content.filter((part) => part.type === "text"))
content.push(...response.toolCalls)
return Message.assistant(content)
}
+6 -3
View File
@@ -161,11 +161,14 @@ function isPathAction(action: string): action is PathAction {
}
function expandHome(resource: string, home: string) {
if (resource.startsWith("~/")) return home + resource.slice(1)
if (resource === "~") return home
if (resource === "$HOME") return home
if (resource.startsWith("$HOME/")) return home + resource.slice(5)
if (resource.startsWith("$HOME\\")) return home + resource.slice(5)
const relative = resource.startsWith("~/")
? resource.slice(2)
: resource.startsWith("$HOME/") || resource.startsWith("$HOME\\")
? resource.slice(6)
: undefined
if (relative !== undefined) return (path.posix.isAbsolute(home) ? path.posix : path.win32).join(home, relative)
return resource
}
+5
View File
@@ -51,6 +51,11 @@ describe("ConfigAgentPlugin.Plugin", () => {
it.effect("matches Windows paths against home-relative permissions", () =>
Effect.gen(function* () {
const permissions = yield* loadHomePermissions("C:\\Users\\test")
expect(permissions).toContainEqual({
action: "external_directory",
resource: "C:\\Users\\test\\p\\**",
effect: "allow",
})
expect(
Permission.evaluate("external_directory", "C:\\Users\\test\\p\\opencode\\src\\*", permissions).effect,
).toBe("allow")
+1 -1
View File
@@ -512,7 +512,7 @@ function App(props: { pair?: DialogPairCredentials }) {
const terminalTitleEnabled = () => config.data.terminal?.title ?? true
const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32"
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
const tabsVertical = () => (config.data.tabs?.vertical ?? false) && sessionTabsFitVertically(dimensions().width)
const tabsVertical = () => config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width)
const tabsVisible = () =>
sessionTabs.enabled() && (sessionTabs.tabs().length > 0 || sessionTabs.newTab()) && route.data.type !== "plugin"
+4 -5
View File
@@ -101,12 +101,11 @@ export const settings: Setting[] = [
labels: ["current directory", "global"],
},
{
title: "Vertical",
title: "Layout",
category: "Tabs",
path: ["tabs", "vertical"],
default: false,
values: [false, true],
labels: ["off", "on"],
path: ["tabs", "layout"],
default: "horizontal",
values: ["horizontal", "vertical"],
keywords: ["sidebar", "orientation", "left"],
},
{
@@ -317,19 +317,15 @@ function CommandView(props: { title: string; output: string; message: string })
esc close
</text>
</box>
<Show when={props.output.trim()}>
{(output) => (
<box
backgroundColor={overlayTheme.background.default}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
>
<text fg={overlayTheme.text.default}>{output()}</text>
</box>
)}
</Show>
<box
backgroundColor={overlayTheme.background.default}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
>
<text fg={overlayTheme.text.default}>{props.output.trim()}</text>
</box>
<box paddingLeft={2} paddingRight={2}>
<text fg={theme.text.subdued}>{props.message}</text>
</box>
+6 -2
View File
@@ -8,17 +8,21 @@ import * as fuzzysort from "fuzzysort"
import { useConnected } from "./use-connected"
import { useData } from "../context/data"
import { modelPreferenceKey } from "../model-preference"
import { useLocation } from "../context/location"
export function DialogModel(props: { providerID?: string }) {
const local = useLocal()
const data = useData()
const dialog = useDialog()
const location = useLocation()
const [query, setQuery] = createSignal("")
const favoritePriority = new Set(local.model.favorite().map(modelPreferenceKey))
const connected = useConnected()
const providers = createMemo(() => new Map((data.location.provider.list() ?? []).map((item) => [item.id, item])))
const models = createMemo(() => data.location.model.list() ?? [])
const providers = createMemo(
() => new Map((data.location.provider.list(location.ref) ?? []).map((item) => [item.id, item])),
)
const models = createMemo(() => data.location.model.list(location.ref) ?? [])
const showExtra = createMemo(() => connected() && !props.providerID)
+50 -51
View File
@@ -327,10 +327,6 @@ export function Prompt(props: PromptProps) {
if (!session) return
const agent = session.agent && local.agent.list().find((agent) => agent.id === session.agent)
if (agent && !args.agent) local.agent.set(agent.id)
if (session.model) {
local.model.set({ providerID: session.model.providerID, modelID: session.model.id })
local.model.variant.set(session.model.variant)
}
syncedSessionID = sessionID
})
@@ -943,15 +939,43 @@ export function Prompt(props: PromptProps) {
await slash.command.run(slash.input)
return true
}
const inputText = expandTrackedPastedText(
store.prompt.text,
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
const ref = store.extmarkToPart.get(extmark.id)
if (ref?.type !== "pasted") return []
const part = store.prompt.pasted[ref.index]
if (!part) return []
return [{ start: extmark.start, end: extmark.end, text: part.text }]
}),
)
const slashHead = parseSlashHead(inputText, /\s/)
const isSkill =
slashHead !== undefined &&
(data.location.skill.list(currentLocation.ref) ?? []).some(
(skill) => skill.slash === true && skill.id === slashHead.name,
)
const isCommand =
slashHead !== undefined &&
(data.location.command.list(currentLocation.ref) ?? []).some((command) => command.name === slashHead.name)
const agent = local.agent.current()
if (!agent) return false
const selectedModel = local.model.current()
if (!selectedModel) {
const selection = local.model.selection()
if (!selection) {
void promptModelWarning()
return false
}
const usesModel = !props.sessionID || (store.mode !== "shell" && !isSkill)
if (usesModel && !local.model.available(selection)) {
toast.show({
title: "Model unavailable",
message: `${selection.providerID}/${selection.modelID} is not available in this session's location`,
variant: "warning",
})
return false
}
const variant = local.model.variant.current()
const variant = selection.variant
let sessionID = props.sessionID
let session = sessionID ? data.session.get(sessionID) : undefined
let finishMoveProgress = false
@@ -969,8 +993,8 @@ export function Prompt(props: PromptProps) {
location: directory ? { directory } : location,
agent: agent.id,
model: {
providerID: selectedModel.providerID,
id: selectedModel.modelID,
providerID: selection.providerID,
id: selection.modelID,
variant,
},
})
@@ -990,17 +1014,6 @@ export function Prompt(props: PromptProps) {
session = created
}
const inputText = expandTrackedPastedText(
store.prompt.text,
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
const ref = store.extmarkToPart.get(extmark.id)
if (ref?.type !== "pasted") return []
const part = store.prompt.pasted[ref.index]
if (!part) return []
return [{ start: extmark.start, end: extmark.end, text: part.text }]
}),
)
// Capture mode before it gets reset
const currentMode = store.mode
const editorSelection = editorContext()
@@ -1013,43 +1026,30 @@ export function Prompt(props: PromptProps) {
command: inputText,
})
setStore("mode", "normal")
} else if (
inputText.startsWith("/") &&
(data.location.command.list(currentLocation.current) ?? []).some(
(command) => command.name === inputText.split("\n")[0].split(" ")[0].slice(1),
)
) {
} else if (slashHead && isCommand) {
move.startSubmit()
// Parse command from first line, preserve multi-line content in arguments
const firstLineEnd = inputText.indexOf("\n")
const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd)
const [command, ...firstLineArgs] = firstLine.split(" ")
const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1)
const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "")
const model = { providerID: selection.providerID, id: selection.modelID, variant }
const cancelCommit = local.model.trackSessionCommit(sessionID, model)
void client.api.session
.command({
sessionID,
command: command.slice(1),
arguments: args,
command: slashHead.name,
arguments: slashHead.arguments,
agent: agent.id,
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
model,
files: store.prompt.files,
agents: store.prompt.agents,
})
.catch((error) => {
cancelCommit()
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
})
} else if (
inputText.startsWith("/") &&
(data.location.skill.list(currentLocation.current) ?? []).some(
(skill) => skill.slash === true && skill.id === inputText.split("\n")[0].split(" ")[0].slice(1),
)
) {
} else if (isSkill) {
move.startSubmit()
void client.api.session.skill({
sessionID,
skill: inputText.split("\n")[0].split(" ")[0].slice(1),
skill: slashHead!.name,
})
} else {
move.startSubmit()
@@ -1061,13 +1061,15 @@ export function Prompt(props: PromptProps) {
await client.api.session.switchAgent({ sessionID, agent: agent.id })
}
if (
session?.model?.providerID !== selectedModel.providerID ||
session.model.id !== selectedModel.modelID ||
session?.model?.providerID !== selection.providerID ||
session.model.id !== selection.modelID ||
(session.model.variant ?? "default") !== (variant ?? "default")
) {
await client.api.session.switchModel({
sessionID,
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
const model = { providerID: selection.providerID, id: selection.modelID, variant }
const cancelCommit = local.model.trackSessionCommit(sessionID, model)
await client.api.session.switchModel({ sessionID, model }).catch((error) => {
cancelCommit()
throw error
})
}
if (session?.revert) {
@@ -1320,10 +1322,7 @@ export function Prompt(props: PromptProps) {
return `Ask anything... "${list()[store.placeholder % list().length]}"`
})()
if (!value) return undefined
const width =
dimensions().width < 44
? dimensions().width - 5
: Math.min(75, dimensions().width - 4) - 5
const width = dimensions().width < 44 ? dimensions().width - 5 : Math.min(75, dimensions().width - 4) - 5
return Locale.takeWidth(value, Math.max(1, width)).trimEnd()
})
const locationLabel = createMemo(() => {
+4 -3
View File
@@ -132,8 +132,8 @@ export const Info = Schema.Struct({
scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({
description: "Share tabs globally or keep a separate set for each working directory",
}),
vertical: Schema.optional(Schema.Boolean).annotate({
description: "Show tabs in a left sidebar instead of a horizontal strip",
layout: Schema.optional(Schema.Literals(["horizontal", "vertical"])).annotate({
description: "Show tabs in a horizontal strip or vertical sidebar",
}),
}),
).annotate({ description: "Tab strip settings" }),
@@ -194,7 +194,7 @@ export type Resolved = Omit<Info, "attention" | "keybinds" | "leader" | "mouse"
tabs: {
enabled: boolean
scope: "global" | "cwd"
vertical?: boolean
layout: "horizontal" | "vertical"
}
}
@@ -230,6 +230,7 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
...input.tabs,
enabled: input.tabs?.enabled ?? true,
scope: input.tabs?.scope ?? "cwd",
layout: input.tabs?.layout ?? "horizontal",
},
}
}
+184 -80
View File
@@ -1,7 +1,7 @@
import { createStore } from "solid-js/store"
import { dedupeWith } from "effect/Array"
import { createSimpleContext } from "./helper"
import { batch, createMemo } from "solid-js"
import { batch, createMemo, onCleanup } from "solid-js"
import { useEvent } from "./event"
import path from "path"
import { useTuiPaths } from "./runtime"
@@ -22,6 +22,7 @@ import { useToast } from "../ui/toast"
import { useRoute } from "./route"
import { useData } from "./data"
import { usePermission } from "./permission"
import { useLocation } from "./location"
export function parseModel(model: string) {
const [providerID, ...rest] = model.split("/")
@@ -57,26 +58,29 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const args = useArgs()
const event = useEvent()
const permission = usePermission()
const location = useLocation()
const models = () => data.location.model.list(location.ref)
const providers = () => data.location.provider.list(location.ref)
function isModelValid(model: ModelPreferenceModel) {
return !!data.location.model
.list()
?.some((item) => item.providerID === model.providerID && item.id === model.modelID)
return !!models()?.some((item) => item.providerID === model.providerID && item.id === model.modelID)
}
function getFirstValidModel(...modelFns: (() => ModelPreferenceModel | undefined)[]) {
for (const modelFn of modelFns) {
const model = modelFn()
if (!model) continue
if (isModelValid(model)) return model
if (model && isModelValid(model)) return model
}
}
function createAgent() {
const agents = createMemo(() =>
(data.location.agent.list() ?? []).filter((agent) => agent.mode !== "subagent" && !agent.hidden),
(data.location.agent.list(location.ref) ?? []).filter((agent) => agent.mode !== "subagent" && !agent.hidden),
)
const visibleAgents = createMemo(() =>
(data.location.agent.list(location.ref) ?? []).filter((agent) => !agent.hidden),
)
const visibleAgents = createMemo(() => (data.location.agent.list() ?? []).filter((agent) => !agent.hidden))
const [agentStore, setAgentStore] = createStore({
current: undefined as string | undefined,
})
@@ -128,35 +132,40 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const agent = createAgent()
function createModel() {
const [modelStore, setModelStore] = createStore<
ModelPreference & {
ready: boolean
model: Record<string, ModelPreferenceModel>
}
>({
type ModelSelection = ModelPreferenceModel & { variant?: string }
const [preferences, setPreferences] = createStore<ModelPreference & { ready: boolean }>({
ready: false,
model: {},
recent: [],
favorite: [],
variant: {},
})
const [selectionState, setSelectionState] = createStore<{
newSessionModelByLocationAgent: Record<string, ModelPreferenceModel | undefined>
draftBySession: Record<string, ModelSelection | undefined>
}>({
newSessionModelByLocationAgent: {},
draftBySession: {},
})
const repository = createModelPreferenceRepository(path.join(paths.state, "model.json"))
const state = {
const pendingSelectionCommits = new Map<string, string>()
const selectionKey = (value: ModelSelection) =>
`${modelPreferenceKey(value)}:${normalizeModelVariant(value.variant) ?? "default"}`
const saveState = {
pending: false,
}
function save() {
if (!modelStore.ready) {
state.pending = true
function savePreferences() {
if (!preferences.ready) {
saveState.pending = true
return
}
state.pending = false
saveState.pending = false
void repository
.patch({
recent: modelStore.recent,
favorite: modelStore.favorite,
variant: modelStore.variant,
recent: preferences.recent,
favorite: preferences.favorite,
variant: preferences.variant,
})
.catch(() => undefined)
}
@@ -164,14 +173,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
repository
.load()
.then((value) => {
setModelStore("recent", value.recent)
setModelStore("favorite", value.favorite)
setModelStore("variant", value.variant)
setPreferences("recent", value.recent)
setPreferences("favorite", value.favorite)
setPreferences("variant", value.variant)
})
.catch(() => {})
.finally(() => {
setModelStore("ready", true)
if (state.pending) save()
setPreferences("ready", true)
if (saveState.pending) savePreferences()
})
const fallbackModel = createMemo(() => {
@@ -185,13 +194,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
}
for (const item of modelStore.recent) {
for (const item of preferences.recent) {
if (isModelValid(item)) {
return item
}
}
const model = data.location.model.list()?.[0]
const model = models()?.[0]
if (!model) return undefined
return {
providerID: model.providerID,
@@ -199,30 +208,134 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
})
const currentModel = createMemo(() => {
const newSessionModel = createMemo(() => {
const a = agent.current()
return (
getFirstValidModel(
() => a && modelStore.model[a.id],
() => a?.model && { providerID: a.model.providerID, modelID: a.model.id },
fallbackModel,
) ?? undefined
return getFirstValidModel(
() => a && selectionState.newSessionModelByLocationAgent[locationAgentKey(a.id)],
() => a?.model && { providerID: a.model.providerID, modelID: a.model.id },
fallbackModel,
)
})
const currentSelection = createMemo<ModelSelection | undefined>(() => {
if (route.data.type === "session") return sessionSelection(route.data.sessionID)
const model = newSessionModel()
if (!model) return
return { ...model, variant: normalizeModelVariant(preferences.variant[modelPreferenceKey(model)]) }
})
const currentModel = createMemo(() => {
const selection = currentSelection()
if (!selection) return
return { providerID: selection.providerID, modelID: selection.modelID }
})
function locationAgentKey(agentID: string) {
const ref = location.ref ?? data.location.default()
return `${JSON.stringify([ref.directory, ref.workspaceID])}:${agentID}`
}
function durableSelection(sessionID: string): ModelSelection | undefined {
const model = data.session.get(sessionID)?.model
if (!model) return
return {
providerID: model.providerID,
modelID: model.id,
variant: normalizeModelVariant(model.variant),
}
}
function sessionSelection(sessionID: string) {
return selectionState.draftBySession[sessionID] ?? durableSelection(sessionID)
}
function setSessionDraft(sessionID: string, selection: ModelSelection) {
const durable = durableSelection(sessionID)
setSelectionState(
"draftBySession",
sessionID,
durable && selectionKey(durable) === selectionKey(selection) ? undefined : selection,
)
}
function selectModel(model: ModelPreferenceModel) {
if (route.data.type === "session") {
const sessionID = route.data.sessionID
const current = sessionSelection(sessionID)
const preferred = normalizeModelVariant(
current?.providerID === model.providerID && current.modelID === model.modelID
? current.variant
: preferences.variant[modelPreferenceKey(model)],
)
const info = models()?.find((item) => item.providerID === model.providerID && item.id === model.modelID)
const variant = preferred && info?.variants?.some((item) => item.id === preferred) ? preferred : undefined
setSessionDraft(sessionID, { ...model, variant })
return true
}
const current = agent.current()
if (!current) return false
setSelectionState("newSessionModelByLocationAgent", locationAgentKey(current.id), model)
return true
}
onCleanup(
event.on("session.model.selected", (evt) => {
const expected = pendingSelectionCommits.get(evt.data.sessionID)
if (!expected) return
const committed = selectionKey({
providerID: evt.data.model.providerID,
modelID: evt.data.model.id,
variant: evt.data.model.variant,
})
if (committed !== expected) return
pendingSelectionCommits.delete(evt.data.sessionID)
const draft = selectionState.draftBySession[evt.data.sessionID]
if (draft && selectionKey(draft) === committed)
setSelectionState("draftBySession", evt.data.sessionID, undefined)
}),
)
onCleanup(
event.on("session.deleted", (evt) => {
pendingSelectionCommits.delete(evt.data.sessionID)
setSelectionState("draftBySession", evt.data.sessionID, undefined)
}),
)
return {
current: currentModel,
selection: currentSelection,
available(model = currentModel()) {
return model ? isModelValid(model) : false
},
trackSessionCommit(
sessionID: string,
value: {
providerID: string
id: string
variant?: string
},
) {
const committed = selectionKey({ providerID: value.providerID, modelID: value.id, variant: value.variant })
pendingSelectionCommits.set(sessionID, committed)
return () => {
if (pendingSelectionCommits.get(sessionID) === committed) pendingSelectionCommits.delete(sessionID)
}
},
get ready() {
return modelStore.ready
return preferences.ready
},
get catalogReady() {
return models() !== undefined
},
recent() {
return modelStore.recent
return preferences.recent
},
favorite() {
return modelStore.favorite
return preferences.favorite
},
parsed: createMemo(() => {
const value = currentModel()
const value = currentSelection()
if (!value) {
return {
provider: "Connect a provider",
@@ -230,33 +343,28 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
reasoning: false,
}
}
const provider = data.location.provider.list()?.find((item) => item.id === value.providerID)
const info = data.location.model
.list()
?.find((item) => item.providerID === value.providerID && item.id === value.modelID)
const provider = providers()?.find((item) => item.id === value.providerID)
const info = models()?.find((item) => item.providerID === value.providerID && item.id === value.modelID)
return {
provider: provider?.name ?? value.providerID,
model: info?.name ?? value.modelID,
model: info?.name ?? `${value.modelID} (unavailable)`,
reasoning: (info?.variants?.length ?? 0) !== 0,
}
}),
cycle(direction: 1 | -1) {
const current = currentModel()
const current = currentSelection()
if (!current) return
const recent = modelStore.recent
const recent = recentModels(current, preferences.recent).filter(isModelValid)
const index = recent.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
if (index === -1) return
let next = index + direction
let next = index === -1 ? (direction === 1 ? 0 : recent.length - 1) : index + direction
if (next < 0) next = recent.length - 1
if (next >= recent.length) next = 0
const val = recent[next]
if (!val) return
const a = agent.current()
if (!a) return
setModelStore("model", a.id, { ...val })
selectModel({ ...val })
},
cycleFavorite(direction: 1 | -1) {
const favorites = modelStore.favorite.filter((item) => isModelValid(item))
const favorites = preferences.favorite.filter((item) => isModelValid(item))
if (!favorites.length) {
toast.show({
variant: "info",
@@ -265,7 +373,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
return
}
const current = currentModel()
const current = currentSelection()
let index = -1
if (current) {
index = favorites.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
@@ -279,45 +387,39 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
const next = favorites[index]
if (!next) return
const a = agent.current()
if (!a) return
setModelStore("model", a.id, { ...next })
setModelStore("recent", recentModels(next, modelStore.recent))
save()
if (!selectModel({ ...next })) return
setPreferences("recent", recentModels(next, preferences.recent))
savePreferences()
},
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
batch(() => {
if (!isModelValid(model)) return
const a = agent.current()
if (!a) return
setModelStore("model", a.id, model)
if (!selectModel(model)) return
if (options?.recent) {
setModelStore("recent", recentModels(model, modelStore.recent))
save()
setPreferences("recent", recentModels(model, preferences.recent))
savePreferences()
}
})
},
toggleFavorite(model: { providerID: string; modelID: string }) {
batch(() => {
if (!isModelValid(model)) return
const exists = modelStore.favorite.some(
const exists = preferences.favorite.some(
(x) => x.providerID === model.providerID && x.modelID === model.modelID,
)
const next = exists
? modelStore.favorite.filter((x) => x.providerID !== model.providerID || x.modelID !== model.modelID)
: [model, ...modelStore.favorite]
setModelStore(
? preferences.favorite.filter((x) => x.providerID !== model.providerID || x.modelID !== model.modelID)
: [model, ...preferences.favorite]
setPreferences(
"favorite",
next.map((x) => ({ providerID: x.providerID, modelID: x.modelID })),
)
save()
savePreferences()
})
},
variant: {
selected() {
const m = currentModel()
if (!m) return undefined
return normalizeModelVariant(modelStore.variant[modelPreferenceKey(m)])
return currentSelection()?.variant
},
current() {
const v = this.selected()
@@ -325,18 +427,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return undefined
},
list() {
const m = currentModel()
const m = currentSelection()
if (!m) return []
const info = data.location.model
.list()
?.find((item) => item.providerID === m.providerID && item.id === m.modelID)
const info = models()?.find((item) => item.providerID === m.providerID && item.id === m.modelID)
return info?.variants?.map((variant) => variant.id) ?? []
},
set(value: string | undefined) {
const m = currentModel()
const m = currentSelection()
if (!m) return
setModelStore("variant", modelPreferenceKey(m), normalizeModelVariant(value))
save()
if (route.data.type === "session") {
setSessionDraft(route.data.sessionID, { ...m, variant: normalizeModelVariant(value) })
return
}
setPreferences("variant", modelPreferenceKey(m), normalizeModelVariant(value))
savePreferences()
},
cycle() {
const variants = this.list()
+2 -2
View File
@@ -204,7 +204,7 @@ export function Session() {
const availableWidth = createMemo(
() =>
dimensions().width -
(config.tabs?.enabled && config.tabs.vertical && sessionTabsFitVertically(dimensions().width)
(config.tabs?.enabled && config.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width)
? SESSION_SIDEBAR_WIDTH
: 0),
)
@@ -361,7 +361,7 @@ export function Session() {
createEffect(() => {
const current = prompt()
if (sent || !current || !synced() || !local.model.ready) return
if (sent || !current || !synced() || !local.model.ready || !local.model.catalogReady) return
if (!local.agent.current() || !local.model.current()) return
if (!args.prompt || route.prompt?.text !== args.prompt || current.current.text !== args.prompt) return
sent = true
+6 -2
View File
@@ -18,7 +18,10 @@ test("validates mini replay settings", () => {
test("validates the session tabs setting", () => {
const decode = Schema.decodeUnknownSync(Info)
expect(decode({ tabs: { enabled: true, vertical: true } })).toEqual({ tabs: { enabled: true, vertical: true } })
expect(decode({ tabs: { enabled: true, layout: "vertical" } })).toEqual({
tabs: { enabled: true, layout: "vertical" },
})
expect(() => decode({ tabs: { layout: true } })).toThrow()
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
})
@@ -39,12 +42,13 @@ test("resolves nested config and keybind defaults", () => {
expect(config.scroll).toEqual({ speed: 2, acceleration: true })
expect(config.diffs).toEqual({ view: "split" })
expect(config.debug).toEqual({ devtools: true })
expect(config.tabs).toEqual({ enabled: true, scope: "cwd" })
expect(config.tabs).toEqual({ enabled: true, scope: "cwd", layout: "horizontal" })
})
test("shows resolved tab defaults in settings", () => {
expect(settings.find((setting) => setting.path.join(".") === "tabs.enabled")?.default).toBe(true)
expect(settings.find((setting) => setting.path.join(".") === "tabs.scope")?.default).toBe("cwd")
expect(settings.find((setting) => setting.path.join(".") === "tabs.layout")?.default).toBe("horizontal")
})
test("provides config and its host interface", async () => {