Compare commits

..
39 changed files with 1214 additions and 1103 deletions
-4
View File
@@ -165,10 +165,6 @@ Native chronological system messages are route/model-specific. Open Responses lo
The wrapped-user fallback preserves ordering while visibly lowering authority. Never silently pass a raw chronological `role: "system"` through a route that might reject it. Do not insert raw retrieved documents, tool output, or web content into privileged chronological system updates; keep untrusted content in ordinary user/tool channels.
### Effort Updates
`Message.effort({ effort, previous })` is a chronological "reasoning effort changed here" marker (`undefined` means the model default). Changing a top-level effort invalidates the whole provider prompt cache, so protocols with a native per-message update (`Protocol.supportsEffortUpdates`) keep the top-level effort at the first marker's `previous` and lower each marker in place: Anthropic Messages emits an empty `role: "system"` message with `output_config.effort` plus the `mid-conversation-output-config-2026-07-01` beta, and OpenAI Responses emits `configuration_update` items. `applyEffortUpdates` runs in `prepareRequest` and strips the markers for every other route, so a protocol without support keeps today's plain top-level behaviour. When the last marker disagrees with the effort the request asks for (reverted or forked history), `resolveEffortUpdates` strips the markers and falls back to a plain top-level change.
### Tools
Tool loops are represented in common messages and events:
+2 -9
View File
@@ -12,7 +12,6 @@
// count against the four-breakpoint budget; auto only fills remaining slots.
import { CacheHint, type CachePolicy, type CachePolicyObject } from "./schema/options.js"
import { LLMRequest, Message, ToolDefinition, type ContentPart, type ToolEntry } from "./schema/messages.js"
import { effortUpdate } from "./effort-updates.js"
const AUTO: CachePolicyObject = {
tools: true,
@@ -122,15 +121,9 @@ const markMessages = (
return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint, budget)
if (strategy === "latest-assistant")
return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint, budget)
let start = messages.length
let remaining = strategy.tail
while (remaining > 0 && start > 0) {
start -= 1
if (effortUpdate(messages[start]!) === undefined) remaining -= 1
}
const start = Math.max(0, messages.length - strategy.tail)
let next = messages
for (let i = start; i < messages.length; i++)
if (effortUpdate(messages[i]!) === undefined) next = markMessageAt(next, i, hint, budget)
for (let i = start; i < messages.length; i++) next = markMessageAt(next, i, hint, budget)
return next
}
-27
View File
@@ -1,27 +0,0 @@
// A top-level reasoning effort change invalidates the whole provider prompt cache, so a
// mid-conversation switch travels as a `Message.effort(...)` marker: protocols with a native
// per-message update freeze the top-level effort and lower the markers; every other route strips them.
import { LLMRequest, type EffortPart, type Message } from "./schema/messages.js"
export const effortUpdate = (message: Message): EffortPart | undefined => {
if (message.role !== "system" || message.content.length !== 1) return undefined
const part = message.content[0]
return part.type === "effort" ? part : undefined
}
export const stripEffortUpdates = (request: LLMRequest) => {
const messages = request.messages.filter((message) => effortUpdate(message) === undefined)
return messages.length === request.messages.length ? request : LLMRequest.update(request, { messages })
}
export const applyEffortUpdates = (request: LLMRequest): LLMRequest =>
request.model.route.supportsEffortUpdates?.(request) ? request : stripEffortUpdates(request)
// The markers must end at `current`: `revert.ts` never touches `session.model` and forks may select
// another variant, so on disagreement fall back to a plain top-level change instead of misreporting effort.
export const resolveEffortUpdates = (request: LLMRequest, current: string | undefined) => {
const updates = request.messages.flatMap((message) => effortUpdate(message) ?? [])
if (updates.length === 0) return { request, effort: current }
if (updates.at(-1)?.effort !== current) return { request: stripEffortUpdates(request), effort: current }
return { request, effort: updates[0]?.previous }
}
+18 -50
View File
@@ -27,7 +27,6 @@ import {
} from "../schema/index.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { classifyProviderFailure } from "../provider-error.js"
import { effortUpdate, resolveEffortUpdates } from "../effort-updates.js"
import * as Cache from "./utils/cache.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
@@ -37,7 +36,6 @@ const ADAPTER = "anthropic-messages"
export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
export const PATH = "/messages"
export const DEFAULT_MAX_TOKENS = 32_000
const DEFAULT_EFFORT = "high"
const SSE_EVENTS = new Set([
"message",
@@ -288,11 +286,7 @@ type AnthropicToolResultBlock = Schema.Schema.Type<typeof AnthropicToolResultBlo
const AnthropicMessage = Schema.Union([
Schema.Struct({ role: Schema.Literal("user"), content: Schema.Array(AnthropicUserBlock) }),
Schema.Struct({ role: Schema.Literal("assistant"), content: Schema.Array(AnthropicAssistantBlock) }),
Schema.Struct({
role: Schema.Literal("system"),
content: Schema.Array(AnthropicTextBlock),
output_config: Schema.optional(Schema.Struct({ effort: Schema.String })),
}),
Schema.Struct({ role: Schema.Literal("system"), content: Schema.Array(AnthropicTextBlock) }),
]).pipe(Schema.toTaggedUnion("role"))
type AnthropicMessage = Schema.Schema.Type<typeof AnthropicMessage>
@@ -883,12 +877,6 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
for (const [index, message] of request.messages.entries()) {
if (message.role === "system") {
const update = effortUpdate(message)
if (update) {
// Accepted at any position, so the text-update placement rules do not apply.
messages.push({ role: "system", content: [], output_config: { effort: update.effort ?? DEFAULT_EFFORT } })
continue
}
if (splitsLocalToolResults(request.messages, index))
return yield* invalid("Anthropic Messages system updates cannot split a local tool call from its tool result")
if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request, index)) {
@@ -1046,11 +1034,18 @@ const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (
ProviderShared.isRecord(rawOutputConfig) && ProviderShared.isRecord(rawOutputConfig.format)
? (rawOutputConfig.format as { type: "json_schema"; schema: Record<string, unknown> })
: undefined
const output_config =
outputConfigEffort === undefined && outputConfigFormat === undefined
? undefined
: {
...(outputConfigEffort === undefined ? {} : { effort: outputConfigEffort }),
...(outputConfigFormat === undefined ? {} : { format: outputConfigFormat }),
}
const thinking = yield* resolveThinking(input?.thinking)
return {
thinking: applyThinkingBindingDefault(request.model, thinking),
effort: outputConfigEffort,
format: outputConfigFormat,
output_config,
service_tier,
metadata,
container,
@@ -1059,30 +1054,15 @@ const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (
}
})
// Accept gateway namespaces and Vertex suffixes without treating a snapshot date as a minor version.
const claudeVersion = (id: string) => {
const match = /(?:^|[./])claude-(?<family>[a-z]+)-(?<major>\d+)(?:[.-](?<minor>\d{1,2}))?(?:$|[-:@])/.exec(
id.toLowerCase(),
)?.groups
if (!match) return undefined
return { family: match.family, major: Number(match.major), minor: Number(match.minor ?? 0) }
}
const supportsThinkingBlockBinding = (model: LLMRequest["model"]) => {
const override = model.compatibility?.supportsThinkingBlockBinding
if (override !== undefined) return override
const version = claudeVersion(model.id)
return version !== undefined && (version.major > 5 || (version.major === 5 && version.minor >= 1))
}
const supportsEffortUpdates = (model: LLMRequest["model"]) => {
const override = model.compatibility?.supportsEffortUpdates
if (override !== undefined) return override
const version = claudeVersion(model.id)
if (version === undefined) return false
if (version.family === "opus") return version.major >= 5
if (version.family !== "fable" && version.family !== "mythos") return false
return version.major > 5 || (version.major === 5 && version.minor >= 1)
// Accept gateway namespaces and Vertex suffixes without treating a snapshot date as a minor version.
const version = /(?:^|[./])claude-[a-z]+-(?<major>\d+)(?:[.-](?<minor>\d{1,2}))?(?:$|[-:@])/i.exec(model.id)?.groups
if (!version) return false
const major = Number(version.major)
const minor = Number(version.minor ?? 0)
return major > 5 || (major === 5 && minor >= 1)
}
const applyThinkingBindingDefault = (model: LLMRequest["model"], thinking: AnthropicThinking | undefined) => {
@@ -1124,15 +1104,13 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
const management = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
)(request.providerOptions?.contextManagement)
const options = yield* resolveOptions(request)
const updates = resolveEffortUpdates(request, options.effort)
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
// messages. Tools live highest in the cache hierarchy, so when callers
// over-mark we keep their tool hints and shed the message-tail ones first.
const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP)
const flattened = ProviderShared.flattenToolRequest(updates.request)
const flattened = ProviderShared.flattenToolRequest(request)
const tools =
flattened.tools.length === 0
? undefined
@@ -1160,13 +1138,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
`Anthropic Messages: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${ANTHROPIC_BREAKPOINT_CAP} per request.`,
)
}
const output_config =
updates.effort === undefined && options.format === undefined
? undefined
: {
...(updates.effort === undefined ? {} : { effort: updates.effort }),
...(options.format === undefined ? {} : { format: options.format }),
}
const options = yield* resolveOptions(request)
const body = {
model: request.model.id,
system,
@@ -1180,7 +1152,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
top_k: generation?.topK,
stop_sequences: generation?.stop,
thinking: options.thinking,
output_config,
output_config: options.output_config,
// top-level passthrough per SDK MessageCreateParamsBase:4638,4643,4649,4654,4670
cache_control: options.cache_control,
container: options.container,
@@ -1705,7 +1677,6 @@ export const protocol = Protocol.make({
}),
step,
},
supportsEffortUpdates: (request) => supportsEffortUpdates(request.model),
})
export const transport = <
@@ -1744,9 +1715,6 @@ function requiredBetaHeaders(body: Pick<AnthropicMessagesBody, "messages" | "con
)
if (requestsCompaction || replaysCompaction) betas.push("compact-2026-01-12")
if (body.messages.some((message) => message.role === "system" && message.output_config !== undefined))
betas.push("mid-conversation-output-config-2026-07-01")
const thinking = body.thinking
if (thinking && thinking.type !== "disabled" && thinking.block_binding)
betas.push("thinking-binding-controls-2026-08-01")
+2 -20
View File
@@ -20,7 +20,6 @@ import {
} from "../schema/index.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { classifyProviderFailure } from "../provider-error.js"
import { effortUpdate } from "../effort-updates.js"
import { OpenResponsesOptions } from "./utils/open-responses-options.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
@@ -165,13 +164,6 @@ export const CompactionItem = Schema.Struct({
encrypted_content: Schema.String,
})
// Kept out of the baseline `InputItem` union: only the OpenAI extension accepts it.
export const ConfigurationUpdate = Schema.Struct({
type: Schema.Literal("configuration_update"),
reasoning: Schema.Struct({ effort: OpenResponsesOptions.ReasoningEffort }),
})
type ConfigurationUpdate = Schema.Schema.Type<typeof ConfigurationUpdate>
export const InputItem = Schema.Union([
CompactionItem,
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
@@ -216,7 +208,6 @@ export type HostedToolReplayItem = {
type LoweredInputItem =
| OpenResponsesInputItem
| HostedToolReplayItem
| ConfigurationUpdate
| {
readonly type: "message"
readonly id?: string
@@ -643,8 +634,6 @@ const lowerToolResultOutput = Effect.fnUntraced(function* (
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, adapter))
})
const DEFAULT_EFFORT = "medium"
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
request: LLMRequest,
adapter: ProviderAdapter,
@@ -657,14 +646,6 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
Schema.decodeUnknownEffect(Schema.UndefinedOr(MessageMetadata)),
)(message.providerMetadata?.[providerMetadataKey])
if (message.role === "system") {
const update = effortUpdate(message)
if (update) {
// Consecutive updates are rejected, so a newer one replaces its predecessor.
const last = input.at(-1)
if (last !== undefined && "type" in last && last.type === "configuration_update") input.pop()
input.push({ type: "configuration_update", reasoning: { effort: update.effort ?? DEFAULT_EFFORT } })
continue
}
input.push({
role: "developer",
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(adapter.name, message)),
@@ -808,7 +789,8 @@ export const lowerConversation = Effect.fn("OpenResponses.lowerConversation")(fu
}
})
export const lowerGeneration = (request: LLMRequest, options = OpenResponsesOptions.resolve(request)) => {
export const lowerGeneration = (request: LLMRequest) => {
const options = OpenResponsesOptions.resolve(request)
const generation = request.generation
const cacheKey = ProviderShared.promptCacheKey(request)
const parallelToolCalls = resolveParallelToolCalls(request)
+4 -23
View File
@@ -6,9 +6,7 @@ import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js"
import { HttpTransport } from "../route/transport/index.js"
import { LLMRequest, mergeJsonRecords, type JsonSchema, type ToolDefinition, type ToolEntry } from "../schema/index.js"
import { resolveEffortUpdates } from "../effort-updates.js"
import { OpenResponses } from "./open-responses.js"
import { OpenResponsesOptions } from "./utils/open-responses-options.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { OpenAIImage } from "./utils/openai-image.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
@@ -96,15 +94,9 @@ const OpenAIResponsesToolChoice = Schema.Union([
Schema.Struct({ type: Schema.tag("image_generation") }),
])
const OpenAIResponsesInputItem = Schema.Union([
OpenResponses.InputItem,
OpenAIResponsesHostedToolItem,
OpenResponses.ConfigurationUpdate,
])
const OpenAIResponsesCoreFields = {
...OpenResponses.coreFields,
input: Schema.Array(OpenAIResponsesInputItem),
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem])),
tools: optionalArray(OpenAIResponsesTools),
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
context_management: Schema.optional(
@@ -127,7 +119,7 @@ export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
export const CompactionTrigger = Schema.Struct({ type: Schema.Literal("compaction_trigger") })
const CheckpointBody = Schema.Struct({
...OpenAIResponsesBody.fields,
input: Schema.Array(Schema.Union([OpenAIResponsesInputItem, CompactionTrigger])),
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem, CompactionTrigger])),
store: Schema.Literal(false),
prompt_cache_retention: optionalNull(Schema.String),
prompt_cache_options: optionalNull(
@@ -141,14 +133,6 @@ const adapter = {
restoreHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.ProviderAdapter
// Only GPT-6 Astra accepts `configuration_update`, and never alongside automatic `context_management` compaction.
const supportsEffortUpdates = (request: LLMRequest) => {
if (request.providerOptions?.contextManagement !== undefined) return false
const override = request.model.compatibility?.supportsEffortUpdates
if (override !== undefined) return override
return /(?:^|\/)gpt-6-astra$/i.test(request.model.id)
}
const nativeImageToolInput = (tool: ToolDefinition) => {
const native = tool.native?.openai
return ProviderShared.isRecord(native) && native.type === "image_generation" ? native : undefined
@@ -205,12 +189,10 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
const management = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
)(request.providerOptions?.contextManagement)
const options = OpenResponsesOptions.resolve(request)
const updates = resolveEffortUpdates(request, options.reasoningEffort)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
return yield* decodeBody({
...(yield* OpenResponses.lowerConversation(updates.request, adapter)),
...OpenResponses.lowerGeneration(request, { ...options, reasoningEffort: updates.effort }),
...(yield* OpenResponses.lowerConversation(request, adapter)),
...OpenResponses.lowerGeneration(request),
context_management: management?.map((edit) => ({ type: edit.type, compact_threshold: edit.compactThreshold })),
tools:
request.tools.length === 0
@@ -313,7 +295,6 @@ export const protocol = Protocol.make({
step,
terminal: OpenResponses.terminal,
},
supportsEffortUpdates,
})
const endpoint = Endpoint.path<OpenAIResponsesBody>(PATH, { baseURL: DEFAULT_BASE_URL })
@@ -1,7 +1,12 @@
import { Option, Schema } from "effect"
import { ReasoningEffort, ReasoningEfforts, type LLMRequest } from "../../schema/index.js"
import type { LLMRequest } from "../../schema/index.js"
export { ReasoningEffort, ReasoningEfforts }
export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const
export type ReasoningEffort = (typeof ReasoningEfforts)[number] | (string & {})
export const ReasoningEffort = Schema.declare<ReasoningEffort>(
(value): value is ReasoningEffort => typeof value === "string",
{ title: "ReasoningEffort" },
)
export const TextVerbosities = ["low", "medium", "high"] as const
export type TextVerbosity = (typeof TextVerbosities)[number] | (string & {})
@@ -11,7 +11,6 @@ import {
mergeJsonRecords,
} from "../../schema/index.js"
import type { CompactOperation } from "../../route/client.js"
import { stripEffortUpdates } from "../../effort-updates.js"
import { Endpoint } from "../../route/endpoint.js"
import { RequestExecutor } from "../../route/executor.js"
import { HttpTransport } from "../../route/transport/index.js"
@@ -76,8 +75,7 @@ const Response = Schema.Struct({
export const make = (adapter: OpenResponses.ProviderAdapter): CompactOperation =>
Effect.fn("ResponsesCompaction.execute")(function* (request, executor, options) {
const route = request.model.route
// The standalone compaction endpoint rejects histories containing configuration updates.
const native = yield* OpenResponses.lowerConversation(stripEffortUpdates(request), adapter)
const native = yield* OpenResponses.lowerConversation(request, adapter)
const body = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Body))(
mergeJsonRecords(
{
+1 -6
View File
@@ -7,7 +7,6 @@ import { HttpTransport } from "./transport/index.js"
import type { HttpMiddleware, Transport, TransportRuntime, WebSocketChannelExecutor } from "./transport/index.js"
import type { Protocol } from "./protocol.js"
import { applyCachePolicy } from "../cache-policy.js"
import { applyEffortUpdates } from "../effort-updates.js"
import { normalizeToolHistory } from "../tool-history.js"
import { sanitizeSurrogates } from "../utils/sanitize.js"
import * as ProviderShared from "../protocols/shared.js"
@@ -56,7 +55,6 @@ export interface Route<
readonly transport: Transport<Body, Prepared, unknown>
readonly defaults: RouteDefaults
readonly body: RouteBody<Body>
readonly supportsEffortUpdates?: (request: LLMRequest) => boolean
readonly with: {
<Next extends CompactionOperations | undefined>(
patch: RoutePatch<Body, Prepared> & { readonly compact: Next },
@@ -390,7 +388,6 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
transport: routeInput.transport,
defaults: routeInput.defaults ?? {},
body: protocol.body,
supportsEffortUpdates: protocol.supportsEffortUpdates,
with: (patch: RoutePatch<Body, Prepared>) => {
const { compact, id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch
return build({
@@ -561,9 +558,7 @@ const prepareRequest = (request: LLMRequest) => {
[...new Map(tools.map((tool) => [`${tool.type}:${tool.name}`, tool])).values()].map((tool) =>
tool.type === "tool" ? tool : { ...tool, tools: dedupe(tool.tools) },
)
const resolved = applyCachePolicy(
applyEffortUpdates(LLMRequest.update(sanitized, { tools: dedupe(sanitized.tools) })),
)
const resolved = applyCachePolicy(LLMRequest.update(sanitized, { tools: dedupe(sanitized.tools) }))
const headers = resolved.model.route.headers?.({ request: resolved })
return headers === undefined
? resolved
-2
View File
@@ -41,8 +41,6 @@ export interface Protocol<Body, Frame, Event, State> {
readonly body: ProtocolBody<Body>
/** Response side: streaming state machine. */
readonly stream: ProtocolStream<Frame, Event, State>
/** Whether `body.from` lowers `Message.effort(...)` markers; wrappers around another `body.from` must forward it. */
readonly supportsEffortUpdates?: (request: LLMRequest) => boolean
}
export interface ProtocolBody<Body> {
-13
View File
@@ -9,7 +9,6 @@ import {
LanguageModelSchema,
type LanguageModel,
ProviderOptions,
ReasoningEffort,
} from "./options.js"
import { ProviderID } from "./ids.js"
@@ -218,14 +217,6 @@ export const CompactionPart = Object.assign(compactionPartSchema, {
Schema.decodeUnknownSync(compactionPartSchema)({ type: "compaction", ...input }),
})
/** Reasoning effort changed here, from `previous` to `effort`; `undefined` is the model default. */
export const EffortPart = Schema.Struct({
type: Schema.Literal("effort"),
effort: Schema.optional(ReasoningEffort),
previous: Schema.optional(ReasoningEffort),
}).annotate({ identifier: "LLM.Content.Effort" })
export type EffortPart = Schema.Schema.Type<typeof EffortPart>
export const ContentPart = Schema.Union([
TextPart,
MediaPart,
@@ -233,7 +224,6 @@ export const ContentPart = Schema.Union([
ToolResultPart,
ReasoningPart,
CompactionPart,
EffortPart,
]).pipe(Schema.toTaggedUnion("type"))
export type ContentPart = Schema.Schema.Type<typeof ContentPart>
@@ -275,9 +265,6 @@ export namespace Message {
*/
export const system = (content: SystemContentInput) => make({ role: "system", content })
export const effort = (input: { readonly effort?: ReasoningEffort; readonly previous?: ReasoningEffort }) =>
make({ role: "system", content: [{ type: "effort", effort: input.effort, previous: input.previous }] })
export const tool = (result: ToolResultPart | Parameters<typeof ToolResultPart.make>[0]) =>
make({ role: "tool", content: ["type" in result ? result : ToolResultPart.make(result)] })
}
-10
View File
@@ -140,14 +140,6 @@ export namespace LanguageModelDefaults {
}
}
/** Ordered lowest to highest. */
export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const
export type ReasoningEffort = (typeof ReasoningEfforts)[number] | (string & {})
export const ReasoningEffort = Schema.declare<ReasoningEffort>(
(value): value is ReasoningEffort => typeof value === "string",
{ title: "ReasoningEffort" },
)
export const LanguageModelToolSchemaCompatibility = Schema.Literals(["gemini", "moonshot"])
export type LanguageModelToolSchemaCompatibility = Schema.Schema.Type<typeof LanguageModelToolSchemaCompatibility>
@@ -173,8 +165,6 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
requireSignature: Schema.optional(Schema.Boolean),
/** Supports Anthropic's thinking-prefix mismatch controls. Overrides model-ID detection. */
supportsThinkingBlockBinding: Schema.optional(Schema.Boolean),
/** Supports per-message effort updates. Overrides model-ID detection. */
supportsEffortUpdates: Schema.optional(Schema.Boolean),
}) {}
export namespace LanguageModelCompatibility {
-439
View File
@@ -1,439 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMRequest, Message, ToolCallPart } from "../src/index.js"
import { Auth, LLMClient } from "../src/route.js"
import { compileRequest } from "../src/route/client.js"
import { AnthropicMessages } from "../src/protocols/anthropic-messages.js"
import { OpenAIResponses } from "../src/protocols/openai-responses.js"
import { Gemini } from "../src/protocols/gemini.js"
import { GoogleVertexMessages, OpenAI } from "../src/providers.js"
import { applyCachePolicy } from "../src/cache-policy.js"
import { applyEffortUpdates } from "../src/effort-updates.js"
import { it, testEffect } from "./lib/effect.js"
import { dynamicResponse } from "./lib/http.js"
import { sseEvents } from "./lib/sse.js"
const anthropic = (id: string, compatibility?: { readonly supportsEffortUpdates?: boolean }) =>
AnthropicMessages.route
.with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
.model({ id, compatibility })
const openai = (id: string, compatibility?: { readonly supportsEffortUpdates?: boolean }) =>
OpenAIResponses.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id, compatibility })
const opus5 = anthropic("claude-opus-5")
const astra = openai("gpt-6-astra")
const lowFromHigh = Message.effort({ effort: "low", previous: "high" })
const conversation = [Message.user("Before."), lowFromHigh, Message.user("After.")]
const systemMessages = (body: AnthropicMessages.AnthropicMessagesBody) =>
body.messages.filter((message) => message.role === "system")
const updates = (body: OpenAIResponses.OpenAIResponsesBody) =>
body.input.filter((item) => "type" in item && item.type === "configuration_update")
describe("applyEffortUpdates", () => {
test("keeps the request identity without markers and for protocols that lower them", () => {
const plain = LLM.request({ model: opus5, prompt: "hi" })
expect(applyEffortUpdates(plain)).toBe(plain)
const supported = LLM.request({ model: opus5, messages: conversation, providerOptions: { effort: "low" } })
expect(applyEffortUpdates(supported)).toBe(supported)
})
it.effect("compiles markers away for protocols without per-message effort", () =>
Effect.gen(function* () {
const model = Gemini.route
.with({
endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
auth: Auth.header("x-goog-api-key", "test"),
})
.model({ id: "gemini-3.5-flash" })
const withMarkers = yield* compileRequest(LLM.request({ model, messages: conversation }))
const withoutMarkers = yield* compileRequest(
LLM.request({ model, messages: [Message.user("Before."), Message.user("After.")] }),
)
expect(withMarkers.body).toEqual(withoutMarkers.body)
}),
)
})
describe("cache policy", () => {
it.effect("walks the tail breakpoint back past a trailing effort marker", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: opus5,
messages: [Message.user("first"), Message.assistant("reply"), Message.user("latest"), lowFromHigh],
providerOptions: { effort: "low" },
cache: "auto",
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: [{ type: "text", text: "first" }] },
{ role: "assistant", content: [{ type: "text", text: "reply" }] },
{ role: "user", content: [{ type: "text", text: "latest", cache_control: { type: "ephemeral" } }] },
{ role: "system", content: [], output_config: { effort: "low" } },
])
}),
)
})
describe("Anthropic Messages effort updates", () => {
it.effect("lowers markers to per-turn system messages and freezes the top-level effort", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({ model: opus5, messages: conversation, providerOptions: { effort: "low" }, cache: "none" }),
)
expect(prepared.body.output_config).toEqual({ effort: "high" })
expect(prepared.body.messages).toEqual([
{ role: "user", content: [{ type: "text", text: "Before." }] },
{ role: "system", content: [], output_config: { effort: "low" } },
{ role: "user", content: [{ type: "text", text: "After." }] },
])
}),
)
it.effect("omits the frozen effort for the model default and sends `high` for a switch back to it", () =>
Effect.gen(function* () {
const format = { type: "json_schema" as const, schema: { type: "object" } }
const prepared = yield* compileRequest(
LLM.request({
model: opus5,
messages: [
Message.user("One."),
Message.effort({ effort: "low" }),
Message.user("Two."),
Message.effort({ previous: "low" }),
Message.user("Three."),
],
providerOptions: { output_config: { format } },
cache: "none",
}),
)
expect(prepared.body.output_config).toEqual({ format })
expect(systemMessages(prepared.body)).toEqual([
{ role: "system", content: [], output_config: { effort: "low" } },
{ role: "system", content: [], output_config: { effort: "high" } },
])
}),
)
it.effect("requests the mid-conversation output config beta only when markers are sent", () =>
Effect.gen(function* () {
for (const [model, betas] of [
[opus5, "existing-beta,mid-conversation-output-config-2026-07-01"],
[anthropic("claude-sonnet-5"), "existing-beta"],
] as const) {
const request = LLM.request({
model,
messages: conversation,
providerOptions: { effort: "low" },
http: { headers: { "anthropic-beta": "existing-beta" } },
})
const compiled = yield* compileRequest(request)
const prepared = yield* AnthropicMessages.route.prepareTransport(compiled.body, request)
expect(prepared.request.headers["anthropic-beta"]).toBe(betas)
}
}),
)
it.effect("accepts a marker between a tool call and its result", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: opus5,
messages: [
Message.user("Weather?"),
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
lowFromHigh,
Message.tool({ id: "call_1", name: "lookup", result: { temp: 72 } }),
],
providerOptions: { effort: "low" },
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: [{ type: "text", text: "Weather?" }] },
{ role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }] },
{ role: "system", content: [], output_config: { effort: "low" } },
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '{"temp":72}' }] },
])
}),
)
it.effect("falls back to a plain top-level effort when history drifted from the current effort", () =>
Effect.gen(function* () {
const drifted = yield* compileRequest(
LLM.request({ model: opus5, messages: conversation, providerOptions: { effort: "medium" }, cache: "none" }),
)
const plain = yield* compileRequest(
LLM.request({
model: opus5,
messages: [Message.user("Before."), Message.user("After.")],
providerOptions: { effort: "medium" },
cache: "none",
}),
)
expect(drifted.body).toEqual(plain.body)
expect(drifted.body.output_config).toEqual({ effort: "medium" })
}),
)
for (const [id, supported] of [
["claude-opus-5", true],
["claude-opus-5-20260901", true],
["anthropic/claude-opus-5", true],
["claude-fable-5-1", true],
["claude-mythos-5-1", true],
["claude-fable-5", false],
["claude-opus-4-8", false],
["claude-sonnet-5", false],
["kimi-k2.5", false],
] as const) {
it.effect(`${supported ? "lowers" : "strips"} markers for ${id}`, () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({ model: anthropic(id), messages: conversation, providerOptions: { effort: "low" } }),
)
expect(systemMessages(prepared.body)).toHaveLength(supported ? 1 : 0)
expect(prepared.body.output_config).toEqual({ effort: supported ? "high" : "low" })
}),
)
}
it.effect("honors the compatibility override in both directions", () =>
Effect.gen(function* () {
const enabled = yield* compileRequest(
LLM.request({
model: anthropic("claude-sonnet-5", { supportsEffortUpdates: true }),
messages: conversation,
providerOptions: { effort: "low" },
}),
)
const disabled = yield* compileRequest(
LLM.request({
model: anthropic("claude-opus-5", { supportsEffortUpdates: false }),
messages: conversation,
providerOptions: { effort: "low" },
}),
)
expect(systemMessages(enabled.body)).toHaveLength(1)
expect(systemMessages(disabled.body)).toHaveLength(0)
}),
)
it.effect("strips markers on the Vertex Anthropic route, whose protocol wrapper does not forward support", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: GoogleVertexMessages.configure({ accessToken: "test", location: "global", project: "test" }).model(
"claude-opus-5",
),
messages: conversation,
providerOptions: { effort: "low" },
}),
)
expect(systemMessages(prepared.body)).toHaveLength(0)
expect(prepared.body.output_config).toEqual({ effort: "low" })
}),
)
})
describe("OpenAI Responses effort updates", () => {
it.effect("lowers markers to configuration_update items and freezes reasoning.effort", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({ model: astra, messages: conversation, providerOptions: { reasoningEffort: "low" } }),
)
expect(prepared.body.reasoning).toEqual({ effort: "high" })
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ type: "configuration_update", reasoning: { effort: "low" } },
{ role: "user", content: [{ type: "input_text", text: "After." }] },
])
}),
)
it.effect("coalesces consecutive updates so the newest wins", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: astra,
messages: [
Message.user("Before."),
Message.effort({ effort: "low", previous: "medium" }),
Message.effort({ effort: "xhigh", previous: "low" }),
Message.user("After."),
],
providerOptions: { reasoningEffort: "xhigh" },
}),
)
expect(prepared.body.reasoning).toEqual({ effort: "medium" })
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ type: "configuration_update", reasoning: { effort: "xhigh" } },
{ role: "user", content: [{ type: "input_text", text: "After." }] },
])
}),
)
it.effect("omits reasoning.effort for the model default and sends `medium` for a switch back to it", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: astra,
messages: [
Message.user("One."),
Message.effort({ effort: "low" }),
Message.user("Two."),
Message.effort({ previous: "low" }),
Message.user("Three."),
],
}),
)
expect(prepared.body.reasoning).toBeUndefined()
expect(updates(prepared.body)).toEqual([
{ type: "configuration_update", reasoning: { effort: "low" } },
{ type: "configuration_update", reasoning: { effort: "medium" } },
])
}),
)
it.effect("falls back to a plain top-level effort when history drifted from the current effort", () =>
Effect.gen(function* () {
const drifted = yield* compileRequest(
LLM.request({ model: astra, messages: conversation, providerOptions: { reasoningEffort: "xhigh" } }),
)
const plain = yield* compileRequest(
LLM.request({
model: astra,
messages: [Message.user("Before."), Message.user("After.")],
providerOptions: { reasoningEffort: "xhigh" },
}),
)
expect(drifted.body).toEqual(plain.body)
expect(drifted.body.reasoning).toEqual({ effort: "xhigh" })
}),
)
it.effect("strips markers when automatic context management is enabled", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: astra,
messages: conversation,
providerOptions: { reasoningEffort: "low", contextManagement: [{ type: "compaction" }] },
}),
)
expect(updates(prepared.body)).toEqual([])
expect(prepared.body.reasoning).toEqual({ effort: "low" })
expect(prepared.body.context_management).toEqual([{ type: "compaction" }])
}),
)
for (const [id, supported] of [
["gpt-6-astra", true],
["openai/gpt-6-astra", true],
["gpt-6-astra-2026-09-01", false],
["gpt-5.6-sol", false],
] as const) {
it.effect(`${supported ? "lowers" : "strips"} markers for ${id}`, () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({ model: openai(id), messages: conversation, providerOptions: { reasoningEffort: "low" } }),
)
expect(updates(prepared.body)).toHaveLength(supported ? 1 : 0)
expect(prepared.body.reasoning).toEqual({ effort: supported ? "high" : "low" })
}),
)
}
it.effect("honors the compatibility override in both directions", () =>
Effect.gen(function* () {
const enabled = yield* compileRequest(
LLM.request({
model: openai("gpt-5.5", { supportsEffortUpdates: true }),
messages: conversation,
providerOptions: { reasoningEffort: "low" },
}),
)
const disabled = yield* compileRequest(
LLM.request({
model: openai("gpt-6-astra", { supportsEffortUpdates: false }),
messages: conversation,
providerOptions: { reasoningEffort: "low" },
}),
)
expect(updates(enabled.body)).toHaveLength(1)
expect(updates(disabled.body)).toHaveLength(0)
}),
)
const checkpoint = { type: "compaction", id: "cmp_1", encrypted_content: "opaque" }
const compactRequest = LLM.request({
model: OpenAI.configure({ apiKey: "fixture" }).responses("gpt-6-astra"),
messages: conversation,
providerOptions: { reasoningEffort: "low" },
})
testEffect(
dynamicResponse(({ text, respond }) =>
Effect.sync(() => {
const body = JSON.parse(text)
expect(body.reasoning).toEqual({ effort: "high" })
expect(body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ type: "configuration_update", reasoning: { effort: "low" } },
{ role: "user", content: [{ type: "input_text", text: "After." }] },
{ type: "compaction_trigger" },
])
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1", output: [checkpoint] } }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
).effect("keeps configuration updates in the checkpoint body", () =>
Effect.gen(function* () {
const result = yield* LLMClient.compact(compactRequest, { mechanism: "trigger" })
expect(result.checkpoint.encrypted).toBe("opaque")
}),
)
testEffect(
dynamicResponse(({ request, text, respond }) =>
Effect.sync(() => {
expect(new URL(request.url).pathname).toEndWith("/responses/compact")
expect(JSON.parse(text).input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ role: "user", content: [{ type: "input_text", text: "After." }] },
])
return respond(JSON.stringify({ object: "response.compaction", output: [checkpoint] }))
}),
),
).effect("drops markers from the compaction endpoint body", () =>
Effect.gen(function* () {
const result = yield* LLMClient.compact(compactRequest, { mechanism: "endpoint" })
expect(result.replacement.map((message) => message.content[0]?.type)).toEqual(["compaction"])
}),
)
})
+1 -5
View File
@@ -76,10 +76,7 @@ export function buildEffortSelectOption(input: {
category: "thought_level",
type: "select",
currentValue: selectVariant(input.currentVariant, input.variants),
options: [...new Set([...input.variants, DEFAULT_VARIANT_VALUE])].map((variant) => ({
value: variant,
name: formatVariantName(variant),
})),
options: input.variants.map((variant) => ({ value: variant, name: formatVariantName(variant) })),
}
}
@@ -128,7 +125,6 @@ export function formatVariantName(variant: string) {
}
function selectVariant(variant: string | undefined, variants: readonly string[]) {
if (!variant || variant === DEFAULT_VARIANT_VALUE) return DEFAULT_VARIANT_VALUE
if (variant && variants.includes(variant)) return variant
if (variants.includes(DEFAULT_VARIANT_VALUE)) return DEFAULT_VARIANT_VALUE
return variants[0] ?? DEFAULT_VARIANT_VALUE
+2 -4
View File
@@ -201,7 +201,7 @@ export async function streamTurn(input: {
if (!child) assistantMessageID = event.data.assistantMessageID
await send({
sessionUpdate: "agent_thought_chunk",
messageId: `${event.data.assistantMessageID}:reasoning:${event.data.ordinal}`,
messageId: event.data.assistantMessageID,
content: { type: "text", text: event.data.delta },
})
continue
@@ -455,8 +455,6 @@ async function replayMessage(
return
}
if (message.type !== "assistant") return
// Live reasoning ordinals count only reasoning parts, not the mixed content array.
let reasoningOrdinal = 0
for (const part of message.content) {
if (part.type === "text") {
await connection.sessionUpdate({
@@ -474,7 +472,7 @@ async function replayMessage(
sessionId: sessionID,
update: {
sessionUpdate: "agent_thought_chunk",
messageId: `${message.id}:reasoning:${reasoningOrdinal++}`,
messageId: message.id,
content: { type: "text", text: part.text },
},
})
+5 -20
View File
@@ -41,12 +41,7 @@ import type {
} from "@agentclientprotocol/sdk"
import { OPENCODE_VERSION } from "../version"
import { SessionMessage } from "@opencode/schema/session-message"
import {
buildConfigOptions,
DEFAULT_VARIANT_VALUE,
parseModelSelection,
type ConfigOptionProvider,
} from "./config-option"
import { buildConfigOptions, parseModelSelection, type ConfigOptionProvider } from "./config-option"
import { promptContentToParts } from "./content"
import {
ChildSessionUpdateMethod,
@@ -280,7 +275,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
if (typeof params.value !== "string") throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
switch (params.configId) {
case "model": {
const selected = requireModel(state.catalog, params.value, state.model)
const selected = requireModel(state.catalog, params.value)
state.model = selected
await input.client.session.switchModel({ sessionID: state.id, model: selected })
break
@@ -289,10 +284,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
const model = state.catalog.models.find(
(item) => item.providerID === state.model.providerID && item.id === state.model.id,
)
if (
!model ||
(params.value !== DEFAULT_VARIANT_VALUE && !model.variants.some((variant) => variant.id === params.value))
)
if (!model?.variants.some((variant) => variant.id === params.value))
throw new ACPError.InvalidEffortError({ effort: params.value })
state.model = { ...state.model, variant: params.value }
await input.client.session.switchModel({ sessionID: state.id, model: state.model })
@@ -461,7 +453,7 @@ function providers(models: readonly ModelInfo[]): ConfigOptionProvider[] {
}))
}
function requireModel(catalog: Catalog, modelID: string, current: ModelRef): ModelRef {
function requireModel(catalog: Catalog, modelID: string): ModelRef {
const selected = parseModelSelection(modelID, catalog.providers)
const model = catalog.models.find(
(item) => item.providerID === selected.model.providerID && item.id === selected.model.modelID,
@@ -469,14 +461,7 @@ function requireModel(catalog: Catalog, modelID: string, current: ModelRef): Mod
if (!model) throw new ACPError.InvalidModelError({ providerId: selected.model.providerID, modelId: modelID })
if (selected.variant && !model.variants.some((variant) => variant.id === selected.variant))
throw new ACPError.InvalidEffortError({ effort: selected.variant })
const variant =
selected.variant ??
(current.providerID === model.providerID &&
current.id === model.id &&
(current.variant === DEFAULT_VARIANT_VALUE || model.variants.some((variant) => variant.id === current.variant))
? current.variant
: undefined)
return { providerID: model.providerID, id: model.id, variant }
return { providerID: model.providerID, id: model.id, variant: selected.variant }
}
async function selectMode(client: OpenCodeClient, state: Attached, modeID: string) {
@@ -50,11 +50,11 @@ describe("acp config option subprocess", () => {
const effort = requireSelectOption((await newSession(acp, fixture.home)).configOptions, "effort")
expect(effort.category).toBe("thought_level")
expect(effort.currentValue).toBe("default")
expect(flattenSelectOptions(effort).map((option) => option.value)).toEqual(["low", "high", "default"])
expect(effort.currentValue).toBe("low")
expect(flattenSelectOptions(effort).map((option) => option.value)).toEqual(["low", "high"])
}, 60_000)
test("effort survives model synchronization and can be reset to default", async () => {
test("effort switch updates currentValue", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
await initialize(acp)
@@ -70,23 +70,5 @@ describe("acp config option subprocess", () => {
)
expect(selectConfigOption(updated.configOptions, "effort")?.currentValue).toBe(nextEffort)
const synchronized = expectOk(
await acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "model",
value: requireSelectOption(session.configOptions, "model").currentValue,
}),
)
expect(selectConfigOption(synchronized.configOptions, "effort")?.currentValue).toBe(nextEffort)
const reset = expectOk(
await acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "effort",
value: "default",
}),
)
expect(selectConfigOption(reset.configOptions, "effort")?.currentValue).toBe("default")
}, 60_000)
})
+6 -44
View File
@@ -91,7 +91,7 @@ describe("acp event behavior", () => {
}
})
test("preserves reasoning boundaries and update order during streaming and replay", async () => {
test("preserves text and reasoning order before returning the terminal response", async () => {
const firstUpdate = Promise.withResolvers<void>()
const releaseUpdate = Promise.withResolvers<void>()
const allUpdates = Promise.withResolvers<void>()
@@ -108,14 +108,6 @@ describe("acp event behavior", () => {
delta: "think-1",
}),
)
send(
ephemeralEvent("session.reasoning.delta", {
sessionID: "ses_order",
assistantMessageID: "msg_order",
ordinal: 0,
delta: " continued",
}),
)
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_order",
@@ -128,7 +120,7 @@ describe("acp event behavior", () => {
ephemeralEvent("session.reasoning.delta", {
sessionID: "ses_order",
assistantMessageID: "msg_order",
ordinal: 1,
ordinal: 2,
delta: "think-2",
}),
)
@@ -152,7 +144,7 @@ describe("acp event behavior", () => {
firstUpdate.resolve()
await releaseUpdate.promise
}
if (updates.length === 4) allUpdates.resolve()
if (updates.length === 3) allUpdates.resolve()
},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
} satisfies Connection
@@ -179,48 +171,18 @@ describe("acp event behavior", () => {
) {
return [
item.update.sessionUpdate,
item.update.messageId,
item.update.content.type === "text" ? item.update.content.text : undefined,
]
}
return [item.update.sessionUpdate, undefined]
}),
).toEqual([
["agent_thought_chunk", "msg_order:reasoning:0", "think-1"],
["agent_thought_chunk", "msg_order:reasoning:0", " continued"],
["agent_message_chunk", "msg_order", "answer"],
["agent_thought_chunk", "msg_order:reasoning:1", "think-2"],
["agent_thought_chunk", "think-1"],
["agent_message_chunk", "answer"],
["agent_thought_chunk", "think-2"],
])
expect(fixture.requests.at(-1)?.path).toBe("/api/session/ses_order/message/msg_order")
expect(response).toMatchObject({ stopReason: "end_turn", usage: { totalTokens: 2 } })
const replayed: SessionUpdateParams[] = []
await replayMessages(recordingConnection(replayed), "ses_order", "/workspace", [
{
id: "msg_order",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test-model" },
time: { created: 1 },
content: [
{ type: "reasoning", text: "think-1 continued" },
{ type: "text", text: "answer" },
{ type: "reasoning", text: "think-2" },
],
},
])
expect(replayed).toEqual([
{
sessionId: "ses_order",
update: {
sessionUpdate: "agent_thought_chunk",
messageId: "msg_order:reasoning:0",
content: { type: "text", text: "think-1 continued" },
},
},
updates[2],
updates[3],
])
} finally {
releaseUpdate.resolve()
releaseSubmit.resolve()
@@ -222,7 +222,7 @@ describe("acp service directory behavior", () => {
await fixture.service.setSessionMode({ sessionId: session.sessionId, modeId: "build" })
expect(currentValue(selectedModel, "model")).toBe("test/second-model")
expect(currentValue(selectedModel, "effort")).toBe("default")
expect(currentValue(selectedModel, "effort")).toBe("low")
expect(currentValue(selectedEffort, "effort")).toBe("medium")
expect(currentValue(selectedMode, "mode")).toBe("plan")
expect(
@@ -32,7 +32,7 @@ describe("acp service lifecycle", () => {
model: { providerID: "test", id: "second-model" },
},
})
expect(currentValue(created, "effort")).toBe("default")
expect(currentValue(created, "effort")).toBe("none")
})
test("loads and forks with paginated replay while resume does not replay", async () => {
-8
View File
@@ -547,14 +547,6 @@ function assistantPart(part: ContentPart): AssistantContent {
]
case "tool-result":
return toolResultPart(part)
case "effort":
throw ProviderShared.unsupportedContent("AI SDK", "assistant", [
"text",
"media",
"reasoning",
"tool-call",
"tool-result",
])
}
}
-1
View File
@@ -196,7 +196,6 @@ const estimateMedia = (mime: string) => {
const estimatePart = (part: ContentPart): number => {
// Encrypted checkpoints have no locally measurable token size.
if (part.type === "compaction") return Token.estimate(part.text ?? "")
if (part.type === "effort") return 0
if (part.type === "text" || part.type === "reasoning") return Token.estimate(part.text)
if (part.type === "media") return estimateMedia(part.mediaType)
if (part.type === "tool-call") return Token.estimate(part.name + (JSON.stringify(part.input) ?? ""))
@@ -1,11 +1,4 @@
import {
Message,
ReasoningEfforts,
ToolCallPart,
ToolResultPart,
type ContentPart,
type ProviderMetadata,
} from "@opencode/ai"
import { Message, ToolCallPart, ToolResultPart, type ContentPart, type ProviderMetadata } from "@opencode/ai"
import type { Model } from "@opencode/schema/model"
import { Option, Schema } from "effect"
import { fileURLToPath } from "url"
@@ -229,31 +222,11 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
]
}
const EFFORT_VARIANTS = new Set<string>(ReasoningEfforts)
// Budget and toggle variants reuse these names; the protocol's drift check catches ones that are not effort options.
const variantEffort = (variant: Model.VariantID | undefined) => {
if (variant === undefined || variant === "default") return { effort: undefined }
return EFFORT_VARIANTS.has(variant) ? { effort: variant } : undefined
}
const modelSwitched = (message: SessionMessage.ModelSelected, model: Model.Ref): Message[] => {
const previous = message.previous
if (previous === undefined) return []
const same = (ref: Model.Ref) => ref.providerID === model.providerID && ref.id === model.id
if (!same(message.model) || !same(previous)) return []
const to = variantEffort(message.model.variant)
const from = variantEffort(previous.variant)
if (to === undefined || from === undefined) return []
return [Message.effort({ effort: to.effort, previous: from.effort })]
}
function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMetadataKey: string): Message[] {
switch (message.type) {
case "agent-switched":
return []
case "model-switched":
return modelSwitched(message, model)
return []
case "location-switched":
return [
Message.make({
@@ -202,44 +202,6 @@ Recent work
])
})
describe("model-switched", () => {
const ref = (variant?: string) =>
Model.Ref.make({
id: Model.ID.make("model"),
providerID: Provider.ID.make("provider"),
...(variant === undefined ? {} : { variant: Model.VariantID.make(variant) }),
})
const switched = (to: Model.Ref, previous?: Model.Ref) =>
SessionMessage.ModelSelected.make({
id: id("model"),
type: "model-switched",
model: to,
previous,
time: { created },
})
test("records a same-model effort switch as an effort update", () => {
expect(toLLMMessages([switched(ref("low"), ref("high"))], ref("low"))).toEqual([
Message.effort({ effort: "low", previous: "high" }),
])
})
test("maps the default variant and no variant to the model default effort", () => {
expect(toLLMMessages([switched(ref("low"), ref())], ref("low"))).toEqual([Message.effort({ effort: "low" })])
expect(toLLMMessages([switched(ref("default"), ref("max"))], ref())).toEqual([
Message.effort({ previous: "max" }),
])
})
test("ignores switches that are not effort changes on the requested model", () => {
const other = Model.Ref.make({ id: Model.ID.make("other"), providerID: Provider.ID.make("provider") })
expect(toLLMMessages([switched(ref("low"))], ref("low"))).toEqual([])
expect(toLLMMessages([switched(ref("low"), other)], ref("low"))).toEqual([])
expect(toLLMMessages([switched(ref("thinking"), ref("high"))], ref("thinking"))).toEqual([])
expect(toLLMMessages([switched(ref("low"), ref("high"))], other)).toEqual([])
})
})
test("lowers text attachments after the prompt in one user message", () => {
const file = FileAttachment.make({
data: Base64.make(Buffer.from("export const value = 1").toString("base64")),
-33
View File
@@ -1996,39 +1996,6 @@ describe("SessionRunnerLLM", () => {
yield* s.runPrompt("Fourth")
})
scenario("records a same-model effort switch as a cache-preserving effort update", function* (s) {
s.currentModel = LanguageModel.make({ id: "claude-opus-5", provider: "anthropic", route: AnthropicMessages.route })
const model = { id: ID.make("claude-opus-5"), providerID: Provider.ID.make("anthropic") }
yield* s.bus.publish(SessionEvent.ModelSelected, {
sessionID,
model: { ...model, variant: Model.VariantID.make("high") },
})
yield* s.llm.push(TestLLM.text("Earlier answer", "text-effort-high"))
yield* s.runPrompt("First")
yield* s.bus.publish(SessionEvent.ModelSelected, {
sessionID,
model: { ...model, variant: Model.VariantID.make("low") },
})
// The selected variant reaches the request as the provider effort option.
s.currentModel = LanguageModel.update(s.currentModel, { defaults: { providerOptions: { effort: "low" } } })
yield* s.llm.push(TestLLM.text("Later answer", "text-effort-low"))
yield* s.runPrompt("Second")
expect(messageRoles(s.requests[1])).toEqual(["user", "assistant", "system", "user"])
expect(s.requests[1]?.messages[2]).toEqual(Message.effort({ effort: "low", previous: "high" }))
const compiled = yield* compileRequest(s.requests[1]!)
expect(compiled.body).toMatchObject({
output_config: { effort: "high" },
messages: [
{ role: "user" },
{ role: "assistant" },
{ role: "system", content: [], output_config: { effort: "low" } },
{ role: "user" },
],
})
})
scenario("preserves instruction values while a source is temporarily unavailable", function* (s) {
yield* s.runPrompt("First")
yield* s.bus.publish(SessionEvent.ModelSelected, {
+21 -5
View File
@@ -14,6 +14,7 @@ import { useStorage } from "./storage"
import { useTuiPaths } from "./runtime"
import { newSessionLocation } from "../config/new-session-location"
import { createSessionRetention } from "./session-retention"
import { anchorKey, type AnchorTarget } from "../routes/session/anchors"
import {
closeSessionTab,
cycleSessionTab,
@@ -40,8 +41,8 @@ type PersistedState = {
cwd: Record<string, TabsState>
}
type ScrollAnchor = {
messageID: string
export type ScrollAnchor = {
target: AnchorTarget
screenY: number
}
@@ -90,6 +91,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
// the mark.
const cancelledTabs = new Set<string>()
const scrollAnchors = new Map<string, ScrollAnchor>()
const [expandedGroups, setExpandedGroups] = createStore<Record<string, Record<string, boolean> | undefined>>({})
const onFocus = () => setFocused(true)
const onBlur = () => setFocused(false)
@@ -202,7 +204,11 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
if (state().tabs.some((tab) => tab.sessionID === sessionID)) return
const fallback = newTab() ? NEW_SESSION_TAB_TITLE : undefined
const replaced = permanent ? undefined : previewID()
if (replaced) family(replaced).forEach((id) => scrollAnchors.delete(id))
if (replaced)
family(replaced).forEach((id) => {
scrollAnchors.delete(id)
setExpandedGroups(id, undefined)
})
if (!permanent) setPreview(sessionID)
update((draft) => {
if (cancelledTabs.has(sessionID)) return
@@ -346,7 +352,10 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
function remove(sessionID: string, navigate: boolean) {
const target = root(sessionID)
cancelledTabs.add(target)
family(target).forEach((id) => scrollAnchors.delete(id))
family(target).forEach((id) => {
scrollAnchors.delete(id)
setExpandedGroups(id, undefined)
})
if (previewID() === target) setPreview(undefined)
const closed = closeSessionTab(state().tabs, target)
const selected = navigate && current() === target
@@ -393,9 +402,16 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
return
}
const current = scrollAnchors.get(sessionID)
if (current?.messageID === anchor.messageID && current.screenY === anchor.screenY) return
if (current && anchorKey(current.target) === anchorKey(anchor.target) && current.screenY === anchor.screenY)
return
scrollAnchors.set(sessionID, anchor)
},
groupExpanded(sessionID: string, groupID: string) {
return expandedGroups[sessionID]?.[groupID]
},
setGroupExpanded(sessionID: string, groupID: string, expanded: boolean) {
setExpandedGroups(sessionID, (current) => ({ ...current, [groupID]: expanded }))
},
select(sessionID: string) {
if (!enabled()) return
route.navigate({ type: "session", sessionID: root(sessionID) })
@@ -0,0 +1,53 @@
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
import type { BoxRenderable } from "@opentui/core"
import type { JSX } from "@opentui/solid"
import type { SessionEntry, SessionNode } from "./grouping/session"
import { entryRef } from "./anchors"
import { use } from "./render-context"
export function visitEntries(nodes: readonly SessionNode[], visit: (entry: SessionEntry) => void) {
nodes.forEach((node) => {
if (node.type === "entry") visit(node.entry)
if (node.type === "group") visitEntries(node.children, visit)
})
}
export function useEntryAnchor(props: {
entry: Accessor<SessionEntry | undefined>
node: Accessor<BoxRenderable | undefined>
}) {
const ctx = use()
createEffect(() => {
const entry = props.entry()
const node = props.node()
const ref = entry && entryRef(entry)
if (!ref || !node) return
onCleanup(ctx.anchors.register({ target: { type: "part", ref }, node }))
})
}
export function EntryAnchor(props: { entry: SessionEntry; children: JSX.Element; marginTop?: number }) {
const [node, setNode] = createSignal<BoxRenderable>()
useEntryAnchor({ entry: () => props.entry, node })
return (
<box ref={setNode} marginTop={props.marginTop} flexShrink={0}>
{props.children}
</box>
)
}
export function GroupAnchor(props: { groupID: string | undefined; active: boolean; children: JSX.Element }) {
const ctx = use()
const [node, setNode] = createSignal<BoxRenderable>()
createEffect(() => {
const target = node()
const groupID = props.groupID
if (!target || !groupID || !props.active) return
onCleanup(ctx.anchors.register({ target: { type: "group", groupID }, node: target }))
})
return (
<box ref={setNode} flexDirection="column" flexShrink={0}>
{props.children}
</box>
)
}
@@ -0,0 +1,85 @@
import type { Renderable } from "@opentui/core"
import type { PartRef, SessionEntry, SessionNode } from "./grouping/session"
export type AnchorTarget = { type: "part"; ref: PartRef } | { type: "group"; groupID: string }
type Anchor = {
target: AnchorTarget
node: Pick<Renderable, "y" | "height" | "isDestroyed">
}
export function anchorKey(target: AnchorTarget) {
return target.type === "part"
? JSON.stringify(["part", target.ref.messageID, target.ref.partID])
: JSON.stringify(["group", target.groupID])
}
/** Whole non-assistant messages have one canonical UI body part. Derived
* footer/usage rows use the preceding entry as their scroll reference. */
export function entryRef(entry: SessionEntry): PartRef | undefined {
// Saved identities must not follow an in-place Solid store reconciliation.
if (entry.type === "part") return { messageID: entry.ref.messageID, partID: entry.ref.partID }
if (entry.type === "message") return { messageID: entry.messageID, partID: "message" }
}
export function groupID(node: Extract<SessionNode, { type: "group" }>, level: number) {
const ref = firstRef(node.children)
return ref && JSON.stringify([ref.messageID, ref.partID, node.kind, level])
}
function firstRef(nodes: readonly SessionNode[]): PartRef | undefined {
for (const node of nodes) {
const ref = node.type === "entry" ? entryRef(node.entry) : firstRef(node.children)
if (ref) return ref
}
}
export function containsAnchor(
node: SessionEntry | Extract<SessionNode, { type: "group" }>,
target: AnchorTarget,
level = 0,
): boolean {
if (node.type === "group") {
if (target.type === "group" && groupID(node, level) === target.groupID) return true
return node.children.some((child) =>
containsAnchor(child.type === "entry" ? child.entry : child, target, level + 1),
)
}
const ref = entryRef(node)
return target.type === "part" && ref?.messageID === target.ref.messageID && ref.partID === target.ref.partID
}
/** Only mounted parts and actual group headers register. Geometry stays in OpenTUI. */
export function createTimelineAnchors() {
const entries = new Map<string, Anchor>()
const list = () =>
[...entries.values()]
.filter((anchor) => !anchor.node.isDestroyed && anchor.node.height > 0)
.sort((a, b) => a.node.y - b.node.y)
return {
register(anchor: Anchor) {
const key = anchorKey(anchor.target)
entries.set(key, anchor)
return () => {
if (entries.get(key) === anchor) entries.delete(key)
}
},
get(target: AnchorTarget) {
const anchor = entries.get(anchorKey(target))
return anchor && !anchor.node.isDestroyed && anchor.node.height > 0 ? anchor : undefined
},
forMessage(messageID: string) {
return list().find((anchor) => anchor.target.type === "part" && anchor.target.ref.messageID === messageID)
},
messagePositions() {
const seen = new Set<string>()
return list().flatMap((anchor) => {
if (anchor.target.type !== "part" || seen.has(anchor.target.ref.messageID)) return []
const id = anchor.target.ref.messageID
seen.add(id)
return [{ id, y: anchor.node.y }]
})
},
list,
}
}
@@ -0,0 +1,321 @@
import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
import { RGBA } from "@opentui/core"
import { useRenderer, type JSX } from "@opentui/solid"
import type { SessionMessageAssistantTool, SessionMessageInfo } from "@opencode/client"
import { createSyntaxStyleMemo, useTheme, useThemes } from "../../context/theme"
import { reasoningSummary } from "../../context/thinking"
import { SplitBorder } from "../../ui/border"
import { Locale } from "../../util/locale"
import { EntryAnchor, GroupAnchor, visitEntries } from "./anchor-view"
import { groupID } from "./anchors"
import type { PartRef, SessionEntry, SessionGroup, SessionNode } from "./grouping/session"
import { InlineToolRow, reasoningContent, toolDisplay } from "./message-parts"
import { use } from "./render-context"
import { resolvePart } from "./rows"
import { generateThinkingSyntax } from "./thinking-syntax"
type Renderers = {
message: (messageID: string) => SessionMessageInfo | undefined
entry: (entry: SessionEntry, images?: boolean) => JSX.Element
images: (parts: readonly SessionMessageAssistantTool[]) => JSX.Element
}
type GroupProps = Renderers & {
node: Extract<SessionNode, { type: "group" }>
level: number
completed: boolean
pending: readonly PartRef[]
pendingOutside?: boolean
imagesOutside?: boolean
}
export function SessionGroupView(props: Renderers & { row: SessionGroup }) {
return (
<Group
{...props}
node={props.row}
level={0}
completed={props.row.completed}
pending={props.row.kind === "exploration" ? props.row.pending : []}
/>
)
}
function Group(props: GroupProps) {
// Keep kind-specific hover/title state isolated during reconciliation.
return (
<Show when={props.node.kind} keyed>
{(_kind) => <GroupContent {...props} />}
</Show>
)
}
function GroupContent(props: GroupProps) {
const ctx = use()
const theme = useTheme()
const renderer = useRenderer()
const id = createMemo(() => groupID(props.node, props.level))
const expanded = () => {
const key = id()
return key ? (ctx.groupExpanded(key) ?? false) : false
}
const [hover, setHover] = createSignal(false)
const entries = createMemo(() => {
const result: SessionEntry[] = []
visitEntries(props.node.children, (entry) => result.push(entry))
return result
})
const refs = createMemo(() =>
entries().flatMap((entry) => (entry.type === "part" && !isPending(entry, props.pending) ? [entry.ref] : [])),
)
const thoughts = createMemo(() =>
props.node.kind !== "reasoning"
? []
: refs().flatMap((ref) => {
const message = props.message(ref.messageID)
if (message?.type !== "assistant") return []
const part = resolvePart(message, ref.partID)
if (part?.type !== "reasoning" || !reasoningContent(part)) return []
return [{ message, part }]
}),
)
const tools = createMemo(() =>
props.node.kind !== "exploration"
? []
: refs().flatMap((ref) => {
const message = props.message(ref.messageID)
if (message?.type !== "assistant") return []
const part = resolvePart(message, ref.partID)
return part?.type === "tool" ? [part] : []
}),
)
const latest = createMemo((previous: string | null) => {
const item = thoughts().at(-1)
if (!item) return previous
const title = reasoningSummary(reasoningContent(item.part)).title
if (title) return title
if (item.part.time?.completed !== undefined || item.message.time.completed !== undefined) return null
return previous
}, null)
const duration = createMemo(() =>
thoughts().reduce((total, item) => {
const start = item.part.time?.created
const end = item.part.time?.completed
return total + (start === undefined || end === undefined ? 0 : Math.max(0, end - start))
}, 0),
)
const grouped = () => (props.node.kind === "reasoning" ? ctx.thinkingMode() === "hide" : ctx.groupExploration())
const completed = () =>
props.node.kind === "reasoning"
? props.completed
: props.completed || (tools().length > 0 && tools().every((part) => part.time.completed !== undefined))
const label = createMemo(() => {
const counts = tools().reduce<Record<string, number>>((result, part) => {
const tool = toolDisplay(part.name)
const name = tool === "grep" || tool === "glob" ? "search" : tool
result[name] = (result[name] ?? 0) + 1
return result
}, {})
const names = Object.entries(counts).map(
([name, count]) => `${count} ${count === 1 ? name : name === "search" ? "searches" : `${name}s`}`,
)
return `${completed() ? "Explored" : "Exploring"}${names.join(", ")}`
})
const toggle = () => {
if (renderer.getSelection()?.getSelectedText()) return
const key = id()
if (key) ctx.setGroupExpanded(key, !expanded())
}
const children = (mode: "normal" | "thought" | "tool") => (
<Children {...props} nodes={props.node.children} mode={mode} />
)
return (
<GroupAnchor
groupID={id()}
active={grouped() && (props.node.kind === "reasoning" ? thoughts().length > 0 : tools().length > 0)}
>
<Show
when={props.node.kind === "reasoning"}
fallback={
<Show when={grouped()} fallback={children("normal")}>
<Show when={tools().length > 0}>
<InlineToolRow
icon={completed() ? "→" : "✱"}
color={hover() ? theme.text.default : theme.text.subdued}
complete={completed()}
pending={label()}
spinner={!completed()}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={toggle}
>
{label()}
</InlineToolRow>
</Show>
<Show when={expanded() && tools().length > 0}>{children("tool")}</Show>
<Show when={!props.imagesOutside}>{props.images(tools())}</Show>
</Show>
}
>
<Show when={thoughts().length > 0}>
<Show when={grouped()} fallback={children("normal")}>
<InlineToolRow
icon={expanded() ? "-" : "+"}
color={
!props.completed
? theme.text.default
: hover() || expanded()
? theme.text.feedback.warning.default
: RGBA.fromValues(
theme.text.feedback.warning.default.r,
theme.text.feedback.warning.default.g,
theme.text.feedback.warning.default.b,
0.6,
)
}
complete={props.completed}
pending={latest() ? `Thinking: ${latest()}` : "Thinking"}
spinner={!props.completed}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={toggle}
>
{props.completed ? "Thought" : latest() ? `Thinking: ${latest()}` : "Thinking"}
<Show when={props.completed && !expanded() && latest()}>: {latest()}</Show>
<Show when={props.completed && thoughts().length > 1}> · {thoughts().length} steps</Show>
<Show when={props.completed && duration()}> · {Locale.duration(duration())}</Show>
</InlineToolRow>
<Show when={expanded()}>
<box paddingLeft={3}>{children("thought")}</box>
</Show>
</Show>
</Show>
</Show>
<Show when={!props.pendingOutside}>
<For each={props.pending}>
{(ref) => {
const leaf = createMemo(() => {
return entries().find(
(entry) =>
entry.type === "part" && entry.ref.messageID === ref.messageID && entry.ref.partID === ref.partID,
)
})
return (
<Show when={leaf()}>{(item) => <EntryAnchor entry={item()}>{props.entry(item())}</EntryAnchor>}</Show>
)
}}
</For>
</Show>
</GroupAnchor>
)
}
function Children(props: GroupProps & { nodes: readonly SessionNode[]; mode: "normal" | "thought" | "tool" }) {
return (
<For each={props.nodes}>
{(node, index) => {
return (
<Switch>
<Match when={node.type === "group" ? node : undefined}>
{(node) => (
<Group
{...props}
node={node()}
level={props.level + 1}
pendingOutside
imagesOutside={props.imagesOutside || props.mode === "tool"}
completed={
props.completed ||
props.nodes
.slice(index() + 1)
.some((next) => next.type === "group" || !isPending(next.entry, props.pending)) ||
(node().kind === "reasoning" && reasoningCompleted(node().children, props.message))
}
/>
)}
</Match>
<Match when={node.type === "entry" ? node : undefined}>
{(node) => (
<Show when={!isPending(node().entry, props.pending)}>
<Show
when={props.mode === "thought"}
fallback={
<EntryAnchor entry={node().entry}>
{props.entry(node().entry, props.mode === "tool" ? false : undefined)}
</EntryAnchor>
}
>
<ThoughtEntry entry={node().entry} message={props.message} />
</Show>
</Show>
)}
</Match>
</Switch>
)
}}
</For>
)
}
function ThoughtEntry(props: { entry: SessionEntry; message: Renderers["message"] }) {
const ctx = use()
const theme = useTheme()
const { currentSyntax: syntax } = useThemes()
const thinkingSyntax = createSyntaxStyleMemo(() => generateThinkingSyntax(syntax(), theme.text.subdued))
const message = createMemo(() => {
if (props.entry.type !== "part") return
const item = props.message(props.entry.ref.messageID)
return item?.type === "assistant" ? item : undefined
})
const part = createMemo(() => {
const item = message()
if (!item || props.entry.type !== "part") return
const part = resolvePart(item, props.entry.ref.partID)
return part?.type === "reasoning" ? part : undefined
})
const content = createMemo(() => {
const item = part()
return item ? reasoningContent(item) : ""
})
return (
<Show when={content()}>
<EntryAnchor entry={props.entry} marginTop={1}>
<box
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.raise(theme.background.surface.offset)}
paddingLeft={1}
>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={part()?.time?.completed === undefined && message()?.time.completed === undefined}
syntaxStyle={thinkingSyntax()}
content={content()}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.text.subdued}
/>
</box>
</EntryAnchor>
</Show>
)
}
function isPending(entry: SessionEntry, pending: readonly PartRef[]) {
return (
entry.type === "part" &&
pending.some((ref) => ref.messageID === entry.ref.messageID && ref.partID === entry.ref.partID)
)
}
function reasoningCompleted(nodes: readonly SessionNode[], message: Renderers["message"]): boolean {
return nodes.every((node) => {
if (node.type === "group") return reasoningCompleted(node.children, message)
if (node.entry.type !== "part") return false
const item = message(node.entry.ref.messageID)
if (item?.type !== "assistant") return false
const part = resolvePart(item, node.entry.ref.partID)
return part?.type === "reasoning" && part.time?.completed !== undefined
})
}
@@ -18,8 +18,9 @@ export type SessionEntry =
| { type: "assistant-footer"; messageID: string }
| { type: "turn-usage"; messageIDs: string[]; previousCache?: CacheUsage }
type GroupKind = "reasoning" | "exploration"
type SessionGroup = {
export type GroupKind = "reasoning" | "exploration"
export type SessionNode = GroupNode<SessionEntry, GroupKind>
export type SessionGroup = {
type: "group"
children: readonly GroupNode<SessionEntry, GroupKind>[]
size: number
+121 -276
View File
@@ -23,7 +23,7 @@ import { SplitBorder } from "../../ui/border"
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
import { PatchDiff } from "../../component/patch-diff"
import { createSyntaxStyleMemo, ThemeContextProvider, useTheme, useThemes } from "../../context/theme"
import { ThemeContextProvider, useTheme, useThemes } from "../../context/theme"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA, MouseEvent } from "@opentui/core"
import { Prompt, type PromptRef } from "../../component/prompt"
import type {
@@ -75,7 +75,7 @@ import { DialogExportResult } from "../../ui/dialog-export-result"
import { sessionEpilogue } from "../../util/presentation"
import { useConfig } from "../../config"
import { useClipboard } from "../../context/clipboard"
import { nextThinkingMode, reasoningSummary, type ThinkingMode } from "../../context/thinking"
import { nextThinkingMode, type ThinkingMode } from "../../context/thinking"
import { getScrollAcceleration } from "../../util/scroll"
import { collapseToolOutput } from "../../util/collapse-tool-output"
import { Keymap, type KeymapCommand } from "../../context/keymap"
@@ -100,18 +100,21 @@ import { findMessageBoundary, messageNavigationSlack } from "./message-navigatio
import { stringWidth } from "../../util/string-width"
import { useArgs } from "../../context/args"
import { withTimestampedFallback } from "@opencode/util/session-title-fallback"
import { useSessionTabs } from "../../context/session-tabs"
import { useSessionTabs, type ScrollAnchor } from "../../context/session-tabs"
import { createSingleFlight } from "../../util/single-flight"
import type { SessionInbox } from "@opencode/schema/session-inbox"
import { generateThinkingSyntax } from "./thinking-syntax"
import { createDelayedPresence } from "../../util/delayed-presence"
import { SessionLocationMissing } from "./location-missing"
import { isRecord } from "../../util/record"
import { createHistoryPrepend } from "./history"
import { context, use, type PendingAction } from "./render-context"
import { INLINE_TOOL_ICON_WIDTH, InlineToolRow, ReasoningPart, reasoningContent, TextPart } from "./message-parts"
import { groupRefs } from "./grouping/session"
import { INLINE_TOOL_ICON_WIDTH, InlineToolRow, ReasoningPart, TextPart, toolDisplay } from "./message-parts"
import type { SessionEntry } from "./grouping/session"
import { SessionGroupView } from "./group-view"
import { useEntryAnchor } from "./anchor-view"
import { containsAnchor, createTimelineAnchors } from "./anchors"
export { InlineToolRow } from "./message-parts"
export { toolDisplay } from "./message-parts"
addDefaultParsers(parsers.parsers)
@@ -260,7 +263,7 @@ export function Session(props: {
},
)
const boundaries = createMemo(() => messageBoundaryIDs(rows, messages()))
const boundaryIDs = createMemo(() => new Set(boundaries().filter((id) => id !== undefined)))
const anchors = createTimelineAnchors()
const [navigationMessage, setNavigationMessage] = createSignal<string>()
const [navigationSlack, setNavigationSlack] = createSignal(0)
const [firstJump, setFirstJump] = createSignal<() => void>()
@@ -353,7 +356,7 @@ export function Session(props: {
firstJump()?.()
if (!scroll || scroll.isDestroyed) return
scroll.verticalScrollBar.off("change", updateAwayFromBottom)
saveScrollAnchor()
saveScrollAnchor(true)
})
const [prompt, setPrompt] = createSignal<PromptRef>()
const bind = (r: PromptRef | undefined) => {
@@ -451,7 +454,14 @@ export function Session(props: {
}
function isAwayFromBottom() {
if (revealingOlderRows || revealingNewerRows || ensureAllRowsPending || navigationMessage() || firstJump())
if (
revealingOlderRows ||
revealingNewerRows ||
ensureAllRowsPending ||
navigationMessage() ||
navigationSlack() ||
firstJump()
)
return true
if (visibleEnd() < rows.length) return true
return scroll.scrollTop < Math.max(0, scroll.scrollHeight - scroll.viewport.height)
@@ -472,20 +482,31 @@ export function Session(props: {
saveScrollAnchor()
})
}
function saveScrollAnchor() {
function saveScrollAnchor(unmounting = false) {
// Initial layout must not overwrite the saved position before synchronization restores it.
if (!restored) return
const mounted = anchors.list()
// Solid disposes child registrations before the route's cleanup. Keep the
// last scroll-event anchor once those children have gone away.
if (unmounting && !mounted.length) return
if (!isAwayFromBottom()) {
sessionTabs.setScrollAnchor(sessionID, undefined)
return
}
let first: { messageID: string; screenY: number } | undefined
let anchor: { messageID: string; screenY: number } | undefined
for (const child of scroll.getChildren()) {
if (!child.id || !boundaryIDs().has(child.id)) continue
const item = { messageID: child.id, screenY: child.y - scroll.viewport.y }
let first: ScrollAnchor | undefined
let anchor: ScrollAnchor | undefined
for (const child of mounted) {
const item = {
target: child.target,
screenY: child.node.y - scroll.viewport.y,
}
first ??= item
if (item.screenY <= 0) anchor = item
const inset =
item.target.type === "group" ||
data.session.message.get(sessionID, item.target.ref.messageID)?.type === "assistant"
? 1
: 0
if (item.screenY <= inset && (!anchor || item.screenY > anchor.screenY)) anchor = item
}
anchor ??= first
if (anchor) sessionTabs.setScrollAnchor(sessionID, anchor)
@@ -493,7 +514,7 @@ export function Session(props: {
}
function restoreScrollPosition() {
const anchor = sessionTabs.scrollAnchor(sessionID)
const index = anchor ? boundaries().indexOf(anchor.messageID) : -1
const index = anchor ? rows.findIndex((row) => containsAnchor(row, anchor.target)) : -1
if (!anchor || index === -1) {
scroll.scrollTo(scroll.scrollHeight)
setAwayFromBottom(false)
@@ -505,7 +526,7 @@ export function Session(props: {
scroll.stickyScroll = false
const restore = () =>
afterLayout(() => {
const boundary = scroll.getRenderable(anchor.messageID)
const boundary = anchors.get(anchor.target)
if (!boundary) {
sessionTabs.setScrollAnchor(sessionID, undefined)
scroll.stickyScroll = true
@@ -513,7 +534,7 @@ export function Session(props: {
setAwayFromBottom(false)
return
}
const contentY = scroll.scrollTop + boundary.y - scroll.viewport.y
const contentY = scroll.scrollTop + boundary.node.y - scroll.viewport.y
const target = contentY - anchor.screenY
const maximum = Math.max(0, scroll.scrollHeight - scroll.viewport.height)
if (target > maximum && visibleEnd() < rows.length) {
@@ -522,6 +543,18 @@ export function Session(props: {
restore()
return
}
if (target > maximum) {
setNavigationSlack(
messageNavigationSlack({
top: target,
viewportHeight: scroll.viewport.height,
scrollHeight: scroll.scrollHeight,
currentSlack: scroll.getRenderable(NAVIGATION_SLACK_ID)?.height ?? 0,
}),
)
restore()
return
}
scroll.scrollTo(target)
updateAwayFromBottom()
})
@@ -614,7 +647,7 @@ export function Session(props: {
ensureAllRows(() => {
const target = findMessageBoundary({
direction,
children: scroll.getChildren(),
children: anchors.messagePositions(),
messages: messages(),
scrollTop: scroll.scrollTop,
viewportY: scroll.viewport.y,
@@ -623,7 +656,7 @@ export function Session(props: {
})
if (target) {
alignMessage(target.id, target.top)
jumpToMessage(target.id)
dialog.clear()
return
}
@@ -636,9 +669,9 @@ export function Session(props: {
const jumpToMessage = (messageID: string) =>
ensureAllRows(() => {
const child = scroll.getRenderable(messageID)
const child = anchors.forMessage(messageID)
if (!child) return
const y = scroll.scrollTop + child.y - scroll.viewport.y
const y = scroll.scrollTop + child.node.y - scroll.viewport.y
const message = data.session.message.get(route.sessionID, messageID)
alignMessage(messageID, Math.max(0, y - (message?.type === "assistant" ? 1 : 0)))
})
@@ -1242,6 +1275,12 @@ export function Session(props: {
return (
<context.Provider
value={{
anchors,
groupExpanded: (groupID) => sessionTabs.groupExpanded(sessionID, groupID),
setGroupExpanded: (groupID, expanded) => {
sessionTabs.setGroupExpanded(sessionID, groupID, expanded)
afterLayout(saveScrollAnchor)
},
get width() {
return contentWidth()
},
@@ -1292,7 +1331,7 @@ export function Session(props: {
foregroundColor: theme.border.default,
},
}}
stickyScroll={!navigationMessage()}
stickyScroll={!navigationMessage() && !navigationSlack()}
stickyStart="bottom"
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
@@ -1428,56 +1467,66 @@ type SessionRowViewProps = {
}
function SessionRowView(props: SessionRowViewProps) {
const [target, setTarget] = createSignal<BoxRenderable>()
useEntryAnchor({
entry: () => (props.row.type === "group" ? undefined : props.row),
node: target,
})
return (
<box id={sessionRowID(props.row, props.boundaryID)} marginTop={1} flexShrink={0}>
<box ref={setTarget} id={sessionRowID(props.row, props.boundaryID)} marginTop={1} flexShrink={0}>
<Switch>
<Match when={props.row.type === "message" ? props.row : undefined}>
{(row) => (
<Show when={props.message(row().messageID)}>{(message) => <SessionMessageView message={message()} />}</Show>
)}
</Match>
<Match when={props.row.type === "compaction-queued"}>
<CompactionQueued />
</Match>
<Match when={props.row.type === "part" ? props.row : undefined}>
{(row) => <SessionPartView partRef={row().ref} message={props.message} />}
</Match>
<Match when={props.row.type === "group" && props.row.kind === "reasoning" ? props.row : undefined}>
{(row) => (
<SessionReasoningGroupView refs={groupRefs(row())} completed={row().completed} message={props.message} />
)}
</Match>
<Match when={props.row.type === "group" && props.row.kind === "exploration" ? props.row : undefined}>
<Match when={props.row.type === "group" ? props.row : undefined}>
{(row) => (
<SessionGroupView
refs={groupRefs(row())}
pending={row().pending}
completed={row().completed}
row={row()}
message={props.message}
entry={(entry, images) => <SessionEntryView row={entry} message={props.message} images={images} />}
images={(parts) => <ToolImages parts={parts} />}
/>
)}
</Match>
<Match when={props.row.type === "assistant-footer" ? props.row : undefined}>
{(row) => (
<Show when={props.message(row().messageID)}>
{(message) => (
<Show when={message().type === "assistant"}>
<AssistantFooter message={message() as SessionMessageAssistant} />
</Show>
)}
</Show>
)}
</Match>
<Match when={props.row.type === "turn-usage" ? props.row : undefined}>
{(row) => (
<TurnTokenUsage messageIDs={row().messageIDs} previousCache={row().previousCache} message={props.message} />
)}
<Match when={props.row.type !== "group" ? props.row : undefined}>
{(row) => <SessionEntryView row={row()} message={props.message} />}
</Match>
</Switch>
</box>
)
}
function SessionEntryView(props: { row: SessionEntry; message: SessionRowViewProps["message"]; images?: boolean }) {
return (
<Switch>
<Match when={props.row.type === "message" ? props.row : undefined}>
{(row) => (
<Show when={props.message(row().messageID)}>{(message) => <SessionMessageView message={message()} />}</Show>
)}
</Match>
<Match when={props.row.type === "compaction-queued"}>
<CompactionQueued />
</Match>
<Match when={props.row.type === "part" ? props.row : undefined}>
{(row) => <SessionPartView partRef={row().ref} message={props.message} images={props.images} />}
</Match>
<Match when={props.row.type === "assistant-footer" ? props.row : undefined}>
{(row) => (
<Show when={props.message(row().messageID)}>
{(message) => (
<Show when={message().type === "assistant"}>
<AssistantFooter message={message() as SessionMessageAssistant} />
</Show>
)}
</Show>
)}
</Match>
<Match when={props.row.type === "turn-usage" ? props.row : undefined}>
{(row) => (
<TurnTokenUsage messageIDs={row().messageIDs} previousCache={row().previousCache} message={props.message} />
)}
</Match>
</Switch>
)
}
function TurnTokenUsage(props: {
messageIDs: string[]
previousCache?: CacheUsage
@@ -1702,7 +1751,11 @@ function SessionMessageView(props: { message: SessionMessageInfo }) {
)
}
function SessionPartView(props: { partRef: PartRef; message: (messageID: string) => SessionMessageInfo | undefined }) {
function SessionPartView(props: {
partRef: PartRef
message: (messageID: string) => SessionMessageInfo | undefined
images?: boolean
}) {
const message = createMemo(() => props.message(props.partRef.messageID))
const part = createMemo(() => {
const item = message()
@@ -1728,7 +1781,7 @@ function SessionPartView(props: { partRef: PartRef; message: (messageID: string)
/>
</Match>
<Match when={item().type === "tool"}>
<ToolPart part={item() as SessionMessageAssistantTool} />
<ToolPart part={item() as SessionMessageAssistantTool} images={props.images} />
</Match>
</Switch>
)}
@@ -1736,198 +1789,6 @@ function SessionPartView(props: { partRef: PartRef; message: (messageID: string)
)
}
function SessionReasoningGroupView(props: {
refs: PartRef[]
completed: boolean
message: (messageID: string) => SessionMessageInfo | undefined
}) {
const ctx = use()
const theme = useTheme()
const { currentSyntax: syntax } = useThemes()
const thinkingSyntax = createSyntaxStyleMemo(() => generateThinkingSyntax(syntax(), theme.text.subdued))
const renderer = useRenderer()
const [expanded, setExpanded] = createSignal(false)
const [hover, setHover] = createSignal(false)
const parts = createMemo(() =>
props.refs.flatMap((ref) => {
const message = props.message(ref.messageID)
if (message?.type !== "assistant") return []
const part = resolvePart(message, ref.partID)
if (part?.type !== "reasoning" || !reasoningContent(part)) return []
return [{ message, part }]
}),
)
const latest = createMemo((previous: string | null) => {
const item = parts().at(-1)
if (!item) return previous
const title = reasoningSummary(reasoningContent(item.part)).title
if (title) return title
if (item.part.time?.completed !== undefined || item.message.time.completed !== undefined) return null
return previous
}, null)
const duration = createMemo(() =>
parts().reduce((total, item) => {
const start = item.part.time?.created
const end = item.part.time?.completed
return total + (start === undefined || end === undefined ? 0 : Math.max(0, end - start))
}, 0),
)
return (
<Show when={parts().length > 0}>
<Show
when={ctx.thinkingMode() === "hide"}
fallback={<For each={props.refs}>{(ref) => <SessionPartView partRef={ref} message={props.message} />}</For>}
>
<box flexDirection="column" flexShrink={0}>
<InlineToolRow
icon={expanded() ? "-" : "+"}
color={
!props.completed
? theme.text.default
: hover() || expanded()
? theme.text.feedback.warning.default
: RGBA.fromValues(
theme.text.feedback.warning.default.r,
theme.text.feedback.warning.default.g,
theme.text.feedback.warning.default.b,
0.6,
)
}
complete={props.completed}
pending={latest() ? `Thinking: ${latest()}` : "Thinking"}
spinner={!props.completed}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
setExpanded((value) => !value)
}}
>
{props.completed ? "Thought" : latest() ? `Thinking: ${latest()}` : "Thinking"}
<Show when={props.completed && !expanded() && latest()}>: {latest()}</Show>
<Show when={props.completed && parts().length > 1}> · {parts().length} steps</Show>
<Show when={props.completed && duration()}> · {Locale.duration(duration())}</Show>
</InlineToolRow>
<Show when={expanded()}>
<box paddingLeft={3}>
<For each={props.refs}>
{(ref) => {
const message = createMemo(() => {
const item = props.message(ref.messageID)
return item?.type === "assistant" ? item : undefined
})
const part = createMemo(() => {
const item = message()
if (!item) return undefined
const part = resolvePart(item, ref.partID)
return part?.type === "reasoning" ? part : undefined
})
const content = createMemo(() => {
const item = part()
return item ? reasoningContent(item) : ""
})
return (
<Show when={content()}>
<box marginTop={1}>
<box
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.raise(theme.background.surface.offset)}
paddingLeft={1}
>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={part()?.time?.completed === undefined && message()?.time.completed === undefined}
syntaxStyle={thinkingSyntax()}
content={content()}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.text.subdued}
/>
</box>
</box>
</Show>
)
}}
</For>
</box>
</Show>
</box>
</Show>
</Show>
)
}
function SessionGroupView(props: {
refs: PartRef[]
pending: PartRef[]
completed: boolean
message: (messageID: string) => SessionMessageInfo | undefined
}) {
const theme = useTheme()
const ctx = use()
const renderer = useRenderer()
const [expanded, setExpanded] = createSignal(false)
const [hover, setHover] = createSignal(false)
const parts = (refs: PartRef[]) =>
refs.flatMap((ref) => {
const message = props.message(ref.messageID)
if (message?.type !== "assistant") return []
const part = resolvePart(message, ref.partID)
if (part?.type !== "tool") return []
return [part]
})
const grouped = createMemo(() => parts(props.refs))
const pending = createMemo(() => parts(props.pending))
const completed = createMemo(
() => props.completed || (grouped().length > 0 && grouped().every((part) => part.time.completed !== undefined)),
)
const label = createMemo(() => {
const counts = grouped().reduce<Record<string, number>>((result, part) => {
const tool = toolDisplay(part.name)
const name = tool === "grep" || tool === "glob" ? "search" : tool
result[name] = (result[name] ?? 0) + 1
return result
}, {})
const tools = Object.entries(counts).map(
([name, count]) => `${count} ${count === 1 ? name : name === "search" ? "searches" : `${name}s`}`,
)
return `${completed() ? "Explored" : "Exploring"}${tools.join(", ")}`
})
return (
<Show when={grouped().length > 0 || pending().length > 0}>
<Show
when={ctx.groupExploration()}
fallback={<For each={[...grouped(), ...pending()]}>{(part) => <ToolPart part={part} />}</For>}
>
<Show when={grouped().length > 0}>
<InlineToolRow
icon={completed() ? "→" : "✱"}
color={hover() ? theme.text.default : theme.text.subdued}
complete={completed()}
pending={label()}
spinner={!completed()}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
setExpanded((value) => !value)
}}
>
{label()}
</InlineToolRow>
</Show>
<Show when={expanded() && grouped().length > 0}>
<For each={grouped()}>{(part) => <ToolPart part={part} images={false} />}</For>
</Show>
<ToolImages parts={grouped()} />
<For each={pending()}>{(part) => <ToolPart part={part} />}</For>
</Show>
</Show>
)
}
function AssistantFooter(props: { message: SessionMessageAssistant }) {
const ctx = use()
const config = useConfig()
@@ -3552,32 +3413,16 @@ function stringValue(value: unknown) {
return typeof value === "string" ? value : undefined
}
const toolDisplays = new Set([
"shell",
"glob",
"read",
"grep",
"webfetch",
"websearch",
"write",
"edit",
"subagent",
"execute",
"patch",
"question",
"skill",
])
export function toolDisplay(tool: string) {
const normalized = canonicalToolName(tool)
return toolDisplays.has(normalized) ? normalized : "generic"
}
function recordValue(value: unknown): Record<string, unknown> | undefined {
return isRecord(value) ? value : undefined
}
function formatSessionTranscript(session: SessionInfo, messages: SessionMessageInfo[], thinking: boolean, tools = true) {
function formatSessionTranscript(
session: SessionInfo,
messages: SessionMessageInfo[],
thinking: boolean,
tools = true,
) {
const body = messages.flatMap((message) => {
if (message.type === "user") return [`## User\n\n${message.text}`]
if (message.type === "shell")
@@ -14,9 +14,31 @@ import { SplitBorder } from "../../ui/border"
import { Locale } from "../../util/locale"
import { use } from "./render-context"
import { generateThinkingSyntax } from "./thinking-syntax"
import { canonicalToolName } from "../../util/tool-display"
export const INLINE_TOOL_ICON_WIDTH = 2
const toolDisplays = new Set([
"shell",
"glob",
"read",
"grep",
"webfetch",
"websearch",
"write",
"edit",
"subagent",
"execute",
"patch",
"question",
"skill",
])
export function toolDisplay(tool: string) {
const normalized = canonicalToolName(tool)
return toolDisplays.has(normalized) ? normalized : "generic"
}
export function ReasoningPart(props: {
last: boolean
part: SessionMessageAssistantReasoning
@@ -3,6 +3,7 @@ import type { ModelInfo } from "@opencode/client"
import type { SessionInbox } from "@opencode/schema/session-inbox"
import type { useConfig } from "../../config"
import type { ThinkingMode } from "../../context/thinking"
import type { createTimelineAnchors } from "./anchors"
export type PendingAction = "steer" | "queue" | "cancel"
@@ -16,6 +17,9 @@ export const context = createContext<{
*/
terminal: { width: number; height: number }
sessionID: string
anchors: ReturnType<typeof createTimelineAnchors>
groupExpanded: (groupID: string) => boolean | undefined
setGroupExpanded: (groupID: string, expanded: boolean) => void
thinkingMode: () => ThinkingMode
markdownMode: () => "source" | "rendered"
groupExploration: () => boolean
+77
View File
@@ -0,0 +1,77 @@
import { expect, test } from "bun:test"
import {
anchorKey,
containsAnchor,
createTimelineAnchors,
entryRef,
groupID,
type AnchorTarget,
} from "../../../src/routes/session/anchors"
import { groupEntries } from "../../../src/routes/session/grouping/tree"
import type { SessionEntry } from "../../../src/routes/session/grouping/session"
test("part anchors identify the exact part and whole messages have a body reference", () => {
const a: AnchorTarget = { type: "part", ref: { messageID: "a", partID: "reasoning:0" } }
const b: AnchorTarget = { type: "part", ref: { messageID: "b", partID: "reasoning:0" } }
const later: AnchorTarget = { type: "part", ref: { messageID: "a", partID: "reasoning:1" } }
expect(new Set([a, b, later].map(anchorKey)).size).toBe(3)
expect(entryRef({ type: "message", messageID: "user" })).toEqual({ messageID: "user", partID: "message" })
const entry: SessionEntry = { type: "part", ref: { messageID: "a", partID: "read" } }
const saved = entryRef(entry)
entry.ref.partID = "replacement"
expect(saved?.partID).toBe("read")
})
test("group IDs use the first descendant reference, kind and nesting level", () => {
const a: SessionEntry = { type: "part", ref: { messageID: "a", partID: "read" } }
const b: SessionEntry = { type: "part", ref: { messageID: "b", partID: "read" } }
const [root] = groupEntries([a], () => ["exploration", "exploration"] as const)
const [appended] = groupEntries([a, b], () => ["exploration", "exploration"] as const)
const [prepended] = groupEntries([b, a], () => ["exploration", "exploration"] as const)
if (root.type !== "group" || appended.type !== "group" || prepended.type !== "group")
throw new Error("Expected groups")
const inner = root.children[0]
if (inner.type !== "group") throw new Error("Expected inner group")
expect(groupID(root, 0)).toBe(groupID(appended, 0))
expect(groupID(root, 0)).not.toBe(groupID(prepended, 0))
expect(groupID(root, 0)).not.toBe(groupID(inner, 1))
expect(groupID(root, 0)).not.toBe(groupID({ ...root, kind: "reasoning" }, 0))
const id = groupID(inner, 1)
if (!id) throw new Error("Missing group ID")
expect(containsAnchor(root, { type: "group", groupID: id })).toBe(true)
expect(containsAnchor(root, { type: "part", ref: b.ref })).toBe(false)
})
test("mounted headers and parts are independent targets with current geometry", () => {
const anchors = createTimelineAnchors()
const part: AnchorTarget = { type: "part", ref: { messageID: "a", partID: "read" } }
const group: AnchorTarget = { type: "group", groupID: "group-a" }
const header = { y: 2, height: 1, isDestroyed: false }
const node = { y: 8, height: 1, isDestroyed: false }
anchors.register({ target: group, node: header })
expect(anchors.get(part)).toBeUndefined()
const remove = anchors.register({ target: part, node })
expect(anchors.get(group)?.node).toBe(header)
expect(anchors.get(part)?.node).toBe(node)
node.y = -4
expect(anchors.list()[0].target).toEqual(part)
expect(anchors.messagePositions()).toEqual([{ id: "a", y: -4 }])
remove()
expect(anchors.get(part)).toBeUndefined()
expect(anchors.get(group)?.node).toBe(header)
})
test("cleanup cannot remove a replacement registration", () => {
const anchors = createTimelineAnchors()
const target: AnchorTarget = { type: "part", ref: { messageID: "a", partID: "text:0" } }
const remove = anchors.register({ target, node: { y: 0, height: 1, isDestroyed: false } })
const node = { y: 3, height: 1, isDestroyed: false }
anchors.register({ target, node })
remove()
expect(anchors.get(target)?.node).toBe(node)
node.height = 0
expect(anchors.get(target)).toBeUndefined()
node.height = 1
node.isDestroyed = true
expect(anchors.get(target)).toBeUndefined()
})
@@ -0,0 +1,184 @@
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { createStore, reconcile } from "solid-js/store"
import { addDefaultParsers, type TextRenderable } from "@opentui/core"
import parsers from "../../../src/parsers-config"
import type { SessionMessageAssistant } from "@opencode/client"
import { ConfigProvider } from "../../../src/config"
import { ThemeProvider } from "../../../src/context/theme"
import { SessionGroupView } from "../../../src/routes/session/group-view"
import { createTimelineAnchors, groupID, type AnchorTarget } from "../../../src/routes/session/anchors"
import { context } from "../../../src/routes/session/render-context"
import type { SessionGroup } from "../../../src/routes/session/grouping/session"
import { emptyThemeSource } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
test("retains nested expansion state and registers exact headers and parts", async () => {
addDefaultParsers(parsers.parsers)
const anchors = createTimelineAnchors()
const [expanded, setExpanded] = createStore<Record<string, boolean>>({})
const config = createTuiResolvedConfig({ animations: false })
const messages = new Map<string, SessionMessageAssistant>(
["a", "b"].map((id) => [
id,
{
id,
type: "assistant",
agent: "build",
model: { providerID: "fixture", id: "fixture" },
time: { created: 0, completed: 2 },
content: [
{
type: "tool",
id: `read-${id}`,
name: "read",
time: { created: 0, completed: 2 },
state: { status: "completed", input: { path: id }, content: [{ type: "text", text: id }], metadata: {} },
},
{ type: "reasoning", text: "**Reset title**\n\nReset thought body", time: { created: 0, completed: 2 } },
],
},
]),
)
const [row, setRow] = createStore<SessionGroup>({
type: "group",
kind: "exploration",
size: 2,
completed: true,
pending: [],
children: [
{
type: "group",
kind: "exploration",
size: 2,
children: [
{ type: "entry", size: 1, entry: { type: "part", ref: { messageID: "a", partID: "read-a" } } },
{ type: "entry", size: 1, entry: { type: "part", ref: { messageID: "b", partID: "read-b" } } },
],
},
],
})
let target: TextRenderable | undefined
const app = await testRender(
() => (
<TestTuiContexts>
<ConfigProvider config={config}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<context.Provider
value={{
width: 40,
terminal: { width: 40, height: 24 },
sessionID: "fixture",
anchors,
groupExpanded: (id) => expanded[id],
setGroupExpanded: (id, value) => setExpanded(id, value),
thinkingMode: () => "hide",
markdownMode: () => "rendered",
groupExploration: () => true,
diffWrapMode: () => "word",
models: () => [],
messageIndex: () => undefined,
config,
mutatePending: async () => true,
pendingDelivery: () => undefined,
}}
>
<box paddingTop={2}>
<SessionGroupView
row={row}
message={(id) => messages.get(id)}
images={() => <text>Image previews</text>}
entry={(entry) =>
entry.type === "part" && entry.ref.messageID === "b" ? (
<text ref={(node) => (target = node)}>Target B</text>
) : (
<text>A wrapped entry with enough text to occupy more than one terminal line</text>
)
}
/>
</box>
</context.Provider>
</ThemeProvider>
</ConfigProvider>
</TestTuiContexts>
),
{ width: 40, height: 24 },
)
app.renderer.start()
const outerID = groupID(row, 0)
const inner = row.children[0]
if (inner.type !== "group") throw new Error("Missing nested group")
const innerID = groupID(inner, 1)
if (!outerID || !innerID) throw new Error("Missing group IDs")
const outer: AnchorTarget = { type: "group", groupID: outerID }
const nested: AnchorTarget = { type: "group", groupID: innerID }
const a: AnchorTarget = { type: "part", ref: { messageID: "a", partID: "read-a" } }
const b: AnchorTarget = { type: "part", ref: { messageID: "b", partID: "read-b" } }
try {
await app.waitForFrame((frame) => frame.includes("Explored"))
expect(app.captureCharFrame()).not.toContain("Target B")
expect(anchors.get(b)).toBeUndefined()
expect(anchors.get(nested)).toBeUndefined()
await app.mockMouse.click(4, anchors.get(outer)?.node.y ?? -1)
await app.renderOnce()
expect(app.captureCharFrame()).not.toContain("Target B")
expect(expanded[outerID]).toBe(true)
expect(anchors.get(outer)).toBeDefined()
await app.mockMouse.click(4, anchors.get(nested)?.node.y ?? -1)
await app.renderOnce()
expect(app.captureCharFrame()).toContain("Target B")
expect(expanded[innerID]).toBe(true)
expect(anchors.get(b)?.node.y).toBe(target?.y)
expect(anchors.get(b)?.node.y).toBeGreaterThan(anchors.get(a)?.node.y ?? Infinity)
expect(anchors.get(nested)).toBeDefined()
expect(app.captureCharFrame().match(/Image previews/g)?.length).toBe(1)
setExpanded(outerID, false)
await app.renderOnce()
expect(app.captureCharFrame()).not.toContain("Target B")
expect(anchors.get(b)).toBeUndefined()
expect(anchors.get(outer)).toBeDefined()
expect(expanded[innerID]).toBe(true)
setExpanded(outerID, true)
await app.renderOnce()
setRow(
reconcile({
type: "group",
kind: "exploration",
size: 2,
completed: true,
pending: [],
children: [
{ type: "entry", size: 1, entry: { type: "part", ref: { messageID: "a", partID: "read-a" } } },
{ type: "entry", size: 1, entry: { type: "part", ref: { messageID: "b", partID: "read-b" } } },
],
}),
)
await app.renderOnce()
expect(app.captureCharFrame()).toContain("Target B")
expect(anchors.get(b)?.node.y).toBe(target?.y)
setRow(
reconcile({
type: "group",
kind: "reasoning",
size: 2,
completed: true,
children: [
{ type: "entry", size: 1, entry: { type: "part", ref: { messageID: "a", partID: "reasoning:0" } } },
{ type: "entry", size: 1, entry: { type: "part", ref: { messageID: "b", partID: "reasoning:0" } } },
],
}),
)
await app.renderOnce()
expect(app.captureCharFrame()).toContain("Thought")
expect(app.captureCharFrame()).not.toContain("Reset thought body")
const thinkingID = groupID(row, 0)
if (!thinkingID) throw new Error("Missing thinking group ID")
setExpanded(thinkingID, true)
await app.waitForFrame((frame) => frame.includes("Reset thought body"))
expect(app.captureCharFrame()).toContain("Reset thought body")
} finally {
app.renderer.destroy()
}
expect(anchors.list()).toEqual([])
})
@@ -592,13 +592,21 @@ test("keeps scroll anchors for open session tabs", async () => {
try {
await wait(() => setup.tabs.current() === "first")
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "first"))
setup.tabs.setScrollAnchor("first", { messageID: "msg_1", screenY: -3 })
expect(setup.tabs.scrollAnchor("first")).toEqual({ messageID: "msg_1", screenY: -3 })
const target = { type: "part" as const, ref: { messageID: "msg_1", partID: "text:0" } }
setup.tabs.setScrollAnchor("first", { target, screenY: -3 })
expect(setup.tabs.scrollAnchor("first")).toEqual({ target, screenY: -3 })
const group = { type: "group" as const, groupID: "group-1" }
setup.tabs.setScrollAnchor("first", { target: group, screenY: -3 })
expect(setup.tabs.scrollAnchor("first")?.target).toEqual(group)
setup.tabs.setGroupExpanded("first", group.groupID, true)
expect(setup.tabs.groupExpanded("first", group.groupID)).toBe(true)
setup.tabs.setGroupExpanded("first", group.groupID, false)
expect(setup.tabs.groupExpanded("first", group.groupID)).toBe(false)
setup.tabs.close("first")
await wait(() => setup.tabs.tabs().every((tab) => tab.sessionID !== "first"))
expect(setup.tabs.scrollAnchor("first")).toBeUndefined()
expect(setup.tabs.groupExpanded("first", group.groupID)).toBeUndefined()
} finally {
await setup.destroy()
}
@@ -612,8 +620,11 @@ test("keeps parent and subagent scroll anchors independent", async () => {
try {
await wait(() => setup.data.session.get("child") !== undefined)
const parent = { messageID: "msg_parent", screenY: -3 }
const child = { messageID: "msg_child", screenY: -5 }
const parent = {
target: { type: "part" as const, ref: { messageID: "msg_parent", partID: "message" } },
screenY: -3,
}
const child = { target: { type: "part" as const, ref: { messageID: "msg_child", partID: "message" } }, screenY: -5 }
setup.tabs.setScrollAnchor("root", parent)
// A short subagent transcript is at the bottom, so it saves no anchor.
@@ -0,0 +1,249 @@
import { expect, test } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import { ScrollBoxRenderable, type Renderable } from "@opentui/core"
import { Effect, FileSystem } from "effect"
import { Global } from "@opencode/util/global"
import type { SessionMessageInfo } from "@opencode/client"
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
import { mkdir } from "node:fs/promises"
test.each([
[48, 30],
[80, 30],
[120, 30],
[80, 0],
])(
"restores exact part/group anchors at width %s with %s trailing lines",
async (width, lines) => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width, height: 30, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const session = {
id: "ses_group_navigation",
title: "Grouped navigation",
projectID: "proj_test",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
const other = { ...session, id: "ses_other", title: "Other session" }
await mkdir(`${state.path}/test/tui`, { recursive: true })
await Bun.write(
`${state.path}/test/tui/tabs.json`,
JSON.stringify({
global: { tabs: [{ sessionID: session.id }, { sessionID: other.id }], unread: {} },
cwd: {},
}),
)
const messages: SessionMessageInfo[] = [
{ type: "user", id: "msg_user", text: "User prompt", time: { created: 0 } },
{
type: "assistant",
id: "msg_a",
agent: "build",
model: { providerID: "fixture", id: "fixture" },
time: { created: 1, completed: 3 },
content: [
{ type: "text", text: "First response" },
{
type: "reasoning",
text: "**First title**\n\nFirst thought body with enough text to wrap differently at each tested terminal width, changing the target's measured offset.",
time: { created: 1, completed: 2 },
},
],
},
{
type: "assistant",
id: "msg_b",
agent: "build",
model: { providerID: "fixture", id: "fixture" },
time: { created: 4, completed: 6 },
finish: "stop",
content: [
{ type: "reasoning", text: "**Second title**\n\nSecond thought body", time: { created: 4, completed: 5 } },
{ type: "text", text: `Second response\n${"A later line of the response.\n".repeat(lines)}Final marker` },
],
},
]
const calls = createFetch((url) => {
if (url.pathname === "/api/session") return json({ data: [session, other], cursor: {} })
if (url.pathname === `/api/session/${other.id}`) return json({ data: other })
if (url.pathname === `/api/session/${other.id}/message`)
return json({
data: [{ type: "user", id: "msg_other", text: "Other session content", time: { created: 0 } }],
cursor: {},
})
if (url.pathname === `/api/session/${other.id}/inbox` || url.pathname === `/api/session/${other.id}/permission`)
return json({ data: [] })
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
if (url.pathname === `/api/session/${session.id}/message`)
return json({ data: messages.toReversed(), cursor: {} })
if (
url.pathname === `/api/session/${session.id}/inbox` ||
url.pathname === `/api/session/${session.id}/permission`
)
return json({ data: [] })
}, createEventStream())
const server = Bun.serve({ port: 0, idleTimeout: 0, fetch: (request) => calls.fetch(request) })
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: {
get: async () => ({
animations: false,
tabs: { enabled: true, scope: "global" },
keybinds: { "session.messages_last_user": "ctrl+shift+u", "session.message.next": "ctrl+shift+n" },
}),
update: async () => ({}),
},
packages: { prepare: async () => ({ directory: "" }) },
args: { sessionID: session.id },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
log: () => {},
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
try {
await setup.waitForFrame((frame) => frame.includes("Final marker"))
expect(setup.captureCharFrame()).not.toContain("Second thought body")
setup.mockInput.pressKey("u", { ctrl: true, shift: true })
await setup.waitForFrame((frame) => frame.includes("Thought:"))
await setup.waitForVisualIdle({ quietFrames: 3 })
const find = (node: Renderable): ScrollBoxRenderable | undefined =>
node instanceof ScrollBoxRenderable && node.getRenderable("msg_b")
? node
: node.getChildren().map(find).find(Boolean)
const initial = find(setup.renderer.root)
if (!initial) throw new Error("Missing transcript scrollbox")
const summaryLine = setup
.captureCharFrame()
.split("\n")
.findIndex((line) => line.includes("Thought:"))
initial.scrollTo(initial.scrollTop + summaryLine - initial.viewport.y)
await setup.waitForVisualIdle({ quietFrames: 3 })
const summaryOffset =
setup
.captureCharFrame()
.split("\n")
.findIndex((line) => line.includes("Thought:")) - initial.viewport.y
if (lines > 0) expect(summaryOffset).toBe(0)
setup.mockInput.pressKey("2", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Other session content"))
setup.mockInput.pressKey("1", { ctrl: true })
await setup.waitForFrame((frame) => {
const viewport = find(setup.renderer.root)
return (
!!viewport &&
frame.split("\n").findIndex((line) => line.includes("Thought:")) - viewport.viewport.y === summaryOffset
)
})
await setup.waitForVisualIdle({ quietFrames: 3 })
expect(setup.captureCharFrame()).not.toContain("Second thought body")
const summary = find(setup.renderer.root)
if (!summary) throw new Error("Missing restored summary viewport")
expect(
setup
.captureCharFrame()
.split("\n")
.findIndex((line) => line.includes("Thought:")) - summary.viewport.y,
).toBe(summaryOffset)
setup.mockInput.pressKey("u", { ctrl: true, shift: true })
await setup.waitForVisualIdle({ quietFrames: 3 })
setup.mockInput.pressKey("n", { ctrl: true, shift: true })
await setup.waitForVisualIdle({ quietFrames: 3 })
setup.mockInput.pressKey("n", { ctrl: true, shift: true })
await setup.waitForFrame((frame) => frame.includes("Second response"))
await setup.waitForVisualIdle({ quietFrames: 3 })
expect(setup.captureCharFrame()).not.toContain("Second thought body")
setup.mockInput.pressKey("u", { ctrl: true, shift: true })
await setup.waitForFrame((frame) => frame.includes("Thought:"))
await setup.waitForVisualIdle({ quietFrames: 3 })
const header = setup
.captureCharFrame()
.split("\n")
.findIndex((line) => line.includes("Thought:"))
await setup.mockMouse.click(8, header)
await setup.waitForFrame((frame) => frame.includes("First thought body"))
setup.mockInput.pressKey("u", { ctrl: true, shift: true })
await setup.waitForVisualIdle({ quietFrames: 3 })
setup.mockInput.pressKey("n", { ctrl: true, shift: true })
await setup.waitForVisualIdle({ quietFrames: 3 })
setup.mockInput.pressKey("n", { ctrl: true, shift: true })
await setup.waitForFrame((frame) => frame.includes("Second thought body"))
await setup.waitForVisualIdle({ quietFrames: 3 })
const scroll = find(setup.renderer.root)
if (!scroll) throw new Error("Missing transcript scrollbox")
const titleLine = setup
.captureCharFrame()
.split("\n")
.findIndex((line) => line.includes("Second title"))
expect(titleLine - scroll.viewport.y).toBe(1)
expect(scroll.scrollTop).toBeGreaterThan(0)
setup.mockInput.pressKey("2", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Other session content"))
setup.mockInput.pressKey("1", { ctrl: true })
await setup.waitForFrame((frame) => {
const viewport = find(setup.renderer.root)
return (
!!viewport && frame.split("\n").findIndex((line) => line.includes("Second title")) - viewport.viewport.y === 1
)
})
await setup.waitForVisualIdle({ quietFrames: 3 })
const restored = find(setup.renderer.root)
if (!restored) throw new Error("Missing restored transcript")
const restoredTitle = setup
.captureCharFrame()
.split("\n")
.findIndex((line) => line.includes("Second title"))
expect(restoredTitle - restored.viewport.y).toBe(1)
if (lines === 0) return
// Save inside A's second part, not relative to A's earlier text part.
setup.mockInput.pressKey("u", { ctrl: true, shift: true })
await setup.waitForFrame((frame) => frame.includes("First title"))
await setup.waitForVisualIdle({ quietFrames: 3 })
const reading = find(setup.renderer.root)
if (!reading) throw new Error("Missing reading viewport")
const firstTitle = setup
.captureCharFrame()
.split("\n")
.findIndex((line) => line.includes("First title"))
reading.scrollTo(reading.scrollTop + firstTitle - reading.viewport.y + 2)
await setup.waitForVisualIdle({ quietFrames: 3 })
const bodyOffset =
setup
.captureCharFrame()
.split("\n")
.findIndex((line) => line.includes("First thought body")) - reading.viewport.y
expect(bodyOffset).toBe(0)
setup.mockInput.pressKey("2", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Other session content"))
setup.mockInput.pressKey("1", { ctrl: true })
await setup.waitForFrame((frame) => {
const viewport = find(setup.renderer.root)
return (
!!viewport &&
frame.split("\n").findIndex((line) => line.includes("First thought body")) - viewport.viewport.y ===
bodyOffset
)
})
await setup.waitForVisualIdle({ quietFrames: 3 })
const final = find(setup.renderer.root)
if (!final) throw new Error("Missing final viewport")
expect(
setup
.captureCharFrame()
.split("\n")
.findIndex((line) => line.includes("First thought body")) - final.viewport.y,
).toBe(bodyOffset)
} finally {
setup.renderer.destroy()
await task
await server.stop()
}
},
15000,
)
@@ -1099,7 +1099,7 @@ effect: (ctx) =>
const session = ctx.session
yield* session.hook("context", (event) =>
Effect.sync(() => {
event.system.push({ type: "text", text: "Keep the review focused on correctness." })
event.system.push({ text: "Keep the review focused on correctness." })
delete event.tools.write
}),
)
@@ -1222,7 +1222,7 @@ differently:
```ts
await ctx.session.hook("context", (event) => {
event.system.push({ type: "text", text: "Keep the review focused on correctness." })
event.system.push({ text: "Keep the review focused on correctness." })
delete event.tools.write
event.options.temperature = 0.2
event.options.maxTokens = 8_000