mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-12 11:56:23 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b087728684 | ||
|
|
4073d07a69 |
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)] })
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user