Compare commits

..

2 Commits

Author SHA1 Message Date
Kit Langton d7ffc7fec1 refactor(session): simplify pending withdrawal 2026-07-30 16:13:18 -04:00
Kit Langton 330ab9acae fix(tui): undo pending session input 2026-07-30 16:13:18 -04:00
142 changed files with 1505 additions and 3235 deletions
-2
View File
@@ -67,7 +67,6 @@
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:",
"@shikijs/transformers": "3.9.2",
@@ -521,7 +520,6 @@
"@opencode-ai/core": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@pierre/diffs": "catalog:",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
-2
View File
@@ -12,8 +12,6 @@
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `Model.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, `LLM.updateRequest`, and `LLM.generateObject`. Two ways to construct the same thing is one too many.
- Keep provider-defined string enums forward-compatible. Expose known values for autocomplete while accepting future values with `Known | (string & {})`; use `Schema.String` at runtime unless rejecting unknown values is required for correctness.
## Tests
- Use `testEffect(...)` from `test/lib/effect.ts` for tests requiring Effect layers.
+1 -2
View File
@@ -38,7 +38,7 @@ const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
// Protocols whose wire format ignores inline cache markers (OpenAI's implicit
// prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the
// whole policy pass for these — emitting hints would be harmless but pointless.
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse", "openrouter"])
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse"])
const makeHint = (ttlSeconds: number | undefined): CacheHint =>
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
@@ -133,7 +133,6 @@ const countHints = (request: LLMRequest) =>
export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
if (request.model.route.id === "openrouter" && (request.cache === undefined || request.cache === "auto")) return request
const policy = resolve(request.cache)
if (!policy.tools && !policy.system && !policy.messages) return request
+4 -58
View File
@@ -29,30 +29,9 @@ export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1bet
export interface OptionsInput {
readonly [key: string]: unknown
readonly cachedContent?: string
readonly safetySettings?: ReadonlyArray<{
readonly category:
| "HARM_CATEGORY_UNSPECIFIED"
| "HARM_CATEGORY_HATE_SPEECH"
| "HARM_CATEGORY_DANGEROUS_CONTENT"
| "HARM_CATEGORY_HARASSMENT"
| "HARM_CATEGORY_SEXUALLY_EXPLICIT"
| "HARM_CATEGORY_CIVIC_INTEGRITY"
| (string & {})
readonly threshold:
| "HARM_BLOCK_THRESHOLD_UNSPECIFIED"
| "BLOCK_LOW_AND_ABOVE"
| "BLOCK_MEDIUM_AND_ABOVE"
| "BLOCK_ONLY_HIGH"
| "BLOCK_NONE"
| "OFF"
| (string & {})
}>
readonly serviceTier?: "standard" | "flex" | "priority" | (string & {})
readonly thinkingConfig?: {
readonly thinkingBudget?: number
readonly includeThoughts?: boolean
readonly thinkingLevel?: "minimal" | "low" | "medium" | "high" | (string & {})
}
}
@@ -132,12 +111,6 @@ const GeminiToolConfig = Schema.Struct({
const GeminiThinkingConfig = Schema.Struct({
thinkingBudget: Schema.optional(Schema.Number),
includeThoughts: Schema.optional(Schema.Boolean),
thinkingLevel: Schema.optional(Schema.String),
})
const GeminiSafetySetting = Schema.Struct({
category: Schema.String,
threshold: Schema.String,
})
const GeminiGenerationConfig = Schema.Struct({
@@ -150,10 +123,7 @@ const GeminiGenerationConfig = Schema.Struct({
})
const GeminiBodyFields = {
cachedContent: Schema.optional(Schema.String),
contents: Schema.Array(GeminiContent),
safetySettings: optionalArray(GeminiSafetySetting),
serviceTier: Schema.optional(Schema.String),
systemInstruction: Schema.optional(GeminiSystemInstruction),
tools: optionalArray(GeminiTool),
toolConfig: Schema.optional(GeminiToolConfig),
@@ -346,38 +316,17 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
})
const resolveOptions = (request: LLMRequest) => {
const input = request.providerOptions?.gemini
const value = input?.thinkingConfig
const value = request.providerOptions?.gemini?.thinkingConfig
if (!ProviderShared.isRecord(value)) return {}
const thinkingConfig = {
thinkingBudget:
ProviderShared.isRecord(value) && typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
includeThoughts:
ProviderShared.isRecord(value) && typeof value.includeThoughts === "boolean"
? value.includeThoughts
: ProviderShared.isRecord(value)
? true
: undefined,
thinkingLevel:
ProviderShared.isRecord(value) && typeof value.thinkingLevel === "string" ? value.thinkingLevel : undefined,
thinkingBudget: typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
includeThoughts: typeof value.includeThoughts === "boolean" ? value.includeThoughts : undefined,
}
return {
cachedContent: typeof input?.cachedContent === "string" ? input.cachedContent : undefined,
safetySettings: mapSafetySettings(input?.safetySettings),
serviceTier: typeof input?.serviceTier === "string" ? input.serviceTier : undefined,
thinkingConfig: Object.values(thinkingConfig).some((item) => item !== undefined) ? thinkingConfig : undefined,
}
}
function mapSafetySettings(value: unknown) {
if (!Array.isArray(value)) return undefined
const settings = value.flatMap((item) =>
ProviderShared.isRecord(item) && typeof item.category === "string" && typeof item.threshold === "string"
? [{ category: item.category, threshold: item.threshold }]
: [],
)
return settings
}
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
const hasTools = request.tools.length > 0
const generation = request.generation
@@ -393,10 +342,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
}
return {
cachedContent: options.cachedContent,
contents: yield* lowerMessages(request),
safetySettings: options.safetySettings,
serviceTier: options.serviceTier,
systemInstruction:
request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] },
tools: hasTools
+26 -125
View File
@@ -11,7 +11,6 @@ import {
Usage,
type FinishReason,
type FinishReasonDetails,
type CacheHint,
type JsonSchema,
type LLMRequest,
type MediaPart,
@@ -39,11 +38,6 @@ export const PATH = "/chat/completions"
// The body schema is the provider-native JSON body. `fromRequest` below builds
// this shape from the common `LLMRequest`, then `Route.make` validates and
// JSON-encodes it before transport.
const OpenAIChatCacheControl = Schema.Struct({
type: Schema.Literal("ephemeral"),
ttl: Schema.optional(Schema.String),
})
const OpenAIChatFunction = Schema.Struct({
name: Schema.String,
description: Schema.String,
@@ -53,7 +47,6 @@ const OpenAIChatFunction = Schema.Struct({
const OpenAIChatTool = Schema.Struct({
type: Schema.tag("function"),
function: OpenAIChatFunction,
cache_control: Schema.optional(OpenAIChatCacheControl),
})
type OpenAIChatTool = Schema.Schema.Type<typeof OpenAIChatTool>
@@ -68,11 +61,7 @@ const OpenAIChatAssistantToolCall = Schema.Struct({
type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall>
const OpenAIChatUserContent = Schema.Union([
Schema.Struct({
type: Schema.Literal("text"),
text: Schema.String,
cache_control: Schema.optional(OpenAIChatCacheControl),
}),
Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }),
Schema.Struct({
type: Schema.Literal("image_url"),
image_url: Schema.Struct({ url: Schema.String }),
@@ -80,10 +69,7 @@ const OpenAIChatUserContent = Schema.Union([
])
const OpenAIChatMessage = Schema.Union([
Schema.Struct({
role: Schema.Literal("system"),
content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]),
}),
Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }),
Schema.Struct({
role: Schema.Literal("user"),
content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]),
@@ -97,16 +83,10 @@ const OpenAIChatMessage = Schema.Union([
reasoning: Schema.optional(Schema.String),
reasoning_text: Schema.optional(Schema.String),
reasoning_details: Schema.optional(Schema.Unknown),
cache_control: Schema.optional(OpenAIChatCacheControl),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
Schema.Struct({
role: Schema.Literal("tool"),
tool_call_id: Schema.String,
content: Schema.String,
cache_control: Schema.optional(OpenAIChatCacheControl),
}),
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
]).pipe(Schema.toTaggedUnion("role"))
type OpenAIChatMessage = Schema.Schema.Type<typeof OpenAIChatMessage>
@@ -127,7 +107,6 @@ export const bodyFields = {
stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })),
store: Schema.optional(Schema.Boolean),
reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
max_completion_tokens: Schema.optional(Schema.Number),
max_tokens: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Number),
top_p: Schema.optional(Schema.Number),
@@ -230,20 +209,13 @@ export interface ParserState {
// Lowering is the only place that knows how common LLM messages map onto the
// OpenAI Chat wire format. Keep provider quirks here instead of leaking native
// fields into `LLMRequest`.
interface LoweringOptions {
readonly cacheControl?: (
cache: CacheHint | undefined,
) => Schema.Schema.Type<typeof OpenAIChatCacheControl> | undefined
}
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema, options: LoweringOptions): OpenAIChatTool => ({
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): OpenAIChatTool => ({
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: ToolSchemaProjection.openAI(inputSchema),
},
cache_control: options.cacheControl?.(tool.cache),
})
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
@@ -285,14 +257,11 @@ const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown)
if (isRecord(native) && Array.isArray(native.reasoning_details)) return native.reasoning_details
}
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (
message: OpenAIChatRequestMessage,
options: LoweringOptions,
) {
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) {
const content: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
for (const part of message.content) {
if (part.type === "text") {
content.push({ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) })
content.push({ type: "text", text: part.text })
continue
}
if (part.type === "media") {
@@ -301,18 +270,14 @@ const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (
}
return yield* ProviderShared.unsupportedContent("OpenAI Chat", "user", ["text", "media"])
}
if (content.every((part) => part.type === "text" && part.cache_control === undefined))
return {
role: "user" as const,
content: content.map((part) => (part.type === "text" ? part.text : "")).join(""),
}
if (content.every((part) => part.type === "text"))
return { role: "user" as const, content: content.map((part) => part.text).join("") }
return { role: "user" as const, content }
})
const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(function* (
message: OpenAIChatRequestMessage,
configuredField?: string,
options: LoweringOptions = {},
) {
const content: TextPart[] = []
const reasoning: ReasoningPart[] = []
@@ -350,44 +315,29 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
if (reasoning.length === 0) return nativeReasoning
return text
})()
const cached = message.content.findLast((part) => "cache" in part && part.cache !== undefined)
const result = {
role: "assistant" as const,
content: content.length === 0 ? null : ProviderShared.joinText(content),
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
reasoning_details: details,
cache_control: options.cacheControl?.(cached && "cache" in cached ? cached.cache : undefined),
}
if (field === undefined || reasoningText === undefined) return result
return { ...result, [field]: reasoningText }
})
const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
message: OpenAIChatRequestMessage,
options: LoweringOptions,
) {
const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (message: OpenAIChatRequestMessage) {
const messages: OpenAIChatMessage[] = []
const images: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["tool-result"]))
return yield* ProviderShared.unsupportedContent("OpenAI Chat", "tool", ["tool-result"])
if (part.result.type !== "content") {
messages.push({
role: "tool",
tool_call_id: part.id,
content: ProviderShared.toolResultText(part),
cache_control: options.cacheControl?.(part.cache),
})
messages.push({ role: "tool", tool_call_id: part.id, content: ProviderShared.toolResultText(part) })
continue
}
const content: ReadonlyArray<Tool.Content> = part.result.value
const text = content.filter((item) => item.type === "text").map((item) => item.text)
messages.push({
role: "tool",
tool_call_id: part.id,
content: text.join("\n"),
cache_control: options.cacheControl?.(part.cache),
})
messages.push({ role: "tool", tool_call_id: part.id, content: text.join("\n") })
const files = content.filter((item) => item.type === "file")
images.push(
...(yield* Effect.forEach(files, (item) =>
@@ -401,29 +351,15 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
message: OpenAIChatRequestMessage,
reasoningField?: string,
options: LoweringOptions = {},
) {
if (message.role === "user") return [yield* lowerUserMessage(message, options)]
if (message.role === "assistant") return [yield* lowerAssistantMessage(message, reasoningField, options)]
return (yield* lowerToolMessages(message, options)).messages
if (message.role === "user") return [yield* lowerUserMessage(message)]
if (message.role === "assistant") return [yield* lowerAssistantMessage(message, reasoningField)]
return (yield* lowerToolMessages(message)).messages
})
const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest, options: LoweringOptions) {
const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest) {
const system: OpenAIChatMessage[] =
request.system.length === 0
? []
: request.system.some((part) => part.cache !== undefined) && options.cacheControl !== undefined
? [
{
role: "system",
content: request.system.map((part) => ({
type: "text",
text: part.text,
cache_control: options.cacheControl?.(part.cache),
})),
},
]
: [{ role: "system", content: ProviderShared.joinText(request.system) }]
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
const messages = [...system]
const pendingImages: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
const flushImages = () => {
@@ -434,53 +370,28 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message)
if (pendingImages.length > 0) {
messages.push({
role: "user",
content: [
...pendingImages.splice(0),
{ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) },
],
})
messages.push({ role: "user", content: [...pendingImages.splice(0), { type: "text", text: part.text }] })
continue
}
const previous = messages.at(-1)
if (previous?.role === "user" && typeof previous.content === "string")
messages[messages.length - 1] = options.cacheControl?.(part.cache)
? {
role: "user",
content: [
{ type: "text", text: previous.content },
{ type: "text", text: part.text, cache_control: options.cacheControl(part.cache) },
],
}
: { role: "user", content: `${previous.content}\n${part.text}` }
messages[messages.length - 1] = { role: "user", content: `${previous.content}\n${part.text}` }
else if (previous?.role === "user" && Array.isArray(previous.content))
messages[messages.length - 1] = {
role: "user",
content: [
...previous.content,
{ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) },
],
content: [...previous.content, { type: "text", text: part.text }],
}
else
messages.push(
options.cacheControl?.(part.cache)
? {
role: "user",
content: [{ type: "text", text: part.text, cache_control: options.cacheControl(part.cache) }],
}
: { role: "user", content: part.text },
)
else messages.push({ role: "user", content: part.text })
continue
}
if (message.role === "tool") {
const lowered = yield* lowerToolMessages(message, options)
const lowered = yield* lowerToolMessages(message)
messages.push(...lowered.messages)
pendingImages.push(...lowered.images)
continue
}
flushImages()
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField, options)))
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField)))
}
flushImages()
return messages
@@ -494,10 +405,7 @@ const lowerOptions = (request: LLMRequest) => {
}
}
export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
request: LLMRequest,
options: LoweringOptions = {},
) {
const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMRequest) {
// `fromRequest` returns the provider body only. Endpoint, auth, framing,
// validation, and HTTP execution are composed by `Route.make`.
const reasoningField = request.model.compatibility?.reasoningField
@@ -507,26 +415,19 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
)
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const maxTokensField = request.model.compatibility?.maxTokensField ?? "max_tokens"
return {
model: request.model.id,
messages: yield* lowerMessages(request, options),
messages: yield* lowerMessages(request),
tools:
request.tools.length === 0
? undefined
: request.tools.map((tool) =>
lowerTool(
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
options,
),
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
),
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
stream: true as const,
stream_options: { include_usage: true },
...(maxTokensField === "max_completion_tokens"
? { max_completion_tokens: generation?.maxTokens }
: { max_tokens: generation?.maxTokens }),
max_tokens: generation?.maxTokens,
temperature: generation?.temperature,
top_p: generation?.topP,
frequency_penalty: generation?.frequencyPenalty,
-2
View File
@@ -1,12 +1,10 @@
import type { Model, ProviderOptions } from "./schema"
export interface Settings extends Readonly<Record<string, unknown>> {
readonly baseURL?: string
readonly headers?: Readonly<Record<string, string>>
readonly body?: Readonly<Record<string, unknown>>
readonly limits?: {
readonly context: number
readonly input?: number
readonly output: number
}
}
+10 -84
View File
@@ -4,72 +4,21 @@ import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing"
import { Protocol } from "../route/protocol"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import { ProviderID, type CacheHint, type ModelID, type ProviderOptions } from "../schema"
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
import type { ProviderPackage } from "../provider-package"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
import * as OpenAIChat from "../protocols/openai-chat"
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache"
import { isRecord } from "../protocols/shared"
export const profile = OpenAICompatibleProfiles.profiles.openrouter
export const id = ProviderID.make(profile.provider)
const ADAPTER = "openrouter"
type OpenRouterString<Known extends string> = Known | (string & {})
export interface OpenRouterProviderRouting {
readonly [key: string]: unknown
readonly order?: ReadonlyArray<string>
readonly allow_fallbacks?: boolean
readonly require_parameters?: boolean
readonly data_collection?: OpenRouterString<"allow" | "deny">
readonly only?: ReadonlyArray<string>
readonly ignore?: ReadonlyArray<string>
readonly quantizations?: ReadonlyArray<string>
readonly sort?: OpenRouterString<"price" | "throughput" | "latency">
readonly max_price?: Readonly<{
prompt?: number | string
completion?: number | string
image?: number | string
audio?: number | string
request?: number | string
}>
readonly zdr?: boolean
}
export type OpenRouterPlugin =
| Readonly<{
id: "web"
max_results?: number
search_prompt?: string
engine?: OpenRouterString<"native" | "exa">
}>
| Readonly<{ id: "file-parser"; max_files?: number; pdf?: { engine?: string } }>
| Readonly<{ id: "moderation" }>
| Readonly<{ id: "response-healing" }>
| Readonly<{ id: "auto-router"; allowed_models?: ReadonlyArray<string> }>
| Readonly<{ id: string & {}; [key: string]: unknown }>
export interface OpenRouterOptions {
readonly [key: string]: unknown
readonly debug?: Readonly<{ echo_upstream_body?: boolean }>
readonly models?: ReadonlyArray<string>
readonly plugins?: ReadonlyArray<OpenRouterPlugin>
readonly usage?: boolean | Record<string, unknown>
readonly reasoning?: Record<string, unknown>
readonly promptCacheKey?: string
readonly provider?: OpenRouterProviderRouting
readonly reasoning?: Readonly<{
enabled?: boolean
exclude?: boolean
effort?: OpenRouterString<"none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max">
max_tokens?: number
}>
readonly usage?: boolean | Readonly<{ include: boolean }>
readonly user?: string
readonly web_search_options?: Readonly<{
max_results?: number
search_prompt?: string
engine?: OpenRouterString<"native" | "exa">
}>
}
export type OpenRouterProviderOptionsInput = ProviderOptions & {
@@ -98,7 +47,7 @@ export const protocol = Protocol.make({
body: {
schema: OpenRouterBody,
from: (request) =>
OpenAIChat.fromRequest(request, { cacheControl: cacheControl() }).pipe(
OpenAIChat.protocol.body.from(request).pipe(
Effect.map((body) => {
const sourceAssistants = request.messages.filter((message) => message.role === "assistant")
let assistantIndex = 0
@@ -129,39 +78,16 @@ export const protocol = Protocol.make({
stream: OpenAIChat.protocol.stream,
})
const cacheControl = () => {
const breakpoints = newBreakpoints(4)
return (cache: CacheHint | undefined) => {
if (cache === undefined || breakpoints.remaining === 0) return undefined
breakpoints.remaining -= 1
return {
type: "ephemeral" as const,
...(ttlBucket(cache.ttlSeconds) === "1h" ? { ttl: "1h" } : {}),
}
}
}
const bodyOptions = (input: unknown) => {
const openrouter = isRecord(input) ? input : {}
const { usage, models, provider, plugins, web_search_options, debug, user, reasoning, promptCacheKey, ...options } =
openrouter
return {
...options,
...(usage === undefined || usage === true
...(openrouter.usage === true
? { usage: { include: true } }
: usage === false
? { usage: { include: false } }
: isRecord(usage)
? { usage }
: {}),
...(Array.isArray(models) ? { models } : {}),
...(isRecord(provider) ? { provider } : {}),
...(Array.isArray(plugins) ? { plugins } : {}),
...(isRecord(web_search_options) ? { web_search_options } : {}),
...(isRecord(debug) ? { debug } : {}),
...(typeof user === "string" ? { user } : {}),
...(isRecord(reasoning) ? { reasoning } : {}),
...(typeof promptCacheKey === "string" ? { prompt_cache_key: promptCacheKey } : {}),
: isRecord(openrouter.usage)
? { usage: openrouter.usage }
: {}),
...(isRecord(openrouter.reasoning) ? { reasoning: openrouter.reasoning } : {}),
...(typeof openrouter.promptCacheKey === "string" ? { prompt_cache_key: openrouter.promptCacheKey } : {}),
}
}
+17 -26
View File
@@ -5,7 +5,7 @@ import { Endpoint, type EndpointPatch } from "./endpoint"
import { RequestExecutor } from "./executor"
import { Framing } from "./framing"
import { HttpTransport } from "./transport"
import type { HttpRequestTransform, Transport, TransportRuntime } from "./transport"
import type { Transport, TransportRuntime } from "./transport"
import { WebSocketExecutor } from "./transport"
import type { Protocol } from "./protocol"
import { applyCachePolicy } from "../cache-policy"
@@ -46,11 +46,7 @@ export interface Route<Body, Prepared = unknown> {
readonly body: RouteBody<Body>
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared>
readonly model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedModelInput) => Model<Options>
readonly prepareTransport: (
body: Body,
request: LLMRequest,
options?: StreamOptions,
) => Effect.Effect<Prepared, LLMError>
readonly prepareTransport: (body: Body, request: LLMRequest) => Effect.Effect<Prepared, LLMError>
readonly streamPrepared: (
prepared: Prepared,
request: LLMRequest,
@@ -149,16 +145,12 @@ export interface Interface {
readonly generate: GenerateMethod
}
export interface StreamOptions {
readonly transform?: HttpRequestTransform
}
export interface StreamMethod {
(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, LLMError>
(request: LLMRequest): Stream.Stream<LLMEvent, LLMError>
}
export interface GenerateMethod {
(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, LLMError>
(request: LLMRequest): Effect.Effect<LLMResponse, LLMError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
@@ -294,7 +286,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
},
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedModelInput) =>
makeRouteModel<Options>(route, input),
prepareTransport: (body, request, options) =>
prepareTransport: (body, request) =>
routeInput.transport.prepare({
body,
request,
@@ -302,7 +294,6 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
auth: routeInput.auth ?? Auth.none,
encodeBody,
headers: routeInput.headers,
transform: options?.transform,
}),
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
const route = `${request.model.provider}/${request.model.route.id}`
@@ -368,14 +359,14 @@ export function make<Body, Prepared, Frame, Event, State>(
})
}
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
const resolved = applyCachePolicy(resolveRequestOptions(request))
const route = resolved.model.route
const body = yield* route.body
.from(resolved)
.pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema))))
const prepared = yield* route.prepareTransport(body, resolved, options)
const prepared = yield* route.prepareTransport(body, resolved)
return {
request: resolved,
@@ -398,17 +389,17 @@ export const compileRequest = Effect.fn("LLM.compileRequest")(function* (request
}
})
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest, options?: StreamOptions) =>
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =>
Stream.unwrap(
Effect.gen(function* () {
const compiled = yield* compile(request, options)
const compiled = yield* compile(request)
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)
}),
)
const generateWith = (stream: Interface["stream"]) =>
Effect.fn("LLM.generate")(function* (request: LLMRequest, options?: StreamOptions) {
const state = yield* stream(request, options).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
Effect.fn("LLM.generate")(function* (request: LLMRequest) {
const state = yield* stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
const response = LLMResponse.complete(state)
if (response) return response
return yield* ProviderShared.eventError(
@@ -417,24 +408,24 @@ const generateWith = (stream: Interface["stream"]) =>
)
})
export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, LLMError> {
export function stream(request: LLMRequest): Stream.Stream<LLMEvent, LLMError> {
return Stream.unwrap(
Effect.gen(function* () {
return (yield* Service).stream(request, options)
return (yield* Service).stream(request)
}),
) as Stream.Stream<LLMEvent, LLMError>
}
export function generate(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, LLMError> {
export function generate(request: LLMRequest): Effect.Effect<LLMResponse, LLMError> {
return Effect.gen(function* () {
return yield* (yield* Service).generate(request, options)
return yield* (yield* Service).generate(request)
}) as Effect.Effect<LLMResponse, LLMError>
}
export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
export const streamRequest = (request: LLMRequest) =>
Stream.unwrap(
Effect.gen(function* () {
return (yield* Service).stream(request, options)
return (yield* Service).stream(request)
}),
)
+1 -2
View File
@@ -8,7 +8,6 @@ export type {
AnyRoute,
Interface as LLMClientShape,
Service as LLMClientService,
StreamOptions,
} from "./client"
export * from "./executor"
export { Auth } from "./auth"
@@ -23,4 +22,4 @@ export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-option
export type { Definition as EndpointFn, EndpointInput } from "./endpoint"
export type { Definition as FramingDef } from "./framing"
export type { Protocol as ProtocolDef } from "./protocol"
export type { HttpRequest, HttpRequestTransform, Transport as TransportDef, TransportRuntime } from "./transport"
export type { Transport as TransportDef, TransportRuntime } from "./transport"
+55 -12
View File
@@ -28,9 +28,57 @@ const applyQuery = (url: string, query: Record<string, string> | undefined) => {
return next.toString()
}
const PROTOCOL_BODY_OVERLAY_DENYLIST = new Set([
"anthropic_version",
"content",
"contents",
"frequencyPenalty",
"frequency_penalty",
"generationConfig",
"inferenceConfig",
"input",
"maxTokens",
"max_tokens",
"messages",
"model",
"presencePenalty",
"presence_penalty",
"responseFormat",
"response_format",
"seed",
"stop",
"stopSequences",
"stop_sequences",
"stream",
"streamOptions",
"stream_options",
"system",
"systemInstruction",
"system_instruction",
"temperature",
"thinking",
"toolChoice",
"toolConfig",
"tool_choice",
"tool_config",
"tools",
"topK",
"topP",
"top_k",
"top_p",
])
const forbiddenBodyOverlayKeys = (body: Record<string, unknown>) =>
Object.keys(body).filter((key) => PROTOCOL_BODY_OVERLAY_DENYLIST.has(key))
const bodyWithOverlay = <Body>(body: Body, request: LLMRequest, encodeBody: (body: Body) => string) =>
Effect.gen(function* () {
if (request.http?.body === undefined) return { jsonBody: body, bodyText: encodeBody(body) }
const forbiddenKeys = forbiddenBodyOverlayKeys(request.http.body)
if (forbiddenKeys.length > 0)
return yield* ProviderShared.invalidRequest(
`http.body cannot overlay protocol-owned field(s): ${forbiddenKeys.join(", ")}`,
)
if (ProviderShared.isRecord(body)) {
const overlaid = mergeJsonRecords(body, request.http.body) ?? {}
return { jsonBody: overlaid, bodyText: ProviderShared.encodeJson(overlaid) }
@@ -72,19 +120,14 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
id: "http-json",
with: (patch) => httpJson({ ...input, ...patch }),
prepare: (prepareInput) =>
Effect.gen(function* () {
const parts = yield* jsonRequestParts({ ...prepareInput })
const request = { url: parts.url, method: "POST", headers: { ...parts.headers }, body: parts.bodyText }
yield* (prepareInput.transform?.(request) ?? Effect.void)
return {
request: ProviderShared.jsonPost({
url: request.url,
body: request.body ?? "",
headers: Headers.fromInput(request.headers),
}),
jsonRequestParts({
...prepareInput,
}).pipe(
Effect.map((parts) => ({
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
framing: input.framing,
}
}),
})),
),
frames: (prepared, request, runtime) =>
Stream.unwrap(
runtime.http
-10
View File
@@ -10,15 +10,6 @@ export interface TransportRuntime {
readonly webSocket?: WebSocketExecutorInterface
}
export interface HttpRequest {
url: string
readonly method: string
headers: Record<string, string>
body: string | undefined
}
export type HttpRequestTransform = (request: HttpRequest) => Effect.Effect<void>
export interface Transport<Body, Prepared, Frame> {
readonly id: string
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, LLMError>
@@ -36,7 +27,6 @@ export interface TransportPrepareInput<Body> {
readonly auth: Auth.Definition
readonly encodeBody: (body: Body) => string
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
readonly transform?: HttpRequestTransform
}
export * as HttpTransport from "./http"
-2
View File
@@ -124,7 +124,6 @@ export const ToolCallPart = Object.assign(
name: Schema.String,
input: Schema.Unknown,
providerExecuted: Schema.optional(Schema.Boolean),
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Content.ToolCall" }),
@@ -169,7 +168,6 @@ export const ReasoningPart = Schema.Struct({
type: Schema.Literal("reasoning"),
text: Schema.String,
encrypted: Schema.optional(Schema.String),
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Content.Reasoning" })
-5
View File
@@ -123,7 +123,6 @@ export const mergeGenerationOptions = (...items: ReadonlyArray<GenerationOptions
export class ModelLimits extends Schema.Class<ModelLimits>("LLM.ModelLimits")({
context: Schema.optional(Schema.Number),
input: Schema.optional(Schema.Number),
output: Schema.optional(Schema.Number),
}) {}
@@ -167,13 +166,9 @@ export namespace ModelDefaults {
export const ModelToolSchemaCompatibility = Schema.Literals(["gemini", "moonshot"])
export type ModelToolSchemaCompatibility = Schema.Schema.Type<typeof ModelToolSchemaCompatibility>
export const ModelMaxTokensFieldCompatibility = Schema.Literals(["max_completion_tokens", "max_tokens"])
export type ModelMaxTokensFieldCompatibility = Schema.Schema.Type<typeof ModelMaxTokensFieldCompatibility>
export class ModelCompatibility extends Schema.Class<ModelCompatibility>("LLM.ModelCompatibility")({
toolSchema: Schema.optional(ModelToolSchemaCompatibility),
reasoningField: Schema.optional(Schema.String),
maxTokensField: Schema.optional(ModelMaxTokensFieldCompatibility),
}) {}
export namespace ModelCompatibility {
+17 -54
View File
@@ -137,61 +137,24 @@ describe("request option precedence", () => {
),
)
it.effect("transforms the final HTTP request after serialization and authentication", () =>
LLMClient.generate(
LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("fresh-key") })
.model({ id: "gpt-4o-mini" }),
prompt: "Say hello.",
}),
{
transform: (request) =>
Effect.sync(() => {
expect(request.headers.authorization).toBe("Bearer fresh-key")
request.url = "https://proxy.test/v1/chat/completions"
request.headers["x-plugin"] = "transformed"
request.body = JSON.stringify({ transformed: true })
}),
},
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(web.url).toBe("https://proxy.test/v1/chat/completions")
expect(web.headers.get("x-plugin")).toBe("transformed")
expect(decodeJson(input.text)).toEqual({ transformed: true })
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
)
it.effect("rejects raw body overlays for protocol-owned roots", () =>
Effect.gen(function* () {
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" })
const error = yield* compileRequest(
LLM.request({
model,
prompt: "Say hello.",
http: { body: { model: "gpt-5", messages: [], tools: [] } },
}),
).pipe(Effect.flip)
it.effect("applies raw body overlays after protocol lowering", () =>
LLMClient.generate(
LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" }),
prompt: "Say hello.",
http: { body: { model: "gpt-5", messages: [], tools: [] } },
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
expect(decodeJson(input.text)).toMatchObject({ model: "gpt-5", messages: [], tools: [] })
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
expect(error.reason).toMatchObject({
_tag: "InvalidRequest",
message: "http.body cannot overlay protocol-owned field(s): model, messages, tools",
})
}),
)
it.effect("uses model output limits after route limits and before call maxTokens", () =>
@@ -9,39 +9,9 @@ LLM.request({
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1024 } } },
})
LLM.request({
model,
prompt: "Hello",
providerOptions: {
gemini: {
// @ts-expect-error Gemini safety settings require a threshold for every category.
safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH" }],
},
},
})
LLM.request({
model,
prompt: "Hello",
providerOptions: {
gemini: {
cachedContent: "cachedContents/example",
safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" }],
serviceTier: "future-tier",
thinkingConfig: { thinkingLevel: "high", includeThoughts: true },
},
},
})
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Gemini thinking budgets must be numeric.
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "large" } } },
})
LLM.request({
model,
prompt: "Hello",
providerOptions: { gemini: { thinkingConfig: { thinkingLevel: "maximum" } } },
})
@@ -5,28 +5,6 @@ const model = OpenRouter.provider.model("anthropic/claude-sonnet-4.5")
LLM.request({ model, prompt: "Hello", providerOptions: { openrouter: { usage: true } } })
LLM.request({
model,
prompt: "Hello",
providerOptions: {
openrouter: {
models: ["google/gemini-3.1-pro"],
provider: {
order: ["anthropic"],
require_parameters: true,
data_collection: "future-policy",
sort: "future-sort",
max_price: { prompt: "0.50" },
},
reasoning: { effort: "future-effort", exclude: false },
plugins: [{ id: "future-plugin", enabled: true }],
web_search_options: { engine: "future-engine" },
debug: { echo_upstream_body: true },
user: "user_123",
},
},
})
LLM.request({
model,
prompt: "Hello",
+1 -29
View File
@@ -41,14 +41,7 @@ describe("Gemini route", () => {
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: {
gemini: {
cachedContent: "cachedContents/example",
safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" }],
serviceTier: "priority",
thinkingConfig: { thinkingBudget: 0, includeThoughts: false, thinkingLevel: "high" },
},
},
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 0, includeThoughts: false } } },
}),
)
const filtered = yield* compileRequest(
@@ -56,33 +49,12 @@ describe("Gemini route", () => {
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "invalid", includeThoughts: false } } },
}),
)
const defaulted = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: { gemini: { thinkingConfig: { thinkingLevel: "high" } } },
}),
)
const emptySafetySettings = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: { gemini: { safetySettings: [] } },
}),
)
expect(prepared.body.generationConfig?.thinkingConfig).toEqual({
thinkingBudget: 0,
includeThoughts: false,
thinkingLevel: "high",
})
expect(prepared.body.cachedContent).toBe("cachedContents/example")
expect(prepared.body.safetySettings).toEqual([
{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" },
])
expect(prepared.body.serviceTier).toBe("priority")
expect(filtered.body.generationConfig?.thinkingConfig).toEqual({ includeThoughts: false })
expect(defaulted.body.generationConfig?.thinkingConfig).toEqual({
includeThoughts: true,
thinkingLevel: "high",
})
expect(emptySafetySettings.body.safetySettings).toEqual([])
}),
)
@@ -181,6 +181,23 @@ describe("Google Vertex providers", () => {
}),
)
it.effect("protects the Vertex Messages API version from body overlays", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
model: GoogleVertexMessages.configure({
accessToken: "vertex-token",
http: { body: { anthropic_version: "wrong" } },
project: "vertex-project",
}).model("claude-sonnet-4-6"),
prompt: "Say hello.",
}),
).pipe(Effect.flip)
expect(error.message).toContain("http.body cannot overlay protocol-owned field(s): anthropic_version")
}),
)
it.effect("routes tuned Gemini models through their deployed endpoint", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
@@ -144,20 +144,6 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
it.effect("configures the max tokens request field", () =>
Effect.gen(function* () {
const compatible = OpenAICompatibleChat.route
.with({ provider: "custom", endpoint: { baseURL: "https://api.custom.test/v1" } })
.model({ id: "custom-model", compatibility: { maxTokensField: "max_completion_tokens" } })
const prepared = yield* compileRequest(
LLM.request({ model: compatible, prompt: "Say hello.", generation: { maxTokens: 20 } }),
)
expect(prepared.body).toMatchObject({ max_completion_tokens: 20 })
expect(prepared.body).not.toHaveProperty("max_tokens")
}),
)
it.effect("matches AI SDK compatible tool request body fixture", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
+1 -177
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { CacheHint, LLM, Message } from "../../src"
import { LLM, Message } from "../../src"
import { LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import * as OpenRouter from "../../src/providers/openrouter"
@@ -27,131 +27,10 @@ describe("OpenRouter", () => {
model: "openai/gpt-4o-mini",
messages: [{ role: "user", content: "Say hello." }],
stream: true,
usage: { include: true },
})
}),
)
it.effect("lowers the native cache policy to OpenRouter cache controls", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
system: [
{ type: "text", text: "Base agent", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3_600 }) },
{ type: "text", text: "Project instructions" },
],
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
prompt: "Hello",
cache: { tools: true, system: true, messages: { tail: 1 } },
}),
)
expect(prepared.body).toMatchObject({
tools: [{ cache_control: { type: "ephemeral" } }],
messages: [
{
role: "system",
content: [
{ text: "Base agent", cache_control: { type: "ephemeral", ttl: "1h" } },
{ text: "Project instructions", cache_control: { type: "ephemeral" } },
],
},
{
role: "user",
content: [{ text: "Hello", cache_control: { type: "ephemeral" } }],
},
],
})
}),
)
it.effect("lowers manual assistant and tool-result cache hints", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
messages: [
Message.user("Call the tool"),
Message.assistant([
{ type: "text", text: "Calling", cache: new CacheHint({ type: "ephemeral" }) },
{ type: "tool-call", id: "call_1", name: "lookup", input: {} },
]),
Message.tool({
id: "call_1",
name: "lookup",
result: "Done",
cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3_600 }),
}),
],
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "user", content: "Call the tool" },
{ role: "assistant", content: "Calling", cache_control: { type: "ephemeral" } },
{ role: "tool", content: '"Done"', cache_control: { type: "ephemeral", ttl: "1h" } },
])
}),
)
it.effect("caps manual cache controls at four breakpoints", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" })
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
system: [1, 2, 3, 4, 5].map((index) => ({ type: "text" as const, text: `System ${index}`, cache })),
prompt: "Hello",
}),
)
const system = prepared.body.messages[0]
expect(system?.role).toBe("system")
expect(
system && Array.isArray(system.content)
? system.content.filter((part) => "cache_control" in part && part.cache_control !== undefined)
: [],
).toHaveLength(4)
}),
)
it.effect("preserves cache policy hints on reasoning-only assistant messages", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: { messages: "latest-assistant" },
messages: [Message.user("Think"), Message.assistant([{ type: "reasoning", text: "Reasoning" }])],
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "user", content: "Think" },
{ role: "assistant", cache_control: { type: "ephemeral" } },
])
}),
)
it.effect("allows usage accounting to be disabled explicitly", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({
apiKey: "test-key",
providerOptions: { openrouter: { usage: false } },
}).model("openai/gpt-4o-mini"),
cache: "none",
prompt: "Hello",
}),
)
expect(prepared.body.usage).toEqual({ include: false })
}),
)
it.effect("applies OpenRouter payload options from the model helper", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -163,13 +42,6 @@ describe("OpenRouter", () => {
usage: true,
reasoning: { effort: "high" },
promptCacheKey: "session_123",
models: ["anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro"],
provider: { order: ["anthropic", "google"], require_parameters: true },
plugins: [{ id: "response-healing" }],
web_search_options: { engine: "native", max_results: 3 },
debug: { echo_upstream_body: true },
user: "user_123",
future_option: { enabled: true },
},
},
}).model("anthropic/claude-3.7-sonnet:thinking"),
@@ -181,54 +53,10 @@ describe("OpenRouter", () => {
usage: { include: true },
reasoning: { effort: "high" },
prompt_cache_key: "session_123",
models: ["anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro"],
provider: { order: ["anthropic", "google"], require_parameters: true },
plugins: [{ id: "response-healing" }],
web_search_options: { engine: "native", max_results: 3 },
debug: { echo_upstream_body: true },
user: "user_123",
future_option: { enabled: true },
})
}),
)
it.effect("filters invalid known OpenRouter options while preserving extensions", () =>
Effect.gen(function* () {
const invalid: Record<string, unknown> = {
usage: "yes",
models: "anthropic/claude-sonnet-4.6",
provider: [],
plugins: {},
web_search_options: [],
debug: [],
user: 123,
reasoning: [],
promptCacheKey: 123,
future_option: { enabled: true },
}
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({
apiKey: "test-key",
providerOptions: { openrouter: invalid },
}).model("openai/gpt-4o-mini"),
prompt: "Hello",
}),
)
expect(prepared.body).toMatchObject({ future_option: { enabled: true } })
expect(prepared.body).not.toHaveProperty("usage")
expect(prepared.body).not.toHaveProperty("models")
expect(prepared.body).not.toHaveProperty("provider")
expect(prepared.body).not.toHaveProperty("plugins")
expect(prepared.body).not.toHaveProperty("web_search_options")
expect(prepared.body).not.toHaveProperty("debug")
expect(prepared.body).not.toHaveProperty("user")
expect(prepared.body).not.toHaveProperty("reasoning")
expect(prepared.body).not.toHaveProperty("prompt_cache_key")
}),
)
it.effect("preserves the upstream provider finish reason", () =>
Effect.gen(function* () {
const model = OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6")
@@ -276,7 +104,6 @@ describe("OpenRouter", () => {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
messages: [
Message.assistant([
{
@@ -310,7 +137,6 @@ describe("OpenRouter", () => {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
messages: [
Message.assistant({
type: "reasoning",
@@ -336,7 +162,6 @@ describe("OpenRouter", () => {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
messages: [
Message.assistant({
type: "reasoning",
@@ -358,7 +183,6 @@ describe("OpenRouter", () => {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
messages: [Message.assistant({ type: "reasoning", text: "Thinking" })],
}),
)
-1
View File
@@ -59,7 +59,6 @@
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:",
"@shikijs/transformers": "3.9.2",
@@ -4,7 +4,6 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createMutation } from "@tanstack/solid-query"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { useGlobal } from "@/context/global"
import { ServerConnection, serverName } from "@/context/server"
import { displayName, projectForSession } from "@/pages/layout/helpers"
@@ -55,10 +54,7 @@ export function TabNavItem(props: {
if (!session) return
return projectForSession(session, serverCtx()?.projects.list() ?? [])
})
const title = createMemo(() => {
const session = props.session()
return session ? displayLabel(session) : props.fallbackTitle
})
const title = createMemo(() => props.session()?.title ?? props.fallbackTitle)
const projectName = createMemo(() => {
const session = props.session()
@@ -147,7 +143,7 @@ export function TabNavItem(props: {
if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return
const session = props.session()
if (!session) return
titleEl.textContent = session.title ?? ""
titleEl.textContent = session.title
setEditing(true)
requestAnimationFrame(() => {
@@ -306,7 +302,7 @@ export function TabNavItem(props: {
}}
data={{
projectName: projectName(),
title: title(),
title: props.session()?.title,
path: previewPath(),
serverName: serverLabel(),
}}
@@ -153,4 +153,42 @@ describe("v2 session reducer", () => {
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
})
test("forgets withdrawn input before a later promotion", () => {
const reducer = createV2SessionReducer()
reducer.reduce(
[],
event({
...base,
id: "evt_admitted",
type: "session.input.admitted",
data: {
sessionID: "ses_1",
inputID: "msg_user",
input: { type: "user", delivery: "steer", data: { text: "hello" } },
},
}),
)
reducer.reduce(
[],
event({
...base,
id: "evt_withdrawn",
type: "session.input.withdrawn",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
const result = reducer.reduce(
[],
event({
...base,
id: "evt_promoted",
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
})
})
@@ -29,6 +29,9 @@ export function createV2SessionReducer() {
case "session.input.admitted":
pending.set(key(sessionID, event.data.inputID), event.data.input)
return result([...source])
case "session.input.withdrawn":
pending.delete(key(sessionID, event.data.inputID))
return
case "session.input.promoted": {
const input = pending.get(key(sessionID, event.data.inputID))
pending.delete(key(sessionID, event.data.inputID))
@@ -3,7 +3,6 @@ import { useLanguage } from "@/context/language"
import { serverName } from "@/context/server"
import { displayName } from "@/pages/layout/helpers"
import { makeEventListener } from "@solid-primitives/event-listener"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { HomeController } from "./home-controller"
@@ -24,7 +23,7 @@ export function createHomeSessionSearchController(home: HomeController, sessions
if (!value) return []
return sessions.data
.searchRecords()
.filter((record) => `${displayLabel(record.session)} ${record.projectName}`.toLowerCase().includes(value))
.filter((record) => `${record.session.title} ${record.projectName}`.toLowerCase().includes(value))
})
const active = createMemo(() => {
const records = results()
@@ -6,10 +6,10 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
import { sessionTitle } from "@/utils/session-title"
import { shouldOpenSessionInBackground } from "../home-session-open"
import {
HomeSessionStatusController,
@@ -344,7 +344,7 @@ function HomeSessionSearchResultRow(
selected: boolean
},
) {
const title = createMemo(() => displayLabel(props.record.session))
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const showProjectName = () => props.showProjectName() && props.record.projectName
const key = () => homeSessionSearchKey(props.record)
@@ -415,7 +415,7 @@ function HomeSessionGroupHeader(props: {
}
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
const title = createMemo(() => displayLabel(props.record.session))
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const showProjectName = () => props.showProjectName() && props.record.projectName
return (
@@ -5,7 +5,6 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { getFilename } from "@opencode-ai/core/util/path"
import { A, useParams } from "@solidjs/router"
import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js"
@@ -15,6 +14,7 @@ import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { messageAgentColor } from "@/utils/agent"
import { sessionTitle } from "@/utils/session-title"
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
import { childSessionOnPath, getProjectAvatarSource, hasProjectPermissions } from "./helpers"
@@ -104,7 +104,7 @@ const SessionRow = (props: {
warmPress: () => void
warmFocus: () => void
}): JSX.Element => {
const title = () => displayLabel(props.session)
const title = () => sessionTitle(props.session.title)
return (
<A
@@ -229,7 +229,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
fallback={
<Tooltip
placement={props.mobile ? "bottom" : "right"}
value={displayLabel(props.session)}
value={sessionTitle(props.session.title)}
gutter={10}
class="min-w-0 w-full"
>
@@ -70,7 +70,7 @@ import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/sessio
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { sessionTitle } from "@/utils/session-title"
import { scheduleConnectedMeasure } from "./measure"
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
import { createTimelineProjection } from "./projection"
@@ -296,11 +296,8 @@ export function MessageTimeline(props: {
if (!id) return
return sync().session.get(id)
})
const titleLabel = createMemo(() => {
const session = info()
if (!session) return
return displayLabel(session)
})
const titleValue = createMemo(() => info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()))
const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
const parentID = createMemo(() => info()?.parentID)
@@ -314,10 +311,7 @@ export function MessageTimeline(props: {
if (!id) return emptyMessages
return sync().data.message[id] ?? emptyMessages
})
const parentTitle = createMemo(() => {
const session = parent()
return session ? displayLabel(session) : language.t("command.session.new")
})
const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
const getMsgParts = (msgId: string) => sync().data.part[msgId] ?? emptyParts
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
const childTaskDescription = createMemo(() => {
@@ -335,7 +329,7 @@ export function MessageTimeline(props: {
if (value) return value
return language.t("command.session.new")
})
const showHeader = createMemo(() => !!(titleLabel() || parentID()))
const showHeader = createMemo(() => !!(titleValue() || parentID()))
const projection = createTimelineProjection({
messages: sessionMessages,
userMessages: () => props.userMessages,
@@ -918,10 +912,9 @@ export function MessageTimeline(props: {
}
function DialogDeleteSession(props: { sessionID: string }) {
const name = createMemo(() => {
const session = sync().session.get(props.sessionID)
return session ? displayLabel(session) : language.t("command.session.new")
})
const name = createMemo(
() => sessionTitle(sync().session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
)
const handleDelete = async () => {
await deleteSession(props.sessionID)
dialog.close()
@@ -38,19 +38,17 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
instance.scrollOffset = offset
})
document.body.append(unrelated)
unrelated.remove()
await frames(2)
expect(calls).toEqual([])
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
await frames(3)
expect(calls).toEqual([[0, false]])
instance.scrollOffset = 79_400
document.body.append(unrelated)
unrelated.remove()
await frames(2)
expect(calls).toEqual([[0, false]])
instance.scrollOffset = 0
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
+7
View File
@@ -0,0 +1,7 @@
const pattern = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
export function sessionTitle(title?: string) {
if (!title) return title
const match = title.match(pattern)
return match?.[1] ?? title
}
+1 -2
View File
@@ -1,6 +1,5 @@
import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
import type { Session } from "@opencode-ai/sdk/v2/client"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
export function normalizeSessionInfo(input: SessionInfo | Session): Session {
if (!("location" in input)) return input
@@ -14,7 +13,7 @@ export function normalizeSessionInfo(input: SessionInfo | Session): Session {
parentID: input.parentID,
cost: input.cost,
tokens: input.tokens,
title: withTimestampedFallback(input),
title: input.title,
agent: input.agent,
model: input.model,
version: "",
+2 -3
View File
@@ -58,9 +58,8 @@ export async function collectNodeAssets(target: NodeTarget) {
source: path.join(ptyRoot, relative),
})),
]
const unique = [...new Map(assets.map((asset) => [asset.key, asset])).values()]
await Promise.all(unique.map((asset) => stat(asset.source)))
return unique
await Promise.all(assets.map((asset) => stat(asset.source)))
return assets
}
export async function hashNodeAssets(assets: readonly NodeAsset[]) {
+1 -2
View File
@@ -8,7 +8,6 @@ import {
type SessionMessageInfo,
type SkillInfo,
} from "@opencode-ai/client/promise"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import type {
AgentSideConnection,
AuthenticateRequest,
@@ -214,7 +213,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
sessions: page.data.map((session) => ({
sessionId: session.id,
cwd: session.location.directory,
title: withTimestampedFallback(session),
title: session.title,
updatedAt: new Date(session.time.updated).toISOString(),
})),
...(page.cursor.next ? { nextCursor: page.cursor.next } : {}),
-17
View File
@@ -1,17 +0,0 @@
import { expect, test } from "bun:test"
import { fileURLToPath } from "node:url"
import { collectNodeAssets } from "../script/node-assets"
import { nodeTarget, shellParserWasmAssets } from "../src/node/target"
test("collects each SEA asset key once", async () => {
const assets = await collectNodeAssets(nodeTarget(process.platform, process.arch))
const keys = assets.map((asset) => asset.key)
expect(new Set(keys).size).toBe(keys.length)
expect(assets.filter((asset) => asset.key === shellParserWasmAssets.runtime)).toEqual([
{
key: shellParserWasmAssets.runtime,
source: fileURLToPath(import.meta.resolve(shellParserWasmAssets.runtime)),
},
])
})
+19 -1
View File
@@ -370,6 +370,15 @@ export type Endpoint5_26Output =
readonly input: SessionPending.Message
}
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.input.withdrawn"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
@@ -841,6 +850,12 @@ export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly messa
export type Endpoint5_29Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
export type Endpoint5_30Output = boolean
export type SessionPendingWithdrawOperation<E = never> = (
input: Endpoint5_30Input,
) => Effect.Effect<Endpoint5_30Output, E>
export interface SessionApi<E = never> {
readonly list: SessionListOperation<E>
readonly create: SessionCreateOperation<E>
@@ -865,7 +880,10 @@ export interface SessionApi<E = never> {
readonly commit: SessionRevertCommitOperation<E>
}
readonly context: SessionContextOperation<E>
readonly pending: { readonly list: SessionPendingListOperation<E> }
readonly pending: {
readonly list: SessionPendingListOperation<E>
readonly withdraw: SessionPendingWithdrawOperation<E>
}
readonly instructions: {
readonly entry: {
readonly list: SessionInstructionsEntryListOperation<E>
+11 -1
View File
@@ -76,6 +76,8 @@ import type {
Endpoint5_28Output,
Endpoint5_29Input,
Endpoint5_29Output,
Endpoint5_30Input,
Endpoint5_30Output,
Endpoint6_0Input,
Endpoint6_0Output,
Endpoint7_0Input,
@@ -550,6 +552,14 @@ const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29I
),
)
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
preserveEffect<Endpoint5_30Output>()(
raw["session.pending.withdraw"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const adaptGroup5 = (raw: RawClient["server.session"]) => ({
list: Endpoint5_0(raw),
create: Endpoint5_1(raw),
@@ -570,7 +580,7 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
wait: Endpoint5_16(raw),
revert: { stage: Endpoint5_17(raw), clear: Endpoint5_18(raw), commit: Endpoint5_19(raw) },
context: Endpoint5_20(raw),
pending: { list: Endpoint5_21(raw) },
pending: { list: Endpoint5_21(raw), withdraw: Endpoint5_30(raw) },
instructions: { entry: { list: Endpoint5_22(raw), put: Endpoint5_23(raw), remove: Endpoint5_24(raw) } },
generate: Endpoint5_25(raw),
log: Endpoint5_26(raw),
@@ -70,6 +70,8 @@ import type {
SessionBackgroundOutput,
SessionMessageInput,
SessionMessageOutput,
SessionPendingWithdrawInput,
SessionPendingWithdrawOutput,
MessageListInput,
MessageListOutput,
ModelListInput,
@@ -732,6 +734,17 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
withdraw: (input: SessionPendingWithdrawInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionPendingWithdrawOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/withdraw`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
},
instructions: {
entry: {
+21 -2
View File
@@ -637,6 +637,16 @@ export type SessionInputPromoted = {
data: { sessionID: string; inputID: string }
}
export type SessionInputWithdrawn = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.input.withdrawn"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inputID: string }
}
export type SessionExecutionStarted = {
id: string
created: number
@@ -1757,7 +1767,7 @@ export type SessionInfo = {
cost: MoneyUSD
tokens: TokenUsageInfo
time: { created: number; updated: number; archived?: number }
title?: string
title: string
location: LocationRef
subpath?: string
revert?: SessionRevert
@@ -2006,7 +2016,7 @@ export type SessionV1Info = {
cost?: number
tokens?: { input: number; output: number; reasoning: number; cache: { read: number; write: number } }
share?: { url: string }
title?: string
title: string
agent?: string
model?: { id: string; providerID: string; variant?: string }
version: string
@@ -2157,6 +2167,7 @@ export type SessionEventDurable =
| SessionForked
| SessionInputPromoted
| SessionInputAdmitted
| SessionInputWithdrawn
| SessionExecutionStarted
| SessionExecutionSucceeded
| SessionExecutionFailed
@@ -2252,6 +2263,7 @@ export type V2Event =
| SessionForked
| SessionInputPromoted
| SessionInputAdmitted
| SessionInputWithdrawn
| SessionExecutionStarted
| SessionExecutionSucceeded
| SessionExecutionFailed
@@ -3207,6 +3219,13 @@ export type SessionMessageInput = {
export type SessionMessageOutput = { data: SessionMessageInfo }["data"]
export type SessionPendingWithdrawInput = {
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
}
export type SessionPendingWithdrawOutput = { data: boolean }["data"]
export type MessageListInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly limit?: {
+11 -3
View File
@@ -300,7 +300,7 @@ test("session instructions methods use the public HTTP contract", async () => {
])
})
test("session.pending.list uses the public HTTP contract", async () => {
test("session.pending uses the public HTTP contract", async () => {
const requests: Array<{ method: string; url: string }> = []
const pending = [
{
@@ -317,14 +317,22 @@ test("session.pending.list uses the public HTTP contract", async () => {
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push({ method: request.method, url: request.url })
return Response.json({ data: pending })
return Response.json({ data: request.method === "GET" ? pending : true })
},
})
const result = await client.session.pending.list({ sessionID: "ses_test" })
const withdrawn = await client.session.pending.withdraw({ sessionID: "ses_test", inputID: "msg_pending" })
expect(result).toEqual(pending)
expect(requests).toEqual([{ method: "GET", url: "http://localhost:3000/api/session/ses_test/pending" }])
expect(withdrawn).toBe(true)
expect(requests).toEqual([
{ method: "GET", url: "http://localhost:3000/api/session/ses_test/pending" },
{
method: "POST",
url: "http://localhost:3000/api/session/ses_test/pending/msg_pending/withdraw",
},
])
})
test("event.subscribe exposes the Promise event stream wire projection", async () => {
+3 -3
View File
@@ -1,9 +1,9 @@
{
"version": "7",
"dialect": "sqlite",
"id": "e43ed7e2-b9fc-4178-beae-3646e4a976e1",
"id": "db37a97f-9b5e-4c87-be8b-4feace35136c",
"prevIds": [
"db37a97f-9b5e-4c87-be8b-4feace35136c"
"a4ba73b4-21bc-41ab-a415-94e2ca38d798"
],
"ddl": [
{
@@ -1302,7 +1302,7 @@
},
{
"type": "text",
"notNull": false,
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
-131
View File
@@ -1,131 +0,0 @@
export * as AISDKNative from "./aisdk-native"
import { isRecord } from "@opencode-ai/ai/utils/record"
import { Provider } from "./provider"
export interface Mapping {
readonly package: string
readonly settings: Readonly<Record<string, unknown>>
readonly headers?: Readonly<Record<string, string>>
readonly body?: Readonly<Record<string, unknown>>
}
export function map(packageName: string | undefined, settings: Readonly<Record<string, unknown>>): Mapping | undefined {
const baseSettings = mapBaseSettings(settings)
switch (packageName) {
case "@ai-sdk/google":
return {
package: "@opencode-ai/ai/providers/google",
settings: {
...baseSettings,
...mapAPIKey(settings),
...mapGoogleOptions(settings),
},
}
case "@openrouter/ai-sdk-provider":
return mapOpenRouter(settings, baseSettings)
case "@ai-sdk/xai":
return {
package: "@opencode-ai/ai/providers/xai",
settings: {
...baseSettings,
...mapAPIKey(settings),
...mapXAIOptions(settings),
},
}
}
}
function mapBaseSettings(settings: Readonly<Record<string, unknown>>) {
return {
...(typeof settings.baseURL === "string" ? { baseURL: settings.baseURL } : {}),
}
}
function mapAPIKey(settings: Readonly<Record<string, unknown>>) {
return typeof settings.apiKey === "string" ? { apiKey: settings.apiKey } : {}
}
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
const input = settings.thinkingConfig
const thinkingConfig = {
...(isRecord(input) && typeof input.thinkingBudget === "number" ? { thinkingBudget: input.thinkingBudget } : {}),
...(isRecord(input) && typeof input.includeThoughts === "boolean"
? { includeThoughts: input.includeThoughts }
: {}),
...(isRecord(input) && typeof input.thinkingLevel === "string" ? { thinkingLevel: input.thinkingLevel } : {}),
}
const options = {
...(typeof settings.cachedContent === "string" ? { cachedContent: settings.cachedContent } : {}),
...(Array.isArray(settings.safetySettings) ? { safetySettings: settings.safetySettings } : {}),
...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}),
...(Object.keys(thinkingConfig).length > 0 ? { thinkingConfig } : {}),
}
if (Object.keys(options).length === 0) return {}
return { providerOptions: { gemini: options } }
}
function mapOpenRouter(
settings: Readonly<Record<string, unknown>>,
baseSettings: Readonly<Record<string, unknown>>,
): Mapping {
const headers =
Provider.mergeHeaders(
{
...(typeof settings.appName === "string" ? { "X-OpenRouter-Title": settings.appName } : {}),
...(typeof settings.appUrl === "string" ? { "HTTP-Referer": settings.appUrl } : {}),
...(isStringRecord(settings.api_keys) && Object.keys(settings.api_keys).length > 0
? { "X-Provider-API-Keys": JSON.stringify(settings.api_keys) }
: {}),
},
isStringRecord(settings.headers) ? settings.headers : undefined,
) ?? {}
return {
package: "@opencode-ai/ai/providers/openrouter",
settings: {
...baseSettings,
...mapAPIKey(settings),
...mapOpenRouterOptions(settings),
},
...(Object.keys(headers).length > 0 ? { headers } : {}),
...(isRecord(settings.extraBody) ? { body: settings.extraBody } : {}),
}
}
function mapOpenRouterOptions(settings: Readonly<Record<string, unknown>>) {
const options = Object.fromEntries(
Object.entries(settings).filter(
([key]) =>
![
"apiKey",
"api_keys",
"appName",
"appUrl",
"authToken",
"baseURL",
"chunkTimeout",
"compatibility",
"extraBody",
"fetch",
"headers",
"timeout",
].includes(key),
),
)
if (Object.keys(options).length === 0) return {}
return { providerOptions: { openrouter: options } }
}
function isStringRecord(value: unknown): value is Readonly<Record<string, string>> {
return isRecord(value) && Object.values(value).every((item) => typeof item === "string")
}
function mapXAIOptions(settings: Readonly<Record<string, unknown>>) {
const options = {
...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}),
...(typeof settings.store === "boolean" ? { store: settings.store } : {}),
...(typeof settings.promptCacheKey === "string" ? { promptCacheKey: settings.promptCacheKey } : {}),
}
if (Object.keys(options).length === 0) return {}
return { providerOptions: { xai: options } }
}
+1 -1
View File
@@ -332,7 +332,7 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
body: projected.body === undefined ? undefined : { ...projected.body },
headers: info.headers,
},
limits: { context: info.limit.context, input: info.limit.input, output: info.limit.output },
limits: { context: info.limit.context, output: info.limit.output },
providerOptions,
},
body: {
+3 -3
View File
@@ -16,7 +16,7 @@ import { Global } from "@opencode-ai/util/global"
import { Location } from "./location"
import { AbsolutePath } from "./schema"
import { ConfigAgent } from "./config/agent"
import { ConfigMedia } from "./config/media"
import { ConfigAttachments } from "./config/attachments"
import { ConfigCompaction } from "./config/compaction"
import { ConfigCommand } from "./config/command"
import { ConfigExperimental } from "./config/experimental"
@@ -85,8 +85,8 @@ export class Info extends Schema.Class<Info>("Config.Info")({
lsp: ConfigLSP.Info.pipe(Schema.optional).annotate({
description: "Enable built-in language servers or configure server overrides",
}),
media: ConfigMedia.Info.pipe(Schema.optional).annotate({
description: "Media processing configuration",
attachments: ConfigAttachments.Info.pipe(Schema.optional).annotate({
description: "Attachment processing configuration",
}),
tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({
description: "Tool output truncation thresholds",
@@ -1,15 +1,15 @@
export * as ConfigMedia from "./media"
export * as ConfigAttachments from "./attachments"
import { Schema } from "effect"
import { PositiveInt } from "../schema"
export class Image extends Schema.Class<Image>("Config.Media.Image")({
export class Image extends Schema.Class<Image>("Config.Attachments.Image")({
auto_resize: Schema.Boolean.pipe(Schema.optional),
max_width: PositiveInt.pipe(Schema.optional),
max_height: PositiveInt.pipe(Schema.optional),
max_base64_bytes: PositiveInt.pipe(Schema.optional),
}) {}
export class Info extends Schema.Class<Info>("Config.Media")({
export class Info extends Schema.Class<Info>("Config.Attachments")({
image: Image.pipe(Schema.optional),
}) {}
-1
View File
@@ -58,6 +58,5 @@ export const migrations = (
import("./migration/20260722011141_delete_tool_progress_events"),
import("./migration/20260722170000_canonical_tool_results"),
import("./migration/20260729022634_session_fork_boundary"),
import("./migration/20260730195856_optional_session_title"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -1,14 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260730195856_optional_session_title",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session\` RENAME COLUMN \`title\` TO \`title_old\``)
yield* tx.run(`ALTER TABLE \`session\` ADD COLUMN \`title\` text`)
yield* tx.run(`UPDATE \`session\` SET \`title\` = \`title_old\``)
yield* tx.run(`ALTER TABLE \`session\` DROP COLUMN \`title_old\``)
})
},
} satisfies DatabaseMigration.Migration
+1 -1
View File
@@ -217,7 +217,7 @@ export default {
\`slug\` text NOT NULL,
\`directory\` text NOT NULL,
\`path\` text,
\`title\` text,
\`title\` text NOT NULL,
\`version\` text NOT NULL,
\`share_url\` text,
\`summary_additions\` integer,
+1 -1
View File
@@ -61,7 +61,7 @@ const layer = Layer.effect(
const image = Object.assign(
{},
...(yield* config.entries()).flatMap((entry) =>
entry.type === "document" && entry.info.media?.image ? [entry.info.media.image] : [],
entry.type === "document" && entry.info.attachments?.image ? [entry.info.attachments.image] : [],
),
)
const normalize = yield* loadAdapter
+63 -13
View File
@@ -12,12 +12,12 @@ import { Auth, type AnyRoute } from "@opencode-ai/ai/route"
import { Context, Effect, Layer, Schema } from "effect"
import { produce } from "immer"
import { AISDK } from "./aisdk"
import { AISDKNative } from "./aisdk-native"
import { Catalog } from "./catalog"
import { Credential } from "./credential"
import { Integration } from "./integration"
import { Capabilities, ID, Info, Ref, VariantID } from "./model"
import { Npm } from "@opencode-ai/util/npm"
import { OpenAICodex } from "./plugin/provider/openai-codex"
import { Provider } from "./provider"
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
@@ -81,7 +81,7 @@ const withDefaults = (model: Info, route: AnyRoute) =>
headers: providerHeaders(model),
providerOptions: providerOptions(model),
http: model.body === undefined ? undefined : { body: model.body },
limits: { context: model.limit.context, input: model.limit.input, output: model.limit.output },
limits: { context: model.limit.context, output: model.limit.output },
})
const providerHeaders = (model: Info) => {
@@ -96,7 +96,9 @@ const providerHeaders = (model: Info) => {
return Provider.mergeHeaders(generated.size === 0 ? undefined : Object.fromEntries(generated), model.headers)
}
const providerOptions = (model: Info): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => {
const providerOptions = (
model: Info,
): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => {
if (!Provider.isAISDK(model.package) || model.settings === undefined) return undefined
const { apiKey: _, baseURL: _baseURL, ...settings } = model.settings
if (Object.keys(settings).length === 0) return undefined
@@ -150,7 +152,12 @@ export const fromCatalogModel = (
const packageName = Provider.packageName(resolved.package)
const key = apiKey(resolved, credential)
if (OpenAICodex.isChatGPT(credential) && !Provider.isAISDK(resolved.package) && isNativeOpenAI(resolved.package)) {
return Effect.succeed(codexModel(resolved, credential, key))
}
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
if (OpenAICodex.isChatGPT(credential)) return Effect.succeed(codexModel(resolved, credential, key))
return Effect.succeed(
withDefaults(resolved, OpenAIResponses.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
@@ -175,10 +182,8 @@ export const fromCatalogModel = (
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
const configured = { ...resolved.settings, ...credential?.metadata }
const mapping = Provider.isAISDK(resolved.package) ? AISDKNative.map(packageName, configured) : undefined
const native = mapping?.package ?? resolved.package
if (Provider.isAISDK(resolved.package) && !mapping) {
const native = Provider.isAISDK(resolved.package) ? nativePackage(packageName) : resolved.package
if (Provider.isAISDK(resolved.package) && !native) {
if (!dependencies?.loadAISDK) return Effect.fail(unsupported(resolved))
const runtime = produce(resolved, (draft) => {
draft.settings = Provider.mergeOverlay(draft.settings, {
@@ -196,13 +201,15 @@ export const fromCatalogModel = (
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
Effect.mapError(() => unsupported(resolved)),
)
const mapped = mapping?.settings ?? configured
const configured = { ...resolved.settings, ...credential?.metadata }
const providerOptions = nativeProviderOptions(packageName, configured)
const settings = {
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
...(credential ? withoutNativeAuthSettings(configured) : configured),
...nativeCredentialSettings(specifier, credential),
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
body: Provider.mergeOverlay(mapping?.body, resolved.body),
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
headers: resolved.headers,
body: resolved.body,
limits: { context: resolved.limit.context, output: resolved.limit.output },
...(providerOptions ? { providerOptions } : {}),
}
return yield* Effect.try({
try: () => {
@@ -219,6 +226,30 @@ export const fromCatalogModel = (
})
}
const nativePackage = (packageName: string | undefined) => {
if (packageName === "@ai-sdk/google") return "@opencode-ai/ai/providers/google"
if (packageName === "@openrouter/ai-sdk-provider") return "@opencode-ai/ai/providers/openrouter"
if (packageName === "@ai-sdk/xai") return "@opencode-ai/ai/providers/xai"
return undefined
}
const nativeProviderOptions = (packageName: string | undefined, settings: Readonly<Record<string, unknown>>) => {
const values = Object.fromEntries(
Object.entries(settings).filter(
([key]) => !["apiKey", "authToken", "baseURL", "chunkTimeout", "fetch", "timeout"].includes(key),
),
)
if (Object.keys(values).length === 0) return undefined
if (packageName === "@ai-sdk/google") return { gemini: values }
if (packageName === "@openrouter/ai-sdk-provider") return { openrouter: values }
if (packageName === "@ai-sdk/xai") return { xai: values }
return undefined
}
const isNativeOpenAI = (packageName: string | undefined) =>
packageName === "@opencode-ai/ai/providers/openai" ||
packageName?.startsWith("@opencode-ai/ai/providers/openai/") === true
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
if (!credential) return {}
if (credential.type === "key") return { apiKey: credential.key }
@@ -240,6 +271,22 @@ const withoutNativeAuthSettings = (settings: Record<string, unknown>) => {
return rest
}
const codexModel = (
model: Info,
credential: Credential.Value | undefined,
key: ReturnType<typeof Auth.value> | undefined,
) => {
const account = OpenAICodex.accountID(credential)
return withDefaults(model, OpenAIResponses.route)
.with({
endpoint: { baseURL: OpenAICodex.baseURL },
auth: (key === undefined ? Auth.none : Auth.bearer(key)).andThen(
account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }),
),
})
.model({ id: model.modelID ?? model.id, compatibility: model.compatibility })
}
const unsupported = (model: Info) =>
new UnsupportedPackageError({
providerID: model.providerID,
@@ -264,7 +311,10 @@ export const layer = Layer.effect(
const integrations = yield* Integration.Service
const npm = yield* Npm.Service
const aisdk = yield* AISDK.Service
const load = Effect.fn("ModelResolver.resolveModel")(function* (selected: Info, variant?: VariantID) {
const load = Effect.fn("ModelResolver.resolveModel")(function* (
selected: Info,
variant?: VariantID,
) {
const provider = yield* catalog.provider.get(selected.providerID)
const connection = yield* integrations.connection.active(
provider?.integrationID ?? Integration.ID.make(selected.providerID),
@@ -0,0 +1,42 @@
export * as OpenAICodex from "./openai-codex"
// TEMPORARY SEAM (#34765): plugins have no hook into LLM route construction, so
// Codex routing lives in ModelResolver and catalog filtering.
// in OpenAIPlugin, sharing this module. Once the native provider packages land
// (#33689/#33925/#34462) this should collapse into the native OpenAI provider.
// The eligibility rules mirror V1's CodexAuthPlugin allowlist; models.dev has no
// plan-eligibility data for OpenAI today, but models other vendors' subscriptions
// as dedicated providers (e.g. zai-coding-plan) - a future openai-chatgpt-plan
// provider entry could replace the hardcoded rules with catalog data.
/** ChatGPT-plan requests must target the codex backend instead of the public API. */
export const baseURL = "https://chatgpt.com/backend-api/codex"
const methodIDs: readonly string[] = ["chatgpt-browser", "chatgpt-headless"]
/** Structural credential shape so both core and plugin-facing credential types fit. */
type CredentialLike = {
readonly type: string
readonly methodID?: string
readonly metadata?: Record<string, unknown> | undefined
}
export const isChatGPT = (credential: CredentialLike | undefined) =>
credential?.type === "oauth" && credential.methodID !== undefined && methodIDs.includes(credential.methodID)
export const accountID = (credential: CredentialLike | undefined) => {
if (!isChatGPT(credential)) return undefined
const value = credential?.metadata?.accountID
return typeof value === "string" ? value : undefined
}
const allowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"])
const disallowed = new Set(["gpt-5.5-pro", "gpt-5.6"])
/** Which API model ids a ChatGPT subscription may call through the codex backend. */
export const eligible = (apiID: string) => {
if (allowed.has(apiID)) return true
if (disallowed.has(apiID)) return false
const match = apiID.match(/^gpt-(\d+\.\d+)/)
return match ? Number.parseFloat(match[1]) > 5.4 : false
}
+24 -36
View File
@@ -10,16 +10,14 @@ import { Model } from "../../model"
import { OauthCallbackPage } from "../../oauth/page"
import { Provider } from "../../provider"
import type { PluginInternal } from "../internal"
import { OpenAICodex } from "./openai-codex"
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
const issuer = "https://auth.openai.com"
const callbackPort = 1455
const pollingSafetyMargin = 3000
const codexBaseURL = "https://chatgpt.com/backend-api/codex"
const browserMethodID = Integration.MethodID.make("chatgpt-browser")
const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
const codexAllowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"])
const codexDisallowed = new Set(["gpt-5.5-pro", "gpt-5.6"])
type Pkce = {
verifier: string
@@ -166,18 +164,14 @@ export const OpenAIPlugin = define({
effect: Effect.fn(function* (ctx) {
const bus = yield* Bus.Service
const loading = Semaphore.makeUnsafe(1)
let chatgpt: Credential.OAuth | undefined
let chatgpt = false
const load = Effect.fn("OpenAIPlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active("openai")
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
: undefined
chatgpt =
credential?.type === "oauth" &&
(credential.methodID === browserMethodID || credential.methodID === headlessMethodID)
? credential
: undefined
chatgpt = OpenAICodex.isChatGPT(credential)
})
yield* ctx.integration.transform((draft) => {
@@ -189,19 +183,16 @@ export const OpenAIPlugin = define({
for (const item of evt.provider.list()) {
if (!Provider.isAISDK(item.provider.package)) continue
if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai") continue
evt.provider.update(item.provider.id, (provider) => {
provider.package = "@opencode-ai/ai/providers/openai"
if (!item.models.has(Model.ID.make("gpt-5-chat-latest"))) continue
evt.model.update(item.provider.id, Model.ID.make("gpt-5-chat-latest"), (model) => {
// OpenAIPlugin sends OpenAI models through Responses; this alias is a
// chat-completions-only model, so hide it only from OpenAI's catalog.
model.enabled = false
})
}
if (!chatgpt) return
const item = evt.provider.get(Provider.ID.openai)
if (!item) return
item.provider.settings = Provider.mergeOverlay(item.provider.settings, { baseURL: codexBaseURL })
const account = chatgpt.metadata?.accountID
item.provider.headers = Provider.mergeHeaders(
item.provider.headers,
typeof account === "string" ? { "chatgpt-account-id": account } : undefined,
)
for (const model of item.models.values()) {
// ChatGPT-plan tokens only authorize codex-eligible models, and the
// subscription covers usage, so hide the rest and zero the cost.
@@ -210,32 +201,14 @@ export const OpenAIPlugin = define({
draft.enabled = false
return
}
const apiID = draft.modelID ?? draft.id
const match = apiID.match(/^gpt-(\d+\.\d+)/)
if (
!codexAllowed.has(apiID) &&
(codexDisallowed.has(apiID) || !match || Number.parseFloat(match[1]) <= 5.4)
) {
if (!OpenAICodex.eligible(draft.modelID ?? draft.id)) {
draft.enabled = false
return
}
draft.cost = []
// Match Codex CLI so context consumption and subscription usage stay consistent between clients.
draft.limit = { ...draft.limit, context: 272_000, input: 272_000 }
})
}
})
yield* ctx.session.hook("request", (evt) =>
Effect.sync(() => {
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
const url = new URL(evt.url)
if (url.origin === "https://api.openai.com") {
evt.url = `${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`
}
evt.headers.originator = "opencode"
evt.headers["session-id"] = evt.sessionID
}),
)
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
@@ -243,6 +216,21 @@ export const OpenAIPlugin = define({
Stream.runForEach(refresh),
Effect.forkScoped({ startImmediately: true }),
)
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/openai") return
const mod = yield* Effect.promise(() => import("@ai-sdk/openai"))
evt.sdk = mod.createOpenAI(evt.options)
}),
)
yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) {
if (evt.model.providerID !== Provider.ID.openai) return
evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id)
}),
)
}),
} satisfies PluginInternal.InternalPlugin)
+12 -4
View File
@@ -209,6 +209,10 @@ export interface Interface {
* unhandled compaction barriers.
*/
readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError>
readonly withdraw: (input: {
sessionID: SessionSchema.ID
inputID: SessionMessage.ID
}) => Effect.Effect<boolean, NotFoundError>
/**
* Durable, ordered session log read. Replays durable session bus after
* the exclusive `after` cursor, emits a `Synced` marker at the captured
@@ -374,7 +378,7 @@ const layer = Layer.effect(
directory: location.directory,
path: path.relative(project.directory, location.directory).replaceAll("\\", "/"),
workspaceID: location.workspaceID ? Workspace.ID.make(location.workspaceID) : undefined,
title: input.title,
title: input.title ?? `New session - ${new Date(now).toISOString()}`,
agent: input.agent,
model: input.model
? {
@@ -540,6 +544,10 @@ const layer = Layer.effect(
yield* result.get(sessionID)
return yield* SessionPending.list(db, sessionID)
}),
withdraw: Effect.fn("Session.withdraw")(function* (input) {
yield* result.get(input.sessionID)
return yield* SessionPending.withdraw(db, bus, input)
}),
log: (input) =>
Stream.unwrap(
result
@@ -938,7 +946,7 @@ const materializeAttachment = Effect.fn("Session.materializeAttachment")(functio
: resolved.bytes
const normalized = yield* normalizeImageAttachment(
input,
Buffer.from(content).toString("base64"),
Base64.make(Buffer.from(content).toString("base64")),
mime,
image,
)
@@ -954,11 +962,11 @@ const materializeAttachment = Effect.fn("Session.materializeAttachment")(functio
const normalizeImageAttachment = Effect.fn("Session.normalizeImageAttachment")(function* (
input: PromptInput.FileAttachment,
data: string,
data: Base64,
mime: string,
image: Effect.Effect<Image.Interface>,
) {
if (!mime.startsWith("image/")) return { data: Base64.make(data), mime }
if (!mime.startsWith("image/")) return { data, mime }
const service = yield* image
const label = input.name ?? (input.uri.startsWith("data:") ? "inline attachment" : input.uri)
const content = { uri: label, content: data, encoding: "base64" as const, mime }
+10 -12
View File
@@ -152,11 +152,14 @@ const settings = (documents: readonly Config.Entry[]) => {
const configured = documents
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((entry) => (entry.info.compaction ? [entry.info.compaction] : []))
return {
auto: configured.findLast((value) => value.auto !== undefined)?.auto ?? true,
buffer: configured.findLast((value) => value.buffer !== undefined)?.buffer ?? DEFAULT_BUFFER,
tokens: configured.findLast((value) => value.keep?.tokens !== undefined)?.keep?.tokens ?? DEFAULT_KEEP_TOKENS,
}
return configured.reduce<Settings>(
(result, current) => ({
auto: current.auto ?? result.auto,
buffer: current.buffer ?? result.buffer,
tokens: current.keep?.tokens ?? result.tokens,
}),
{ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS },
)
}
const select = (
@@ -347,16 +350,11 @@ const make = (dependencies: Dependencies) => {
message.type === "assistant" && message.tokens !== undefined,
)
if (!last) return false
const limits = input.model.route.defaults.limits
const output = Math.min(limits?.output ?? 0, OUTPUT_TOKEN_MAX)
const promptCeiling = Math.min(
limits?.input === undefined ? Number.POSITIVE_INFINITY : limits.input - config.buffer,
context - Math.max(output, config.buffer),
)
const output = Math.min(input.model.route.defaults.limits?.output ?? 0, OUTPUT_TOKEN_MAX)
const used =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
if (used <= 0) return false
return used >= promptCeiling
return used >= context - (output || config.buffer)
}
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
const content = planContent(input.messages, config.tokens)
+1 -1
View File
@@ -71,7 +71,7 @@ export const layer = Layer.effect(
LLM.request({
model: model.model,
http: { headers: SessionModelHeaders.make(selection.session, app) },
providerOptions: { [providerMetadataKey]: { promptCacheKey } },
providerOptions: { openai: { promptCacheKey } },
system: contextEvent.system,
messages: contextEvent.messages,
tools: hookedTools,
+7 -6
View File
@@ -117,20 +117,21 @@ export const preview = Effect.fn("SessionHistory.preview")(function* (
.pipe(Effect.catch((error) => (error instanceof Instructions.InitializationBlocked ? error : Effect.die(error))))
})
/** Returns the session's first user message. */
export const firstUserMessage = Effect.fn("SessionHistory.firstUserMessage")(function* (
/** Returns the session's sole user message, or `undefined` once a second one exists. */
export const firstUserMessageIfOnly = Effect.fn("SessionHistory.firstUserMessageIfOnly")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
) {
const row = yield* db
const rows = yield* db
.select()
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "user")))
.orderBy(asc(SessionMessageTable.seq))
.get()
.limit(2)
.all()
.pipe(Effect.orDie)
if (!row) return undefined
const message = yield* decodeMessageRow(row).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (rows.length !== 1) return undefined
const message = yield* decodeMessageRow(rows[0]).pipe(Effect.catch(() => Effect.succeed(undefined)))
return message?.type === "user" ? message : undefined
})
+1 -1
View File
@@ -17,7 +17,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
return SessionSchema.Info.make({
id: SessionSchema.ID.make(row.id),
projectID: Project.ID.make(row.project_id),
title: row.title ?? undefined,
title: row.title,
parentID: row.parent_id ? SessionSchema.ID.make(row.parent_id) : undefined,
fork:
row.fork_session_id && row.fork_boundary
@@ -175,6 +175,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
"session.forked": () => Effect.void,
"session.input.promoted": () => Effect.void,
"session.input.admitted": () => Effect.void,
"session.input.withdrawn": () => Effect.void,
"session.execution.started": () => Effect.void,
"session.execution.succeeded": () => clearCurrentRetry,
"session.execution.failed": () => clearCurrentRetry,
+2 -80
View File
@@ -1,7 +1,6 @@
export * as SessionModelRequest from "./model-request"
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import type { Content } from "@opencode-ai/schema/tool"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Cause, Config, Context, Effect, Layer, Result } from "effect"
@@ -19,11 +18,6 @@ import { MAX_STEPS_PROMPT } from "./runner/max-steps"
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
import { toLLMMessages } from "./runner/to-llm-message"
const IMAGE_BYTES_TRIGGER = 25 * 1024 * 1024 // 25 MiB
const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB
const IMAGE_REMOVED =
"[This image was removed to reduce the request size and is no longer visible. Do not make claims about its contents from memory. If needed, retrieve it again with an available tool or ask the user to attach it again.]"
/** Failures a prepared execution can surface: infrastructure errors plus user declines resurfaced from the defect tunnel. */
export type ExecuteError = Tool.Error | Permission.DeclinedError | QuestionTool.CancelledError
@@ -43,7 +37,6 @@ const declineDefect = (cause: Cause.Cause<Tool.Error>) => {
interface Prepared {
readonly request: LLMRequest
readonly options: StreamOptions
/**
* One request-scoped execution operation. Unknown, hook-removed, and
* step-limit-violating calls fail individually through the same seam.
@@ -99,56 +92,6 @@ export const unsupportedParts = (messages: LLMRequest["messages"], capabilities:
}),
)
export const boundImages = (messages: LLMRequest["messages"]) => {
const isImage = (mime: string) => mime.toLowerCase().startsWith("image/")
const size = (data: string | Uint8Array) =>
typeof data === "string" ? Buffer.byteLength(data) : Math.ceil(data.byteLength / 3) * 4
const imageBytes = messages.reduce(
(total, message) =>
total +
message.content.reduce((sum, part) => {
if (part.type === "media" && isImage(part.mediaType)) return sum + size(part.data)
if (part.type !== "tool-result" || part.result.type !== "content") return sum
return (
sum +
part.result.value.reduce(
(bytes: number, item: Content) =>
bytes + (item.type === "file" && isImage(item.mime) ? Buffer.byteLength(item.uri) : 0),
0,
)
)
}, 0),
0,
)
if (imageBytes <= IMAGE_BYTES_TRIGGER) return messages
let removed = 0
return messages.map((message) =>
Message.make({
...message,
content: message.content.map((part) => {
if (part.type === "media" && isImage(part.mediaType) && imageBytes - removed > IMAGE_BYTES_TARGET) {
removed += size(part.data)
return Message.text(IMAGE_REMOVED)
}
if (part.type !== "tool-result" || part.result.type !== "content") return part
return {
...part,
result: {
...part.result,
value: part.result.value.map((item: Content) => {
if (item.type !== "file" || !isImage(item.mime) || imageBytes - removed <= IMAGE_BYTES_TARGET)
return item
removed += Buffer.byteLength(item.uri)
return { type: "text" as const, text: IMAGE_REMOVED }
}),
},
}
}),
}),
)
}
/**
* Builds an outbound model request and captures the tool-call capability that
* must remain paired with it. It does not execute the request or mutate
@@ -213,32 +156,12 @@ export const layer = Layer.effect(
http: {
headers: SessionModelHeaders.make(session, app),
},
providerOptions: { [providerMetadataKey]: { promptCacheKey } },
providerOptions: { openai: { promptCacheKey } },
system: contextEvent.system,
messages: boundImages(unsupportedParts(contextEvent.messages, resolved.capabilities)),
messages: unsupportedParts(contextEvent.messages, resolved.capabilities),
tools: hookedTools,
toolChoice: stepLimitReached ? "none" : undefined,
})
const options: StreamOptions = {
transform: (request) =>
hooks
.trigger("session", "request", {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
...request,
})
.pipe(
Effect.tap((event) =>
Effect.sync(() => {
request.url = event.url
request.headers = event.headers
request.body = event.body
}),
),
Effect.asVoid,
),
}
if (promptCacheSnapshots) {
const current = PromptCacheDiagnostics.snapshot(request)
const comparison = PromptCacheDiagnostics.compare(promptCacheSnapshots.get(session.id), current)
@@ -267,7 +190,6 @@ export const layer = Layer.effect(
}
return {
request,
options,
executeTool,
stepLimitReached,
}
+108 -22
View File
@@ -1,6 +1,6 @@
export * as SessionPending from "./pending"
import { and, asc, eq, or } from "drizzle-orm"
import { and, asc, eq, or, sql } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect"
import {
Compaction,
@@ -38,10 +38,15 @@ const encodeUser = Schema.encodeSync(UserData)
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
const encodeSynthetic = Schema.encodeSync(SyntheticData)
const decodeAdmittedEvent = Schema.decodeUnknownOption(SessionEvent.InputAdmitted.data)
const decodeWithdrawnEvent = Schema.decodeUnknownOption(SessionEvent.InputWithdrawn.data)
const admittedEventType = Bus.versionedType(
SessionEvent.InputAdmitted.type,
SessionEvent.InputAdmitted.durable.version,
)
const withdrawnEventType = Bus.versionedType(
SessionEvent.InputWithdrawn.type,
SessionEvent.InputWithdrawn.durable.version,
)
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()(
@@ -103,6 +108,38 @@ export const compaction = Effect.fn("SessionPending.compaction")(function* (
return entry.type === "compaction" ? entry : undefined
})
const admittedFromHistory = Effect.fn("SessionPending.admittedFromHistory")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
id: SessionMessage.ID,
) {
const row = yield* db
.select()
.from(EventTable)
.where(
and(
eq(EventTable.aggregate_id, sessionID),
eq(EventTable.type, admittedEventType),
sql`json_extract(${EventTable.data}, '$.inputID') = ${id}`,
),
)
.limit(1)
.get()
.pipe(Effect.orDie)
if (row) {
const decoded = decodeAdmittedEvent(row.data)
if (decoded._tag !== "Some" || decoded.value.inputID !== id)
return yield* Effect.die(new LifecycleConflict({ id }))
const base = { id, sessionID, timeCreated: DateTime.makeUnsafe(row.created) }
return decoded.value.input.type === "user"
? User.make({ ...base, ...decoded.value.input })
: Synthetic.make({ ...base, ...decoded.value.input })
}
// A projected message without an admitted event in this aggregate (for
// example fork-copied history) is not a retryable admission.
return yield* Effect.die(new LifecycleConflict({ id }))
})
/**
* Reconstruct the admitted record for a pending row that was already consumed
* by promotion. The projected `session_message` row proves promotion happened;
@@ -123,27 +160,30 @@ const promotedFromHistory = Effect.fn("SessionPending.promotedFromHistory")(func
if (message === undefined) return undefined
if (message.session_id !== sessionID || (message.type !== "user" && message.type !== "synthetic"))
return yield* Effect.die(new LifecycleConflict({ id }))
const rows = yield* db
.select()
return yield* admittedFromHistory(db, sessionID, id)
})
const wasWithdrawn = Effect.fn("SessionPending.wasWithdrawn")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
id: SessionMessage.ID,
) {
const row = yield* db
.select({ data: EventTable.data })
.from(EventTable)
.where(and(eq(EventTable.aggregate_id, sessionID), eq(EventTable.type, admittedEventType)))
.all()
.where(
and(
eq(EventTable.aggregate_id, sessionID),
eq(EventTable.type, withdrawnEventType),
sql`json_extract(${EventTable.data}, '$.inputID') = ${id}`,
),
)
.limit(1)
.get()
.pipe(Effect.orDie)
for (const row of rows) {
const decoded = decodeAdmittedEvent(row.data)
if (decoded._tag !== "Some" || decoded.value.inputID !== id) continue
const base = {
id,
sessionID,
timeCreated: DateTime.makeUnsafe(row.created),
}
return decoded.value.input.type === "user"
? User.make({ ...base, ...decoded.value.input })
: Synthetic.make({ ...base, ...decoded.value.input })
}
// A projected message without an admitted event in this aggregate (for
// example fork-copied history) is not a retryable admission.
return yield* Effect.die(new LifecycleConflict({ id }))
if (!row) return false
const decoded = decodeWithdrawnEvent(row.data)
return decoded._tag === "Some" && decoded.value.inputID === id
})
export const admit = Effect.fn("SessionPending.admit")(function* (
@@ -162,6 +202,8 @@ export const admit = Effect.fn("SessionPending.admit")(function* (
}
const promoted = yield* promotedFromHistory(db, request.sessionID, request.id)
if (promoted !== undefined) return promoted
if (yield* wasWithdrawn(db, request.sessionID, request.id))
return yield* admittedFromHistory(db, request.sessionID, request.id)
return yield* bus
.publish(SessionEvent.InputAdmitted, {
inputID: request.id,
@@ -309,14 +351,13 @@ export const projectCompactionAdmitted = Effect.fn("SessionPending.projectCompac
* message insert inside the same event transaction; the deleted row is what
* makes the table pending-only.
*/
export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(function* (
const consumeInput = Effect.fn("SessionPending.consumeInput")(function* (
db: DatabaseService,
input: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
},
) {
if (yield* compaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const deleted = yield* db
.delete(SessionPendingTable)
.where(and(eq(SessionPendingTable.id, input.id), eq(SessionPendingTable.session_id, input.sessionID)))
@@ -329,6 +370,27 @@ export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(funct
return stored
})
export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(function* (
db: DatabaseService,
input: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
},
) {
if (yield* compaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
return yield* consumeInput(db, input)
})
export const projectWithdrawn = Effect.fn("SessionPending.projectWithdrawn")(function* (
db: DatabaseService,
input: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
},
) {
return yield* consumeInput(db, input)
})
export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(function* (
db: DatabaseService,
input: { readonly sessionID: SessionSchema.ID },
@@ -406,6 +468,30 @@ export const equivalent = (
return false
}
export const withdraw = Effect.fn("SessionPending.withdraw")(function* (
db: DatabaseService,
bus: Bus.Interface,
input: { readonly sessionID: SessionSchema.ID; readonly inputID: SessionMessage.ID },
) {
return yield* inboxLocks.withLock(input.sessionID)(
Effect.gen(function* () {
const pending = yield* find(db, input.inputID)
if (!pending) return yield* wasWithdrawn(db, input.sessionID, input.inputID)
if (pending.sessionID !== input.sessionID || pending.type === "compaction") return false
yield* bus
.publish(SessionEvent.InputWithdrawn, input)
.pipe(
Effect.catchDefect((defect) =>
wasWithdrawn(db, input.sessionID, input.inputID).pipe(
Effect.flatMap((withdrawn) => (withdrawn ? Effect.void : Effect.die(defect))),
),
),
)
return true
}),
)
})
const publish = Effect.fn("SessionPending.publish")(function* (
db: DatabaseService,
bus: Bus.Interface,
+8 -3
View File
@@ -43,8 +43,7 @@ type Usage = {
const ForkBatchSize = 500
const forkTitle = (value?: string) => {
if (value === undefined) return
const forkTitle = (value: string) => {
const match = value.match(/^(.+) \(fork #(\d+)\)$/)
if (match) return `${match[1]} (fork #${Number.parseInt(match[2], 10) + 1})`
return `${value} (fork #1)`
@@ -217,7 +216,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
slug: Slug.create(),
directory: parent.directory,
path: parent.path,
title: forkTitle(parent.title ?? undefined),
title: forkTitle(parent.title),
agent: parent.agent,
model: parent.model,
version: parent.version,
@@ -666,6 +665,12 @@ const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
yield* bus.project(SessionEvent.InputWithdrawn, (event) =>
SessionPending.projectWithdrawn(db, {
id: event.data.inputID,
sessionID: event.data.sessionID,
}),
)
yield* bus.project(SessionEvent.Compaction.Admitted, (event) =>
Effect.gen(function* () {
if (event.durable === undefined)
+11 -19
View File
@@ -93,9 +93,10 @@ const layer = Layer.effect(
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
const title = yield* SessionTitle.Service
// Title generation is a side effect of a successful step; it must not delay continuation.
// The in-flight set coalesces overlapping steps while title presence records success durably.
const titlesRunning = new Set<SessionSchema.ID>()
// Title generation is a side effect of the first step; it must not delay step continuation.
// Tracked per process so repeated wakes before the second user message arrives don't
// re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history.
const titleStarted = new Set<SessionSchema.ID>()
const forkTitle = yield* FiberSet.makeRuntime<never, void, never>()
/**
* Drains eligible manual compaction and user input until the Session becomes idle.
@@ -124,7 +125,7 @@ const layer = Layer.effect(
let step = 1
while (true) {
const result = yield* runStep(sessionID, promotable, step)
if (step === 1) yield* startTitle(sessionID)
yield* startTitleOnce(sessionID)
yield* runPendingCompaction(sessionID)
if (!result.needsContinuation && !(yield* SessionPending.has(db, sessionID, "steer"))) return
promotable = "steer"
@@ -278,7 +279,7 @@ const layer = Layer.effect(
// event durably, fork one fiber per local tool call, and hold back a virgin
// context-overflow provider error so settlement may recover it via compaction.
let overflowFailure: ProviderErrorEvent | undefined
const providerStream = llm.stream(prepared.request, prepared.options).pipe(
const providerStream = llm.stream(prepared.request).pipe(
Stream.runForEach((event) =>
Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return
@@ -480,20 +481,11 @@ const layer = Layer.effect(
}
})
/** Starts one title request at a time after a successful step makes user input visible. */
const startTitle = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
if (titlesRunning.has(sessionID)) return
titlesRunning.add(sessionID)
forkTitle(
title.generateForFirstPrompt(sessionID).pipe(
Effect.ignore,
Effect.ensuring(
Effect.sync(() => {
titlesRunning.delete(sessionID)
}),
),
),
)
/** Fires title generation once per process after the first step makes a user message visible. */
const startTitleOnce = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
if (titleStarted.has(sessionID)) return
titleStarted.add(sessionID)
forkTitle(title.generateForFirstPrompt(yield* getSession(sessionID)).pipe(Effect.ignore))
})
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
+1 -1
View File
@@ -36,7 +36,7 @@ export const SessionTable = sqliteTable(
slug: text().notNull(),
directory: directoryColumn().notNull(),
path: pathColumn(),
title: text(),
title: text().notNull(),
version: text().notNull(),
share_url: text(),
summary_additions: integer(),
+12 -34
View File
@@ -1,12 +1,11 @@
export * as SessionTitle from "./title"
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
import { Context, DateTime, Effect, Layer, Stream } from "effect"
import { Context, Effect, Layer, Stream } from "effect"
import { Agent } from "../agent"
import { Database } from "../database/database"
import { Bus } from "../bus"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { isExactRootFallback } from "@opencode-ai/util/session-title-fallback"
import { App } from "../app"
import { llmClient } from "../effect/app-node-platform"
import { SessionEvent } from "./event"
@@ -15,10 +14,8 @@ import { SessionModelHeaders } from "./model-headers"
import { SessionRunnerModel } from "./runner/model"
import { SessionSchema } from "./schema"
import { SessionUsage } from "./usage"
import { SessionStore } from "./store"
const MAX_LENGTH = 100
const titleChanged = Symbol("Session title changed")
type Dependencies = {
readonly app: App.Info
@@ -28,33 +25,24 @@ type Dependencies = {
}
readonly agents: Agent.Interface
readonly models: SessionRunnerModel.Interface
readonly store: SessionStore.Interface
}
export interface Interface {
/** Generates a title from the session's first user message when the session remains untitled. */
readonly generateForFirstPrompt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
/** Generates a title from the session's first user message and renames the session. Runs at most once per session. */
readonly generateForFirstPrompt: (session: SessionSchema.Info) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionTitle") {}
const truncate = (value: string) => (value.length <= MAX_LENGTH ? value : `${value.slice(0, MAX_LENGTH - 3)}...`)
const isUntitled = (session: SessionSchema.Info) =>
isExactRootFallback({
title: session.title,
time: { created: DateTime.toEpochMillis(session.time.created) },
})
const make = (dependencies: Dependencies) => {
const generateForFirstPrompt = Effect.fn("SessionTitle.generateForFirstPrompt")(function* (
db: Database.Interface["db"],
sessionID: SessionSchema.ID,
session: SessionSchema.Info,
) {
const session = yield* dependencies.store.get(sessionID)
if (!session) return
if (session.parentID) return
if (!isUntitled(session)) return
const firstUser = yield* SessionHistory.firstUserMessage(db, session.id)
const firstUser = yield* SessionHistory.firstUserMessageIfOnly(db, session.id)
if (!firstUser) return
const agent = yield* dependencies.agents.get(Agent.ID.make("title"))
if (!agent) return
@@ -108,19 +96,10 @@ const make = (dependencies: Dependencies) => {
.map((line) => line.trim())
.find((line) => line.length > 0)
if (!title) return
const expectedSequence = (yield* Bus.latestSequence(db, sessionID)) + 1
const current = yield* dependencies.store.get(sessionID)
if (!current || !isUntitled(current)) return
yield* dependencies.bus
.publish(
SessionEvent.Renamed,
{
sessionID: session.id,
title: truncate(title),
},
{ commit: (sequence) => (sequence === expectedSequence ? Effect.void : Effect.die(titleChanged)) },
)
.pipe(Effect.catchDefect((defect) => (defect === titleChanged ? Effect.void : Effect.die(defect))))
yield* dependencies.bus.publish(SessionEvent.Renamed, {
sessionID: session.id,
title: truncate(title),
})
})
return { generateForFirstPrompt }
}
@@ -132,12 +111,11 @@ export const layer = Layer.effect(
const llm = yield* LLMClient.Service
const agents = yield* Agent.Service
const models = yield* SessionRunnerModel.Service
const store = yield* SessionStore.Service
const database = yield* Database.Service
const app = yield* App.Metadata
const title = make({ bus, llm, agents, models, store, app })
const title = make({ bus, llm, agents, models, app })
return Service.of({
generateForFirstPrompt: (sessionID) => title.generateForFirstPrompt(database.db, sessionID),
generateForFirstPrompt: (session) => title.generateForFirstPrompt(database.db, session),
})
}),
)
@@ -145,5 +123,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, llmClient, Agent.node, SessionRunnerModel.node, SessionStore.node, Database.node, App.node],
deps: [Bus.node, llmClient, Agent.node, SessionRunnerModel.node, Database.node, App.node],
})
+1 -1
View File
@@ -63,7 +63,7 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
watcher: info.watcher,
formatter: info.formatter,
lsp: info.lsp,
media: info.attachment,
attachments: info.attachment,
tool_output: info.tool_output,
mcp: mcp(info),
compaction: info.compaction && {
-142
View File
@@ -1,142 +0,0 @@
import { describe, expect, test } from "bun:test"
import { AISDKNative } from "@opencode-ai/core/aisdk-native"
describe("AISDKNative", () => {
test("maps OpenRouter settings to native destinations", () => {
expect(
AISDKNative.map("@openrouter/ai-sdk-provider", {
appName: "OpenCode",
appUrl: "https://opencode.ai",
headers: { "x-openrouter-title": "Configured", "x-provider-api-keys": "Configured BYOK" },
api_keys: { anthropic: "provider-key" },
extraBody: { transforms: ["middle-out"] },
models: ["anthropic/claude-sonnet-4.6"],
provider: { only: ["anthropic"], require_parameters: true },
reasoning: { effort: "high" },
promptCacheKey: "session_123",
future_option: { enabled: true },
}),
).toEqual({
package: "@opencode-ai/ai/providers/openrouter",
settings: {
providerOptions: {
openrouter: {
models: ["anthropic/claude-sonnet-4.6"],
provider: { only: ["anthropic"], require_parameters: true },
reasoning: { effort: "high" },
promptCacheKey: "session_123",
future_option: { enabled: true },
},
},
},
headers: {
"x-openrouter-title": "Configured",
"HTTP-Referer": "https://opencode.ai",
"x-provider-api-keys": "Configured BYOK",
},
body: { transforms: ["middle-out"] },
})
})
test("maps every Google thinking setting", () => {
expect(
AISDKNative.map("@ai-sdk/google", {
cachedContent: "cachedContents/example",
safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
serviceTier: "flex",
thinkingConfig: {
thinkingBudget: 0,
includeThoughts: false,
thinkingLevel: "high",
unknown: true,
},
}),
).toEqual({
package: "@opencode-ai/ai/providers/google",
settings: {
providerOptions: {
gemini: {
cachedContent: "cachedContents/example",
safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
serviceTier: "flex",
thinkingConfig: {
thinkingBudget: 0,
includeThoughts: false,
thinkingLevel: "high",
},
},
},
},
})
})
test("maps Google thinking settings independently", () => {
for (const thinkingConfig of [{ thinkingBudget: -1 }, { includeThoughts: true }, { thinkingLevel: "medium" }]) {
expect(AISDKNative.map("@ai-sdk/google", { thinkingConfig })).toMatchObject({
settings: { providerOptions: { gemini: { thinkingConfig } } },
})
}
})
test("maps Google request options without thinking settings", () => {
expect(
AISDKNative.map("@ai-sdk/google", {
cachedContent: "cachedContents/example",
safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
serviceTier: "future-tier",
}),
).toMatchObject({
settings: {
providerOptions: {
gemini: {
cachedContent: "cachedContents/example",
safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
serviceTier: "future-tier",
},
},
},
})
})
test("maps supported xAI settings", () => {
expect(
AISDKNative.map("@ai-sdk/xai", {
apiKey: "secret",
baseURL: "https://xai.example/v1",
reasoningEffort: "custom",
store: true,
promptCacheKey: "cache-key",
}),
).toEqual({
package: "@opencode-ai/ai/providers/xai",
settings: {
apiKey: "secret",
baseURL: "https://xai.example/v1",
providerOptions: {
xai: {
reasoningEffort: "custom",
store: true,
promptCacheKey: "cache-key",
},
},
},
})
})
test("omits invalid and unsupported xAI settings", () => {
expect(
AISDKNative.map("@ai-sdk/xai", {
reasoningEffort: 10,
store: "yes",
include: ["unknown"],
logprobs: true,
topLogprobs: 8,
previousResponseId: "response-id",
searchParameters: { mode: "auto" },
}),
).toEqual({
package: "@opencode-ai/ai/providers/xai",
settings: {},
})
})
})
+3 -3
View File
@@ -803,7 +803,7 @@ describe("Config", () => {
custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] },
},
lsp: { typescript: { disabled: true }, custom: { command: ["custom-lsp"], extensions: [".foo"] } },
media: {
attachments: {
image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 },
},
tool_output: { max_lines: 1000, max_bytes: 32768 },
@@ -890,7 +890,7 @@ describe("Config", () => {
typescript: { disabled: true },
custom: { command: ["custom-lsp"], extensions: [".foo"] },
})
expect(documents[0]?.info.media).toEqual({
expect(documents[0]?.info.attachments).toEqual({
image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 },
})
expect(documents[0]?.info.tool_output).toEqual({ max_lines: 1000, max_bytes: 32768 })
@@ -1097,7 +1097,7 @@ describe("Config", () => {
expect(documents[0]?.info.references).toEqual({
docs: { path: "../docs", description: "Use for product documentation", hidden: true },
})
expect(documents[0]?.info.media).toEqual({ image: { auto_resize: false, max_width: 1200 } })
expect(documents[0]?.info.attachments).toEqual({ image: { auto_resize: false, max_width: 1200 } })
expect(documents[0]?.info.providers?.custom).toMatchObject({
settings: { apiKey: "secret" },
models: {
@@ -26,7 +26,6 @@ import timeSuspendedMigration from "@opencode-ai/core/database/migration/2026070
import instructionSyncMigration from "@opencode-ai/core/database/migration/20260710025429_instruction_sync"
import deleteToolProgressEventsMigration from "@opencode-ai/core/database/migration/20260722011141_delete_tool_progress_events"
import canonicalToolResultsMigration from "@opencode-ai/core/database/migration/20260722170000_canonical_tool_results"
import optionalSessionTitleMigration from "@opencode-ai/core/database/migration/20260730195856_optional_session_title"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
@@ -400,40 +399,6 @@ describe("DatabaseMigration", () => {
).rejects.toThrow("Database is not empty and has no session table")
})
test("makes session titles nullable without deleting dependent rows", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`PRAGMA foreign_keys = ON`)
yield* db.run(sql`
CREATE TABLE session (
id text PRIMARY KEY,
title text NOT NULL
)
`)
yield* db.run(sql`
CREATE TABLE message (
id text PRIMARY KEY,
session_id text NOT NULL REFERENCES session(id) ON DELETE CASCADE
)
`)
yield* db.run(sql`INSERT INTO session VALUES ('ses_existing', 'Existing title')`)
yield* db.run(sql`INSERT INTO message VALUES ('msg_existing', 'ses_existing')`)
yield* DatabaseMigration.applyOnly(db, [optionalSessionTitleMigration])
expect(yield* db.get(sql`SELECT title FROM session WHERE id = 'ses_existing'`)).toEqual({
title: "Existing title",
})
expect(yield* db.get(sql`SELECT id FROM message WHERE id = 'msg_existing'`)).toEqual({ id: "msg_existing" })
expect(
yield* db.get<{ notnull: number }>(sql`SELECT "notnull" FROM pragma_table_info('session') WHERE name = 'title'`),
).toEqual({ notnull: 0 })
expect(yield* db.get<{ foreign_keys: number }>(sql`PRAGMA foreign_keys`)).toEqual({ foreign_keys: 1 })
}),
)
})
test("backfills existing Context Epoch rows to the build agent", async () => {
await run(
Effect.gen(function* () {
+73 -62
View File
@@ -1,6 +1,5 @@
import { describe, expect } from "bun:test"
import { LLM, Model } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { compileRequest } from "@opencode-ai/ai/route/client"
import { Effect } from "effect"
import { Headers } from "effect/unstable/http"
@@ -18,7 +17,6 @@ interface ModelOptions {
readonly headers?: Info["headers"]
readonly body?: Info["body"]
readonly variants?: Info["variants"]
readonly limit?: Info["limit"]
}
const model = (packageName: string | undefined, options: ModelOptions = {}) =>
@@ -38,7 +36,7 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) =>
cost: [],
status: "active",
enabled: true,
limit: options.limit ?? { context: 100, output: 20 },
limit: { context: 100, output: 20 },
})
describe("ModelResolver", () => {
@@ -46,7 +44,6 @@ describe("ModelResolver", () => {
Effect.gen(function* () {
const catalog = model(Provider.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
limit: { context: 100, input: 80, output: 20 },
})
const resolved = yield* ModelResolver.fromCatalogModel(catalog)
@@ -58,7 +55,7 @@ describe("ModelResolver", () => {
endpoint: { baseURL: "https://openai.example/v1" },
defaults: {
headers: { "x-test": "header" },
limits: { context: 100, input: 80, output: 20 },
limits: { context: 100, output: 20 },
http: { body: { custom_extension: { enabled: true } } },
},
})
@@ -310,12 +307,12 @@ describe("ModelResolver", () => {
}),
)
it.effect("applies plugin-projected OpenAI endpoint and headers", () =>
it.effect("routes ChatGPT OAuth credentials to the codex backend", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model("@opencode-ai/ai/providers/openai", {
settings: { baseURL: "https://chatgpt.com/backend-api/codex" },
headers: { "chatgpt-account-id": "acct_123" },
model(Provider.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
headers: {},
body: {},
}),
Credential.OAuth.make({
@@ -340,8 +337,37 @@ describe("ModelResolver", () => {
id: "openai-responses",
endpoint: { baseURL: "https://chatgpt.com/backend-api/codex" },
})
expect(resolved.route.defaults.headers).toMatchObject({ "chatgpt-account-id": "acct_123" })
expect(headers.authorization).toBe("Bearer chatgpt-token")
expect(headers["chatgpt-account-id"]).toBe("acct_123")
}),
)
it.effect("routes native OpenAI provider packages with ChatGPT credentials to the codex backend", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model("@opencode-ai/ai/providers/openai", {
settings: { baseURL: "https://openai.example/v1" },
}),
Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("chatgpt-browser"),
access: "chatgpt-token",
refresh: "refresh",
expires: Date.now() + 60_000,
metadata: { accountID: "acct_123" },
}),
)
const headers = yield* resolved.route.auth.apply({
request: LLM.request({ model: resolved, prompt: "Hello" }),
method: "POST",
url: "https://chatgpt.com/backend-api/codex/responses",
body: "{}",
headers: Headers.empty,
})
expect(resolved.route.endpoint.baseURL).toBe("https://chatgpt.com/backend-api/codex")
expect(headers.authorization).toBe("Bearer chatgpt-token")
expect(headers["chatgpt-account-id"]).toBe("acct_123")
}),
)
@@ -381,6 +407,37 @@ describe("ModelResolver", () => {
}),
)
it.effect("routes ChatGPT OAuth credentials without an account id to the codex backend", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
headers: {},
body: {},
}),
Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("chatgpt-headless"),
access: "chatgpt-token",
refresh: "refresh",
expires: Date.now() + 60_000,
}),
)
const request = LLM.request({ model: resolved, prompt: "Hello" })
const headers = yield* resolved.route.auth.apply({
request,
method: "POST",
url: "https://chatgpt.com/backend-api/codex/responses",
body: "{}",
headers: Headers.empty,
})
expect(resolved.route.endpoint.baseURL).toBe("https://chatgpt.com/backend-api/codex")
expect(headers.authorization).toBe("Bearer chatgpt-token")
expect(headers["chatgpt-account-id"]).toBeUndefined()
}),
)
it.effect("keeps non-ChatGPT OAuth credentials on the configured endpoint", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
@@ -493,31 +550,16 @@ describe("ModelResolver", () => {
Effect.gen(function* () {
const native = yield* ModelResolver.fromCatalogModel(model(Provider.aisdk("@ai-sdk/openai")))
const packages = [
[
"@ai-sdk/google",
"@opencode-ai/ai/providers/google",
{ thinkingConfig: { thinkingLevel: "high" } },
{ gemini: { thinkingConfig: { thinkingLevel: "high" } } },
],
[
"@openrouter/ai-sdk-provider",
"@opencode-ai/ai/providers/openrouter",
{ reasoning: { effort: "high" } },
{ openrouter: { reasoning: { effort: "high" } } },
],
[
"@ai-sdk/xai",
"@opencode-ai/ai/providers/xai",
{ reasoningEffort: "high" },
{ xai: { reasoningEffort: "high" } },
],
["@ai-sdk/google", "@opencode-ai/ai/providers/google", "gemini"],
["@openrouter/ai-sdk-provider", "@opencode-ai/ai/providers/openrouter", "openrouter"],
["@ai-sdk/xai", "@opencode-ai/ai/providers/xai", "xai"],
] as const
yield* Effect.forEach(packages, ([catalogPackage, nativePackage, sourceOptions, providerOptions]) =>
yield* Effect.forEach(packages, ([catalogPackage, nativePackage, optionKey]) =>
ModelResolver.fromCatalogModel(
model(Provider.aisdk(catalogPackage), {
modelID: "api-model",
settings: { baseURL: "https://provider.example/v1", ...sourceOptions },
settings: { baseURL: "https://provider.example/v1", reasoningEffort: "high" },
headers: { "x-provider": "header" },
body: { custom: true },
}),
@@ -534,7 +576,7 @@ describe("ModelResolver", () => {
headers: { "x-provider": "header" },
body: { custom: true },
limits: { context: 100, output: 20 },
providerOptions,
providerOptions: { [optionKey]: { reasoningEffort: "high" } },
})
return Model.make({ id: modelID, provider: "native-provider", route: native.route })
},
@@ -547,37 +589,6 @@ describe("ModelResolver", () => {
}),
)
it.effect("merges mapped OpenRouter headers and body with catalog overlays", () =>
ModelResolver.fromCatalogModel(
model(Provider.aisdk("@openrouter/ai-sdk-provider"), {
settings: {
appName: "OpenCode",
appUrl: "https://opencode.ai",
extraBody: { transforms: ["middle-out"], provider: { sort: "price" } },
},
headers: { "X-OpenRouter-Title": "Custom" },
body: { provider: { only: ["anthropic"] } },
}),
undefined,
{
loadPackage: () =>
Effect.succeed({
model: (modelID, settings) => {
expect(settings.headers).toEqual({
"HTTP-Referer": "https://opencode.ai",
"X-OpenRouter-Title": "Custom",
})
expect(settings.body).toEqual({
transforms: ["middle-out"],
provider: { sort: "price", only: ["anthropic"] },
})
return Model.make({ id: modelID, provider: "openrouter", route: OpenAIChat.route })
},
}),
},
),
)
it.effect("loads supported AISDK catalog packages as native routes", () =>
Effect.gen(function* () {
const google = yield* ModelResolver.fromCatalogModel(
+140 -75
View File
@@ -1,7 +1,7 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { Money } from "@opencode-ai/schema/money"
import { Agent } from "@opencode-ai/schema/agent"
import { Session } from "@opencode-ai/schema/session"
import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
@@ -9,7 +9,6 @@ import { Integration } from "@opencode-ai/core/integration"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
@@ -19,6 +18,7 @@ const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
const integrations = yield* Integration.Service
yield* OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations))
@@ -29,6 +29,19 @@ function required<T>(value: T | undefined): T {
return value
}
function fakeSelectorSdk(calls: string[]) {
const make = (method: string) => (id: string) => {
calls.push(`${method}:${id}`)
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
}
return {
responses: make("responses"),
messages: make("messages"),
chat: make("chat"),
languageModel: make("languageModel"),
}
}
describe("OpenAIPlugin", () => {
it.effect("registers browser and headless ChatGPT OAuth methods", () =>
Effect.gen(function* () {
@@ -48,6 +61,104 @@ describe("OpenAIPlugin", () => {
}),
)
it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("custom-openai"), Model.ID.make("gpt-5")),
modelID: Model.ID.make("gpt-5"),
package: Provider.aisdk("test-provider"),
}),
package: "@ai-sdk/openai",
options: { name: "custom-openai", apiKey: "test" },
})
expect(result.sdk?.responses("gpt-5").provider).toBe("custom-openai.responses")
}),
)
it.effect("ignores non-OpenAI SDK packages", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.openai, Model.ID.make("gpt-5")),
modelID: Model.ID.make("gpt-5"),
package: Provider.aisdk("test-provider"),
}),
package: "@ai-sdk/openai-compatible",
options: { name: "openai" },
})
expect(result.sdk).toBeUndefined()
}),
)
it.effect("uses the Responses API for language models", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: Model.Info.make({
...Model.Info.default(Provider.ID.openai, Model.ID.make("alias")),
modelID: Model.ID.make("gpt-5"),
package: Provider.aisdk("test-provider"),
}),
sdk: fakeSelectorSdk(calls),
options: {},
})
expect(calls).toEqual(["responses:gpt-5"])
expect(result.language).toBeDefined()
}),
)
it.effect("ignores non-OpenAI providers", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: Model.Info.make({
...Model.Info.default(Provider.ID.anthropic, Model.ID.make("gpt-5")),
modelID: Model.ID.make("gpt-5"),
package: Provider.aisdk("test-provider"),
}),
sdk: fakeSelectorSdk(calls),
options: {},
})
expect(calls).toEqual([])
expect(result.language).toBeUndefined()
}),
)
it.effect("disables gpt-5-chat-latest during catalog transforms", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const item = Provider.Info.make({
...Provider.Info.empty(Provider.ID.openai),
package: Provider.aisdk("@ai-sdk/openai"),
})
catalog.provider.update(item.id, (draft) => {
draft.package = item.package
})
catalog.model.update(item.id, Model.ID.make("gpt-5"), () => {})
catalog.model.update(item.id, Model.ID.make("gpt-5-chat-latest"), () => {})
})
yield* addPlugin()
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5"))).enabled).toBe(true)
expect(
required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5-chat-latest"))).enabled,
).toBe(false)
}),
)
it.effect("filters the OpenAI catalog to codex-eligible models under a ChatGPT connection", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
@@ -61,7 +172,6 @@ describe("OpenAIPlugin", () => {
draft.package = item.package
})
catalog.model.update(item.id, Model.ID.make("gpt-5.5"), (model) => {
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
model.cost = [
{
input: Money.USDPerMillionTokens.make(1),
@@ -74,17 +184,12 @@ describe("OpenAIPlugin", () => {
]
})
catalog.model.update(item.id, Model.ID.make("gpt-5.5-pro"), () => {})
catalog.model.update(item.id, Model.ID.make("gpt-5.4"), (model) => {
model.limit = { context: 1_050_000, input: 922_000, output: 64_000 }
})
catalog.model.update(item.id, Model.ID.make("gpt-5.4-pro"), (model) => {
model.modelID = Model.ID.make("gpt-5.4")
model.body = { reasoning: { mode: "pro" } }
})
catalog.model.update(item.id, Model.ID.make("gpt-5.6"), () => {})
catalog.model.update(item.id, Model.ID.make("gpt-5.6-sol"), (model) => {
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
})
catalog.model.update(item.id, Model.ID.make("gpt-5.6-sol"), () => {})
catalog.model.update(item.id, Model.ID.make("gpt-4.1"), () => {})
})
yield* credentials.create({
@@ -100,47 +205,8 @@ describe("OpenAIPlugin", () => {
})
yield* addPlugin()
const request = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
url: "https://api.openai.com/v1/responses",
method: "POST",
headers: {},
body: "{}",
})
const custom = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("custom-openai"), id: Model.ID.make("gpt-5.5") }),
url: "https://custom.example/v1/responses",
method: "POST",
headers: {},
body: "{}",
})
const proxy = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
url: "https://proxy.example/v1/responses?region=us",
method: "POST",
headers: {},
body: "{}",
})
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
expect(provider.package).toBe("@opencode-ai/ai/providers/openai")
expect(provider.settings).toMatchObject({ baseURL: "https://chatgpt.com/backend-api/codex" })
expect(provider.headers).toMatchObject({ "chatgpt-account-id": "acct_123" })
expect(request.url).toBe("https://chatgpt.com/backend-api/codex/responses")
expect(request.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
expect(custom.headers).toEqual({})
expect(proxy.url).toBe("https://proxy.example/v1/responses?region=us")
expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(eligible.package).toBe("@opencode-ai/ai/providers/openai")
expect(eligible.cost).toEqual([])
expect(eligible.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
expect(eligible.enabled).toBe(true)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(
false,
@@ -148,15 +214,10 @@ describe("OpenAIPlugin", () => {
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4-pro"))).enabled).toBe(
false,
)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4"))).limit).toEqual({
context: 272_000,
input: 272_000,
output: 64_000,
})
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6"))).enabled).toBe(false)
const gpt56 = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol")))
expect(gpt56.enabled).toBe(true)
expect(gpt56.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol"))).enabled).toBe(
true,
)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(false)
}),
)
@@ -173,9 +234,7 @@ describe("OpenAIPlugin", () => {
catalog.provider.update(item.id, (draft) => {
draft.package = item.package
})
catalog.model.update(item.id, Model.ID.make("gpt-5.5"), (model) => {
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
})
catalog.model.update(item.id, Model.ID.make("gpt-5.5"), () => {})
catalog.model.update(item.id, Model.ID.make("gpt-4.1"), () => {})
})
yield* credentials.create({
@@ -184,23 +243,29 @@ describe("OpenAIPlugin", () => {
})
yield* addPlugin()
const request = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
url: "https://api.openai.com/v1/responses",
method: "POST",
headers: {},
body: "{}",
})
const model = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(model.package).toBe("@opencode-ai/ai/providers/openai")
expect(model.enabled).toBe(true)
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
expect(request.headers).toEqual({})
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5"))).enabled).toBe(true)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true)
}),
)
it.effect("does not disable gpt-5-chat-latest for non-OpenAI providers", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const item = Provider.Info.make({
...Provider.Info.empty(Provider.ID.make("custom-openai")),
package: Provider.aisdk("test-provider"),
})
catalog.provider.update(item.id, (draft) => {
draft.package = item.package
})
catalog.model.update(item.id, Model.ID.make("gpt-5-chat-latest"), () => {})
})
yield* addPlugin()
expect(
required(yield* catalog.model.get(Provider.ID.make("custom-openai"), Model.ID.make("gpt-5-chat-latest")))
.enabled,
).toBe(true)
}),
)
})
+1 -49
View File
@@ -19,11 +19,9 @@ import { Session } from "@opencode-ai/core/session"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { App } from "@opencode-ai/core/app"
import { Agent } from "@opencode-ai/core/agent"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Money } from "@opencode-ai/schema/money"
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
@@ -132,52 +130,6 @@ test("compaction prompt requires the checkpoint headings in order", () => {
expect(prompt).toContain("Keep every section, even when empty.")
})
it.effect("auto compaction reserves a buffer below the prompt ceiling", () =>
Effect.gen(function* () {
const compaction = yield* SessionCompaction.Service
const session = Session.Info.make({
id: Session.ID.make("ses_input_limit"),
projectID: Project.ID.global,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp") }),
})
const input = (tokens: number, limits: { context: number; input?: number; output: number }) => ({
session,
model: Model.make({
id: "test-model",
provider: "test-provider",
route: OpenAIChat.route.with({ limits }),
}),
cost: [],
messages: [
Schema.decodeUnknownSync(SessionMessage.Assistant)({
id: SessionMessage.ID.make("msg_assistant"),
type: "assistant",
agent: Agent.defaultID,
model: { id: "test-model", providerID: "test-provider" },
content: [],
tokens: { input: tokens, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, completed: 0 },
}),
],
})
const inputLimited = { context: 400_000, input: 272_000, output: 128_000 }
expect(compaction.required(input(251_999, inputLimited))).toBe(false)
expect(compaction.required(input(252_000, inputLimited))).toBe(true)
const contextLimited = { context: 100_000, output: 10_000 }
expect(compaction.required(input(79_999, contextLimited))).toBe(false)
expect(compaction.required(input(80_000, contextLimited))).toBe(true)
const outputLimited = { context: 100_000, output: 30_000 }
expect(compaction.required(input(69_999, outputLimited))).toBe(false)
expect(compaction.required(input(70_000, outputLimited))).toBe(true)
}),
)
it.effect("manual compaction summarizes short context instead of no-op", () =>
Effect.gen(function* () {
requests = []
-38
View File
@@ -71,27 +71,6 @@ function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
}
describe("Session.create", () => {
it.effect("persists a missing title until one is generated or supplied", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const { db } = yield* Database.Service
const created = yield* session.create({ location })
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, created.id)).get().pipe(Effect.orDie)
const event = yield* db
.select({ data: EventTable.data })
.from(EventTable)
.where(eq(EventTable.aggregate_id, created.id))
.get()
.pipe(Effect.orDie)
expect(created.title).toBeUndefined()
expect(row?.title).toBeNull()
expect(event?.data).not.toHaveProperty("info.title")
expect((yield* session.create({ location, title: "Explicit title" })).title).toBe("Explicit title")
}),
)
it.effect("creates a fresh projected session when the ID is omitted", () =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -317,23 +296,6 @@ describe("Session.create", () => {
}),
)
it.effect("keeps a fork untitled when its parent is untitled", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const parent = yield* session.create({ location })
yield* session.prompt({ sessionID: parent.id, text: "First", resume: false })
yield* SessionPending.promote(db, bus, parent.id, "steer")
const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, forked.id)).get().pipe(Effect.orDie)
expect(forked.title).toBeUndefined()
expect(row?.title).toBeNull()
}),
)
it.effect("rejects forking an empty session", () =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Message, ToolResultPart } from "@opencode-ai/ai"
import { boundImages, unsupportedParts } from "@opencode-ai/core/session/model-request"
import { unsupportedParts } from "@opencode-ai/core/session/model-request"
const capabilities = (input: string[]) => ({ tools: true, input, output: ["text"] })
@@ -62,51 +62,3 @@ describe("SessionModelRequest.unsupportedParts", () => {
expect(unsupportedParts([message], capabilities(["text", "image"]))[0]?.content).toEqual(message.content)
})
})
describe("SessionModelRequest.boundImages", () => {
test("preserves images below the trigger", () => {
const messages = [Message.user({ type: "media", mediaType: "image/png", data: "aGVsbG8=" })]
expect(boundImages(messages)).toBe(messages)
})
test("replaces oldest images until the retained payload reaches the target", () => {
const image = "a".repeat(9 * 1024 * 1024)
const messages = [
Message.user({ type: "media", mediaType: "image/png", data: image, filename: "first.png" }),
Message.user({ type: "media", mediaType: "image/png", data: image, filename: "second.png" }),
Message.user({ type: "media", mediaType: "image/png", data: image, filename: "third.png" }),
]
const result = boundImages(messages)
expect(result[0]?.content[0]).toMatchObject({ type: "text" })
expect(result[1]?.content[0]).toMatchObject({ type: "text" })
expect(result[2]?.content[0]).toMatchObject({ type: "media", filename: "third.png" })
})
test("replaces images nested in tool results", () => {
const image = "a".repeat(13 * 1024 * 1024)
const result = boundImages([
Message.tool(
ToolResultPart.make({
id: "call_1",
name: "read",
result: {
type: "content",
value: [
{ type: "file", uri: `data:image/png;base64,${image}`, mime: "image/png", name: "first.png" },
{ type: "file", uri: `data:image/png;base64,${image}`, mime: "image/png", name: "second.png" },
],
},
}),
),
])
expect(result[0]?.content[0]).toMatchObject({
type: "tool-result",
result: {
type: "content",
value: [{ type: "text" }, { type: "file", name: "second.png" }],
},
})
})
})
+48 -34
View File
@@ -26,8 +26,8 @@ import { SessionPendingTable, SessionMessageTable, SessionTable } from "@opencod
import { SessionStore } from "@opencode-ai/core/session/store"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
const executionCalls: Session.ID[] = []
const interruptCalls: Session.ID[] = []
@@ -58,10 +58,7 @@ const locations = Layer.effect(
() =>
// Attachment admission only needs the location-scoped Image service.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
Layer.mock(Image.Service, {
normalize: (_resource, content) =>
Effect.succeed(content.content.length > 5 * 1024 * 1024 ? { ...content, content: "AA==" } : content),
}) as unknown as Layer.Layer<LocationServices>,
imagePassthrough as unknown as Layer.Layer<LocationServices>,
),
)
const it = testEffect(
@@ -389,35 +386,6 @@ describe("Session.prompt", () => {
}),
)
it.effect("normalizes large image content before validating persisted Base64", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const pixel = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
)
const bytes = Buffer.concat([pixel, Buffer.alloc(4_323_030 - pixel.length)])
const data = bytes.toString("base64")
expect(data).toHaveLength(5_764_040)
const message = yield* session.prompt({
sessionID,
text: "Inspect this image",
files: [{ uri: `data:image/png;base64,${data}` }],
resume: false,
})
expect(message.data.files).toEqual([
{
data: "AA==",
mime: "image/png",
source: { type: "inline" },
},
])
}),
)
it.effect("sniffs data URL content instead of trusting its declared MIME", () =>
Effect.gen(function* () {
yield* setup
@@ -1028,6 +996,52 @@ describe("Session.pending", () => {
}),
)
it.effect("withdraws an interrupted input before promotion without resurrecting exact retries", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const admitted = yield* session.prompt({
id: SessionMessage.ID.make("msg_withdrawn"),
sessionID,
text: "Withdraw me",
resume: false,
})
yield* session.interrupt(sessionID)
expect(yield* session.withdraw({ sessionID, inputID: admitted.id })).toBe(true)
expect(yield* session.pending(sessionID)).toEqual([])
expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* session.withdraw({ sessionID, inputID: admitted.id })).toBe(true)
const retried = yield* session.prompt({
id: admitted.id,
sessionID,
text: "Withdraw me",
resume: false,
})
expect(retried.id).toBe(admitted.id)
expect(yield* session.pending(sessionID)).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputAdmitted.type, 1))).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputWithdrawn.type, 1))).toBe(1)
}),
)
it.effect("leaves promoted input for revert when withdrawal loses the race", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const admitted = yield* session.prompt({ sessionID, text: "Promote me", resume: false })
yield* SessionPending.promote(db, bus, sessionID, "steer")
expect(yield* session.withdraw({ sessionID, inputID: admitted.id })).toBe(false)
expect(yield* session.messages({ sessionID })).toMatchObject([{ id: admitted.id, type: "user" }])
}),
)
it.effect("lists an unhandled compaction barrier until it settles", () =>
Effect.gen(function* () {
yield* setup
-53
View File
@@ -796,59 +796,6 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
})
describe("SessionRunnerLLM", () => {
it.effect("retries title generation from the first prompt after execution and title failures", () =>
Effect.gen(function* () {
const session = yield* setup
const agents = yield* Agent.Service
const { db } = yield* Database.Service
yield* db.update(SessionTable).set({ title: null }).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie)
yield* agents.transform((draft) =>
draft.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "Generate a title."
}),
)
yield* admit(session, "First prompt")
yield* TestLLM.push(Stream.fail(invalidRequest()))
expect((yield* session.resume(sessionID).pipe(Effect.exit))._tag).toBe("Failure")
yield* admit(session, "Second prompt")
const titleFailed = yield* Deferred.make<void>()
yield* TestLLM.push(
TestLLM.text("Recovered", "text-recovered"),
Stream.make(LLMEvent.providerError({ message: "Title provider unavailable" })).pipe(
Stream.ensuring(Deferred.succeed(titleFailed, undefined)),
),
)
yield* session.resume(sessionID)
yield* Deferred.await(titleFailed)
yield* Effect.yieldNow
expect((yield* session.get(sessionID)).title).toBeUndefined()
const bus = yield* Bus.Service
const renamed = yield* bus.subscribe(SessionEvent.Renamed).pipe(
Stream.filter((event) => event.data.sessionID === sessionID),
Stream.take(1),
Stream.runCollect,
Effect.forkScoped({ startImmediately: true }),
)
yield* admit(session, "Third prompt")
yield* TestLLM.push(
TestLLM.text("Recovered again", "text-recovered-again"),
TestLLM.text("Generated title", "text-title"),
)
yield* session.resume(sessionID)
yield* Fiber.join(renamed)
expect(requests).toHaveLength(5)
expect(requests[2]?.messages).toContainEqual(Message.user("First prompt"))
expect(requests[4]?.messages).toContainEqual(Message.user("First prompt"))
expect((yield* session.get(sessionID)).title).toBe("Generated title")
}),
)
it.effect("applies session context hooks without exposing unavailable tools", () =>
Effect.gen(function* () {
const session = yield* setup
+44 -148
View File
@@ -20,7 +20,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
import { App } from "@opencode-ai/core/app"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Money } from "@opencode-ai/schema/money"
import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
import { Effect, Layer, Stream } from "effect"
import { testEffect } from "./lib/effect"
let requests: LLMRequest[] = []
@@ -39,30 +39,27 @@ const cost = [
},
},
]
const successfulTitle = () =>
Stream.make(
LLMEvent.textDelta({ id: "title", text: "Generated Title\n" }),
LLMEvent.stepFinish({
index: 0,
reason: { normalized: "stop" },
usage: {
inputTokens: 15,
outputTokens: 6,
nonCachedInputTokens: 10,
cacheReadInputTokens: 3,
cacheWriteInputTokens: 2,
reasoningTokens: 2,
},
}),
LLMEvent.finish({
reason: { normalized: "stop" },
}),
)
let titleStream: () => Stream.Stream<LLMEvent> = successfulTitle
const client = Layer.mock(LLMClient.Service)({
stream: (request: LLMRequest) => {
requests.push(request)
return titleStream()
return Stream.make(
LLMEvent.textDelta({ id: "title", text: "Generated Title\n" }),
LLMEvent.stepFinish({
index: 0,
reason: { normalized: "stop" },
usage: {
inputTokens: 15,
outputTokens: 6,
nonCachedInputTokens: 10,
cacheReadInputTokens: 3,
cacheWriteInputTokens: 2,
reasoningTokens: 2,
},
}),
LLMEvent.finish({
reason: { normalized: "stop" },
}),
)
},
generate: () => Effect.die("unused"),
})
@@ -92,7 +89,7 @@ const it = testEffect(
),
)
const insertSession = (id: Session.ID, title?: string, created?: number) =>
const insertSession = (id: Session.ID) =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
@@ -108,8 +105,7 @@ const insertSession = (id: Session.ID, title?: string, created?: number) =>
project_id: Project.ID.global,
slug: id,
directory: "/project",
title,
time_created: created,
title: "New session - fake",
version: "test",
})
.onConflictDoNothing()
@@ -135,7 +131,6 @@ const prompt = (sessionID: Session.ID, text: string) =>
it.effect("generates a title from the sole user message and renames the session", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
@@ -149,8 +144,11 @@ it.effect("generates a title from the sole user message and renames the session"
yield* prompt(sessionID, "Help me debug the failing build")
const store = yield* SessionStore.Service
const session = yield* store
.get(sessionID)
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
const title = yield* SessionTitle.Service
yield* title.generateForFirstPrompt(sessionID)
yield* title.generateForFirstPrompt(session)
expect(requests).toHaveLength(1)
expect(requests[0]?.http?.headers).toEqual({
@@ -169,10 +167,9 @@ it.effect("generates a title from the sole user message and renames the session"
}),
)
it.effect("generates from the first user message after later messages exist", () =>
it.effect("does not generate once a second user message exists", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
@@ -187,46 +184,21 @@ it.effect("generates from the first user message after later messages exist", ()
yield* prompt(sessionID, "Second message")
const store = yield* SessionStore.Service
const session = yield* store
.get(sessionID)
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
const title = yield* SessionTitle.Service
yield* title.generateForFirstPrompt(sessionID)
yield* title.generateForFirstPrompt(session)
expect(requests).toHaveLength(1)
expect(JSON.stringify(requests[0]?.messages)).toContain("First message")
expect(JSON.stringify(requests[0]?.messages)).not.toContain("Second message")
expect((yield* store.get(sessionID))?.title).toBe("Generated Title")
}),
)
it.effect("retries a legacy persisted fallback title", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = Session.ID.make("ses_title_legacy")
const created = Date.parse("2026-07-30T18:45:03.662Z")
yield* insertSession(sessionID, "New session - 2026-07-30T18:45:03.662Z", created)
yield* prompt(sessionID, "Retry the legacy title")
const title = yield* SessionTitle.Service
yield* title.generateForFirstPrompt(sessionID)
const store = yield* SessionStore.Service
expect(requests).toHaveLength(1)
expect((yield* store.get(sessionID))?.title).toBe("Generated Title")
expect(requests).toHaveLength(0)
const untouched = yield* store.get(sessionID)
expect(untouched?.title).toBe("New session - fake")
}),
)
it.effect("does not generate for a child session", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
@@ -251,6 +223,7 @@ it.effect("does not generate for a child session", () =>
parent_id: Session.ID.make("ses_title_parent"),
slug: sessionID,
directory: "/project",
title: "Child session - fake",
version: "test",
})
.onConflictDoNothing()
@@ -258,8 +231,12 @@ it.effect("does not generate for a child session", () =>
.pipe(Effect.orDie)
yield* prompt(sessionID, "Do this subtask")
const store = yield* SessionStore.Service
const session = yield* store
.get(sessionID)
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
const title = yield* SessionTitle.Service
yield* title.generateForFirstPrompt(sessionID)
yield* title.generateForFirstPrompt(session)
expect(requests).toHaveLength(0)
}),
@@ -268,100 +245,19 @@ it.effect("does not generate for a child session", () =>
it.effect("does not generate when the title agent is removed", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
const sessionID = Session.ID.make("ses_title_no_agent")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Help me debug the failing build")
const store = yield* SessionStore.Service
const session = yield* store
.get(sessionID)
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
const title = yield* SessionTitle.Service
yield* title.generateForFirstPrompt(sessionID)
yield* title.generateForFirstPrompt(session)
expect(requests).toHaveLength(0)
const untouched = yield* store.get(sessionID)
expect(untouched?.title).toBeUndefined()
}),
)
it.effect("does not overwrite an explicit title", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
const sessionID = Session.ID.make("ses_title_explicit")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Help me debug the failing build")
const events = yield* Bus.Service
yield* events.publish(SessionEvent.Renamed, { sessionID, title: "New session - 2099-01-01T00:00:00.000Z" })
const title = yield* SessionTitle.Service
yield* title.generateForFirstPrompt(sessionID)
const store = yield* SessionStore.Service
expect(requests).toHaveLength(0)
expect((yield* store.get(sessionID))?.title).toBe("New session - 2099-01-01T00:00:00.000Z")
}),
)
it.effect("retries after a failed title request", () =>
Effect.gen(function* () {
requests = []
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = Session.ID.make("ses_title_retry")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Retry this title")
const title = yield* SessionTitle.Service
titleStream = () => Stream.make(LLMEvent.providerError({ message: "Provider unavailable" }))
yield* title.generateForFirstPrompt(sessionID)
titleStream = successfulTitle
yield* title.generateForFirstPrompt(sessionID)
const store = yield* SessionStore.Service
expect(requests).toHaveLength(2)
expect((yield* store.get(sessionID))?.title).toBe("Generated Title")
}),
)
it.effect("preserves a manual rename completed while generation is in flight", () =>
Effect.gen(function* () {
requests = []
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = Session.ID.make("ses_title_manual_rename")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Generate this title")
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
titleStream = () =>
Stream.unwrap(
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.as(successfulTitle()),
),
)
const title = yield* SessionTitle.Service
const fiber = yield* title.generateForFirstPrompt(sessionID).pipe(Effect.forkScoped)
yield* Deferred.await(started)
const events = yield* Bus.Service
yield* events.publish(SessionEvent.Renamed, { sessionID, title: "Manual title" })
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(fiber)
const store = yield* SessionStore.Service
expect(requests).toHaveLength(1)
expect((yield* store.get(sessionID))?.title).toBe("Manual title")
expect(untouched?.title).toBe("New session - fake")
}),
)
+6 -6
View File
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect } from "bun:test"
import path from "path"
import { Effect, Exit, Layer, PlatformError, Stream } from "effect"
import { Config } from "@opencode-ai/core/config"
import { ConfigMedia } from "@opencode-ai/core/config/media"
import { ConfigAttachments } from "@opencode-ai/core/config/attachments"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FileSystem } from "@opencode-ai/core/filesystem"
@@ -432,8 +432,8 @@ describe("ReadTool", () => {
new Config.Document({
type: "document",
info: new Config.Info({
media: new ConfigMedia.Info({
image: new ConfigMedia.Image({ auto_resize: false, max_width: 4 }),
attachments: new ConfigAttachments.Info({
image: new ConfigAttachments.Image({ auto_resize: false, max_width: 4 }),
}),
}),
}),
@@ -475,7 +475,7 @@ describe("ReadTool", () => {
new Config.Document({
type: "document",
info: new Config.Info({
media: new ConfigMedia.Info({ image: new ConfigMedia.Image({ max_width: 4 }) }),
attachments: new ConfigAttachments.Info({ image: new ConfigAttachments.Image({ max_width: 4 }) }),
}),
}),
])
@@ -514,8 +514,8 @@ describe("ReadTool", () => {
new Config.Document({
type: "document",
info: new Config.Info({
media: new ConfigMedia.Info({
image: new ConfigMedia.Image({ max_base64_bytes: 1 }),
attachments: new ConfigAttachments.Info({
image: new ConfigAttachments.Image({ max_base64_bytes: 1 }),
}),
}),
}),
+1 -1
View File
@@ -47,7 +47,7 @@ const executionNode = makeGlobalNode({
const completed = new Set<Session.ID>()
const complete = Effect.fn("SubagentTest.complete")(function* (sessionID: Session.ID) {
if (completed.has(sessionID)) return
if ((yield* store.get(sessionID))?.title?.includes("fail")) {
if ((yield* store.get(sessionID))?.title.includes("fail")) {
yield* new SessionRunnerModel.ModelNotSelectedError({ sessionID })
return
}
-1
View File
@@ -16,7 +16,6 @@
"@opencode-ai/core": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"aws4fetch": "^1.0.20",
"@pierre/diffs": "catalog:",
"@solidjs/router": "catalog:",
@@ -4,7 +4,6 @@ import { SessionReview } from "@opencode-ai/session-ui/session-review"
import { DataProvider } from "@opencode-ai/session-ui/context"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { WorkerPoolProvider } from "@opencode-ai/ui/context/worker-pool"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { createAsync, query, useParams } from "@solidjs/router"
import { createMemo, createSignal, ErrorBoundary, For, Match, Show, Switch } from "solid-js"
import { Share } from "~/core/share"
@@ -159,7 +158,6 @@ export default function () {
const match = createMemo(() => Binary.search(data().session, data().sessionID, (s) => s.id))
if (!match().found) throw new Error(`Session ${data().sessionID} not found`)
const info = createMemo(() => data().session[match().index])
const title = createMemo(() => withTimestampedFallback(info()))
const ogImage = createMemo(() => {
const models = new Set<string>()
const messages = data().message[data().sessionID] ?? []
@@ -169,7 +167,7 @@ export default function () {
}
}
const modelIDs = Array.from(models)
const encodedTitle = encodeURIComponent(Base64.encode(encodeURIComponent(title().substring(0, 700))))
const encodedTitle = encodeURIComponent(Base64.encode(encodeURIComponent(info().title.substring(0, 700))))
let modelParam: string
if (modelIDs.length === 1) {
modelParam = modelIDs[0]
@@ -186,7 +184,9 @@ export default function () {
return (
<>
<Title>{title()} | OpenCode</Title>
<Show when={info().title}>
<Title>{info().title} | OpenCode</Title>
</Show>
<Meta name="description" content="opencode - The AI coding agent built for the terminal." />
<Meta property="og:image" content={ogImage()} />
<Meta name="twitter:image" content={ogImage()} />
@@ -240,7 +240,7 @@ export default function () {
</div>
</div>
</div>
<div class="text-left text-16-medium text-text-strong">{title()}</div>
<div class="text-left text-16-medium text-text-strong">{info().title}</div>
</div>
)
-8
View File
@@ -1,6 +1,5 @@
import type { SessionApi } from "@opencode-ai/client/effect/api"
import type { Message, SystemPart } from "@opencode-ai/ai"
import type { HttpRequest } from "@opencode-ai/ai/route"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
@@ -16,15 +15,8 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionRequest extends HttpRequest {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
}
export interface SessionHooks {
readonly context: SessionContext
readonly request: SessionRequest
}
export type SessionDomain = Pick<
-8
View File
@@ -1,6 +1,5 @@
import type { SessionApi } from "@opencode-ai/client/promise/api"
import type { Message, SystemPart } from "@opencode-ai/ai"
import type { HttpRequest } from "@opencode-ai/ai/route"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
@@ -16,15 +15,8 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionRequest extends HttpRequest {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
}
export interface SessionHooks {
readonly context: SessionContext
readonly request: SessionRequest
}
export type SessionDomain = Pick<
+16
View File
@@ -628,6 +628,22 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}),
),
)
.add(
HttpApiEndpoint.post("session.pending.withdraw", "/api/session/:sessionID/pending/:inputID/withdraw", {
params: { sessionID: Session.ID, inputID: SessionMessage.ID },
success: Schema.Struct({ data: Schema.Boolean }),
error: SessionNotFoundError,
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.pending.withdraw",
summary: "Withdraw pending session input",
description:
"Withdraw one admitted input before promotion. Returns true when the input was withdrawn or had already been withdrawn, and false when it is no longer pending so callers can fall back to reverting projected history.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "session",
+11
View File
@@ -155,6 +155,16 @@ export const InputAdmitted = Event.durable({
})
export type InputAdmitted = typeof InputAdmitted.Type
export const InputWithdrawn = Event.durable({
type: "session.input.withdrawn",
...options,
schema: {
...Base,
inputID: SessionMessage.ID,
},
})
export type InputWithdrawn = typeof InputWithdrawn.Type
export namespace Execution {
export const Started = Event.durable({ type: "session.execution.started", ...options, schema: Base })
export type Started = typeof Started.Type
@@ -556,6 +566,7 @@ export const Definitions = Event.inventory(
Forked,
InputPromoted,
InputAdmitted,
InputWithdrawn,
Execution.Started,
Execution.Succeeded,
Execution.Failed,
+1 -1
View File
@@ -42,7 +42,7 @@ export const Info = Schema.Struct({
updated: DateTimeUtcFromMillis,
archived: DateTimeUtcFromMillis.pipe(optional),
}),
title: Schema.String.pipe(optional),
title: Schema.String,
location: Location.Ref,
subpath: RelativePath.pipe(optional),
revert: Revert.pipe(optional),
+1 -1
View File
@@ -552,7 +552,7 @@ export const SessionInfo = Schema.Struct({
cost: optional(Schema.Finite),
tokens: optional(SessionTokens),
share: optional(SessionShare),
title: optional(Schema.String),
title: Schema.String,
agent: optional(Schema.String),
model: optional(SessionModel),
version: Schema.String,
+1 -13
View File
@@ -17,7 +17,7 @@ import { Money } from "../src/money.js"
import { Skill } from "../src/skill.js"
import { Shell } from "../src/shell.js"
import { PersistedRevert } from "../src/session-revert.js"
import { AbsolutePath, optional } from "../src/schema.js"
import { optional } from "../src/schema.js"
describe("contract hygiene", () => {
test("restricts agent colors to six-digit hex values", () => {
@@ -52,18 +52,6 @@ describe("contract hygiene", () => {
metadata: undefined,
}),
).toEqual({ text: "completed" })
expect(
Schema.encodeSync(Session.Info)({
id: Session.ID.make("ses_untitled"),
projectID: Project.ID.make("global"),
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
title: undefined,
location: { directory: AbsolutePath.make("/project") },
}),
).not.toHaveProperty("title")
})
test("pending session items omit the internal admission sequence", () => {
@@ -108,6 +108,7 @@ describe("public event manifest", () => {
"session.forked.2",
"session.input.promoted.1",
"session.input.admitted.1",
"session.input.withdrawn.1",
"session.execution.started.1",
"session.execution.succeeded.1",
"session.execution.failed.1",
+17
View File
@@ -608,6 +608,23 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}
}),
)
.handle(
"session.pending.withdraw",
Effect.fn(function* (ctx) {
return {
data: yield* session.withdraw(ctx.params).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
),
}
}),
)
.handle(
"session.instructions.entry.list",
Effect.fn(function* (ctx) {
+30 -19
View File
@@ -66,7 +66,7 @@ import { DialogThemeList } from "./component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { DialogAgent } from "./component/dialog-agent"
import { DialogSessionList } from "./component/dialog-session-list"
import { DialogOpen } from "./component/dialog-open"
import { DialogProject } from "./component/dialog-project"
import { SessionTabs } from "./component/session-tabs"
import { ThemeErrorToast } from "./component/theme-error-toast"
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
@@ -76,7 +76,7 @@ import { PromptHistoryProvider } from "./component/prompt/history"
import { FrecencyProvider } from "./component/prompt/frecency"
import { PromptStashProvider } from "./component/prompt/stash"
import { Toast, ToastProvider, useToast } from "./ui/toast"
import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback"
import { isDefaultTitle } from "./util/session"
import * as Model from "./util/model"
import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
@@ -95,11 +95,13 @@ import { StorageProvider } from "./context/storage"
registerOpencodeSpinner()
const appGlobalBindingCommands = ["session.list", "session.new", "open.menu"] as const
const appGlobalBindingCommands = ["session.list", "session.new"] as const
const sessionTabBindingCommands = [
"session.tab.next",
"session.tab.previous",
"session.tab.history.back",
"session.tab.history.forward",
"session.tab.next_unread",
"session.tab.previous_unread",
"session.tab.close",
@@ -523,11 +525,8 @@ function App(props: { pair?: DialogPairCredentials }) {
renderer.useMouse = config.data.mouse
})
let active: { id: string; title?: string } | undefined
// Update terminal window title based on current route and session
createEffect(() => {
const session = route.data.type === "session" ? data.session.get(route.data.sessionID) : undefined
if (session) active = { id: session.id, title: session.title }
if (!terminalTitleEnabled()) return
if (route.data.type === "home") {
@@ -536,13 +535,14 @@ function App(props: { pair?: DialogPairCredentials }) {
}
if (route.data.type === "session") {
const title = session?.title
if (!title || isFallbackTitle(title)) {
const session = data.session.get(route.data.sessionID)
if (!session || isDefaultTitle(session.title)) {
renderer.setTerminalTitle("OpenCode")
return
}
renderer.setTerminalTitle(`OC | ${title.length > 40 ? title.slice(0, 37) + "..." : title}`)
const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title
renderer.setTerminalTitle(`OC | ${title}`)
return
}
@@ -646,21 +646,17 @@ function App(props: { pair?: DialogPairCredentials }) {
run: () => {
route.navigate({
type: "home",
location:
route.data.type === "session"
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
: undefined,
})
dialog.clear()
},
},
{
name: "open.menu",
title: "Open session or project",
name: "project.switch",
title: "Switch project",
category: "Session",
slash: { name: "open", aliases: ["projects", "project"] },
slash: { name: "projects", aliases: ["project"] },
run: () => {
dialog.replace(() => <DialogOpen />)
dialog.replace(() => <DialogProject />)
},
},
...Array.from({ length: 9 }, (_, i) => ({
@@ -687,6 +683,22 @@ function App(props: { pair?: DialogPairCredentials }) {
enabled: sessionTabs.enabled,
run: () => sessionTabs.cycle(-1),
},
{
name: "session.tab.history.back",
title: "Back in tab history",
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.history(-1),
},
{
name: "session.tab.history.forward",
title: "Forward in tab history",
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.history(1),
},
{
name: "session.tab.next_unread",
title: "Next unread tab",
@@ -1148,11 +1160,10 @@ function App(props: { pair?: DialogPairCredentials }) {
event.on("session.deleted", (evt) => {
if (route.data.type === "session" && route.data.sessionID === evt.data.sessionID) {
const title = active?.id === evt.data.sessionID ? active.title : undefined
route.navigate({ type: "home" })
toast.show({
variant: "info",
message: title ? `Session "${title}" was deleted` : "The current session was deleted",
message: "The current session was deleted",
})
}
})
+1 -1
View File
@@ -105,7 +105,7 @@ export const settings: Setting[] = [
title: "Scope",
category: "Tabs",
path: ["tabs", "scope"],
default: "global",
default: "cwd",
values: ["cwd", "global"],
labels: ["current directory", "global"],
},
@@ -2,8 +2,8 @@ import { useTerminalDimensions } from "@opentui/solid"
import { TextAttributes } from "@opentui/core"
import { createMemo, createResource, createSignal, onMount, Show } from "solid-js"
import path from "path"
import { DialogSelect, dialogSelectContentWidth, type DialogSelectOption } from "../ui/dialog-select"
import { dialogWidth, useDialog } from "../ui/dialog"
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useClient } from "../context/client"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
@@ -159,10 +159,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
if (b.location === b.root.directory) return 1
return a.location.localeCompare(b.location)
})
const titleWidth = Math.max(
1,
dialogSelectContentWidth(Math.min(dialogWidth("xlarge"), dimensions().width - 2)),
)
const titleWidth = Math.max(1, Math.min(116, dimensions().width - 2) - 12)
return list.map((item) => {
const title = abbreviateHome(item.location, paths.home)
-178
View File
@@ -1,178 +0,0 @@
import path from "path"
import { createMemo, createResource, createSignal, onMount } from "solid-js"
import type { SessionInfo } from "@opencode-ai/client"
import { useTerminalDimensions } from "@opentui/solid"
import { dialogWidth, useDialog } from "../ui/dialog"
import { DialogSelect, dialogSelectContentWidth } from "../ui/dialog-select"
import { useRoute } from "../context/route"
import { useData } from "../context/data"
import { useClient } from "../context/client"
import { useLocation } from "../context/location"
import { useSessionTabs } from "../context/session-tabs"
import { useTheme, useThemes } from "../context/theme"
import { Keymap } from "../context/keymap"
import { Locale } from "../util/locale"
import { abbreviateHome } from "../runtime"
import { useTuiPaths } from "../context/runtime"
import { truncateFilePath } from "../ui/file-path"
import { stringWidth } from "../util/string-width"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { Spinner } from "./spinner"
const RECENT_LIMIT = 8
type OpenTarget = { type: "session"; sessionID: string } | { type: "project"; directory: string }
export function DialogOpen() {
const dialog = useDialog()
const route = useRoute()
const data = useData()
const client = useClient()
const location = useLocation()
const sessionTabs = useSessionTabs()
const themes = useThemes()
const theme = useTheme("elevated")
const mode = themes.mode
const paths = useTuiPaths()
const dimensions = useTerminalDimensions()
const shortcuts = Keymap.useShortcuts()
const [filter, setFilter] = createSignal("")
const [selectionMoved, setSelectionMoved] = createSignal(false)
void data.project.sync().catch(() => {})
// One background fetch fills in recent sessions from other projects; the menu renders
// immediately from the local store and never blocks on the network.
const [fetched] = createResource(
() =>
client.api.session
.list({ limit: 50, order: "desc", parentID: null })
.then((response) => response.data)
.catch(() => [] as SessionInfo[]),
{ initialValue: [] },
)
const openTabs = createMemo(
() => new Set(sessionTabs.enabled() ? sessionTabs.tabs().map((tab) => tab.sessionID) : []),
)
const currentSessionID = createMemo(() =>
route.data.type === "session" ? data.session.root(route.data.sessionID) : undefined,
)
const sessions = createMemo(() => {
const seen = new Set<string>()
return [...data.session.list(), ...fetched()]
.filter((session) => {
if (session.parentID || seen.has(session.id)) return false
seen.add(session.id)
return true
})
.toSorted((a, b) => b.time.updated - a.time.updated)
})
const options = createMemo(() => {
const tabs = openTabs()
// With an empty query the menu shows what is not already one keystroke away: open tabs are
// visible in the strip, so recents exclude them. Typing widens the pool to every session so
// matching a loaded tab by name still switches to it.
const recent = filter().trim()
? sessions()
: sessions()
.filter((session) => !tabs.has(session.id))
.slice(0, RECENT_LIMIT)
const sessionOptions = recent.map((session) => {
const project = data.project.get(session.projectID)
const name = project?.canonical === "/" ? undefined : project?.name || path.basename(project?.canonical ?? "")
const running =
data.session.status(session.id) === "running" ||
data.session.family(session.id).some((id) => data.session.status(id) === "running")
return {
title: withTimestampedFallback(session),
value: { type: "session", sessionID: session.id } as OpenTarget,
category: "Sessions",
footer: `${name ? `${Locale.truncate(name, 20)} · ` : ""}${timeAgo(session.time.updated)}`,
onSelect: () => location.set(session.location),
gutter: running
? () => <Spinner />
: tabs.has(session.id)
? () => <text fg={theme.hue.accent[mode() === "light" ? 800 : 200]}></text>
: undefined,
}
})
const current = location.current?.project
const seen = new Set<string>()
const projectOptions = data.project
.list()
.filter((project) => {
if (project.canonical === "/" || seen.has(project.canonical)) return false
seen.add(project.canonical)
return true
})
.map((project) => {
const title = project.name ?? path.basename(project.canonical)
const footer = abbreviateHome(project.canonical, paths.home)
const width =
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(title)
return {
title,
footer: truncateFilePath(footer, width),
searchText: footer,
value: { type: "project", directory: project.canonical } as OpenTarget,
category: "Projects",
gutter:
project.canonical === current?.canonical
? () => <text fg={theme.text.formfield.selected}></text>
: undefined,
}
})
return [...sessionOptions, ...projectOptions]
})
onMount(() => dialog.setSize("large"))
return (
<DialogSelect
title="Open"
placeholder="Search sessions and projects…"
options={options()}
current={currentSessionID() ? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget) : undefined}
focusCurrent={false}
preserveSelection={selectionMoved()}
onMove={() => setSelectionMoved(true)}
onFilter={setFilter}
noMatchView={
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>
{shortcuts.get("session.list")
? `No matches · search all sessions with ${shortcuts.get("session.list")}`
: "No matches"}
</text>
</box>
}
onSelect={(option) => {
dialog.clear()
if (option.value.type === "session") {
route.navigate({ type: "session", sessionID: option.value.sessionID })
return
}
const target = { directory: option.value.directory }
route.navigate({ type: "home", location: target })
location.set(target)
}}
/>
)
}
function timeAgo(timestamp: number) {
const minutes = Math.floor((Date.now() - timestamp) / 60_000)
if (minutes < 1) return "now"
if (minutes < 60) return `${minutes}m`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h`
const days = Math.floor(hours / 24)
if (days < 30) return `${days}d`
const months = Math.floor(days / 30)
if (months < 12) return `${months}mo`
return `${Math.floor(days / 365)}y`
}
@@ -0,0 +1,77 @@
import path from "path"
import { createMemo } from "solid-js"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useData } from "../context/data"
import { useRoute } from "../context/route"
import { abbreviateHome } from "../runtime"
import { useTuiPaths } from "../context/runtime"
import { useLocation } from "../context/location"
import { useToast } from "../ui/toast"
import { useTerminalDimensions } from "@opentui/solid"
import { truncateFilePath } from "../ui/file-path"
import { stringWidth } from "../util/string-width"
export function DialogProject() {
const dialog = useDialog()
const data = useData()
const route = useRoute()
const paths = useTuiPaths()
const location = useLocation()
const toast = useToast()
const dimensions = useTerminalDimensions()
data.project.invalidate()
void data.project.sync().catch(toast.error)
const current = () => location.current?.project
const options = createMemo(() => {
const seen = new Set<string>()
return data.project
.list()
.filter((project) => {
if (project.canonical === "/" || seen.has(project.canonical)) return false
seen.add(project.canonical)
return true
})
.toSorted((a, b) => {
if (a.id === current()?.id) return -1
if (b.id === current()?.id) return 1
return 0
})
.map((project) => {
const title = project.name ?? path.basename(project.canonical)
const description = abbreviateHome(project.canonical, paths.home)
// Dialog padding, the current marker, title padding, and the separating space use nine columns.
const width = Math.min(60, dimensions().width - 2) - 9 - stringWidth(title)
return {
title,
description: truncateFilePath(description, width),
searchText: description,
value: project.canonical,
}
})
})
return (
<DialogSelect
title="Switch project"
placeholder="Search projects…"
options={options()}
current={current()?.canonical}
emptyView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text>No projects found</text>
</box>
}
onSelect={(option) => {
dialog.clear()
if (option.value === current()?.canonical) return
const target = { directory: option.value }
route.navigate({ type: "home", location: target })
location.set(target)
}}
/>
)
}
@@ -17,9 +17,6 @@ import { DialogSessionRename } from "./dialog-session-rename"
import { Spinner } from "./spinner"
import { errorMessage } from "../util/error"
import { useSessionTabs } from "../context/session-tabs"
import { useStorage } from "../context/storage"
import { useConfig } from "../config"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
export function DialogSessionList() {
const dialog = useDialog()
@@ -31,16 +28,12 @@ export function DialogSessionList() {
const client = useClient()
const local = useLocal()
const sessionTabs = useSessionTabs()
const config = useConfig().data
const toast = useToast()
const [filter, setFilter] = createSignal("")
const shortcuts = Keymap.useShortcuts()
const [search, setSearch] = createDebouncedSignal("", 150)
const [toDelete, setToDelete] = createSignal<string>()
const [prefs, updatePrefs] = useStorage().store("session-list", {
initial: { allProjects: config.tabs?.scope !== "cwd" },
})
const allProjects = () => prefs.allProjects
const [allProjects, setAllProjects] = createSignal(false)
const [searchResults, { mutate: setSearchResults }] = createResource(
() => ({ query: search().trim(), allProjects: allProjects() }),
@@ -82,15 +75,12 @@ export function DialogSessionList() {
(session.projectID === current?.project.id && session.location.directory === current.directory),
)
if (!query) return sessions
return sessions.filter(
(session) => !session.parentID && withTimestampedFallback(session).toLowerCase().includes(query),
)
return sessions.filter((session) => !session.parentID && session.title.toLowerCase().includes(query))
})
const sessions = createMemo(() => {
const query = filter().trim()
const local = localSessions()
if (query !== search().trim()) return searchResults.latest?.sessions ?? local
if (searchResults.loading) return searchResults.latest?.sessions ?? []
if (query !== search().trim() || searchResults.loading) return searchResults.latest?.sessions ?? local
const result = searchResults()
if (result?.query !== query || result.allProjects !== allProjects() || result.error) return local
return result.sessions
@@ -147,21 +137,17 @@ export function DialogSessionList() {
const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
const deleting = toDelete() === session.id
return {
title: deleting
? `Press ${shortcuts.get("session.delete")} again to confirm`
: withTimestampedFallback(session),
title: deleting ? `Press ${shortcuts.get("session.delete")} again to confirm` : session.title,
value: session.id,
category,
footer,
bg: deleting ? theme.background.action.destructive.focused : undefined,
fg: deleting ? theme.text.action.destructive.focused : undefined,
gutter:
data.session.status(session.id) === "running" ||
data.session.family(session.id).some((id) => data.session.status(id) === "running")
? () => <Spinner />
: slot === undefined
? undefined
: () => <text fg={theme.hue.accent[mode() === "light" ? 800 : 200]}>{slot}</text>,
gutter: data.session.family(session.id).some((id) => data.session.status(id) === "running")
? () => <Spinner />
: slot === undefined
? undefined
: () => <text fg={theme.hue.accent[mode() === "light" ? 800 : 200]}>{slot}</text>,
}
}
@@ -203,9 +189,7 @@ export function DialogSessionList() {
title: allProjects() ? "Show current directory sessions" : "Show all project sessions",
group: "Dialog",
run: () => {
void updatePrefs((draft) => {
draft.allProjects = !draft.allProjects
}).catch(() => {})
setAllProjects((value) => !value)
},
},
]}
@@ -247,9 +231,7 @@ export function DialogSessionList() {
.remove({ sessionID: option.value })
.then(() => {
setSearchResults((result) =>
result
? { ...result, sessions: result.sessions.filter((session) => session.id !== option.value) }
: result,
result ? { ...result, sessions: result.sessions.filter((session) => session.id !== option.value) } : result,
)
})
.catch((error) => {
+9 -26
View File
@@ -1,13 +1,11 @@
import { RGBA, TextAttributes } from "@opentui/core"
import { For, type JSX } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid"
import { useTheme } from "../context/theme"
import { tint } from "../theme/color"
import { go, logo } from "../logo"
import { logo } from "../logo"
export function Logo() {
const theme = useTheme()
const dimensions = useTerminalDimensions()
const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => {
const shadow = tint(theme.background.default, fg, 0.25)
@@ -51,29 +49,14 @@ export function Logo() {
return (
<box>
{dimensions().height < 12 ? null : dimensions().width < 22 ? (
<For each={go.right.slice(1)}>
{(line) => <box flexDirection="row">{renderLine(line, theme.text.default, true)}</box>}
</For>
) : dimensions().width < 44 ? (
<>
<For each={logo.left.slice(1)}>
{(line) => <box flexDirection="row">{renderLine(line, theme.text.subdued, false)}</box>}
</For>
<For each={logo.right}>
{(line) => <box flexDirection="row">{renderLine(line, theme.text.default, true)}</box>}
</For>
</>
) : (
<For each={logo.left}>
{(line, index) => (
<box flexDirection="row" gap={1}>
<box flexDirection="row">{renderLine(line, theme.text.subdued, false)}</box>
<box flexDirection="row">{renderLine(logo.right[index()], theme.text.default, true)}</box>
</box>
)}
</For>
)}
<For each={logo.left}>
{(line, index) => (
<box flexDirection="row" gap={1}>
<box flexDirection="row">{renderLine(line, theme.text.subdued, false)}</box>
<box flexDirection="row">{renderLine(logo.right[index()], theme.text.default, true)}</box>
</box>
)}
</For>
</box>
)
}
-83
View File
@@ -1,83 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { DiffRenderable, LineNumberRenderable, type ColorInput } from "@opentui/core"
import type { JSX } from "@opentui/solid"
import { createMemo, For, Show, splitProps } from "solid-js"
import { splitPatchHunks } from "../util/diff"
import { stringWidth } from "../util/string-width"
export interface PatchDiffRef {
readonly hunks: () => readonly DiffRenderable[]
}
type Props = Omit<JSX.IntrinsicElements["diff"], "diff" | "lineNumberBg" | "ref"> & {
diff: string
hunkFg: ColorInput
lineNumberBg: ColorInput
ref?: (value: PatchDiffRef) => void
}
export function PatchDiff(props: Props) {
const [local, diffProps] = splitProps(props, ["diff", "hunkFg", "lineNumberBg", "ref"])
const hunks = createMemo(() => splitPatchHunks(local.diff))
const nodes = new Map<number, DiffRenderable>()
local.ref?.({
hunks: () =>
[...nodes.entries()]
.sort(([left], [right]) => left - right)
.map(([, node]) => node)
.filter((node) => !node.isDestroyed),
})
const syncGutters = (attempt = 0) => {
requestAnimationFrame(() => {
const sides = [...nodes.values()]
.filter((item) => !item.isDestroyed)
.flatMap((item) => item.getChildren().filter((side) => side instanceof LineNumberRenderable))
const lineNumbers = sides.map((side) => new Map([...side.getLineNumbers()].filter(([line]) => line >= 0)))
const digits = lineNumbers.map((numbers) => Math.max(0, ...numbers.values()).toString().length)
const after = sides.map((side) =>
Math.max(
0,
...[...side.getLineSigns()].filter(([line]) => line >= 0).map(([, sign]) => stringWidth(sign.after ?? "")),
),
)
const maxDigits = Math.max(...digits)
const maxAfter = Math.max(...after)
if (!maxDigits && attempt < 2) return syncGutters(attempt + 1)
if (!maxDigits) return
sides.forEach((side) => {
const index = sides.indexOf(side)
const signs = new Map([...side.getLineSigns()].filter(([line]) => line >= 0))
signs.set(-1, { after: " ".repeat(maxAfter + maxDigits - digits[index]) })
side.setLineNumbers(lineNumbers[index])
side.setLineSigns(signs)
})
})
}
const register = (index: number, node: DiffRenderable) => {
nodes.set(index, node)
syncGutters()
}
return (
<For each={hunks()}>
{(hunk, index) => (
<>
<Show when={index() > 0}>
<box width="100%" height={1} backgroundColor={local.lineNumberBg}>
<text fg={local.hunkFg} bg={local.lineNumberBg}>
{` ${hunk.header ?? ""}`}
</text>
</box>
</Show>
<diff
{...diffProps}
ref={(node: DiffRenderable) => register(index(), node)}
diff={hunk.patch}
minHeight={hunk.rows}
lineNumberBg={local.lineNumberBg}
/>
</>
)}
</For>
)
}
+20 -71
View File
@@ -240,6 +240,7 @@ export function Prompt(props: PromptProps) {
return undefined
})
if (!location) return
move.setDirectory(location.directory, location.directory !== location.project.directory)
currentLocation.set(location)
return
}
@@ -962,10 +963,7 @@ export function Prompt(props: PromptProps) {
const directory = await move.getDirectory()
if (move.pending() && !directory) return false
finishMoveProgress = Boolean(move.progress())
// The location context is where the next session is created: seeded by the home
// route (launch cwd, inherited session location, or picked project) and updated
// by /cd before a session exists.
const location = currentLocation.ref ?? data.location.default()
const location = data.location.default()
const created = await client.api.session
.create({
@@ -1168,19 +1166,6 @@ export function Prompt(props: PromptProps) {
)
}
function expandPastedText(extmarkId: number) {
const extmark = input.extmarks.get(extmarkId)
const ref = store.extmarkToPart.get(extmarkId)
if (!extmark || ref?.type !== "pasted") return false
const part = store.prompt.pasted[ref.index]
if (!part) return false
input.extmarks.delete(extmarkId)
input.setSelection(extmark.start, extmark.end)
input.insertText(part.text)
return true
}
async function pasteInputText(text: string) {
const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const pastedContent = normalizedText.trim()
@@ -1204,15 +1189,6 @@ export function Prompt(props: PromptProps) {
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
if ((lineCount >= 3 || pastedContent.length > 150) && config.prompt?.paste !== "full") {
const extmark = input.extmarks.getAllForTypeId(promptPartTypeId).find((extmark) => {
const ref = store.extmarkToPart.get(extmark.id)
return (
(extmark.end === input.cursorOffset || extmark.end + 1 === input.cursorOffset) &&
ref?.type === "pasted" &&
store.prompt.pasted[ref.index]?.text === pastedContent
)
})
if (extmark && expandPastedText(extmark.id)) return
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
return
}
@@ -1314,28 +1290,16 @@ export function Prompt(props: PromptProps) {
const placeholderText = createMemo(() => {
if (props.showPlaceholder === false) return undefined
const value = (() => {
if (store.mode === "shell") {
if (!shell().length) return undefined
return `Run a command... "${shell()[store.placeholder % shell().length]}"`
}
if (!list().length) return undefined
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
return Locale.takeWidth(value, Math.max(1, width)).trimEnd()
if (store.mode === "shell") {
if (!shell().length) return undefined
const example = shell()[store.placeholder % shell().length]
return `Run a command... "${example}"`
}
if (!list().length) return undefined
return `Ask anything... "${list()[store.placeholder % list().length]}"`
})
const locationLabel = createMemo(() => {
if (!props.sessionID) {
// No session yet: show where the next session will be created.
const directory = currentLocation.ref?.directory ?? data.location.default().directory
return abbreviateHome(directory, paths.home)
}
if (status() !== "idle") return
if (!props.sessionID || status() !== "idle") return
const directory = data.session.get(props.sessionID)?.location.directory
return directory ? abbreviateHome(directory, paths.home) : undefined
})
@@ -1377,8 +1341,8 @@ export function Prompt(props: PromptProps) {
}}
>
<box
paddingLeft={dimensions().width < 44 ? 1 : 2}
paddingRight={dimensions().width < 44 ? 1 : 2}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
flexShrink={0}
backgroundColor={promptBg()}
@@ -1454,48 +1418,33 @@ export function Prompt(props: PromptProps) {
}, 0)
}}
onMouseDown={(r: MouseEvent) => {
if (props.disabled || r.button !== 0) return
if (props.disabled) return
r.target?.focus()
const extmark = input.extmarks
.getAtOffset(input.cursorOffset)
.find((item) => store.extmarkToPart.get(item.id)?.type === "pasted")
if (!extmark || !expandPastedText(extmark.id)) return
r.preventDefault()
r.stopPropagation()
}}
focusedBackgroundColor="transparent"
cursorColor={props.disabled ? theme.background.surface.offset : theme.text.default}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
<box flexDirection="row" gap={1} flexGrow={1} flexShrink={1} minWidth={0}>
<box flexDirection="row" gap={1}>
<Show when={agentLabel()} fallback={<box height={1} />}>
{(label) => (
<>
<text fg={fadeColor(highlight(), agentMetaAlpha())}>{label()}</text>
<Show
when={store.mode === "normal" && local.permission.mode === "auto" && dimensions().width >= 44}
>
<Show when={store.mode === "normal" && local.permission.mode === "auto"}>
<text fg={fadeColor(theme.text.subdued, agentMetaAlpha())}>auto</text>
</Show>
<Show when={store.mode === "normal" && dimensions().width >= 28}>
<box flexDirection="row" gap={1} flexGrow={1} flexShrink={1} minWidth={0}>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text fg={fadeColor(theme.text.subdued, modelMetaAlpha())}>·</text>
<text
flexShrink={1}
minWidth={0}
wrapMode="none"
truncate
flexShrink={0}
fg={fadeColor(leader() ? theme.text.subdued : theme.text.default, modelMetaAlpha())}
>
{local.model.parsed().model}
</text>
<Show when={dimensions().width >= 50}>
<text flexShrink={0} fg={fadeColor(theme.text.subdued, modelMetaAlpha())}>
{currentProviderLabel()}
</text>
</Show>
<Show when={showVariant() && dimensions().width >= 70}>
<text fg={fadeColor(theme.text.subdued, modelMetaAlpha())}>{currentProviderLabel()}</text>
<Show when={showVariant()}>
<text fg={fadeColor(theme.text.subdued, variantMetaAlpha())}>·</text>
<text>
<span
+1 -7
View File
@@ -6,7 +6,6 @@ import { useSessionTabs } from "../context/session-tabs"
import { useTheme, useThemes } from "../context/theme"
import {
adaptiveSessionTabLayout,
NEW_SESSION_TAB_TITLE,
sessionTabComplete,
seedSessionTabMotion,
sessionTabOverflowWidth,
@@ -37,7 +36,7 @@ export type SessionTabsController = Pick<ContextController, "tabs" | "current" |
status(sessionID: string): SessionTabsStatus
}
const NEW_SESSION_TAB: SessionTab = { sessionID: "new", title: NEW_SESSION_TAB_TITLE }
const NEW_SESSION_TAB: SessionTab = { sessionID: "new", title: "New session" }
export function SessionTabs(props: { controller?: SessionTabsController; animations?: boolean } = {}) {
const tabs = props.controller ?? useSessionTabs()
@@ -210,11 +209,6 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
createEffect((previous: string) => {
const next = title()
if (next === previous) return next
if (previous === NEW_SESSION_TAB_TITLE) {
setOutgoingTitle(undefined)
wipe.jump({ front: 1 })
return next
}
setOutgoingTitle(previous)
wipe.jump({ front: 0 })
wipe.animate({ front: 1 })
+4 -2
View File
@@ -87,9 +87,10 @@ export const Definitions = {
session_move: keybind("none", "Move session"),
session_new: keybind("<leader>n", "Create a new session"),
session_list: keybind("<leader>l", "List all sessions"),
open_menu: keybind("ctrl+o", "Open recent sessions and projects"),
session_tab_next: keybind("ctrl+tab,<leader>right,alt+shift+]", "Switch to next open tab"),
session_tab_previous: keybind("ctrl+shift+tab,<leader>left,alt+shift+[", "Switch to previous open tab"),
session_tab_history_back: keybind("ctrl+o", "Go back in tab history"),
session_tab_history_forward: keybind("ctrl+i", "Go forward in tab history"),
session_tab_next_unread: keybind("<leader>down", "Switch to next unread tab"),
session_tab_previous_unread: keybind("<leader>up", "Switch to previous unread tab"),
session_tab_close: keybind("<leader>w", "Close current tab"),
@@ -290,9 +291,10 @@ export const CommandMap = {
session_move: "session.move",
session_new: "session.new",
session_list: "session.list",
open_menu: "open.menu",
session_tab_next: "session.tab.next",
session_tab_previous: "session.tab.previous",
session_tab_history_back: "session.tab.history.back",
session_tab_history_forward: "session.tab.history.forward",
session_tab_next_unread: "session.tab.next_unread",
session_tab_previous_unread: "session.tab.previous_unread",
session_tab_close: "session.tab.close",
+31 -25
View File
@@ -45,7 +45,6 @@ const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
// server cannot recover their Location when settling them. Preserve the event Location
// until MCP elicitations carry session ownership.
export type FormWithLocation = FormInfo & { readonly location?: LocationRef }
type ShellWithLocation = ShellInfo & { readonly location: LocationRef }
type LocationData = {
info?: LocationGetOutput
@@ -62,7 +61,7 @@ type LocationData = {
websearch?: WebSearchProvider[]
// Currently running shell commands for this location, keyed by shell id. Entries are removed
// once the command exits or is deleted, so this only ever holds in-flight shells.
shell?: Record<string, ShellWithLocation>
shell?: Record<string, ShellInfo>
skill?: SkillInfo[]
}
@@ -189,6 +188,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
index.set(item.id, messages.length)
messages.push(item)
},
reindex(messages: SessionMessageInfo[], index: Map<string, number>, start: number) {
for (let position = start; position < messages.length; position++) {
const item = messages[position]
if (item) index.set(item.id, position)
}
},
activeAssistant(messages: SessionMessageInfo[]) {
const item = messages.findLast((item) => item.type === "assistant" && !item.time.completed)
return item?.type === "assistant" ? item : undefined
@@ -375,11 +380,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
.catch((error) => console.error("Failed to load projected model switch message", error))
break
case "session.renamed":
// Preserve the live title when it races the session's initial read.
void result.session.sync(event.data.sessionID).then(() => {
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "title", event.data.title)
})
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "title", event.data.title)
break
case "session.moved":
if (store.session.info[event.data.sessionID]) {
@@ -399,8 +401,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
existing.time.created = event.created
draft.splice(position, 1)
draft.push(existing)
index.clear()
draft.forEach((message, indexValue) => index.set(message.id, indexValue))
message.reindex(draft, index, position)
})
setStore(
"session",
@@ -442,6 +443,25 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
)
})
break
case "session.input.withdrawn": {
removePending(event.data.sessionID, event.data.inputID)
if (store.session.input[event.data.sessionID]?.includes(event.data.inputID))
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.inputID)
if (position === undefined) return
draft.splice(position, 1)
index.delete(event.data.inputID)
message.reindex(draft, index, position)
})
if (store.session.input[event.data.sessionID]?.includes(event.data.inputID))
setStore(
"session",
"input",
event.data.sessionID,
(store.session.input[event.data.sessionID] ?? []).filter((id) => id !== event.data.inputID),
)
break
}
case "session.instructions.updated":
const instructions = event.metadata?.instructions
if (
@@ -855,10 +875,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "shell.created":
setStore("location", locationKey(event.location ?? defaultLocation()), (data) => ({
...data,
shell: {
...data?.shell,
[event.data.info.id]: { ...event.data.info, location: event.location ?? defaultLocation() },
},
shell: { ...data?.shell, [event.data.info.id]: event.data.info },
}))
break
case "shell.exited":
@@ -1113,18 +1130,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const key = locationKey(response.location)
setStore("location", key, {
...store.location[key],
shell: Object.fromEntries(
response.data.map((info) => [
info.id,
{
...info,
location: {
directory: response.location.directory,
workspaceID: response.location.workspaceID,
},
},
]),
),
shell: Object.fromEntries(response.data.map((info) => [info.id, info])),
})
})
},

Some files were not shown because too many files have changed in this diff Show More