Compare commits

...

10 Commits

Author SHA1 Message Date
Aiden Cline a5a0d3dac7 docs(core): explain Codex limit parity 2026-07-30 22:07:10 -05:00
Aiden Cline fd6a704a17 fix(core): respect model input limits 2026-07-30 22:03:21 -05:00
Aiden Cline 671e164f8d refactor(core): contain Codex in OpenAI plugin (#39734) 2026-07-30 21:43:38 -05:00
Aiden Cline 5b4ebf3e9d fix(core): map xAI native options (#39787) 2026-07-30 21:42:53 -05:00
Aiden Cline 98229d466d feat(plugin): add session request hook (#39764) 2026-07-30 21:11:22 -05:00
Kit Langton 5c7e2b5042 fix(tui): clarify open menu project labels (#39780) 2026-07-30 21:34:29 -04:00
Kit Langton 0a6a5d3e80 fix(session): retry failed title generation (#39748) 2026-07-30 21:26:44 -04:00
Kit Langton eb95bd27fe feat(session): make generated titles optional (#39747) 2026-07-30 21:24:34 -04:00
Kit Langton 865f512a44 fix(tui): preserve current selection across list updates (#39774) 2026-07-31 00:48:51 +00:00
Kit Langton a460f02f67 feat(tui): inherit session directory when creating a new session (#39753) 2026-07-30 20:31:38 -04:00
64 changed files with 872 additions and 481 deletions
+1
View File
@@ -6,6 +6,7 @@ export interface Settings extends Readonly<Record<string, unknown>> {
readonly body?: Readonly<Record<string, unknown>>
readonly limits?: {
readonly context: number
readonly input?: number
readonly output: number
}
}
+26 -17
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 { Transport, TransportRuntime } from "./transport"
import type { HttpRequestTransform, Transport, TransportRuntime } from "./transport"
import { WebSocketExecutor } from "./transport"
import type { Protocol } from "./protocol"
import { applyCachePolicy } from "../cache-policy"
@@ -46,7 +46,11 @@ 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) => Effect.Effect<Prepared, LLMError>
readonly prepareTransport: (
body: Body,
request: LLMRequest,
options?: StreamOptions,
) => Effect.Effect<Prepared, LLMError>
readonly streamPrepared: (
prepared: Prepared,
request: LLMRequest,
@@ -145,12 +149,16 @@ export interface Interface {
readonly generate: GenerateMethod
}
export interface StreamOptions {
readonly transform?: HttpRequestTransform
}
export interface StreamMethod {
(request: LLMRequest): Stream.Stream<LLMEvent, LLMError>
(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, LLMError>
}
export interface GenerateMethod {
(request: LLMRequest): Effect.Effect<LLMResponse, LLMError>
(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, LLMError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
@@ -286,7 +294,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
},
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedModelInput) =>
makeRouteModel<Options>(route, input),
prepareTransport: (body, request) =>
prepareTransport: (body, request, options) =>
routeInput.transport.prepare({
body,
request,
@@ -294,6 +302,7 @@ 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}`
@@ -359,14 +368,14 @@ export function make<Body, Prepared, Frame, Event, State>(
})
}
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
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)
const prepared = yield* route.prepareTransport(body, resolved, options)
return {
request: resolved,
@@ -389,17 +398,17 @@ export const compileRequest = Effect.fn("LLM.compileRequest")(function* (request
}
})
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =>
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest, options?: StreamOptions) =>
Stream.unwrap(
Effect.gen(function* () {
const compiled = yield* compile(request)
const compiled = yield* compile(request, options)
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)
}),
)
const generateWith = (stream: Interface["stream"]) =>
Effect.fn("LLM.generate")(function* (request: LLMRequest) {
const state = yield* stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
Effect.fn("LLM.generate")(function* (request: LLMRequest, options?: StreamOptions) {
const state = yield* stream(request, options).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
const response = LLMResponse.complete(state)
if (response) return response
return yield* ProviderShared.eventError(
@@ -408,24 +417,24 @@ const generateWith = (stream: Interface["stream"]) =>
)
})
export function stream(request: LLMRequest): Stream.Stream<LLMEvent, LLMError> {
export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, LLMError> {
return Stream.unwrap(
Effect.gen(function* () {
return (yield* Service).stream(request)
return (yield* Service).stream(request, options)
}),
) as Stream.Stream<LLMEvent, LLMError>
}
export function generate(request: LLMRequest): Effect.Effect<LLMResponse, LLMError> {
export function generate(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, LLMError> {
return Effect.gen(function* () {
return yield* (yield* Service).generate(request)
return yield* (yield* Service).generate(request, options)
}) as Effect.Effect<LLMResponse, LLMError>
}
export const streamRequest = (request: LLMRequest) =>
export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
Stream.unwrap(
Effect.gen(function* () {
return (yield* Service).stream(request)
return (yield* Service).stream(request, options)
}),
)
+2 -1
View File
@@ -8,6 +8,7 @@ export type {
AnyRoute,
Interface as LLMClientShape,
Service as LLMClientService,
StreamOptions,
} from "./client"
export * from "./executor"
export { Auth } from "./auth"
@@ -22,4 +23,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 { Transport as TransportDef, TransportRuntime } from "./transport"
export type { HttpRequest, HttpRequestTransform, Transport as TransportDef, TransportRuntime } from "./transport"
+12 -7
View File
@@ -120,14 +120,19 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
id: "http-json",
with: (patch) => httpJson({ ...input, ...patch }),
prepare: (prepareInput) =>
jsonRequestParts({
...prepareInput,
}).pipe(
Effect.map((parts) => ({
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
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),
}),
framing: input.framing,
})),
),
}
}),
frames: (prepared, request, runtime) =>
Stream.unwrap(
runtime.http
+10
View File
@@ -10,6 +10,15 @@ 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>
@@ -27,6 +36,7 @@ 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"
+1
View File
@@ -123,6 +123,7 @@ 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),
}) {}
+34
View File
@@ -137,6 +137,40 @@ 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
@@ -11,6 +11,7 @@ import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import type { Session } from "@opencode-ai/sdk/v2"
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
import { TabPreviewPopover } from "./titlebar-tab-popover"
import { sessionTitle } from "@/utils/session-title"
import "./titlebar-tab-nav.css"
// MouseEvent.button uses 1 for the middle/wheel button.
@@ -54,7 +55,10 @@ export function TabNavItem(props: {
if (!session) return
return projectForSession(session, serverCtx()?.projects.list() ?? [])
})
const title = createMemo(() => props.session()?.title ?? props.fallbackTitle)
const title = createMemo(() => {
const session = props.session()
return session ? sessionTitle(session.title, session.parentID) : props.fallbackTitle
})
const projectName = createMemo(() => {
const session = props.session()
@@ -143,7 +147,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(() => {
@@ -302,7 +306,7 @@ export function TabNavItem(props: {
}}
data={{
projectName: projectName(),
title: props.session()?.title,
title: title(),
path: previewPath(),
serverName: serverLabel(),
}}
@@ -7,6 +7,7 @@ import { createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { HomeController } from "./home-controller"
import { homeSessionSearchKey, type HomeSessionRecord, type HomeSessionsController } from "./home-sessions-controller"
import { sessionTitle } from "@/utils/session-title"
type HomeSessionSearchSource = Pick<HomeSessionsController, "data" | "session">
@@ -23,7 +24,7 @@ export function createHomeSessionSearchController(home: HomeController, sessions
if (!value) return []
return sessions.data
.searchRecords()
.filter((record) => `${record.session.title} ${record.projectName}`.toLowerCase().includes(value))
.filter((record) => `${sessionTitle(record.session.title)} ${record.projectName}`.toLowerCase().includes(value))
})
const active = createMemo(() => {
const records = results()
@@ -104,7 +104,7 @@ const SessionRow = (props: {
warmPress: () => void
warmFocus: () => void
}): JSX.Element => {
const title = () => sessionTitle(props.session.title)
const title = () => sessionTitle(props.session.title, props.session.parentID)
return (
<A
@@ -229,7 +229,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
fallback={
<Tooltip
placement={props.mobile ? "bottom" : "right"}
value={sessionTitle(props.session.title)}
value={sessionTitle(props.session.title, props.session.parentID)}
gutter={10}
class="min-w-0 w-full"
>
@@ -297,7 +297,11 @@ export function MessageTimeline(props: {
return sync().session.get(id)
})
const titleValue = createMemo(() => info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()))
const titleLabel = createMemo(() => {
const session = info()
if (!session) return
return sessionTitle(titleValue(), session.parentID)
})
const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
const parentID = createMemo(() => info()?.parentID)
@@ -311,7 +315,10 @@ export function MessageTimeline(props: {
if (!id) return emptyMessages
return sync().data.message[id] ?? emptyMessages
})
const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
const parentTitle = createMemo(() => {
const session = parent()
return session ? sessionTitle(session.title, session.parentID) : 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(() => {
@@ -329,7 +336,7 @@ export function MessageTimeline(props: {
if (value) return value
return language.t("command.session.new")
})
const showHeader = createMemo(() => !!(titleValue() || parentID()))
const showHeader = createMemo(() => !!(titleLabel() || parentID()))
const projection = createTimelineProjection({
messages: sessionMessages,
userMessages: () => props.userMessages,
@@ -912,9 +919,10 @@ export function MessageTimeline(props: {
}
function DialogDeleteSession(props: { sessionID: string }) {
const name = createMemo(
() => sessionTitle(sync().session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
)
const name = createMemo(() => {
const session = sync().session.get(props.sessionID)
return session ? sessionTitle(session.title, session.parentID) : language.t("command.session.new")
})
const handleDelete = async () => {
await deleteSession(props.sessionID)
dialog.close()
@@ -0,0 +1,11 @@
import { describe, expect, test } from "bun:test"
import { sessionTitle } from "./session-title"
describe("sessionTitle", () => {
test("uses a display fallback without persisting it", () => {
expect(sessionTitle(undefined)).toBe("New session")
expect(sessionTitle(undefined, "ses_parent")).toBe("Child session")
expect(sessionTitle("New session - 2026-07-30T18:45:03.662Z")).toBe("New session")
expect(sessionTitle("Generated title")).toBe("Generated title")
})
})
+2 -2
View File
@@ -1,7 +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
export function sessionTitle(title?: string, parentID?: string) {
if (!title) return parentID ? "Child session" : "New session"
const match = title.match(pattern)
return match?.[1] ?? title
}
+1 -1
View File
@@ -13,7 +13,7 @@ export function normalizeSessionInfo(input: SessionInfo | Session): Session {
parentID: input.parentID,
cost: input.cost,
tokens: input.tokens,
title: input.title,
title: input.title ?? `${input.parentID ? "Child" : "New"} session - ${new Date(input.time.created).toISOString()}`,
agent: input.agent,
model: input.model,
version: "",
+3 -1
View File
@@ -213,7 +213,9 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
sessions: page.data.map((session) => ({
sessionId: session.id,
cwd: session.location.directory,
title: session.title,
title:
session.title ??
`${session.parentID ? "Child" : "New"} session - ${new Date(session.time.created).toISOString()}`,
updatedAt: new Date(session.time.updated).toISOString(),
})),
...(page.cursor.next ? { nextCursor: page.cursor.next } : {}),
@@ -1757,7 +1757,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 +2006,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
+3 -3
View File
@@ -1,9 +1,9 @@
{
"version": "7",
"dialect": "sqlite",
"id": "db37a97f-9b5e-4c87-be8b-4feace35136c",
"id": "e43ed7e2-b9fc-4178-beae-3646e4a976e1",
"prevIds": [
"a4ba73b4-21bc-41ab-a415-94e2ca38d798"
"db37a97f-9b5e-4c87-be8b-4feace35136c"
],
"ddl": [
{
@@ -1302,7 +1302,7 @@
},
{
"type": "text",
"notNull": true,
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
+11 -1
View File
@@ -32,7 +32,7 @@ export function map(packageName: string | undefined, settings: Readonly<Record<s
settings: {
...baseSettings,
...mapAPIKey(settings),
...mapProviderOptions("xai", settings),
...mapXAIOptions(settings),
},
}
}
@@ -48,6 +48,16 @@ function mapAPIKey(settings: Readonly<Record<string, unknown>>) {
return typeof settings.apiKey === "string" ? { apiKey: settings.apiKey } : {}
}
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 } }
}
function mapProviderOptions(namespace: string, settings: Readonly<Record<string, unknown>>) {
const values = Object.fromEntries(
Object.entries(settings).filter(
+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, output: info.limit.output },
limits: { context: info.limit.context, input: info.limit.input, output: info.limit.output },
providerOptions,
},
body: {
+1
View File
@@ -58,5 +58,6 @@ 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[]
@@ -0,0 +1,14 @@
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 NOT NULL,
\`title\` text,
\`version\` text NOT NULL,
\`share_url\` text,
\`summary_additions\` integer,
+2 -28
View File
@@ -18,7 +18,6 @@ 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>()(
@@ -82,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, output: model.limit.output },
limits: { context: model.limit.context, input: model.limit.input, output: model.limit.output },
})
const providerHeaders = (model: Info) => {
@@ -153,12 +152,7 @@ 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) })
@@ -210,7 +204,7 @@ export const fromCatalogModel = (
...nativeCredentialSettings(specifier, credential),
headers: resolved.headers,
body: resolved.body,
limits: { context: resolved.limit.context, output: resolved.limit.output },
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
}
return yield* Effect.try({
try: () => {
@@ -227,10 +221,6 @@ export const fromCatalogModel = (
})
}
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 }
@@ -252,22 +242,6 @@ 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,
@@ -1,42 +0,0 @@
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
}
+36 -24
View File
@@ -10,14 +10,16 @@ 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
@@ -164,14 +166,18 @@ export const OpenAIPlugin = define({
effect: Effect.fn(function* (ctx) {
const bus = yield* Bus.Service
const loading = Semaphore.makeUnsafe(1)
let chatgpt = false
let chatgpt: Credential.OAuth | undefined
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 = OpenAICodex.isChatGPT(credential)
chatgpt =
credential?.type === "oauth" &&
(credential.methodID === browserMethodID || credential.methodID === headlessMethodID)
? credential
: undefined
})
yield* ctx.integration.transform((draft) => {
@@ -183,16 +189,19 @@ 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
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
evt.provider.update(item.provider.id, (provider) => {
provider.package = "@opencode-ai/ai/providers/openai"
})
}
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.
@@ -201,14 +210,32 @@ export const OpenAIPlugin = define({
draft.enabled = false
return
}
if (!OpenAICodex.eligible(draft.modelID ?? draft.id)) {
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)
) {
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(
@@ -216,21 +243,6 @@ 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)
+1 -1
View File
@@ -374,7 +374,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 ?? `New session - ${new Date(now).toISOString()}`,
title: input.title,
agent: input.agent,
model: input.model
? {
+5 -1
View File
@@ -351,10 +351,14 @@ const make = (dependencies: Dependencies) => {
)
if (!last) return false
const output = Math.min(input.model.route.defaults.limits?.output ?? 0, OUTPUT_TOKEN_MAX)
const limit = Math.min(
input.model.route.defaults.limits?.input ?? Number.POSITIVE_INFINITY,
context - (output || config.buffer),
)
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 >= context - (output || config.buffer)
return used >= limit
}
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
const content = planContent(input.messages, config.tokens)
+6 -7
View File
@@ -117,21 +117,20 @@ export const preview = Effect.fn("SessionHistory.preview")(function* (
.pipe(Effect.catch((error) => (error instanceof Instructions.InitializationBlocked ? error : Effect.die(error))))
})
/** Returns the session's sole user message, or `undefined` once a second one exists. */
export const firstUserMessageIfOnly = Effect.fn("SessionHistory.firstUserMessageIfOnly")(function* (
/** Returns the session's first user message. */
export const firstUserMessage = Effect.fn("SessionHistory.firstUserMessage")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
) {
const rows = yield* db
const row = yield* db
.select()
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "user")))
.orderBy(asc(SessionMessageTable.seq))
.limit(2)
.all()
.get()
.pipe(Effect.orDie)
if (rows.length !== 1) return undefined
const message = yield* decodeMessageRow(rows[0]).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!row) return undefined
const message = yield* decodeMessageRow(row).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,
title: row.title ?? undefined,
parentID: row.parent_id ? SessionSchema.ID.make(row.parent_id) : undefined,
fork:
row.fork_session_id && row.fork_boundary
@@ -1,6 +1,7 @@
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"
@@ -37,6 +38,7 @@ 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.
@@ -162,6 +164,26 @@ export const layer = Layer.effect(
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)
@@ -190,6 +212,7 @@ export const layer = Layer.effect(
}
return {
request,
options,
executeTool,
stepLimitReached,
}
+3 -2
View File
@@ -43,7 +43,8 @@ type Usage = {
const ForkBatchSize = 500
const forkTitle = (value: string) => {
const forkTitle = (value?: string) => {
if (value === undefined) return
const match = value.match(/^(.+) \(fork #(\d+)\)$/)
if (match) return `${match[1]} (fork #${Number.parseInt(match[2], 10) + 1})`
return `${value} (fork #1)`
@@ -216,7 +217,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
slug: Slug.create(),
directory: parent.directory,
path: parent.path,
title: forkTitle(parent.title),
title: forkTitle(parent.title ?? undefined),
agent: parent.agent,
model: parent.model,
version: parent.version,
+19 -11
View File
@@ -93,10 +93,9 @@ 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 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>()
// 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>()
const forkTitle = yield* FiberSet.makeRuntime<never, void, never>()
/**
* Drains eligible manual compaction and user input until the Session becomes idle.
@@ -125,7 +124,7 @@ const layer = Layer.effect(
let step = 1
while (true) {
const result = yield* runStep(sessionID, promotable, step)
yield* startTitleOnce(sessionID)
if (step === 1) yield* startTitle(sessionID)
yield* runPendingCompaction(sessionID)
if (!result.needsContinuation && !(yield* SessionPending.has(db, sessionID, "steer"))) return
promotable = "steer"
@@ -279,7 +278,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).pipe(
const providerStream = llm.stream(prepared.request, prepared.options).pipe(
Stream.runForEach((event) =>
Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return
@@ -481,11 +480,20 @@ const layer = Layer.effect(
}
})
/** 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))
/** 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)
}),
),
),
)
})
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().notNull(),
title: text(),
version: text().notNull(),
share_url: text(),
summary_additions: integer(),
+30 -12
View File
@@ -1,7 +1,7 @@
export * as SessionTitle from "./title"
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
import { Context, Effect, Layer, Stream } from "effect"
import { Context, DateTime, Effect, Layer, Stream } from "effect"
import { Agent } from "../agent"
import { Database } from "../database/database"
import { Bus } from "../bus"
@@ -14,8 +14,10 @@ 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
@@ -25,24 +27,30 @@ 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 and renames the session. Runs at most once per session. */
readonly generateForFirstPrompt: (session: SessionSchema.Info) => Effect.Effect<void>
/** Generates a title from the session's first user message when the session remains untitled. */
readonly generateForFirstPrompt: (sessionID: SessionSchema.ID) => 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) =>
session.title === undefined || session.title === `New session - ${DateTime.formatIso(session.time.created)}`
const make = (dependencies: Dependencies) => {
const generateForFirstPrompt = Effect.fn("SessionTitle.generateForFirstPrompt")(function* (
db: Database.Interface["db"],
session: SessionSchema.Info,
sessionID: SessionSchema.ID,
) {
const session = yield* dependencies.store.get(sessionID)
if (!session) return
if (session.parentID) return
const firstUser = yield* SessionHistory.firstUserMessageIfOnly(db, session.id)
if (!isUntitled(session)) return
const firstUser = yield* SessionHistory.firstUserMessage(db, session.id)
if (!firstUser) return
const agent = yield* dependencies.agents.get(Agent.ID.make("title"))
if (!agent) return
@@ -96,10 +104,19 @@ const make = (dependencies: Dependencies) => {
.map((line) => line.trim())
.find((line) => line.length > 0)
if (!title) return
yield* dependencies.bus.publish(SessionEvent.Renamed, {
sessionID: session.id,
title: truncate(title),
})
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))))
})
return { generateForFirstPrompt }
}
@@ -111,11 +128,12 @@ 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, app })
const title = make({ bus, llm, agents, models, store, app })
return Service.of({
generateForFirstPrompt: (session) => title.generateForFirstPrompt(database.db, session),
generateForFirstPrompt: (sessionID) => title.generateForFirstPrompt(database.db, sessionID),
})
}),
)
@@ -123,5 +141,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, llmClient, Agent.node, SessionRunnerModel.node, Database.node, App.node],
deps: [Bus.node, llmClient, Agent.node, SessionRunnerModel.node, SessionStore.node, Database.node, App.node],
})
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, test } from "bun:test"
import { AISDKNative } from "@opencode-ai/core/aisdk-native"
describe("AISDKNative", () => {
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: {},
})
})
})
@@ -26,6 +26,7 @@ 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"
@@ -399,6 +400,40 @@ 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* () {
+9 -67
View File
@@ -17,6 +17,7 @@ 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 = {}) =>
@@ -36,7 +37,7 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) =>
cost: [],
status: "active",
enabled: true,
limit: { context: 100, output: 20 },
limit: options.limit ?? { context: 100, output: 20 },
})
describe("ModelResolver", () => {
@@ -44,6 +45,7 @@ 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)
@@ -55,7 +57,7 @@ describe("ModelResolver", () => {
endpoint: { baseURL: "https://openai.example/v1" },
defaults: {
headers: { "x-test": "header" },
limits: { context: 100, output: 20 },
limits: { context: 100, input: 80, output: 20 },
http: { body: { custom_extension: { enabled: true } } },
},
})
@@ -307,12 +309,12 @@ describe("ModelResolver", () => {
}),
)
it.effect("routes ChatGPT OAuth credentials to the codex backend", () =>
it.effect("applies plugin-projected OpenAI endpoint and headers", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
headers: {},
model("@opencode-ai/ai/providers/openai", {
settings: { baseURL: "https://chatgpt.com/backend-api/codex" },
headers: { "chatgpt-account-id": "acct_123" },
body: {},
}),
Credential.OAuth.make({
@@ -337,37 +339,8 @@ 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")
}),
)
@@ -407,37 +380,6 @@ 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(
+75 -140
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,6 +9,7 @@ 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"
@@ -18,7 +19,6 @@ 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,19 +29,6 @@ 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* () {
@@ -61,104 +48,6 @@ 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
@@ -172,6 +61,7 @@ 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),
@@ -184,12 +74,17 @@ 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"), () => {})
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-4.1"), () => {})
})
yield* credentials.create({
@@ -205,8 +100,47 @@ 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,
@@ -214,10 +148,15 @@ 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)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol"))).enabled).toBe(
true,
)
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-4.1"))).enabled).toBe(false)
}),
)
@@ -234,7 +173,9 @@ describe("OpenAIPlugin", () => {
catalog.provider.update(item.id, (draft) => {
draft.package = item.package
})
catalog.model.update(item.id, Model.ID.make("gpt-5.5"), () => {})
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-4.1"), () => {})
})
yield* credentials.create({
@@ -243,29 +184,23 @@ describe("OpenAIPlugin", () => {
})
yield* addPlugin()
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5"))).enabled).toBe(true)
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-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)
}),
)
})
+40 -1
View File
@@ -19,9 +19,11 @@ 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, Stream } from "effect"
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
@@ -130,6 +132,43 @@ test("compaction prompt requires the checkpoint headings in order", () => {
expect(prompt).toContain("Keep every section, even when empty.")
})
it.effect("auto compaction respects explicit model input limits", () =>
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) => ({
session,
model: Model.make({
id: "test-model",
provider: "test-provider",
route: OpenAIChat.route.with({ limits: { context: 1_000, input: 100, output: 100 } }),
}),
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 },
}),
],
})
expect(compaction.required(input(99))).toBe(false)
expect(compaction.required(input(100))).toBe(true)
}),
)
it.effect("manual compaction summarizes short context instead of no-op", () =>
Effect.gen(function* () {
requests = []
+38
View File
@@ -71,6 +71,27 @@ 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
@@ -296,6 +317,23 @@ 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
+53
View File
@@ -796,6 +796,59 @@ 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
+148 -44
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 { Effect, Layer, Stream } from "effect"
import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
import { testEffect } from "./lib/effect"
let requests: LLMRequest[] = []
@@ -39,27 +39,30 @@ 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 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" },
}),
)
return titleStream()
},
generate: () => Effect.die("unused"),
})
@@ -89,7 +92,7 @@ const it = testEffect(
),
)
const insertSession = (id: Session.ID) =>
const insertSession = (id: Session.ID, title?: string, created?: number) =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
@@ -105,7 +108,8 @@ const insertSession = (id: Session.ID) =>
project_id: Project.ID.global,
slug: id,
directory: "/project",
title: "New session - fake",
title,
time_created: created,
version: "test",
})
.onConflictDoNothing()
@@ -131,6 +135,7 @@ 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) => {
@@ -144,11 +149,8 @@ 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(session)
yield* title.generateForFirstPrompt(sessionID)
expect(requests).toHaveLength(1)
expect(requests[0]?.http?.headers).toEqual({
@@ -167,9 +169,10 @@ it.effect("generates a title from the sole user message and renames the session"
}),
)
it.effect("does not generate once a second user message exists", () =>
it.effect("generates from the first user message after later messages exist", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
@@ -184,21 +187,46 @@ it.effect("does not generate once a second user message exists", () =>
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(session)
yield* title.generateForFirstPrompt(sessionID)
expect(requests).toHaveLength(0)
const untouched = yield* store.get(sessionID)
expect(untouched?.title).toBe("New session - fake")
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")
}),
)
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) => {
@@ -223,7 +251,6 @@ 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()
@@ -231,12 +258,8 @@ 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(session)
yield* title.generateForFirstPrompt(sessionID)
expect(requests).toHaveLength(0)
}),
@@ -245,19 +268,100 @@ 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(session)
yield* title.generateForFirstPrompt(sessionID)
expect(requests).toHaveLength(0)
const untouched = yield* store.get(sessionID)
expect(untouched?.title).toBe("New session - fake")
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")
}),
)
+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
}
@@ -158,6 +158,11 @@ 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(
() =>
info().title ??
`${info().parentID ? "Child" : "New"} session - ${new Date(info().time.created).toISOString()}`,
)
const ogImage = createMemo(() => {
const models = new Set<string>()
const messages = data().message[data().sessionID] ?? []
@@ -167,7 +172,7 @@ export default function () {
}
}
const modelIDs = Array.from(models)
const encodedTitle = encodeURIComponent(Base64.encode(encodeURIComponent(info().title.substring(0, 700))))
const encodedTitle = encodeURIComponent(Base64.encode(encodeURIComponent(title().substring(0, 700))))
let modelParam: string
if (modelIDs.length === 1) {
modelParam = modelIDs[0]
@@ -184,9 +189,7 @@ export default function () {
return (
<>
<Show when={info().title}>
<Title>{info().title} | OpenCode</Title>
</Show>
<Title>{title()} | OpenCode</Title>
<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 +243,7 @@ export default function () {
</div>
</div>
</div>
<div class="text-left text-16-medium text-text-strong">{info().title}</div>
<div class="text-left text-16-medium text-text-strong">{title()}</div>
</div>
)
+8
View File
@@ -1,5 +1,6 @@
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"
@@ -15,8 +16,15 @@ 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,5 +1,6 @@
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"
@@ -15,8 +16,15 @@ 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<
+1 -1
View File
@@ -42,7 +42,7 @@ export const Info = Schema.Struct({
updated: DateTimeUtcFromMillis,
archived: DateTimeUtcFromMillis.pipe(optional),
}),
title: Schema.String,
title: Schema.String.pipe(optional),
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: Schema.String,
title: optional(Schema.String),
agent: optional(Schema.String),
model: optional(SessionModel),
version: Schema.String,
+13 -1
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 { optional } from "../src/schema.js"
import { AbsolutePath, optional } from "../src/schema.js"
describe("contract hygiene", () => {
test("restricts agent colors to six-digit hex values", () => {
@@ -52,6 +52,18 @@ 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", () => {
+5 -4
View File
@@ -523,7 +523,7 @@ function App(props: { pair?: DialogPairCredentials }) {
renderer.useMouse = config.data.mouse
})
let active: { id: string; title: string } | undefined
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
@@ -536,13 +536,13 @@ function App(props: { pair?: DialogPairCredentials }) {
}
if (route.data.type === "session") {
if (!session || isDefaultTitle(session.title)) {
const title = session?.title
if (!title || isDefaultTitle(title)) {
renderer.setTerminalTitle("OpenCode")
return
}
const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title
renderer.setTerminalTitle(`OC | ${title}`)
renderer.setTerminalTitle(`OC | ${title.length > 40 ? title.slice(0, 37) + "..." : title}`)
return
}
@@ -646,6 +646,7 @@ function App(props: { pair?: DialogPairCredentials }) {
run: () => {
route.navigate({
type: "home",
location: route.data.type === "session" ? data.session.get(route.data.sessionID)?.location : undefined,
})
dialog.clear()
},
+7 -6
View File
@@ -16,6 +16,7 @@ import { abbreviateHome } from "../runtime"
import { useTuiPaths } from "../context/runtime"
import { truncateFilePath } from "../ui/file-path"
import { stringWidth } from "../util/string-width"
import { sessionTitle } from "../util/session"
import { Spinner } from "./spinner"
const RECENT_LIMIT = 8
@@ -76,13 +77,13 @@ export function DialogOpen() {
.slice(0, RECENT_LIMIT)
const sessionOptions = recent.map((session) => {
const project = data.project.get(session.projectID)
const name = project?.name || path.basename(project?.canonical ?? session.location.directory)
const name = project?.canonical === "/" ? undefined : project?.name || path.basename(project?.canonical ?? "")
const running = data.session.family(session.id).some((id) => data.session.status(id) === "running")
return {
title: session.title,
title: sessionTitle(session),
value: { type: "session", sessionID: session.id } as OpenTarget,
category: "Sessions",
footer: `${Locale.truncate(name, 20)} · ${timeAgo(session.time.updated)}`,
footer: `${name ? `${Locale.truncate(name, 20)} · ` : ""}${timeAgo(session.time.updated)}`,
gutter: running
? () => <Spinner />
: tabs.has(session.id)
@@ -102,13 +103,13 @@ export function DialogOpen() {
})
.map((project) => {
const title = project.name ?? path.basename(project.canonical)
const description = abbreviateHome(project.canonical, paths.home)
const footer = abbreviateHome(project.canonical, paths.home)
// Dialog padding, the gutter column, 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,
footer: truncateFilePath(footer, width),
searchText: footer,
value: { type: "project", directory: project.canonical } as OpenTarget,
category: "Projects",
}
@@ -18,6 +18,7 @@ import { Spinner } from "./spinner"
import { errorMessage } from "../util/error"
import { useSessionTabs } from "../context/session-tabs"
import { useStorage } from "../context/storage"
import { sessionTitle } from "../util/session"
export function DialogSessionList() {
const dialog = useDialog()
@@ -77,7 +78,7 @@ export function DialogSessionList() {
(session.projectID === current?.project.id && session.location.directory === current.directory),
)
if (!query) return sessions
return sessions.filter((session) => !session.parentID && session.title.toLowerCase().includes(query))
return sessions.filter((session) => !session.parentID && sessionTitle(session).toLowerCase().includes(query))
})
const sessions = createMemo(() => {
const query = filter().trim()
@@ -139,7 +140,7 @@ 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` : session.title,
title: deleting ? `Press ${shortcuts.get("session.delete")} again to confirm` : sessionTitle(session),
value: session.id,
category,
footer,
+10 -3
View File
@@ -240,7 +240,6 @@ export function Prompt(props: PromptProps) {
return undefined
})
if (!location) return
move.setDirectory(location.directory, location.directory !== location.project.directory)
currentLocation.set(location)
return
}
@@ -963,7 +962,10 @@ export function Prompt(props: PromptProps) {
const directory = await move.getDirectory()
if (move.pending() && !directory) return false
finishMoveProgress = Boolean(move.progress())
const location = data.location.default()
// 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 created = await client.api.session
.create({
@@ -1299,7 +1301,12 @@ export function Prompt(props: PromptProps) {
return `Ask anything... "${list()[store.placeholder % list().length]}"`
})
const locationLabel = createMemo(() => {
if (!props.sessionID || status() !== "idle") return
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
const directory = data.session.get(props.sessionID)?.location.directory
return directory ? abbreviateHome(directory, paths.home) : undefined
})
+5
View File
@@ -5,6 +5,8 @@ import { useData } from "./data"
const context = createContext<{
readonly current: LocationGetOutput | undefined
// The target location as set, available before the server-synced info in `current` arrives.
readonly ref: LocationRef | undefined
set: (location?: LocationRef) => void
}>()
@@ -37,6 +39,9 @@ export function LocationProvider(props: ParentProps) {
get current() {
return current()
},
get ref() {
return ref()
},
set,
}}
>
+1
View File
@@ -7,6 +7,7 @@ import { useTuiStartup } from "./runtime"
export type HomeRoute = {
type: "home"
prompt?: PromptInfo
// Location carried over from the previous session or project picker so a new session lands there.
location?: LocationRef
}
+7 -11
View File
@@ -3,6 +3,7 @@ import { isDeepEqual } from "remeda"
import { createSimpleContext } from "./helper"
import { useClient } from "./client"
import { useData } from "./data"
import { sessionTitle } from "../util/session"
import { useEvent } from "./event"
import { useRoute } from "./route"
import { useConfig } from "../config"
@@ -114,7 +115,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
if (route.data.type !== "session" || route.data.sessionID === "dummy") return
const sessionID = root(route.data.sessionID)
history = recordSessionTabHistory(history, sessionID)
const title = data.session.get(sessionID)?.title ?? (newTab() ? NEW_SESSION_TAB_TITLE : undefined)
const session = data.session.get(sessionID)
const title = session?.title ?? (newTab() ? NEW_SESSION_TAB_TITLE : session ? sessionTitle(session) : undefined)
const tabs = openSessionTab(state().tabs, { sessionID, title })
if (tabs === state().tabs && !state().unread[sessionID]) return
update((draft) => {
@@ -127,7 +129,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
if (!enabled()) return
const next = state().tabs.reduce<SessionTab[]>((tabs, tab) => {
const sessionID = root(tab.sessionID)
return openSessionTab(tabs, { sessionID, title: data.session.get(sessionID)?.title ?? tab.title })
const session = data.session.get(sessionID)
return openSessionTab(tabs, { sessionID, title: session ? sessionTitle(session) : tab.title })
}, [])
const unread = Object.entries(state().unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
const sessionID = root(entry[0])
@@ -136,15 +139,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
}, {})
if (isDeepEqual(next, state().tabs) && isDeepEqual(unread, state().unread)) return
update((draft) => {
draft.tabs = draft.tabs.reduce<SessionTab[]>((tabs, tab) => {
const sessionID = root(tab.sessionID)
return openSessionTab(tabs, { sessionID, title: data.session.get(sessionID)?.title ?? tab.title })
}, [])
draft.unread = Object.entries(draft.unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
const sessionID = root(entry[0])
result[sessionID] = result[sessionID] === "error" ? "error" : entry[1]
return result
}, {})
draft.tabs = next
draft.unread = unread
})
})
+8 -2
View File
@@ -1,5 +1,5 @@
import { Prompt, type PromptRef } from "../component/prompt"
import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js"
import { createEffect, createMemo, createSignal, onMount, Show, untrack } from "solid-js"
import { Logo } from "../component/logo"
import { useArgs } from "../context/args"
import { useRouteData } from "../context/route"
@@ -31,7 +31,13 @@ export function Home() {
const forms = createMemo(() => data.session.form.list("global", currentLocation()) ?? [])
let sent = false
createEffect(() => location.set(currentLocation()))
// Track only the route location and (when absent) the default location; location.set
// reads other signals internally and tracking them would re-assert the route location
// after the user overrides it with /cd.
createEffect(() => {
const target = currentLocation()
untrack(() => location.set(target))
})
onMount(() => {
editor.clearSelection()
@@ -8,6 +8,7 @@ import { useTheme } from "../../../context/theme"
import { Locale } from "../../../util/locale"
import { Keymap } from "../../../context/keymap"
import { useComposerTab } from "./index"
import { sessionTitle } from "../../../util/session"
interface SubagentEntry {
sessionID: string
@@ -38,13 +39,14 @@ export function SubagentsTab(props: { sessionID: string }) {
if (current.parentID) {
const siblings = data.session.list().filter((s) => s.parentID === current.parentID)
for (const sibling of siblings) {
const agentMatch = sibling.title.match(/@(\w+) subagent/)
const title = sessionTitle(sibling)
const agentMatch = title.match(/@(\w+) subagent/)
const agent = sibling.agent
? Locale.titlecase(sibling.agent)
: agentMatch
? Locale.titlecase(agentMatch[1])
: "Subagent"
const name = agentMatch ? sibling.title.replace(agentMatch[0], "").trim() || sibling.title : sibling.title
const name = agentMatch ? title.replace(agentMatch[0], "").trim() || title : title
result.push({
sessionID: sibling.id,
agent,
@@ -56,13 +58,14 @@ export function SubagentsTab(props: { sessionID: string }) {
} else {
const children = data.session.list().filter((s) => s.parentID === props.sessionID)
for (const child of children) {
const agentMatch = child.title.match(/@(\w+) subagent/)
const title = sessionTitle(child)
const agentMatch = title.match(/@(\w+) subagent/)
const agent = child.agent
? Locale.titlecase(child.agent)
: agentMatch
? Locale.titlecase(agentMatch[1])
: "Subagent"
const name = agentMatch ? child.title.replace(agentMatch[0], "").trim() || child.title : child.title
const name = agentMatch ? title.replace(agentMatch[0], "").trim() || title : title
result.push({
sessionID: child.id,
agent,
+2 -1
View File
@@ -93,6 +93,7 @@ import { switchLabel } from "../../util/model"
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
import { stringWidth } from "../../util/string-width"
import { useArgs } from "../../context/args"
import { sessionTitle } from "../../util/session"
addDefaultParsers(parsers.parsers)
@@ -3334,7 +3335,7 @@ function formatSessionTranscript(session: SessionInfo, messages: SessionMessageI
})
return [`## Assistant\n\n${content.join("\n\n")}`]
})
return `# ${session.title}\n\n**Session ID:** ${session.id}\n**Created:** ${new Date(session.time.created).toLocaleString()}\n**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n---\n\n${body.join("\n\n---\n\n")}\n`
return `# ${sessionTitle(session)}\n\n**Session ID:** ${session.id}\n**Created:** ${new Date(session.time.created).toLocaleString()}\n**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n---\n\n${body.join("\n\n---\n\n")}\n`
}
export function parseApplyPatchFiles(value: unknown) {
+2 -1
View File
@@ -3,6 +3,7 @@ import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme"
import { useConfig } from "../../config"
import { PluginSlot } from "../../plugin/context"
import { sessionTitle } from "../../util/session"
import { getScrollAcceleration } from "../../util/scroll"
@@ -38,7 +39,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<box flexShrink={0} gap={1} paddingRight={1}>
<box paddingRight={1}>
<text fg={theme.text.default}>
<b>{session()!.title}</b>
<b>{sessionTitle(session()!)}</b>
</text>
<Show when={session()!.location.workspaceID}>
<text fg={theme.text.subdued}>{session()!.location.workspaceID}</text>
+2 -2
View File
@@ -240,7 +240,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
on(
() => props.options,
() => {
if (!props.preserveSelection) {
if (!props.preserveSelection && props.current === undefined) {
const count = flat().length
if (count === 0) return
const next = reconcileSelection(store.selected, count)
@@ -276,7 +276,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
setStore("selected", index)
selection = option
if (!moved) return
if (!props.preserveSelection || store.filter.length > 0) return
if ((!props.preserveSelection && props.current === undefined) || store.filter.length > 0) return
scrollAfterLayout(false, option.value)
return
}
+11 -3
View File
@@ -1,8 +1,16 @@
import type { ModelInfo, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
import type { ModelInfo, SessionInfo, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
import { Locale } from "./locale"
export function isDefaultTitle(title: string) {
return /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title)
export function isDefaultTitle(title?: string) {
return (
title === undefined || /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title)
)
}
export function sessionTitle(session: Pick<SessionInfo, "parentID" | "time" | "title">) {
return (
session.title ?? `${session.parentID ? "Child" : "New"} session - ${new Date(session.time.created).toISOString()}`
)
}
export function lastAssistantWithUsage(messages: ReadonlyArray<SessionMessageInfo>, boundary?: string) {
@@ -79,7 +79,7 @@ async function renderSelect(
return app
}
async function mountSelect(root: string, initial: DialogSelectOption<string>[]) {
async function mountSelect(root: string, initial: DialogSelectOption<string>[], current?: string) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
const config = createTuiResolvedConfig()
@@ -114,6 +114,7 @@ async function mountSelect(root: string, initial: DialogSelectOption<string>[])
<DialogSelect
title="Mutable options"
options={options()}
current={current}
onMove={(option) => moved.push(option.value)}
onSelect={(option) => selected.push(option.value)}
/>
@@ -309,3 +310,20 @@ test("keeps the cursor index while options are temporarily empty", async () => {
select.app.renderer.destroy()
}
})
test("keeps the current option selected when options reorder", async () => {
await using tmp = await tmpdir()
const options = ["first", "current", "third"].map((value) => ({ title: value, value }))
const select = await mountSelect(tmp.path, options, "current")
try {
select.replaceOptions([options[1], options[2], options[0]])
await select.app.waitForFrame((frame) => frame.indexOf("current") < frame.indexOf("third"))
select.app.mockInput.pressEnter()
await select.app.waitFor(() => select.selected.length === 1)
expect(select.selected).toEqual(["current"])
} finally {
select.app.renderer.destroy()
}
})
+11 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode-ai/client"
import { isDefaultTitle, lastAssistantWithUsage } from "../../src/util/session"
import { isDefaultTitle, lastAssistantWithUsage, sessionTitle } from "../../src/util/session"
const assistant = (id: string, input: number): SessionMessageInfo => ({
id,
@@ -14,11 +14,21 @@ const assistant = (id: string, input: number): SessionMessageInfo => ({
describe("util.session", () => {
test("recognizes generated parent and child titles", () => {
expect(isDefaultTitle(undefined)).toBeTrue()
expect(isDefaultTitle("New session - 2026-06-06T12:34:56.789Z")).toBeTrue()
expect(isDefaultTitle("Child session - 2026-06-06T12:34:56.789Z")).toBeTrue()
expect(isDefaultTitle("New session - custom")).toBeFalse()
})
test("derives display-only titles for untitled sessions", () => {
expect(sessionTitle({ title: undefined, time: { created: 0, updated: 0 } })).toBe(
"New session - 1970-01-01T00:00:00.000Z",
)
expect(sessionTitle({ title: undefined, parentID: "ses_parent", time: { created: 0, updated: 0 } })).toBe(
"Child session - 1970-01-01T00:00:00.000Z",
)
})
test("tracks usage across undo and redo boundaries", () => {
const messages = [assistant("msg_z", 10), assistant("msg_a", 30)]