Compare commits

..
1 Commits
Author SHA1 Message Date
Aiden Cline 0c553a08b7 fix(core): preserve structured compaction tails 2026-09-02 11:41:01 -05:00
323 changed files with 4124 additions and 6558 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
- uses: ./.github/actions/setup-bun
- name: Deploy update service
if: github.ref_name == 'v2'
if: github.ref_name == 'v2' || github.ref_name == 'beta'
working-directory: packages/updates
run: bun run deploy
env:
+4 -2
View File
@@ -575,7 +575,6 @@
"@opencode-ai/client": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
"zod": "catalog:",
@@ -586,7 +585,6 @@
"@opentui/solid": "catalog:",
"@tsconfig/bun": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"solid-js": "catalog:",
@@ -973,6 +971,7 @@
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
@@ -1124,6 +1123,7 @@
"opentui-spinner": "0.0.7",
"remeda": "2.26.0",
"remend": "1.3.1",
"resolve.exports": "2.0.3",
"semver": "7.7.4",
"shiki": "4.4.3",
"solid-js": "1.9.15",
@@ -5171,6 +5171,8 @@
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="],
"responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
"restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-j3Vtfd5sx+hHnV3qHBq7Ae96E08meNqTqtYN9kWmCqE=",
"aarch64-linux": "sha256-LCvHEYoDgvkXtFFs0u7XBEzyo8fGGX25N8iuLrHSu8I=",
"aarch64-darwin": "sha256-wBRx1e8x6toEHV1sFMDDs26+s8IHyPAqCg6TcBgsxxA=",
"x86_64-darwin": "sha256-ClVls/CTpT76W5SQ/cij2YFmBqv9FGLeVxQh8iStnEk="
"x86_64-linux": "sha256-Vazzj75ji5YbLtX5X0q+IZvygGrfmEi3+PzJsAkaa3s=",
"aarch64-linux": "sha256-QFKzH7wlRcvYG4EHQgQ+++9zNQ+Mw5DVKembGiMjiCg=",
"aarch64-darwin": "sha256-WAYBBA2jLW95wM8Dgk77KsO3yS5efxQs9oBVAcdx8Gc=",
"x86_64-darwin": "sha256-c9P+VQvfdOu5ef6BnKXb3/IUo8pPFaYQv8B+ijofm1s="
}
}
+2
View File
@@ -27,6 +27,7 @@
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
"postinstall": "bun run --cwd packages/core fix-node-pty",
"prepare": "husky",
"reserve-packages": "bun script/reserve-package-names.ts",
"random": "echo 'Random script'",
"sso": "aws sso login --sso-session=opencode --no-browser",
"test": "echo 'do not run tests from root' && exit 1"
@@ -92,6 +93,7 @@
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"zod": "4.1.8",
"remeda": "2.26.0",
"resolve.exports": "2.0.3",
"sst": "4.13.1",
"shiki": "4.4.3",
"solid-list": "0.3.0",
+86 -84
View File
@@ -304,11 +304,6 @@ export const OpenResponsesUsage = Schema.Struct({
})
type OpenResponsesUsage = Schema.Schema.Type<typeof OpenResponsesUsage>
// The spec requires `id` on every output item, but some gateways drop it from
// later item events (Bedrock Mantle renames it to `item_id` on
// `output_item.done` and `response.completed.output`). Decode it as optional
// and let `normalize` recover or mint it once before the parser runs.
// https://www.openresponses.org/specification#extending-items
export const StreamItem = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
@@ -321,7 +316,6 @@ export const StreamItem = Schema.StructWithRest(
[Schema.Record(Schema.String, Schema.Unknown)],
)
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
export type OutputItem = StreamItem & { readonly id: string }
// The Responses schema puts streaming error details at the top level and
// response failures under `response.error`. WebSocket failures use an
@@ -401,7 +395,6 @@ export const Event = Schema.StructWithRest(
[Schema.Record(Schema.String, Schema.Unknown)],
)
export type Event = Schema.Schema.Type<typeof Event>
export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
export interface ProviderAdapter {
readonly id: string
@@ -423,13 +416,14 @@ export interface ParserState {
readonly name: string
readonly providerMetadataKey: string
readonly tools: ToolStream.State<string>
// Item ids are response-scoped identities. Keep completed ids tombstoned so
// reconnect replay cannot reopen fragments already emitted downstream.
// Call ids stay independent of item ids, which may be omitted or reused.
readonly completedTools: ReadonlySet<string>
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
readonly outputItems: Readonly<Record<number, string>>
readonly message: { readonly id: string; readonly phase: MessagePhase | null | undefined } | undefined
// Item ids are response-scoped identities. Keep completed ids tombstoned so
// reconnect replay cannot reopen fragments already emitted downstream.
readonly completedMessages: ReadonlySet<string>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
}
@@ -881,6 +875,9 @@ export const providerMetadata = (state: ParserState, metadata: Record<string, un
[state.providerMetadataKey]: metadata,
})
const isReasoningItem = (item: StreamItem): item is StreamItem & { type: "reasoning"; id: string } =>
item.type === "reasoning" && typeof item.id === "string"
export type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
const NO_EVENTS: StepResult["1"] = []
@@ -924,34 +921,9 @@ const joinReasoningText = (parts: ReadonlyArray<string | undefined>) => {
return parts.filter((part) => part !== undefined).join("\n\n")
}
const outputItemID = (state: ParserState, event: Event) =>
export const outputItemID = (state: ParserState, event: Event) =>
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
const ITEM_ID_PREFIX: Readonly<Record<string, string>> = {
message: "msg",
reasoning: "rs",
function_call: "fc",
compaction: "cmp",
}
// An item without an id adopts the id already open in its output slot,
// otherwise it gets a locally minted one.
const resolveItem = (state: ParserState, item: StreamItem, index: number | undefined): OutputItem => ({
...item,
id:
item.id ??
(index === undefined ? undefined : state.outputItems[index]) ??
`${ITEM_ID_PREFIX[item.type] ?? "item"}_${crypto.randomUUID().replaceAll("-", "")}`,
})
// Registered output slots are authoritative for `item_id` routing, and items
// are resolved here so everything downstream can rely on `item.id`.
export const normalize = (state: ParserState, input: Event): NormalizedEvent => ({
...input,
item_id: input.item_id === undefined ? undefined : outputItemID(state, input),
item: input.item ? resolveItem(state, input.item, input.output_index) : input.item,
})
const startReasoningSummaryPart = (state: ParserState, itemID: string, index: number): StepResult => {
const item = state.reasoningItems[itemID]
if (!item?.open || index === 0 || item.summaryParts[index] !== undefined) return [state, NO_EVENTS]
@@ -1025,7 +997,7 @@ export const onReasoningDone = (state: ParserState, event: Event, itemID: string
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
}
const reasoningMetadata = (state: ParserState, item: OutputItem) =>
const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }) =>
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
// Responses APIs normally stream reasoning items in this order:
@@ -1038,18 +1010,18 @@ const reasoningMetadata = (state: ParserState, item: OutputItem) =>
// `onOutputItemAdded` seeds the per-item entry, while each later part start is
// also an implicit boundary for the previous part. This keeps the common event
// lifecycle ordered when a compatible provider omits or delays a part-done event.
const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResult => {
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const item = event.item
if (!item) return [state, NO_EVENTS]
if (item.type === "message") {
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS]
if (item?.type === "message" && item.id !== undefined) {
const itemID = item.id
if (state.completedMessages.has(itemID)) return [state, NO_EVENTS]
const phase = messagePhase(item.phase)
const completedMessages = new Set(state.completedMessages)
if (state.message !== undefined && state.message.id !== item.id) completedMessages.add(state.message.id)
if (state.message !== undefined && state.message.id !== itemID) completedMessages.add(state.message.id)
// A new message closes earlier messages, including ones that never streamed.
const events: LLMEvent[] = []
const lifecycle = [...state.lifecycle.text]
.filter((id) => id !== item.id)
.filter((id) => id !== itemID)
.reduce((lifecycle, id) => {
completedMessages.add(id)
const openPhase = state.message?.id === id ? state.message.phase : undefined
@@ -1066,14 +1038,14 @@ const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResu
lifecycle,
completedMessages,
message: {
id: item.id,
phase: phase === undefined && state.message?.id === item.id ? state.message.phase : phase,
id: itemID,
phase: phase === undefined && state.message?.id === itemID ? state.message.phase : phase,
},
},
events,
]
}
if (item.type === "reasoning") {
if (item && isReasoningItem(item)) {
if (state.reasoningItems[item.id] !== undefined) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [
@@ -1093,16 +1065,18 @@ const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResu
events,
]
}
if (item.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
if (state.tools[item.id] !== undefined || state.completedTools.has(item.id)) return [state, NO_EVENTS]
const metadata = providerMetadata(state, { itemId: item.id })
if (item?.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
const id = item.id ?? item.call_id
if (Object.values(state.tools).some((tool) => tool?.id === item.call_id) || state.completedTools.has(item.call_id))
return [state, NO_EVENTS]
const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
return [
{
...state,
lifecycle,
tools: ToolStream.start(state.tools, item.id, {
tools: ToolStream.start(state.tools, id, {
id: item.call_id,
name: item.name ?? "",
input: item.arguments ?? "",
@@ -1174,13 +1148,13 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
state: ParserState,
item: NormalizedEvent["item"],
item: Event["item"],
) {
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "compaction") {
if (typeof item.encrypted_content !== "string")
return yield* ProviderShared.eventError(state.id, "Compaction output is missing its encrypted content")
if (!item.id || typeof item.encrypted_content !== "string")
return yield* ProviderShared.eventError(state.id, "Compaction output is missing its id or encrypted content")
if (state.completedCompactions.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
@@ -1197,7 +1171,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
] satisfies StepResult
}
if (item.type === "message") {
if (item.type === "message" && item.id !== undefined) {
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const completedMessages = new Set(state.completedMessages)
completedMessages.add(item.id)
@@ -1230,23 +1204,36 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (item.type === "function_call") {
if (!item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
if (state.completedTools.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const metadata = providerMetadata(state, { itemId: item.id })
const registered = state.tools[item.id] !== undefined
const tools = registered
? state.tools
: ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name, providerMetadata: metadata })
const callID = item.call_id
if (state.completedTools.has(callID)) return [state, NO_EVENTS] satisfies StepResult
const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined
const fallback = item.id ?? callID
// Match the pending tool by call id so item events that disagree on
// whether `item.id` is present still resolve the same call.
const registered =
state.tools[fallback] !== undefined
? fallback
: Object.keys(state.tools).find((key) => state.tools[key]?.id === callID)
const id = registered ?? fallback
const tools =
registered !== undefined
? state.tools
: ToolStream.start(state.tools, id, {
id: callID,
name: item.name,
providerMetadata: metadata,
})
const result =
item.arguments === undefined
? yield* ToolStream.finish(state.id, tools, item.id)
: yield* ToolStream.finishWithInput(state.id, tools, item.id, item.arguments)
? yield* ToolStream.finish(state.id, tools, id)
: yield* ToolStream.finishWithInput(state.id, tools, id, item.arguments)
const events: LLMEvent[] = []
const finished = result.events ?? []
// A done-only call never streamed a start event, so open its lifecycle here.
const resultEvents =
registered || finished.length === 0
registered !== undefined || finished.length === 0
? finished
: [LLMEvent.toolInputStart({ id: item.call_id, name: item.name, providerMetadata: metadata }), ...finished]
: [LLMEvent.toolInputStart({ id: callID, name: item.name, providerMetadata: metadata }), ...finished]
const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
events.push(...resultEvents)
return [
@@ -1257,13 +1244,13 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
state.hasFunctionCall,
tools: result.tools,
completedTools: new Set([...state.completedTools, item.id]),
completedTools: new Set([...state.completedTools, callID]),
},
events,
] satisfies StepResult
}
if (item.type === "reasoning") {
if (isReasoningItem(item)) {
if (state.reasoningItems[item.id]?.open === false) return [state, NO_EVENTS] satisfies StepResult
const metadata = reasoningMetadata(state, item)
const summaryParts: ReadonlyArray<unknown> = Array.isArray(item.summary) ? item.summary : []
@@ -1347,17 +1334,21 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
let current = state
const events: LLMEvent[] = []
if (event.type === "response.completed") {
// An output item's array position is its output index.
for (const item of (event.response?.output ?? []).map((item, index) => resolveItem(state, item, index))) {
// Terminal recovery cannot insert a checkpoint before already-emitted content.
if (item.type === "compaction" && state.lifecycle.stepStarted && !state.completedCompactions.has(item.id))
return yield* ProviderShared.eventError(
state.id,
"Cannot recover a compaction checkpoint after output has been emitted",
)
const recoverable =
item.type === "compaction" || (item.type === "function_call" && current.tools[item.id] !== undefined)
if (!recoverable) continue
for (const item of event.response?.output ?? []) {
if (item.type !== "compaction" && item.type !== "function_call") continue
if (item.type === "compaction") {
// Terminal recovery cannot insert a checkpoint before already-emitted content.
if (state.lifecycle.stepStarted && !state.completedCompactions.has(item.id ?? ""))
return yield* ProviderShared.eventError(
state.id,
"Cannot recover a compaction checkpoint after output has been emitted",
)
}
if (
item.type === "function_call" &&
(!item.call_id || !Object.values(current.tools).some((tool) => tool?.id === item.call_id))
)
continue
const [next, emitted] = yield* onOutputItemDone(current, item)
current = next
events.push(...emitted)
@@ -1424,9 +1415,12 @@ export const providerFailure = (event: Event, fallback: string, body = ProviderS
return new AIError({ reason })
}
// Callers must pass events through `normalize` first. The OpenAPI requires
// string IDs but imposes no minLength; empty is not missing.
export const step = (state: ParserState, event: NormalizedEvent) => {
export const step = (state: ParserState, input: Event) => {
// The OpenAPI requires string IDs but imposes no minLength; empty is not missing.
const event =
input.item_id !== undefined && outputItemID(state, input) !== input.item_id
? { ...input, item_id: outputItemID(state, input) }
: input
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
if (event.item_id === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(
@@ -1466,16 +1460,20 @@ export const step = (state: ParserState, event: NormalizedEvent) => {
? Effect.succeed(onReasoningSummaryPartDone(state, event))
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.output_item.added") {
if (event.item?.type === "message" && event.item.id === undefined)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
if (
event.item?.type === "reasoning" &&
event.item &&
isReasoningItem(event.item) &&
state.reasoningItems[event.item.id] === undefined &&
state.lifecycle.reasoning.size > 0
)
return ProviderShared.eventError(state.id, `${event.type} started reasoning before the previous item ended`)
const id = event.item?.id ?? (event.item?.type === "function_call" ? event.item.call_id : undefined)
return Effect.succeed(
onOutputItemAdded(
event.output_index !== undefined && event.item
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: event.item.id } }
event.output_index !== undefined && id !== undefined
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: id } }
: state,
event,
),
@@ -1485,7 +1483,11 @@ export const step = (state: ParserState, event: NormalizedEvent) => {
return event.item_id !== undefined
? onFunctionCallArgumentsDelta(state, event)
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done") return onOutputItemDone(state, event.item)
if (event.type === "response.output_item.done") {
if (event.item?.type === "message" && event.item.id === undefined)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
return onOutputItemDone(state, event.item)
}
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
if (event.type === "response.failed") return providerFailure(event, `${state.name} response failed`)
if (event.type === "error")
@@ -1535,7 +1537,7 @@ export const protocol = Protocol.make({
stream: {
event: Protocol.jsonEvent(Event),
initial,
step: (state: ParserState, event: Event) => step(state, normalize(state, event)),
step,
terminal,
},
})
@@ -201,11 +201,12 @@ const HOSTED_TOOLS = {
},
} as const satisfies ResponsesHostedTools.Definitions
const step = (state: OpenResponses.ParserState, input: OpenResponses.Event) => {
const event = OpenResponses.normalize(state, input)
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.reasoning_text.delta")
return event.item_id !== undefined
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
? Effect.succeed(
OpenResponses.onReasoningDelta(state, event, OpenResponses.outputItemID(state, event) ?? event.item_id),
)
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
@@ -3,7 +3,8 @@ import { LLMEvent, type AIError, type ToolResultPart } from "../../schema/index.
import { OpenResponses } from "../open-responses.js"
import { Lifecycle } from "./lifecycle.js"
export type Item = OpenResponses.OutputItem & {
export type Item = OpenResponses.StreamItem & {
readonly id: string
readonly status?: string
readonly action?: unknown
readonly queries?: unknown
@@ -26,8 +27,8 @@ export interface Definition {
export type Definitions = Readonly<Record<string, Definition>>
export const isItem = <Tools extends Definitions>(item: OpenResponses.OutputItem, tools: Tools): item is Item =>
item.type in tools
export const isItem = <Tools extends Definitions>(item: OpenResponses.StreamItem, tools: Tools): item is Item =>
item.type in tools && typeof item.id === "string" && item.id.length > 0
export const onDone: (
state: OpenResponses.ParserState,
+1 -2
View File
@@ -69,8 +69,7 @@ const HOSTED_TOOLS = {
// Grok speaks the standard Responses reasoning dialect (`reasoning_summary_text.*`,
// handled by the baseline); only its hosted tool vocabulary differs.
const step = (state: OpenResponses.ParserState, input: OpenResponses.Event) => {
const event = OpenResponses.normalize(state, input)
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
return OpenResponses.step(state, event)
+5 -8
View File
@@ -125,15 +125,12 @@ testEffect(
response: { output: [{ type: "compaction", encrypted_content: "opaque" }] },
}),
),
).effect("mints an id for terminal checkpoints that omit one", () =>
).effect("rejects terminal checkpoints missing an id", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
const error = yield* LLMClient.generate(
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" }),
)
const part = response.message.content[0]
expect(part?.type).toBe("compaction")
if (part?.type !== "compaction") return
expect(part.id).toMatch(/^cmp_[0-9a-f]{32}$/)
expect(part.encrypted).toBe("opaque")
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("missing its id")
}),
)
@@ -329,126 +329,69 @@ describe("Open Responses basic-item lifecycles", () => {
])
}),
)
// Captured from Bedrock Mantle (openai.gpt-oss-120b): the terminal function_call
// items rename `id` to `item_id` and carry a stray `output_index`.
it.effect("recovers a terminal function_call id from its output slot", () =>
Effect.gen(function* () {
const terminal = {
type: "function_call",
item_id: "fc_828bee50dee1d029",
call_id: "call_bc1eb4b42e70ee53",
name: "get_weather",
arguments: '{\n "city": "Paris"\n}',
output_index: 1,
status: "completed",
}
const events = yield* collect(
{
type: "response.output_item.added",
output_index: 0,
item: { type: "reasoning", id: "msg_879a68b589198b4c" },
},
{ type: "response.output_item.done", output_index: 0, item: { type: "reasoning", id: "msg_879a68b589198b4c" } },
{
type: "response.output_item.added",
output_index: 1,
item: {
type: "function_call",
id: "fc_828bee50dee1d029",
call_id: "call_bc1eb4b42e70ee53",
name: "get_weather",
arguments: "",
status: "in_progress",
;[undefined, "fc_1"].forEach((id) => {
it.effect(`opens and closes a done-only tool ${id === undefined ? "without" : "with"} an item id`, () =>
Effect.gen(function* () {
const item = {
type: "function_call",
...(id === undefined ? {} : { id }),
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
}
const events = yield* collect(
{ type: "response.output_item.done", item },
{ type: "response.output_item.done", item: { ...item, id: "fc_1" } },
{ type: "response.output_item.added", item },
completed,
)
const providerMetadata = id === undefined ? undefined : { "openai-compatible": { itemId: id } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
])
expect(events.filter(LLMEvent.is.finish)).toEqual([
{
type: "finish",
reason: { normalized: "tool-calls", raw: undefined },
providerMetadata: { "openai-compatible": { responseId: "resp_1", serviceTier: undefined } },
},
},
{
type: "response.function_call_arguments.delta",
output_index: 1,
item_id: "fc_828bee50dee1d029",
delta: '{\n "city": "Paris"\n}',
},
{
type: "response.function_call_arguments.done",
output_index: 1,
item_id: "fc_828bee50dee1d029",
arguments: '{\n "city": "Paris"\n}',
},
{ type: "response.output_item.done", output_index: 1, item: terminal },
{
type: "response.completed",
response: { id: "resp_1", output: [{ type: "reasoning", id: "msg_879a68b589198b4c" }, terminal] },
},
)
const providerMetadata = { "openai-compatible": { itemId: "fc_828bee50dee1d029" } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_bc1eb4b42e70ee53", name: "get_weather", providerMetadata },
{
type: "tool-input-delta",
id: "call_bc1eb4b42e70ee53",
name: "get_weather",
text: '{\n "city": "Paris"\n}',
input: { city: "Paris" },
},
{ type: "tool-input-end", id: "call_bc1eb4b42e70ee53", name: "get_weather", providerMetadata },
{
type: "tool-call",
id: "call_bc1eb4b42e70ee53",
name: "get_weather",
input: { city: "Paris" },
providerMetadata,
},
])
}),
)
])
}),
)
it.effect("mints an id for a done-only tool that never had one", () =>
Effect.gen(function* () {
const events = yield* collect(
{
type: "response.output_item.done",
output_index: 0,
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
},
completed,
)
const call = events.find(LLMEvent.is.toolCall)
expect(call).toMatchObject({ id: "call_1", name: "lookup", input: { query: "weather" } })
expect(call?.providerMetadata?.["openai-compatible"]).toMatchObject({
itemId: expect.stringMatching(/^fc_[0-9a-f]{32}$/),
})
}),
)
it.effect("opens and closes a done-only tool once", () =>
Effect.gen(function* () {
const item = {
type: "function_call",
id: "fc_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
}
const events = yield* collect(
{ type: "response.output_item.done", item },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
completed,
)
const providerMetadata = { "openai-compatible": { itemId: "fc_1" } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
])
expect(events.filter(LLMEvent.is.finish)).toEqual([
{
type: "finish",
reason: { normalized: "tool-calls", raw: undefined },
providerMetadata: { "openai-compatible": { responseId: "resp_1", serviceTier: undefined } },
},
])
}),
)
it.effect(`deduplicates a pending call whose item id is ${id === undefined ? "introduced" : "omitted"} later`, () =>
Effect.gen(function* () {
const item = { type: "function_call", call_id: "call_1", name: "lookup" }
const first = { ...item, ...(id === undefined ? {} : { id }) }
const duplicate = { ...item, ...(id === undefined ? { id: "fc_1" } : {}) }
const events = yield* collect(
{ type: "response.output_item.added", item: first },
{ type: "response.function_call_arguments.delta", item_id: id ?? "call_1", delta: '{"query":"weather"}' },
{ type: "response.output_item.added", item: duplicate },
{ type: "response.output_item.done", item: duplicate },
{ type: "response.output_item.done", item: first },
{ type: "response.output_item.added", item: duplicate },
completed,
)
// Identity metadata comes from the first admission, not the duplicate.
const providerMetadata = id === undefined ? undefined : { "openai-compatible": { itemId: id } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: '{"query":"weather"}',
input: { query: "weather" },
},
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
])
}),
)
})
it.effect("recovers pending calls without reconciling terminal reasoning", () =>
Effect.gen(function* () {
@@ -493,6 +436,21 @@ describe("Open Responses basic-item lifecycles", () => {
}),
)
it.effect("preserves call identity and pending order when an item id is reused", () =>
Effect.gen(function* () {
const first = { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "{}" }
const events = yield* collect(
{ type: "response.output_item.added", item: first },
{ type: "response.output_item.added", item: { ...first, id: "fc_2", call_id: "call_2" } },
{ type: "response.output_item.done", item: first },
{ type: "response.output_item.added", item: { ...first, call_id: "call_3" } },
{ type: "response.output_item.done", item: first },
completed,
)
expect(events.filter(LLMEvent.is.toolCall).map((event) => event.id)).toEqual(["call_1", "call_2", "call_3"])
}),
)
it.effect("keeps text and reasoning identities separate even with empty item ids", () =>
Effect.gen(function* () {
const events = yield* collect(
@@ -542,15 +500,14 @@ describe("Open Responses basic-item lifecycles", () => {
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Answer" },
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "{}" },
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "{}" },
},
completed,
)
// Generic terminal closure does not repeat the message's phase metadata.
const providerMetadata = { "openai-compatible": { itemId: "fc_1" } }
expect(events.slice(4, -2)).toEqual([
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: {}, providerMetadata },
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-call", id: "call_1", name: "lookup", input: {} },
{ type: "text-end", id: "msg_1" },
])
}),
@@ -586,21 +586,23 @@ describe("Open Responses-compatible route", () => {
Effect.gen(function* () {
yield* Effect.forEach(["response.output_item.added", "response.output_item.done"], (type) =>
Effect.forEach(fixtures, (fixture) =>
Effect.forEach([null, 0, false, {}, []], (id) =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type, item: { ...fixture.item, id } },
{ type: "response.completed", response: { id: "resp_1" } },
Effect.forEach(
fixture.item.type === "message" ? [undefined, null, 0, false, {}, []] : [null, 0, false, {}, []],
(id) =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type, item: { ...fixture.item, id } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
),
),
)
@@ -608,6 +610,43 @@ describe("Open Responses-compatible route", () => {
)
})
it.effect("streams function calls without optional item ids through the shared baseline", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Look it up." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 1, item },
{
type: "response.function_call_arguments.delta",
output_index: 1,
item_id: "opaque_item",
delta: '{"query":"shared"}',
},
{
type: "response.output_item.done",
output_index: 1,
item: { ...item, arguments: '{"query":"complete"}' },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "complete" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
}),
)
it.effect("finalizes pending function calls from completed response output", () =>
Effect.gen(function* () {
const model = configure({
@@ -469,7 +469,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("continues a streamed tool call with only the new tool output", () =>
it.effect("continues an item-id-less tool call with only the new tool output", () =>
Effect.gen(function* () {
const firstRequest = {
type: "response.create",
@@ -485,7 +485,6 @@ describe("OpenAI Responses route", () => {
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
status: "completed",
call_id: "call_1",
name: "weather",
@@ -2130,6 +2129,47 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("routes item-id-less function arguments by output index and prefers item completion", () =>
Effect.gen(function* () {
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 2, item },
{
type: "response.function_call_arguments.delta",
output_index: 2,
item_id: "opaque_delta",
delta: '{"query":"streamed"}',
},
{
type: "response.function_call_arguments.done",
output_index: 2,
item_id: "opaque_done",
arguments: '{"query":"arguments-done"}',
},
{
type: "response.output_item.done",
output_index: 2,
item: { ...item, arguments: '{"query":"output-item-done"}' },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter((event) => event.type === "tool-input-delta")).toMatchObject([
{ id: "call_1", text: '{"query":"streamed"}' },
])
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "output-item-done" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
}),
)
it.effect("routes reasoning summary events by output index", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -2349,7 +2389,7 @@ describe("OpenAI Responses route", () => {
{
type: "response.output_item.added",
output_index: 0,
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" },
},
event,
{ type: "response.completed", response: { id: "resp_1" } },
@@ -2891,10 +2931,14 @@ describe("OpenAI Responses route", () => {
arguments: '{"query":"weather"}',
},
},
// A completed item that is re-added stays closed.
// Duplicates that drop the item id still resolve the same call.
{
type: "response.output_item.done",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
},
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
@@ -3749,6 +3793,43 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("finalizes and replays a completed function call without an optional item id", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "weather" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
response.message,
Message.tool({ id: "call_1", name: "lookup", resultType: "json", result: { forecast: "sunny" } }),
],
}),
)
expect(prepared.body.input).toEqual([
{ type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
{ type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' },
])
}),
)
it.effect("emits only missing function arguments from the arguments done event", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -3936,7 +4017,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("uses completed response output when output item completion is missing", () =>
it.effect("uses completed response output when item completion and its terminal item id are missing", () =>
Effect.gen(function* () {
const body = sseEvents(
{
@@ -3951,7 +4032,6 @@ describe("OpenAI Responses route", () => {
output: [
{
type: "function_call",
id: "fc_item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
@@ -3973,6 +4053,37 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("reconciles an item-id-less pending function call from completed response output", () =>
Effect.gen(function* () {
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 0, item },
{
type: "response.function_call_arguments.delta",
output_index: 0,
item_id: "opaque_delta",
delta: '{"query":"partial',
},
{
type: "response.completed",
response: { id: "resp_1", output: [{ ...item, arguments: '{"query":"complete"}' }] },
},
),
),
),
)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "complete" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
expect(response.events.filter(LLMEvent.is.toolInputEnd)).toHaveLength(1)
}),
)
it.effect("lets completed response output override arguments done", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -11,7 +11,6 @@ import type {
} from "@opencode-ai/client/promise"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { TimelineDetail } from "@opencode-ai/session-ui/timeline/detail"
import { expect, type Page } from "@playwright/test"
import { Schema } from "effect"
import { mockOpenCodeServer } from "../../utils/mock-server"
@@ -123,7 +122,7 @@ export async function setupTimeline(
messages?: TimelineMessage[]
sessionMessages?: SessionMessageInfo[]
sessionStatus?: Record<string, SessionStatus>
settings?: Record<string, boolean | TimelineDetail>
settings?: Record<string, boolean>
sessions?: Session[]
cpuRate?: number
viewport?: { width: number; height: number }
@@ -67,12 +67,6 @@ test("follows a live session move while the agent catalog is still loading", asy
const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Moved session" }
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
await page.addInitScript(() => {
localStorage.setItem(
"settings.v3",
JSON.stringify({ general: { timelineDetail: { notices: { placement: "separate" } } } }),
)
})
const transport = await installSseTransport(page, { server: fixture.serverKey })
await mockOpenCodeServer(page, {
directory: fixture.directory,
@@ -101,7 +95,9 @@ test("follows a live session move while the agent catalog is still loading", asy
await transport.waitForConnection()
const resolved = page.waitForResponse((response) => {
const url = new URL(response.url())
return url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === destination && response.ok()
return (
url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === destination && response.ok()
)
})
session.directory = destination
await transport.send({
@@ -225,8 +221,7 @@ function recoveryRequests(page: Page) {
const requests: string[] = []
page.on("request", (request) => {
const path = new URL(request.url()).pathname
if (request.method() === "POST" && /^\/api\/(session\/[^/]+\/move$|worktree(?:\/|$))/.test(path))
requests.push(path)
if (request.method() === "POST" && /^\/api\/(session\/[^/]+\/move$|worktree(?:\/|$))/.test(path)) requests.push(path)
})
return requests
}
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import { setupTimeline } from "../performance/timeline-stability/fixture"
for (const width of [1400, 390]) {
@@ -17,9 +16,6 @@ for (const width of [1400, 390]) {
`\u0645\u0631\u0627\u062c\u0639\u0629 ${command}--reviewed`,
]
await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } },
},
locale: profile.locale,
viewport: { width, height: 900 },
sessionMessages: [
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import { createTwoFilesPatch } from "diff"
import {
assistantMessage,
@@ -37,9 +36,7 @@ test("renders a completed single-file patch", async ({ page }) => {
),
]),
],
settings: {
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
},
settings: { editToolPartsExpanded: true },
})
const wrapper = page.locator(`[data-timeline-part-id="${id}"]`)
@@ -71,9 +68,7 @@ test("keeps an expanded file diff header at the same viewport position", async (
const before = Array.from({ length: 80 }, (_, index) => `export const value${index} = ${index}\n`).join("")
const after = before.replaceAll(" = ", " = compute(").replaceAll("\n", ")\n")
await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
},
settings: { editToolPartsExpanded: true },
messages: [
userMessage([userText("Preceding context ".repeat(120))]),
assistantMessage([
@@ -105,7 +100,6 @@ test("keeps an expanded file diff header at the same viewport position", async (
const wrapper = page.locator(`[data-timeline-part-id="${id}"]`)
const row = page.locator("[data-timeline-key]", { has: wrapper })
const trigger = wrapper.getByRole("button")
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect
.poll(() =>
row.evaluate((element) => {
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantID,
assistantMessage,
@@ -24,23 +23,20 @@ for (const expanded of [false, true]) {
test(`preserves shell user intent from a ${expanded ? "expanded" : "collapsed"} default`, async ({ page }) => {
const id = `prt_shell_default_${expanded}`
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([shell(id, "running", lines(3))], { completed: false })],
settings: {
timelineDetail: {
...timelinePresets[2].value,
shell: { placement: "separate", details: expanded ? "expanded" : "collapsed" },
},
},
messages: [userMessage(), assistantMessage([shell(id, "completed", lines(3))])],
settings: { shellToolPartsExpanded: expanded },
})
const trigger = page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
const trigger = expanded
? page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
: page.getByRole("button", { name: "Used 1 Shell", exact: true })
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
await timeline.send(partUpdated(shell(id, "completed", lines(6))))
await timeline.send(partUpdated(textPart(`prt_sibling_${expanded}`, "Sibling content")))
await timeline.send(status("idle"))
await expect(page.getByText("Sibling content", { exact: true })).toBeVisible()
await timeline.send(partUpdated(shell(id, "completed", lines(6))), 180)
await timeline.send(partUpdated(textPart(`prt_sibling_${expanded}`, "Sibling content")), 180)
await timeline.send(status("busy"), 100)
await timeline.send(status("idle"), 250)
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
})
}
@@ -50,9 +46,6 @@ test("transitions a streaming shell from writing through command execution", asy
const command = "printf ready"
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([], { completed: false })],
settings: {
timelineDetail: { ...timelinePresets[2].value, shell: { placement: "separate", details: "collapsed" } },
},
})
await timeline.send(toolInputStarted({ sessionID, assistantMessageID: assistantID, id, name: "shell" }))
@@ -102,9 +95,7 @@ test("shimmers and expands a running shell command", async ({ page }) => {
const command = "sleep 10 && echo done"
await setupTimeline(page, {
messages: [userMessage(), assistantMessage([shell(id, "running", "still running", command)], { completed: false })],
settings: {
timelineDetail: { ...timelinePresets[2].value, shell: { placement: "separate", details: "collapsed" } },
},
settings: { shellToolPartsExpanded: false },
})
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
@@ -119,7 +110,7 @@ test("shimmers and expands a running shell command", async ({ page }) => {
})
for (const open of [false, true]) {
test(`keeps ${open ? "expanded" : "collapsed"} Separate reasoning intent through shell completion`, async ({
test(`keeps ${open ? "expanded" : "collapsed"} reasoning intent from Thinking through standalone shell into Used`, async ({
page,
}) => {
const reasoningID = `prt_reasoning_hidden_${open}`
@@ -127,13 +118,7 @@ for (const open of [false, true]) {
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistant],
settings: {
timelineDetail: {
...timelinePresets[2].value,
thinking: { placement: "separate", details: "collapsed" },
shell: { placement: "separate", details: "collapsed" },
},
},
settings: { showReasoningSummaries: false },
cpuRate: 4,
})
const reasoning = page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)
@@ -156,11 +141,28 @@ for (const open of [false, true]) {
await timeline.send(partUpdated(shell(shellID, "completed", "done")))
await timeline.send(messageUpdated(completedAssistantInfo(assistant)))
await timeline.send(status("idle"))
await expect(group).toHaveCount(0)
await expect(thought).toHaveAttribute("aria-expanded", String(open))
const used = group.getByRole("button", { name: "Used 1 Shell", exact: true })
await expect(used).toHaveAttribute("aria-expanded", "false")
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
await expect(group.locator(`[data-timeline-part-id="${shellID}"]`)).toBeVisible()
await expect(group.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
"aria-expanded",
String(open),
)
await expect(used.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Shell")
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(used).toHaveAttribute("aria-expanded", "true")
if (!open) await thought.click()
await expect(reasoning.getByRole("heading", { name: "Inspecting stability", exact: true })).toBeVisible()
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "false")
await used.click()
await expect(reasoning.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
)
await expect(reasoning.getByRole("heading", { name: "Inspecting stability", exact: true })).toBeVisible()
})
}
@@ -170,9 +172,6 @@ for (const transition of ["reasoning-end", "idle", "retry"] as const) {
const text = "## Inspecting stability\n\nThe timeline is ready for the next step."
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([reasoningPart(id, text)], { completed: false })],
settings: {
timelineDetail: { ...timelinePresets[2].value, thinking: { placement: "separate", details: "collapsed" } },
},
})
const part = page.locator(`[data-timeline-part-id="${renderedPartID(id)}"]`)
const trigger = part.locator('[data-slot="collapsible-trigger"]')
@@ -1,6 +1,5 @@
import { expect, test } from "@playwright/test"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
compactionDelta,
compactionEnded,
@@ -50,9 +49,6 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
ownerWarnings.push(message.text())
})
await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } },
},
sessionMessages: [
user,
{ id: "msg_agent", type: "agent-switched", agent: "explore", time: { created: 2 } },
@@ -88,12 +84,7 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
})
test("renders a compaction summary while it streams and after completion", async ({ page }) => {
const timeline = await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } },
},
sessionMessages: [user, assistant(true)],
})
const timeline = await setupTimeline(page, { sessionMessages: [user, assistant(true)] })
await timeline.send(
compactionStarted({
@@ -170,12 +161,7 @@ test("updates running compactions to failed and cancelled boundaries", async ({
})
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, subagents: { placement: "separate" } },
},
sessionMessages: [user, assistant(false, true)],
})
await setupTimeline(page, { sessionMessages: [user, assistant(false, true)] })
const card = page.locator('[data-component="task-tool-card"]')
await expect(card).toBeVisible()
await expect(card).toContainText("Inspect code")
@@ -212,9 +198,6 @@ test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
test("navigates from a running subagent card and hides background controls in the child", async ({ page }) => {
const childID = "ses_running_child"
await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, subagents: { placement: "separate" } },
},
sessionMessages: [user, assistant(false, true, childID)],
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Sleep for 5 minutes" })],
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
@@ -230,7 +213,6 @@ for (const name of ["shell", "subagent"] as const) {
test(`keeps the background shortcut available for a grouped running ${name}`, async ({ page }) => {
const message = assistant(false, true)
await setupTimeline(page, {
settings: { timelineDetail: timelinePresets[2].value },
sessionMessages: [
user,
{
@@ -299,7 +281,6 @@ test("separates blocking and already-backgrounded work into two rows", async ({
const backgroundID = "ses_background_existing"
const blockingID = "ses_background_blocking"
const timeline = await setupTimeline(page, {
settings: { timelineDetail: timelinePresets[2].value },
sessionMessages: [
user,
{
@@ -388,13 +369,6 @@ test("separates blocking and already-backgrounded work into two rows", async ({
})
const backgroundCard = page.locator('[data-timeline-part-id="call_backgrounded"]')
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
const used = page
.locator('[data-timeline-part-ids="call_backgrounded,call_shell_backgrounded,call_blocking"]')
.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await expect(used).toHaveText(/^Used\s*2 Agent, 1 Shell$/)
await expect(used).toHaveAttribute("aria-expanded", "false")
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
await page.getByRole("button", { name: "Session details" }).click()
const summary = page.getByRole("button", { name: "2 items running in background" })
await expect(summary).toContainText("2")
@@ -402,7 +376,6 @@ test("separates blocking and already-backgrounded work into two rows", async ({
const list = page.locator('[data-component="session-background-list"]')
await expect(list).toContainText("Background task")
await expect(list).toContainText("sleep 120")
await expect(list).not.toContainText("Foreground task")
await expect(backgroundCard).toContainText("Background task (background)")
await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
await expect(
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantMessage,
partUpdated,
@@ -15,9 +14,7 @@ test.describe("session timeline projection", () => {
const first = "prt_patch_first"
const second = "prt_patch_second"
const timeline = await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
},
settings: { editToolPartsExpanded: true },
messages: [
userMessage(),
assistantMessage([
@@ -37,7 +34,6 @@ test.describe("session timeline projection", () => {
const initial = page.locator(`[data-timeline-part-id="${first}"]`)
const initialFile = initial.locator('[data-scope="apply-patch"] [data-type="update"]')
await expect(initialFile).toBeVisible()
await expect(initialFile.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await initialFile.getByRole("button").click()
await expect(initialFile.getByRole("button")).toHaveAttribute("aria-expanded", "true")
await initial.evaluate((element) => {
@@ -114,11 +110,8 @@ test.describe("session timeline projection", () => {
parentID: "msg_2000_second_user",
created: 1700000006000,
})
const timeline = await setupTimeline(page, {
settings: { timelineDetail: timelinePresets[2].value },
messages: [firstUser, aborted, failed, nextUser, nextAssistant],
})
await timeline.send(status("idle"))
const timeline = await setupTimeline(page, { messages: [firstUser, aborted, failed, nextUser, nextAssistant] })
await timeline.send(status("idle"), 100)
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.evaluate((element) => (element.scrollTop = 0))
@@ -134,7 +127,6 @@ test.describe("session timeline projection", () => {
const longName = "Company Gateway Extra Long Context Model for Narrow Timeline Layouts"
await setupTimeline(page, {
viewport: { width: 420, height: 700 },
settings: { timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } } },
sessionMessages: [
{
id: "msg_model_fast_nano",
@@ -5,49 +5,135 @@ import {
reasoningPart,
setupTimeline,
textPart,
toolPart,
userMessage,
} from "../performance/timeline-stability/fixture"
test("changes timeline presets and saves custom thinking details", async ({ page }) => {
test("changes live reasoning through Settings and persists Hidden, Compact, and Full", async ({ page }) => {
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
reasoningPart("prt_reasoning_settings", "## Inspecting stability\n\nThe selected mode controls these details."),
]),
assistantMessage(
[
reasoningPart(
"prt_reasoning_settings",
"## Inspecting stability\n\nThe selected mode controls these details.",
),
],
{ completed: false },
),
],
})
const part = page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)
const settings = page.getByTestId("settings-screen")
await page.keyboard.press("Control+,")
const slider = settings.getByRole("slider", { name: "Timeline detail", exact: true })
await expect(slider).toBeEnabled()
await slider.press("Home")
for (const [index, name] of ["Everything", "Detailed", "Compact", "Quiet", "Text only"].entries()) {
if (index) await slider.press("ArrowRight")
await expect(slider).toHaveValue(String(index))
await expect(slider).toHaveAttribute("aria-valuetext", name)
}
await slider.press("Home")
await settings.getByRole("button", { name: "Advanced", exact: true }).click()
await settings.getByRole("button", { name: "Thinking Placement Separate", exact: true }).click()
await page.getByRole("option", { name: "Grouped", exact: true }).click()
await settings.getByRole("button", { name: "Thinking Details Expanded", exact: true }).click()
await page.getByRole("option", { name: "Collapsed", exact: true }).click()
await expect(slider).toHaveAttribute("aria-valuetext", "Custom")
await expect
.poll(() =>
page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.timelineDetail?.thinking),
)
.toEqual({ placement: "grouped", details: "collapsed" })
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(settings).toBeHidden()
await page.getByRole("button", { name: "Reasoning", exact: true }).click()
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await part.getByRole("button").click()
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeVisible()
const settings = page.getByTestId("settings-screen")
const select = settings.locator('[data-action="settings-reasoning-mode"] [data-component="select-v2"]')
for (const label of ["Full", "Hidden", "Compact"] as const) {
await page.keyboard.press("Control+,")
await expect(settings.getByText("Model reasoning", { exact: true })).toBeVisible()
await expect(select).toHaveAttribute("aria-expanded", "false")
await select.click()
await expect(page.getByRole("listbox").getByRole("option")).toHaveText(["Hidden", "Compact", "Full"])
await page.getByRole("option", { name: label, exact: true }).click()
await expect(select).toHaveText(label)
await expect(select).toHaveAttribute("aria-expanded", "false")
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.reasoningMode))
.toBe(label.toLowerCase())
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(settings).toBeHidden()
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(label === "Hidden" ? 0 : 1)
await expect(part).toHaveCount(label === "Hidden" ? 0 : 1)
if (label === "Hidden") {
await expect(page.getByText("The selected mode controls these details.", { exact: true })).toBeHidden()
continue
}
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", String(label === "Full"))
if (label === "Full")
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeVisible()
if (label === "Compact") {
await expect(part.getByRole("button")).toContainText("Inspecting stability")
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeHidden()
}
}
await page.keyboard.press("Control+,")
await expect(select).toHaveText("Compact")
})
// The persisted boolean migrates to compact (false) or full (true).
for (const summaries of [false, true]) {
for (const profile of ["none", "blank", "heading", "tool", "text"] as const) {
test(`projects legacy ${summaries ? "full" : "compact"} reasoning with ${profile}`, async ({ page }) => {
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage(
[
...(profile === "none"
? []
: [
reasoningPart(
`prt_reasoning_${summaries}_${profile}`,
profile === "blank"
? " "
: "## Inspecting stability\n\nI will inspect the timeline before changing its state.",
),
]),
...(profile === "tool"
? [toolPart(`prt_reasoning_tool_${summaries}`, "skill", "running", { name: "inspect" })]
: []),
...(profile === "text" ? [textPart(`prt_reasoning_text_${summaries}`, "The timeline is stable.")] : []),
],
{ completed: false },
),
],
settings: { showReasoningSummaries: summaries },
})
const part = page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(
profile === "blank" || profile === "heading" ? 1 : 0,
)
if (profile === "none") {
await expect(part).toHaveCount(0)
return
}
if (profile === "blank") {
await expect(part).toContainText("Thinking")
await expect(part.getByRole("heading")).toHaveCount(0)
return
}
if (profile === "tool") {
const group = page.locator('[data-component="collapsed-tool-group"]')
const used = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await expect(used).toHaveText(/^Used\s*1 Skill$/)
await expect(used).toHaveAttribute("aria-expanded", "false")
await expect(page.getByText("Inspecting stability", { exact: true })).toBeHidden()
await expect(used.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Skill")
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
await expect(group.locator(`[data-timeline-part-id="prt_reasoning_tool_${summaries}"]`)).toBeVisible()
await expect(group.locator('[data-component="reasoning-part"]')).toHaveCount(1)
}
if (profile === "text") await expect(page.getByText("The timeline is stable.", { exact: true })).toBeVisible()
const trigger = part.locator('[data-slot="collapsible-trigger"]')
const body = part.getByText("I will inspect the timeline before changing its state.", { exact: true })
await expect(trigger).toContainText(profile === "heading" ? "Thinking" : "Thought")
await expect(trigger).toHaveAttribute("aria-expanded", String(summaries))
if (!summaries) {
await expect(body).toBeHidden()
if (profile === "heading") await expect(trigger).toContainText("Inspecting stability")
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
}
await expect(body).toBeVisible()
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(body).toBeHidden()
if (profile !== "heading") await expect(trigger).not.toContainText("Inspecting stability")
})
}
}
test("does not infer reasoning visibility from provider identity", async ({ page }) => {
await setupTimeline(page, {
messages: [
@@ -18,10 +18,10 @@ test("reducer-hardening: converges when idle arrives before final part and messa
const textID = "prt_event_order_text"
const assistant = assistantMessage([textPart(textID, "Partial")], { completed: false })
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant] })
await timeline.send(status("busy"))
await timeline.send(status("idle"))
await timeline.send(partUpdated(textPart(textID, "Final after early idle")))
await timeline.send(messageUpdated(completedAssistantInfo(assistant)))
await timeline.send(status("busy"), 100)
await timeline.send(status("idle"), 100)
await timeline.send(partUpdated(textPart(textID, "Final after early idle")), 120)
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 250)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(textID)}"]`)).toContainText(
@@ -138,7 +138,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
})
test("keeps failed search calls and their error cards outside the collapsed stack", async ({ page }) => {
test("keeps failed search calls and their error cards inside the collapsed stack", async ({ page }) => {
const parts = [
toolPart(
"prt_error_glob",
@@ -161,9 +161,12 @@ test("keeps failed search calls and their error cards outside the collapsed stac
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
await expect(page.locator('[data-component="collapsed-tool-group"]')).toHaveCount(0)
await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(2)
const glob = page.locator('[data-timeline-part-id="prt_error_glob"]')
const group = page.locator('[data-timeline-part-ids="prt_error_glob,prt_error_grep"]')
const summary = group.getByRole("button", { name: "Used 1 Glob, 1 Grep", exact: true })
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Glob, 1 Grep")
await summary.click()
await expect(group.locator('[data-kind="tool-error-card"]')).toHaveCount(2)
const glob = group.locator('[data-timeline-part-id="prt_error_glob"]')
await expect(glob).toContainText("Invalid tool input")
await expect(glob.locator('[data-component="tool-error-card-icon"]')).toBeVisible()
await expect(glob.locator('[data-component="tool-error-card-icon"] use')).toHaveAttribute(
@@ -177,7 +180,7 @@ test("keeps failed search calls and their error cards outside the collapsed stac
.evaluate((element) => getComputedStyle(element, "::before").display),
)
.toBe("none")
await expect(page.locator('[data-timeline-part-id="prt_error_grep"]')).toContainText(
await expect(group.locator('[data-timeline-part-id="prt_error_grep"]')).toContainText(
"Search timed out after 30 seconds",
)
})
@@ -1,5 +1,4 @@
import { expect, test, type Locator, type Page } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantMessage,
setupTimeline,
@@ -75,29 +74,21 @@ test("keeps the patch card inside a fractionally short virtual row", async ({ pa
additions: 1,
deletions: 1,
}
await setupTimeline(page, {
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(
patchID,
"patch",
"completed",
{ patchText: "Update src/outline.ts" },
{ metadata: { files: [file] } },
),
toolPart(patchID, "patch", "completed", { patchText: "Update src/outline.ts" }, { metadata: { files: [file] } }),
]),
],
settings: {
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
},
settings: { editToolPartsExpanded: true },
reducedMotion: true,
})
const part = page.locator(`[data-timeline-part-id="${patchID}"]`)
const card = part.locator('[data-component="accordion"][data-scope="apply-patch"]')
const row = page.locator("[data-timeline-key]", { has: part })
await expect(card).toBeVisible()
await expect(card.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await timeline.settle()
const geometry = await row.evaluate((element) => {
const card = element.querySelector<HTMLElement>('[data-component="accordion"][data-scope="apply-patch"]')
@@ -115,6 +106,8 @@ test("keeps the patch card inside a fractionally short virtual row", async ({ pa
cardHeight: cardRect.height,
}
})
await timeline.settle()
expect(geometry.overflow).toBeCloseTo(0.49, 1)
expect(geometry.paintOverflow).toBeLessThanOrEqual(0)
const edges = await captureCardEdges(page, card)
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantMessage,
partUpdated,
@@ -12,9 +11,7 @@ test("transitions shell and question through running error outcomes", async ({ p
const shellID = "prt_transition_error_shell"
const questionID = "prt_transition_error_question"
const timeline = await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, shell: { placement: "separate", details: "expanded" } },
},
settings: { shellToolPartsExpanded: true },
messages: [
userMessage(),
assistantMessage(
@@ -27,17 +24,18 @@ test("transitions shell and question through running error outcomes", async ({ p
],
})
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await timeline.send(partUpdated(toolPart(shellID, "shell", "running", { command: "exit 1" })))
await expect(page.locator(`[data-timeline-part-id="${shellID}"]`)).toContainText("exit 1")
await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())))
await timeline.send(partUpdated(toolPart(shellID, "shell", "running", { command: "exit 1" })), 120)
await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())), 180)
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await timeline.send(
partUpdated(toolPart(shellID, "shell", "error", { command: "exit 1" }, { error: "Command exited 1" })),
180,
)
await timeline.send(
partUpdated(
toolPart(questionID, "question", "error", questionInput(), { error: "The user dismissed this question" }),
),
250,
)
await expect(page.locator(`[data-timeline-part-id="${shellID}"] [data-kind="tool-error-card"]`)).toBeVisible()
@@ -48,9 +46,7 @@ test("preserves surviving grouped patch state when its first patch fails", async
const failed = "prt_grouped_patch_failed"
const surviving = "prt_grouped_patch_surviving"
const timeline = await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
},
settings: { editToolPartsExpanded: true },
messages: [
userMessage(),
assistantMessage(
@@ -84,7 +80,6 @@ test("preserves surviving grouped patch state when its first patch fails", async
const group = page.locator(`[data-timeline-part-ids="${failed},${surviving}"]`)
const file = group.locator('[data-scope="apply-patch"] button')
await expect(file).toBeVisible()
await expect(file).toHaveAttribute("aria-expanded", "false")
await file.click()
await expect(file).toHaveAttribute("aria-expanded", "true")
await group.evaluate((element) => {
@@ -121,7 +116,6 @@ test("preserves surviving grouped patch state when its first patch fails", async
test("groups instruction files loaded by the same read", async ({ page }) => {
const id = "prt_read_instructions"
await setupTimeline(page, {
settings: { timelineDetail: { ...timelinePresets[2].value, tools: { placement: "separate" } } },
messages: [
userMessage(),
assistantMessage([
@@ -154,10 +148,7 @@ test("groups only consecutive successful skill tools", async ({ page }) => {
toolPart("prt_skill_break", "read", "completed", { path: "src/a.ts" }),
toolPart("prt_skill_last", "skill", "completed", { id: "opencode" }),
]
await setupTimeline(page, {
settings: { timelineDetail: timelinePresets[2].value },
messages: [userMessage(), assistantMessage(parts)],
})
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
const group = page.locator(`[data-timeline-part-ids="${parts.map((part) => part.id).join(",")}"]`)
await group.getByRole("button").click()
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantID,
assistantMessage,
@@ -20,9 +19,6 @@ for (const width of [1400, 390]) {
messages: [userMessage()],
sessionStatus: { [sessionID]: { type: "busy" } },
viewport: { width, height: 900 },
settings: {
timelineDetail: { ...timelinePresets[2].value, thinking: { placement: "separate", details: "collapsed" } },
},
})
const working = page.locator('[data-component="session-working"]')
await expect(working).toHaveCount(1)
@@ -55,9 +51,7 @@ for (const name of ["shell", "patch", "subagent"] as const) {
test(`hides Working during ${name} input and execution, then restores it on completion`, async ({ page }) => {
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([], { completed: false })],
settings: {
timelineDetail: { ...timelinePresets[0].value, shell: { placement: "separate", details: "collapsed" } },
},
settings: { editToolPartsExpanded: true },
})
const working = page.locator('[data-component="session-working"]')
await expect(working).toBeVisible()
@@ -95,8 +89,13 @@ for (const name of ["shell", "patch", "subagent"] as const) {
await expect(working).toHaveCount(0)
await timeline.send(partUpdated(toolPart(id, name, "completed", input, { metadata })))
await expect(tool).toBeVisible()
await expect(page.locator('[data-component="collapsed-tool-group"]')).toHaveCount(0)
if (name === "shell") {
const group = page.locator('[data-component="collapsed-tool-group"]')
await expect(
group.getByRole("button", { name: "Used 1 Shell", exact: true, includeHidden: true }),
).toHaveAttribute("aria-expanded", "false")
await expect(group).toBeVisible()
}
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", "Working")
await expect(working).toBeVisible()
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
@@ -260,7 +259,6 @@ for (const failed of [false, true]) {
const editor = page.locator('[data-component="composer"]').getByRole("textbox")
await expect(editor).toBeEditable()
await editor.fill("Check the working indicator immediately.")
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
const requested = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/prompt`,
@@ -9,42 +9,30 @@ const sessionB = session("ses_tab_b", "Tab B session")
const sessionC = session("ses_tab_c", "Tab C session")
const unresolvedSessionID = "ses_tab_unresolved"
test("new session tab matches neighboring session widths", async ({ page }, testInfo) => {
test("new session tab hugs its content", async ({ page }) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionA, sessionB, directory }) => {
({ server, sessionID, directory }) => {
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server, sessionId: sessionA },
{ type: "session", server, sessionId: sessionID },
{ type: "draft", server, directory, draftID: "draft_tab_width" },
{ type: "session", server, sessionId: sessionB },
]),
)
},
{ server, sessionA: sessionA.id, sessionB: sessionB.id, directory: sessionA.directory },
{ server, sessionID: sessionA.id, directory: sessionA.directory },
)
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
await page.goto(href)
const tabs = page.locator("[data-titlebar-tab-slot]")
await expect(tabs.locator("[data-titlebar-tab-title]")).toHaveText([sessionA.title, "New session", sessionB.title])
await testInfo.attach("new-session-between-tabs", {
body: await page.locator('[data-slot="titlebar-v2"]').screenshot(),
contentType: "image/png",
})
for (const width of [1280, 800]) {
await page.setViewportSize({ width, height: 720 })
await expect
.poll(() =>
tabs.evaluateAll((tabs) => {
const widths = tabs.map((tab) => tab.getBoundingClientRect().width)
return Math.max(...widths) - Math.min(...widths)
}),
)
.toBeLessThan(1)
}
const sessionTab = page.locator(`[data-titlebar-tab-slot]:has(a[href="${href}"])`)
const draftTab = page.locator('[data-titlebar-tab-slot]:has(a[href^="/new-session?draftId="])')
await expect(draftTab).toContainText("New session")
const width = (await draftTab.boundingBox())?.width ?? 0
expect(width).toBeGreaterThan(100)
expect(width).toBeLessThan((await sessionTab.boundingBox())?.width ?? 0)
})
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
@@ -292,7 +280,9 @@ test("appearance experimental settings control vertical tab details", async ({ p
await page.reload()
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
await page.getByRole("button", { name: "Tabs", exact: true }).click()
await expect(page.locator('[data-slot="mobile-tabs-drawer"] [data-slot="tab-project"]')).toHaveText(["tab-project"])
await expect(page.locator('[data-slot="mobile-tabs-drawer"] [data-slot="tab-project"]')).toHaveText([
"tab-project",
])
await expect(
page
.locator('[data-slot="mobile-tabs-drawer"]')
-34
View File
@@ -951,40 +951,6 @@ export const dict = {
"settings.general.section.feed": "Feed",
"settings.general.section.display": "Display",
"settings.timeline.title": "Timeline",
"settings.timeline.detail": "Timeline detail",
"settings.timeline.description": "Choose how much activity appears in the timeline. Messages stay visible.",
"settings.timeline.preset.everything": "Everything",
"settings.timeline.preset.detailed": "Detailed",
"settings.timeline.preset.compact": "Compact",
"settings.timeline.preset.quiet": "Quiet",
"settings.timeline.preset.text-only": "Text only",
"settings.timeline.description.everything": "Show all activity separately. Expand shell output, edits, and thinking.",
"settings.timeline.description.detailed":
"Expand shell output and edits. Show subagents separately and group other activity in Used.",
"settings.timeline.description.compact": "Group all activity in Used with details collapsed.",
"settings.timeline.description.quiet": "Group edits and subagents in Used. Hide other activity.",
"settings.timeline.description.text-only": "Hide all activity. Show only messages.",
"settings.timeline.description.custom": "Use your selected placement and details for each activity category.",
"settings.timeline.custom": "Custom",
"settings.timeline.advanced": "Advanced",
"settings.timeline.advanced.description": "Set placement and details for each activity category.",
"settings.timeline.advanced.explainer": "Grouped activity goes into Used. Details applies after opening the group.",
"settings.timeline.activity": "Activity",
"settings.timeline.category.shell": "Shell",
"settings.timeline.category.edit": "Edits",
"settings.timeline.category.thinking": "Thinking",
"settings.timeline.category.subagents": "Subagents",
"settings.timeline.category.notices": "Notices",
"settings.timeline.category.tools": "Other tools",
"settings.timeline.placement.title": "Placement",
"settings.timeline.placement.separate": "Separate",
"settings.timeline.placement.grouped": "Grouped",
"settings.timeline.placement.hidden": "Hidden",
"settings.timeline.expansion.title": "Details",
"settings.timeline.expansion.collapsed": "Collapsed",
"settings.timeline.expansion.expanded": "Expanded",
"settings.general.row.language.title": "Language",
"settings.general.row.language.description": "Change the display language for OpenCode",
"settings.general.row.shell.title": "Terminal shell",
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { AgentListOutput, ModelListOutput, Project, ProviderListOutput } from "@opencode-ai/client/promise"
import { directoryKey, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils"
import type { AgentListOutput, ModelListOutput, ProviderListOutput } from "@opencode-ai/client/promise"
import { directoryKey, normalizeAgentList, normalizeProviderList } from "./utils"
describe("normalizeAgentList", () => {
test("adapts current agents to the app agent shape", () => {
@@ -85,15 +85,6 @@ describe("normalizeProviderList", () => {
})
})
describe("normalizeProjectInfo", () => {
test("keeps the project VCS backend", () => {
const project = { id: "prj", canonical: "/repo", time: { created: 1, updated: 1 }, sandboxes: [] }
expect(normalizeProjectInfo({ ...project, vcs: "git" } as Project).vcs).toBe("git")
expect(normalizeProjectInfo({ ...project, vcs: "hg" } as Project).vcs).toBe("hg")
expect(normalizeProjectInfo(project as Project).vcs).toBeUndefined()
})
})
describe("directoryKey", () => {
test("normalizes slashes", () => {
expect(String(directoryKey("C:\\Repos\\sst\\opencode"))).toBe("C:/Repos/sst/opencode")
@@ -135,5 +135,6 @@ export function normalizeProjectInfo(project: Project | CurrentProject): Project
...project,
worktree,
worktrees: "worktrees" in project ? project.worktrees : [{ directory: worktree }],
vcs: project.vcs === "git" ? "git" : undefined,
}
}
+2
View File
@@ -230,6 +230,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
children.mark(key)
if (event.type === "config.updated" || event.type === "agent.updated") queue.push(key)
if (event.type === "worktree.updated") void bootstrap.refetch()
if (event.type === "reference.updated" && children.active(key))
void data.location.reference.sync({ directory: key }).catch(() => undefined)
})
onCleanup(unsub)
+5 -5
View File
@@ -55,9 +55,9 @@ export function createSessionReview(input: {
const options = createMemo<ChangeMode[]>(() => {
const list: ChangeMode[] = []
const project = input.session.project()
if (project?.vcs) list.push("git")
if (project?.vcs === "git") list.push("git")
if (
project?.vcs &&
project?.vcs === "git" &&
vcs()?.branch.current &&
vcs()?.branch.default &&
vcs()?.branch.current !== vcs()?.branch.default
@@ -94,7 +94,7 @@ export function createSessionReview(input: {
const value = vcsMode()
return {
queryKey: [...vcsKey(), value] as const,
enabled: server.connection.status() === "connected" && wantsReview() && !!input.session.project()?.vcs,
enabled: server.connection.status() === "connected" && wantsReview() && input.session.project()?.vcs === "git",
refetchOnMount: "always" as const,
refetchOnWindowFocus: true,
queryFn: value
@@ -110,7 +110,7 @@ export function createSessionReview(input: {
})
const detailsQuery = createQuery(() => ({
queryKey: [server.scope, "session-details", input.session.workspace.directory()] as const,
enabled: state.detailsOpen && server.connection.status() === "connected" && !!input.session.project()?.vcs,
enabled: state.detailsOpen && server.connection.status() === "connected" && input.session.project()?.vcs === "git",
queryFn: () =>
server.api.vcs
.diff({ location: { directory: input.session.workspace.directory() }, mode: "working" })
@@ -413,7 +413,7 @@ export function createSessionReview(input: {
tab: () => state.mobileTab,
},
mode,
noGit: createMemo(() => !!input.session.project() && !input.session.project()?.vcs),
noGit: createMemo(() => !!input.session.project() && input.session.project()?.vcs !== "git"),
open,
openFile,
options,
+1 -1
View File
@@ -165,7 +165,7 @@ export function SessionScreen(props: { session: SessionModel }) {
baseBranch={
session.shared.data.location.vcs.info({ directory: project().worktree })?.branch.current
}
diffs={project().vcs ? review.details.diffs() : []}
diffs={project().vcs === "git" ? review.details.diffs() : []}
sessionID={session.identity.params.id ?? ""}
moveEligible={composer.workspaceMoveEligible()}
moveDismissed={store.mobileMoveDismissed}
@@ -3,7 +3,6 @@ import type { SessionInboxInfo, SessionMessageInfo } from "@opencode-ai/client/p
import { createRoot } from "solid-js"
import { applyTimelineMessageHandoff, visibleTimelineMessages } from "./controller-projection"
import { createTimelineProjection } from "./projection"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
const messages = [
{ id: "msg_1", type: "user", text: "first", time: { created: 1 } },
@@ -64,7 +63,6 @@ describe("visibleTimelineMessages", () => {
reasoningMode: () => "compact",
shellToolDefaultOpen: () => false,
editToolDefaultOpen: () => false,
timelineDetail: () => timelinePresets[2].value,
pendingUserMessageIDs: () => new Set([steer.id]),
})
expect(projection.activeMessageID()).toBe("msg_1")
@@ -21,7 +21,6 @@ import { applyTimelineMessageHandoff, timelineChildTitle, visibleTimelineMessage
import { createTimelineProjection } from "./projection"
import { useServer } from "@/runtime/server/current"
import { getSessionMessageHandoff } from "@/session/handoff"
import type { ReasoningMode } from "@opencode-ai/session-ui/timeline/projection"
const emptyMessages: SessionMessageInfo[] = []
const taskDescription = (message: SessionMessageInfo, sessionID: string): string | undefined => {
@@ -102,32 +101,12 @@ export function createTimelineController(input: { session: TimelineSessionSource
})
})
const showHeader = createMemo(() => !!input.session.identity.sessionID())
const timelineDetail = createMemo(() => {
const detail = settings.general.timelineDetail()
return {
shell: { ...detail.shell },
edit: { ...detail.edit },
thinking: { ...detail.thinking },
subagents: { ...detail.subagents },
notices: { ...detail.notices },
tools: { ...detail.tools },
}
})
const reasoningMode = (): ReasoningMode =>
timelineDetail().thinking.placement === "hidden"
? "hidden"
: timelineDetail().thinking.details === "expanded"
? "full"
: "compact"
const shellToolPartsExpanded = () => timelineDetail().shell.details === "expanded"
const editToolPartsExpanded = () => timelineDetail().edit.details === "expanded"
const projection = createTimelineProjection({
sessionMessages: projectedMessages,
status: input.session.data.status,
reasoningMode,
shellToolDefaultOpen: shellToolPartsExpanded,
editToolDefaultOpen: editToolPartsExpanded,
timelineDetail,
reasoningMode: settings.general.reasoningMode,
shellToolDefaultOpen: settings.general.shellToolPartsExpanded,
editToolDefaultOpen: settings.general.editToolPartsExpanded,
pendingUserMessageIDs,
})
const [pending, setPending] = createStore({ rename: false })
@@ -256,10 +235,9 @@ export function createTimelineController(input: { session: TimelineSessionSource
childTitle,
showHeader,
projection,
timelineDetail,
reasoningMode,
shellToolPartsExpanded,
editToolPartsExpanded,
reasoningMode: settings.general.reasoningMode,
shellToolPartsExpanded: settings.general.shellToolPartsExpanded,
editToolPartsExpanded: settings.general.editToolPartsExpanded,
},
pending: {
rename: () => pending.rename,
@@ -420,7 +420,6 @@ function MessageTimelineView(
const messageByID = projection.messageByID
const virtualized = createTimelineVirtualizer({
sessionKey: () => `${server.key}/${props.data.sessionID()}`,
presentationKey: () => JSON.stringify(props.data.timelineDetail()),
projection,
showHeader,
pinned,
@@ -528,7 +527,6 @@ function MessageTimelineView(
reasoningMode: props.data.reasoningMode,
shellToolDefaultOpen: props.data.shellToolPartsExpanded,
editToolDefaultOpen: props.data.editToolPartsExpanded,
timelineDetail: props.data.timelineDetail,
disclosure: virtualized.disclosure,
centered: () => props.centered,
padding: turnPadding,
@@ -21,7 +21,7 @@ import {
type Accessor,
type JSX,
} from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import type { createTimelineProjection } from "./projection"
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
@@ -33,15 +33,7 @@ const pendingMarkdown = '[data-component="markdown"]:not([data-markdown-ready])'
// exactly to the end, while a one-pixel nudge upward is a deliberate move away from it.
const endEpsilon = 0.5
const upwardKeys = new Set(["up", "page-up", "home"])
const cache = new Map<
string,
{
measurements: VirtualItem[]
toolOpen: Record<string, boolean | undefined>
patchGroupKeys: Map<string, string>
presentationKey?: string
}
>()
const cache = new Map<string, { measurements: VirtualItem[]; toolOpen: Record<string, boolean | undefined> }>()
type Projection = Pick<
ReturnType<typeof createTimelineProjection>,
@@ -50,7 +42,6 @@ type Projection = Pick<
type Input = {
sessionKey: Accessor<string>
presentationKey?: Accessor<string>
projection: Projection
showHeader: Accessor<boolean>
/** True while the timeline follows the newest content. Drives every anchoring decision. */
@@ -86,20 +77,11 @@ export function createTimelineVirtualizer(input: Input) {
const isDesktop = createMediaQuery("(min-width: 768px)")
const topOffset = () => (input.showHeader() ? 64 : isDesktop() ? 0 : 16)
const ownerSessionKey = input.sessionKey()
const entry = cache.get(ownerSessionKey)
const cached = entry?.presentationKey === input.presentationKey?.() ? entry : undefined
const cached = cache.get(ownerSessionKey)
const initialMeasurements = cached?.measurements
const coldBottomMount = !initialMeasurements?.length && input.pinned()
const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>(cached?.toolOpen ?? {})
const patchGroupKeys = cached?.patchGroupKeys ?? new Map<string, string>()
createEffect(
on(
() => input.presentationKey?.(),
() => setToolOpen(reconcile({})),
{ defer: true },
),
)
const [rendering, setRendering] = createStore({ initialTail: coldBottomMount })
const rows = input.projection.rows
const rowByKey = input.projection.rowByKey
@@ -207,8 +189,7 @@ export function createTimelineVirtualizer(input: Input) {
},
scrollEndThreshold: 80,
get scrollMargin() {
// Empty projections still need the bottom spacer for running status.
return rows().length > 0 ? topOffset() : 0
return topOffset()
},
paddingEnd: 64,
get rangeExtractor() {
@@ -559,13 +540,15 @@ export function createTimelineVirtualizer(input: Input) {
}}
>
<For each={virtualRowKeys()}>{(rowKey) => <VirtualRow rowKey={rowKey} />}</For>
<div
data-timeline-row="bottom-spacer"
class="h-16 absolute top-0 left-0 w-full"
style={{ transform: `translateY(${virtualizer.getTotalSize() - 64}px)` }}
>
{props.bottomSpacer}
</div>
<Show when={rows().length > 0}>
<div
data-timeline-row="bottom-spacer"
class="h-16 absolute top-0 left-0 w-full"
style={{ transform: `translateY(${virtualizer.getTotalSize() - 64}px)` }}
>
{props.bottomSpacer}
</div>
</Show>
</div>
</ScrollView>
</div>
@@ -574,12 +557,7 @@ export function createTimelineVirtualizer(input: Input) {
onCleanup(() => {
cache.delete(ownerSessionKey)
cache.set(ownerSessionKey, {
measurements: virtualizer.takeSnapshot(),
toolOpen: { ...toolOpen },
patchGroupKeys,
presentationKey: input.presentationKey?.(),
})
cache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } })
while (cache.size > 16) cache.delete(cache.keys().next().value!)
coldPending = false
contentObserver?.disconnect()
@@ -591,7 +569,6 @@ export function createTimelineVirtualizer(input: Input) {
return {
disclosure: {
patchGroupKeys,
value: (key: string) => toolOpen[key],
set: (key: string, open: boolean) => setToolOpen(key, open),
},
+55 -13
View File
@@ -4,7 +4,7 @@ import { Button } from "@opencode-ai/ui/button"
import { Select } from "@opencode-ai/ui/select"
import { Switch } from "@opencode-ai/ui/switch"
import { TextInput } from "@opencode-ai/ui/text-input"
import { TimelineDetailControl } from "@/settings/timeline-detail"
import type { ReasoningMode } from "@opencode-ai/session-ui/timeline/projection"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useUpdaterAction } from "@/shell/updates/action"
@@ -184,6 +184,34 @@ const FollowUpBehaviorSetting: Component = () => {
)
}
const ReasoningModeSetting: Component = () => {
const language = useLanguage()
const settings = useSettings()
const options = createMemo((): { value: ReasoningMode; label: string }[] => [
{ value: "hidden", label: language.t("settings.general.row.reasoningMode.hidden") },
{ value: "compact", label: language.t("settings.general.row.reasoningMode.compact") },
{ value: "full", label: language.t("settings.general.row.reasoningMode.full") },
])
return (
<SettingsRow
title={language.t("settings.general.row.reasoningMode.title")}
description={language.t("settings.general.row.reasoningMode.description")}
>
<Select
data-action="settings-reasoning-mode"
options={options()}
current={options().find((option) => option.value === settings.general.reasoningMode())}
value={(option) => option.value}
label={(option) => option.label}
placement="bottom-end"
gutter={6}
onSelect={(option) => option && settings.general.setReasoningMode(option.value)}
/>
</SettingsRow>
)
}
const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => {
const language = useLanguage()
return (
@@ -332,6 +360,8 @@ export const SettingsGeneral: Component<{
<TerminalPlacementSetting />
<FollowUpBehaviorSetting />
<ReasoningModeSetting />
<SettingsRow
title={language.t("session.review.wrapLines")}
description={language.t("settings.general.row.mobileDiffWrap.description")}
@@ -348,6 +378,30 @@ export const SettingsGeneral: Component<{
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
>
<div data-action="settings-feed-shell-tool-parts-expanded">
<Switch
checked={settings.general.shellToolPartsExpanded()}
onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.editToolPartsExpanded.title")}
description={language.t("settings.general.row.editToolPartsExpanded.description")}
>
<div data-action="settings-feed-edit-tool-parts-expanded">
<Switch
checked={settings.general.editToolPartsExpanded()}
onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
/>
</div>
</SettingsRow>
<Show when={import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
<SettingsRow
title={language.t("settings.general.row.showProjectIcon.title")}
@@ -531,18 +585,6 @@ export const SettingsGeneral: Component<{
<div class="settings-tab-body">
<GeneralSection />
<section class="settings-section" aria-label={language.t("settings.timeline.title")}>
<h3 class="settings-section-title">{language.t("settings.timeline.title")}</h3>
<SettingsList>
<div class="py-5">
<TimelineDetailControl
value={settings.general.timelineDetail()}
onChange={settings.general.setTimelineDetail}
/>
</div>
</SettingsList>
</section>
<Show when={desktop()}>
<UpdatesSection />
</Show>
+53 -20
View File
@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import { Persistence } from "@/runtime/persistence/schema"
import {
settingsSchema,
@@ -17,19 +16,54 @@ const schema = Persistence.withInitial(settingsPersistence, defaultSettings)
const decode = Schema.decodeUnknownSync(schema)
const encode = Schema.encodeSync(schema)
describe("settings timeline detail migration", () => {
test("migrates saved switches and round trips the current settings", () => {
const settings = decode({
general: { shellToolPartsExpanded: true, editToolPartsExpanded: false, showReasoningSummaries: true },
appearance: { fontSize: 16 },
})
expect(settings.general.timelineDetail).toEqual({
...timelinePresets[2].value,
shell: { placement: "separate", details: "expanded" },
thinking: { placement: "separate", details: "expanded" },
})
describe("settings reasoning mode migration", () => {
test.each([
[true, "full"],
[false, "compact"],
] as const)("maps persisted reasoning summaries %s to %s", (showReasoningSummaries, reasoningMode) => {
const value = { general: { showReasoningSummaries, showTerminal: true }, appearance: { fontSize: 16 } }
const settings = decode(value)
expect(settings.general.reasoningMode).toBe(reasoningMode)
expect(settings.general.showTerminal).toBe(true)
expect(settings.appearance.fontSize).toBe(16)
expect(decode(encode(settings))).toEqual(settings)
expect(settings.general).not.toHaveProperty("showReasoningSummaries")
expect(value.general).not.toHaveProperty("reasoningMode")
})
test.each(["hidden", "compact", "full"])(
"preserves an explicit %s mode over either legacy value",
(reasoningMode) => {
;[true, false].forEach((showReasoningSummaries) => {
const value = { general: { reasoningMode, showReasoningSummaries } }
expect(decode(value).general.reasoningMode).toBe(reasoningMode)
})
},
)
test.each([undefined, null, {}, { showReasoningSummaries: "true" }])(
"defaults invalid or absent legacy settings: %j",
(general) => {
expect(decode({ general }).general.reasoningMode).toBe("compact")
},
)
test("migrates an undefined current mode but defaults an invalid current mode", () => {
expect(decode({ general: { reasoningMode: undefined, showReasoningSummaries: true } }).general.reasoningMode).toBe(
"full",
)
expect(decode({ general: { reasoningMode: "invalid", showReasoningSummaries: true } }).general.reasoningMode).toBe(
"compact",
)
})
test("encodes only the current format and round trips migrated settings", () => {
const settings = decode({ general: { showReasoningSummaries: true, obsolete: true }, obsolete: true })
const encoded = encode(settings)
expect(encoded).toEqual(settings)
expect(encoded).not.toHaveProperty("obsolete")
expect(encoded).not.toHaveProperty("general.obsolete")
expect(encoded).not.toHaveProperty("general.showReasoningSummaries")
expect(decode(encoded)).toEqual(settings)
})
})
@@ -37,16 +71,13 @@ describe("settings schema", () => {
test("uses the supplied initial values independently of the current schema", () => {
const initial = {
...defaultSettings,
general: { ...defaultSettings.general, timelineDetail: timelinePresets[4].value, autoSave: false },
general: { ...defaultSettings.general, reasoningMode: "hidden" as const, autoSave: false },
appearance: { ...defaultSettings.appearance, fontSize: 20 },
}
const restore = Schema.decodeUnknownSync(Persistence.withInitial(settingsPersistence, initial))
expect(restore({})).toEqual(initial)
expect(restore({ general: { reasoningMode: "invalid", showReasoningSummaries: true } })).toEqual(initial)
expect(restore({ general: { showReasoningSummaries: true } }).general.timelineDetail.thinking).toEqual({
placement: "separate",
details: "expanded",
})
expect(restore({ general: { showReasoningSummaries: true } }).general.reasoningMode).toBe("full")
expect(() => Schema.decodeUnknownSync(settingsSchema)({})).toThrow()
})
@@ -61,7 +92,9 @@ describe("settings schema", () => {
showStatus: false,
showProjectIcon: false,
showTerminal: false,
timelineDetail: timelinePresets[2].value,
reasoningMode: "compact",
shellToolPartsExpanded: false,
editToolPartsExpanded: false,
showCustomAgents: false,
mobileTitlebarPosition: "top",
mobileDiffWrap: true,
@@ -111,7 +144,7 @@ describe("settings schema", () => {
showTerminal: true,
autoSave: false,
releaseNotes: true,
timelineDetail: timelinePresets[2].value,
reasoningMode: "compact",
followUpBehavior: "steer",
})
expect(settings.appearance).toEqual({
+31 -89
View File
@@ -1,8 +1,8 @@
import { reconcile, unwrap } from "solid-js/store"
import { reconcile } from "solid-js/store"
import { createEffect, createMemo } from "solid-js"
import { Effect, Option, Schema, SchemaGetter } from "effect"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { timelinePresets, type TimelineCategory, type TimelineDetail } from "@opencode-ai/session-ui/timeline/detail"
import type { ReasoningMode } from "@opencode-ai/session-ui/timeline/projection"
import { persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
@@ -68,10 +68,7 @@ export function terminalFontFamily(font: string | undefined) {
return stack(font, terminalBase)
}
const placementSchema = Schema.Literals(["separate", "grouped", "hidden"])
const detailsSchema = Schema.Literals(["collapsed", "expanded"])
const activitySchema = Persistence.struct({ placement: placementSchema, details: detailsSchema })
const placementOnlySchema = Persistence.struct({ placement: placementSchema })
const reasoningModeSchema = Schema.Literals(["hidden", "compact", "full"])
const generalSchema = Persistence.struct({
autoSave: Schema.Boolean,
@@ -82,14 +79,9 @@ const generalSchema = Persistence.struct({
showStatus: Schema.Boolean,
showProjectIcon: Schema.Boolean,
showTerminal: Schema.Boolean,
timelineDetail: Persistence.struct({
shell: activitySchema,
edit: activitySchema,
thinking: activitySchema,
subagents: placementOnlySchema,
notices: placementOnlySchema,
tools: placementOnlySchema,
}),
reasoningMode: reasoningModeSchema,
shellToolPartsExpanded: Schema.Boolean,
editToolPartsExpanded: Schema.Boolean,
showCustomAgents: Schema.Boolean,
mobileTitlebarPosition: Schema.Literals(["top", "bottom"]),
mobileDiffWrap: Schema.Boolean,
@@ -142,91 +134,25 @@ export const settingsSchema = Persistence.struct({
sounds: soundsSchema,
})
function storedTimelineCategory(category: TimelineCategory) {
return Persistence.optional(
Schema.Union([
Schema.Struct({
placement: Persistence.optional(placementSchema),
details: Persistence.optional(detailsSchema),
}),
Schema.Literals(["expanded", "collapsed", "hidden", "visible"]),
]).pipe(
Schema.decode({
decode: SchemaGetter.transform((value) => {
if (typeof value !== "string") return value
return {
placement:
value === "hidden"
? "hidden"
: category === "subagents"
? "separate"
: category === "tools"
? "grouped"
: value === "expanded"
? "separate"
: value === "collapsed"
? "grouped"
: undefined,
details: value === "expanded" ? "expanded" : "collapsed",
}
}),
encode: SchemaGetter.passthrough(),
}),
),
)
}
function legacyTimelineActivity(value: boolean | "hidden" | "compact" | "full" | null | undefined) {
if (value === undefined || value === null) return
const expanded = value === true || value === "full"
return {
placement: value === "hidden" ? "hidden" : expanded ? "separate" : "grouped",
details: expanded ? "expanded" : "collapsed",
} as const
}
export const settingsPersistence = Persistence.migrate(
settingsSchema,
Schema.Struct({
general: Persistence.optional(
Schema.Struct({
// Keep invalid explicit values distinct from absent values so legacy preferences cannot replace them.
timelineDetail: Schema.optional(
Schema.NullOr(
Schema.Struct({
shell: storedTimelineCategory("shell"),
edit: storedTimelineCategory("edit"),
thinking: storedTimelineCategory("thinking"),
subagents: storedTimelineCategory("subagents"),
notices: storedTimelineCategory("notices"),
tools: storedTimelineCategory("tools"),
}),
),
).pipe(Schema.catchDecoding(() => Effect.succeed(Option.some(null)))),
reasoningMode: Schema.optional(Schema.NullOr(Schema.Literals(["hidden", "compact", "full"]))).pipe(
Schema.catchDecoding(() => Effect.succeed(Option.some(null))),
),
reasoningMode: Schema.optional(Schema.Unknown),
showReasoningSummaries: Persistence.optional(Schema.Boolean),
shellToolPartsExpanded: Persistence.optional(Schema.Boolean),
editToolPartsExpanded: Persistence.optional(Schema.Boolean),
}),
),
}).pipe(
Schema.decode({
decode: SchemaGetter.transform((value) => {
const general = value.general
if (!general || general.timelineDetail !== undefined) return value
if (value.general?.reasoningMode !== undefined || value.general?.showReasoningSummaries === undefined)
return value
return {
...value,
general: {
...general,
timelineDetail: {
shell: legacyTimelineActivity(general.shellToolPartsExpanded),
edit: legacyTimelineActivity(general.editToolPartsExpanded),
thinking: legacyTimelineActivity(
general.reasoningMode === undefined ? general.showReasoningSummaries : general.reasoningMode,
),
},
...value.general,
reasoningMode: value.general.showReasoningSummaries ? "full" : "compact",
},
}
}),
@@ -245,7 +171,9 @@ export const defaultSettings: Settings = {
showStatus: false,
showProjectIcon: false,
showTerminal: false,
timelineDetail: { ...timelinePresets[2].value },
reasoningMode: "compact",
shellToolPartsExpanded: false,
editToolPartsExpanded: false,
showCustomAgents: false,
mobileTitlebarPosition: "top",
mobileDiffWrap: true,
@@ -328,9 +256,23 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setShowTerminal(value: boolean) {
setStore("general", "showTerminal", value)
},
timelineDetail: withFallback(() => store.general?.timelineDetail, defaultSettings.general.timelineDetail),
setTimelineDetail(value: TimelineDetail) {
setStore("general", "timelineDetail", structuredClone(unwrap(value)))
reasoningMode: withFallback(() => store.general?.reasoningMode, defaultSettings.general.reasoningMode),
setReasoningMode(value: ReasoningMode) {
setStore("general", "reasoningMode", value)
},
shellToolPartsExpanded: withFallback(
() => store.general?.shellToolPartsExpanded,
defaultSettings.general.shellToolPartsExpanded,
),
setShellToolPartsExpanded(value: boolean) {
setStore("general", "shellToolPartsExpanded", value)
},
editToolPartsExpanded: withFallback(
() => store.general?.editToolPartsExpanded,
defaultSettings.general.editToolPartsExpanded,
),
setEditToolPartsExpanded(value: boolean) {
setStore("general", "editToolPartsExpanded", value)
},
showCustomAgents,
setShowCustomAgents(value: boolean) {
@@ -1,249 +0,0 @@
[data-component="timeline-detail-control"] {
display: flex;
min-width: 0;
flex-direction: column;
gap: 8px;
color: var(--v2-text-text-base);
font-size: 13px;
line-height: var(--line-height-base);
letter-spacing: -0.04px;
container: timeline-detail / inline-size;
[data-slot="timeline-detail-heading"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 24px;
font-weight: 530;
line-height: var(--line-height-compact);
}
[data-slot="timeline-detail-current"] {
color: var(--v2-text-text-muted);
text-align: end;
}
p {
margin: 0;
color: var(--v2-text-text-muted);
}
[data-slot="timeline-detail-scale"] {
position: relative;
height: 28px;
}
[data-slot="timeline-detail-track"] {
position: absolute;
inset-inline: 8px;
top: 12px;
height: 4px;
display: flex;
align-items: center;
justify-content: space-between;
border-radius: 2px;
background: var(--v2-background-bg-layer-03);
pointer-events: none;
span {
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--v2-border-border-strong);
}
}
input[type="range"] {
appearance: none;
position: relative;
display: block;
width: 100%;
height: 28px;
margin: 0;
background: transparent;
cursor: pointer;
border-radius: 4px;
&:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: 2px;
}
&::-webkit-slider-runnable-track {
height: 4px;
background: transparent;
}
&::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
margin-top: -6px;
border: 1px solid var(--v2-border-border-base);
border-radius: 50%;
background: var(--v2-background-bg-base);
box-shadow: var(--v2-elevation-button-neutral);
}
&::-moz-range-track {
height: 4px;
background: transparent;
}
&::-moz-range-thumb {
box-sizing: border-box;
width: 16px;
height: 16px;
border: 1px solid var(--v2-border-border-base);
border-radius: 50%;
background: var(--v2-background-bg-base);
box-shadow: var(--v2-elevation-button-neutral);
}
}
[data-slot="timeline-detail-advanced"] {
margin-top: 4px;
padding-top: 8px;
border-top: 0.5px solid var(--v2-border-border-base);
}
[data-slot="timeline-detail-advanced"] > [data-slot="collapsible-trigger"] {
width: fit-content;
height: 28px;
align-self: flex-start;
gap: 6px;
font-size: inherit;
font-weight: 530;
line-height: var(--line-height-compact);
letter-spacing: inherit;
color: var(--v2-text-text-muted);
[data-slot="collapsible-arrow"] {
width: 16px;
}
&:focus-visible [data-slot="collapsible-arrow"] {
opacity: 1;
}
&:dir(rtl):not([aria-expanded="true"]) [data-slot="collapsible-arrow-icon"] {
transform: rotate(90deg);
}
}
[data-slot="timeline-detail-categories"] {
--timeline-detail-columns: minmax(0, 1fr) 100px 108px;
display: flex;
flex-direction: column;
margin-top: 8px;
}
[data-slot="timeline-detail-explainer"] {
margin-bottom: 12px;
}
[data-slot="timeline-detail-field-label"] {
display: none;
}
[data-slot="timeline-detail-columns"],
[data-slot="timeline-detail-category"] {
display: grid;
grid-template-columns: var(--timeline-detail-columns);
align-items: center;
column-gap: 8px;
line-height: var(--line-height-compact);
> span {
min-width: 0;
overflow-wrap: normal;
}
}
[data-slot="timeline-detail-columns"] {
min-height: 28px;
color: var(--v2-text-text-muted);
span:not(:first-child) {
padding-inline-start: 8px;
}
}
[data-slot="timeline-detail-category"] {
min-height: 40px;
padding-block: 8px;
border-bottom: 0.5px solid var(--v2-border-border-base);
&:last-child {
border-bottom: 0;
}
[data-component="select-v2-root"],
[data-slot="timeline-detail-placement"],
[data-slot="timeline-detail-expansion"] {
min-width: 0;
}
[data-component="select-v2-root"][data-field] {
width: 100%;
}
[data-component="select-v2"][data-appearance="inline"] {
width: 100%;
}
}
}
@container timeline-detail (max-width: 250px) {
[data-component="timeline-detail-control"] [data-slot="timeline-detail-heading"] {
flex-wrap: wrap;
gap: 4px 8px;
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-current"] {
margin-inline-start: auto;
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-columns"] {
display: none;
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-category"] {
grid-template-columns: minmax(0, 1fr);
row-gap: 8px;
padding-block: 12px;
> span {
font-weight: 530;
}
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-field-label"] {
display: block;
margin-bottom: 2px;
padding-inline-start: 8px;
font-size: 12px;
line-height: var(--line-height-compact);
color: var(--v2-text-text-muted);
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-expansion"]:empty {
display: none;
}
}
@container timeline-detail (max-width: 310px) {
[data-component="timeline-detail-control"] [data-slot="timeline-detail-categories"] {
--timeline-detail-columns: minmax(0, 1fr) 90px 96px;
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-columns"],
[data-component="timeline-detail-control"] [data-slot="timeline-detail-category"] {
column-gap: 4px;
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-category"] {
min-height: 48px;
}
}
@@ -1,127 +0,0 @@
import { For, Show, createMemo, createUniqueId } from "solid-js"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { Select } from "@opencode-ai/ui/select"
import {
timelineCategories,
timelinePreset,
timelinePresets,
type TimelineDetail,
type TimelineExpansion,
type TimelinePlacement,
} from "@opencode-ai/session-ui/timeline/detail"
import { useLanguage } from "@/runtime/i18n/language"
import "./timeline-detail.css"
const placements: TimelinePlacement[] = ["separate", "grouped", "hidden"]
const expansions: TimelineExpansion[] = ["collapsed", "expanded"]
export function TimelineDetailControl(props: { value: TimelineDetail; onChange: (value: TimelineDetail) => void }) {
const language = useLanguage()
const id = createUniqueId()
const preset = createMemo(() => timelinePreset(props.value))
const position = () => {
const current = preset()
return current ? timelinePresets.indexOf(current) : 2
}
const label = () => {
const current = preset()
return current ? language.t(`settings.timeline.preset.${current.id}`) : language.t("settings.timeline.custom")
}
return (
<div data-component="timeline-detail-control">
<div data-slot="timeline-detail-heading">
<label for={`${id}-slider`}>{language.t("settings.timeline.detail")}</label>
<span data-slot="timeline-detail-current" aria-live="polite">
{label()}
</span>
</div>
<p id={`${id}-description`} class="sr-only">
{language.t("settings.timeline.description")}
</p>
<div data-slot="timeline-detail-scale">
<div data-slot="timeline-detail-track" aria-hidden="true">
<For each={timelinePresets}>{() => <span />}</For>
</div>
<input
id={`${id}-slider`}
data-action="settings-timeline-detail"
type="range"
min="0"
max={timelinePresets.length - 1}
step="1"
value={position()}
aria-valuetext={label()}
aria-describedby={`${id}-description ${id}-preset-description`}
onInput={(event) => props.onChange({ ...timelinePresets[event.currentTarget.valueAsNumber].value })}
/>
</div>
<p id={`${id}-preset-description`}>{language.t(`settings.timeline.description.${preset()?.id ?? "custom"}`)}</p>
<Collapsible variant="ghost" data-slot="timeline-detail-advanced">
<Collapsible.Trigger>
<span>{language.t("settings.timeline.advanced")}</span>
<Collapsible.Arrow />
</Collapsible.Trigger>
<Collapsible.Content>
<div
data-slot="timeline-detail-categories"
role="group"
aria-label={language.t("settings.timeline.advanced.description")}
>
<p data-slot="timeline-detail-explainer">{language.t("settings.timeline.advanced.explainer")}</p>
<div data-slot="timeline-detail-columns">
<span>{language.t("settings.timeline.activity")}</span>
<span id={`${id}-placement`}>{language.t("settings.timeline.placement.title")}</span>
<span id={`${id}-expansion`}>{language.t("settings.timeline.expansion.title")}</span>
</div>
<For each={timelineCategories}>
{(category) => (
<div data-slot="timeline-detail-category" role="group" aria-labelledby={`${id}-${category}`}>
<span id={`${id}-${category}`}>{language.t(`settings.timeline.category.${category}`)}</span>
<div data-slot="timeline-detail-placement">
<span data-slot="timeline-detail-field-label" aria-hidden="true">
{language.t("settings.timeline.placement.title")}
</span>
<Select
data-category={category}
data-field="placement"
aria-labelledby={`${id}-${category} ${id}-placement`}
options={placements}
current={props.value[category].placement}
label={(value) => language.t(`settings.timeline.placement.${value}`)}
onSelect={(placement) =>
placement &&
props.onChange({ ...props.value, [category]: { ...props.value[category], placement } })
}
/>
</div>
<div data-slot="timeline-detail-expansion">
{category === "shell" || category === "edit" || category === "thinking" ? (
<Show when={props.value[category].placement !== "hidden"}>
<span data-slot="timeline-detail-field-label" aria-hidden="true">
{language.t("settings.timeline.expansion.title")}
</span>
<Select
data-category={category}
data-field="details"
aria-labelledby={`${id}-${category} ${id}-expansion`}
options={expansions}
current={props.value[category].details}
label={(value) => language.t(`settings.timeline.expansion.${value}`)}
onSelect={(details) =>
details &&
props.onChange({ ...props.value, [category]: { ...props.value[category], details } })
}
/>
</Show>
) : null}
</div>
</div>
)}
</For>
</div>
</Collapsible.Content>
</Collapsible>
</div>
)
}
+9 -4
View File
@@ -395,8 +395,11 @@ export function DraftTabItem(props: {
data-active={props.active}
data-dragging={props.dragging}
data-state={props.active || props.pressed ? "pressed" : undefined}
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] px-1.5 [container-type:inline-size] whitespace-nowrap"
classList={{ invisible: props.hidden }}
class="group relative flex h-7 min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] px-1.5 whitespace-nowrap"
classList={{
invisible: props.hidden,
"w-full [container-type:inline-size]": props.orientation === "vertical",
}}
onMouseDown={(event) => {
if (event.button !== MIDDLE_MOUSE_BUTTON) return
event.preventDefault()
@@ -429,14 +432,16 @@ export function DraftTabItem(props: {
if (props.suppressNavigation) return
props.onNavigate()
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
class="flex h-full min-w-0 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
classList={{ "flex-1": props.orientation === "vertical", "flex-none pe-10": props.orientation !== "vertical" }}
>
<span class="flex size-4 shrink-0 items-center justify-center">
<Icon name="edit" />
</span>
<span
data-titlebar-tab-title
class="min-w-0 flex-1 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
class="min-w-0 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
classList={{ "flex-1": props.orientation === "vertical", "flex-none": props.orientation !== "vertical" }}
>
{props.title}
</span>
@@ -211,7 +211,7 @@ function DraftTabSlot(props: {
data-orientation={props.orientation}
class="relative flex"
classList={{
"w-56 min-w-7 max-w-56 flex-shrink": props.orientation === "horizontal",
"w-max min-w-7 max-w-56 shrink-0": props.orientation === "horizontal",
"w-full shrink-0": props.orientation === "vertical",
}}
>
+1 -10
View File
@@ -128,9 +128,6 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
external: ["node-gyp"],
format: "esm",
minify: true,
// Bun 1.4.0 cross-compiled bytecode can crash on Windows (oven-sh/bun#40270).
// Re-enable after both the builder and embedded runtime move to Bun 1.4.1.
bytecode: false,
sourcemap: Script.channel === "dev" || Script.channel === "local" ? "inline" : "none",
splitting: true,
compile: {
@@ -141,19 +138,13 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
target: target.replace(binary, "bun") as Bun.Build.CompileTarget,
...(executablePath ? { executablePath } : {}),
outfile: path.join(outdir, name, "bin", binary),
execArgv: [
`--user-agent=opencode/${Script.channel}/${Script.version}/cli`,
"--use-system-ca",
"--no-warnings",
"--",
],
execArgv: [`--user-agent=${binary}/${Script.version}`, "--use-system-ca", "--no-warnings", "--"],
windows: {},
},
define: {
OPENCODE_VERSION: `'${Script.version}'`,
OPENCODE_CLI_NAME: `'${binary}'`,
OPENCODE_CHANNEL: `'${Script.channel}'`,
OPENCODE_ARTIFACT: `'cli'`,
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
// FFF_LIBC selects the fff native lib variant: "musl" or "gnu".
FFF_LIBC: item.os === "linux" ? `'${item.abi ?? "gnu"}'` : "undefined",
+8 -16
View File
@@ -23,13 +23,7 @@ async function publish(dir: string, name: string, version: string) {
}
}
async function publishDistribution(input: {
root: string
name: string
binary: string
packagePrefix: string
artifact: string
}) {
async function publishDistribution(input: { root: string; name: string; binary: string; packagePrefix: string }) {
const binaries: Record<string, string> = {}
for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: input.root })) {
const item = await Bun.file(`${input.root}/${filepath}`).json()
@@ -78,13 +72,6 @@ async function publishDistribution(input: {
),
)
await publish(`${input.root}/${input.name}`, input.name, version)
await UpdateArtifact.publish({
channel: Script.channel,
name: input.artifact,
distribution: "npm",
version,
metadata: { package: input.name },
})
}
await publishDistribution({
@@ -92,7 +79,6 @@ await publishDistribution({
name: pkg.name,
binary: "opencode2",
packagePrefix: "@opencode-ai/cli-",
artifact: "cli",
})
if (existsSync("./dist/node")) {
await publishDistribution({
@@ -100,6 +86,12 @@ if (existsSync("./dist/node")) {
name: "opencode-node",
binary: "opencode2-node",
packagePrefix: "@opencode-ai/cli-node-",
artifact: "cli-node",
})
}
await UpdateArtifact.publish({
channel: Script.channel,
name: "cli",
distribution: "npm",
version: Script.version,
metadata: {},
})
+1 -5
View File
@@ -93,11 +93,7 @@ try {
}
const output = await Promise.all(errors)
// Windows can retain directory handles briefly after the service processes exit.
await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }).catch((cause: unknown) => {
console.error("Failed to remove service smoke-test directory", cause)
failure ??= cause
})
await fs.rm(root, { recursive: true, force: true })
if (failure)
throw new Error(output.filter(Boolean).join("\n") || "Compiled service lifecycle smoke test failed", {
cause: failure,
@@ -9,7 +9,7 @@ import { ServerConnection } from "../../services/server-connection"
import { Updater } from "../../services/updater"
import { UpdatePreflight } from "../../services/update-preflight"
import { Npm } from "@opencode-ai/util/npm"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_VERSION } from "../../version"
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "../../version"
import { Env } from "../../env"
export default Runtime.handler(Commands, (input) =>
@@ -59,7 +59,7 @@ export default Runtime.handler(Commands, (input) =>
const service = server.service
yield* run({
app: {
name: process.env.OPENCODE_CLIENT ?? OPENCODE_ARTIFACT,
name: process.env.OPENCODE_CLIENT ?? "cli",
version: OPENCODE_VERSION,
channel: process.env.OPENCODE_TUI_CHANNEL ?? OPENCODE_CHANNEL,
},
@@ -89,7 +89,12 @@ export default Runtime.handler(Commands, (input) =>
}
: undefined,
packages: {
prepare: (spec, install = true) => runPromise(install ? npm.add(spec) : npm.resolve(spec)),
resolve: (spec, install = true) =>
runPromise(
(install ? npm.add(spec, { subpaths: ["tui"] }) : npm.resolve(spec, { subpaths: ["tui"] })).pipe(
Effect.map((result) => result.entrypoint),
),
),
},
environment: requestedServer === undefined ? Env.session() : undefined,
terminalHandoff: () => preflight.finish(),
@@ -5,7 +5,6 @@ import { Effect } from "effect"
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { Host } from "@opencode-ai/plugin/host"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { resolveConfigPath } from "../mcp/add"
@@ -17,9 +16,9 @@ export default Runtime.handler(
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(input.package))))
return yield* Effect.fail(new Error("Plugin target must be an npm registry package or Git package specifier"))
const npm = yield* Npm.Service
const installed = yield* npm.add(input.package)
const entrypoints = Host.resolve(installed)
const target = configurationTarget(entrypoints.server, entrypoints.tui)
const installed = yield* npm.add(input.package, { subpaths: ["server", ""] })
const tui = yield* npm.resolve(input.package, { subpaths: ["tui"] })
const target = configurationTarget(installed.entrypoint, tui.entrypoint)
if (!target)
return yield* Effect.fail(new Error(`Plugin package has no server or TUI entrypoint: ${input.package}`))
@@ -62,7 +62,7 @@ export const inspect = Effect.fn("cli.plugin.inspect")(function* (selected?: str
tuiTargets,
(target) =>
Effect.gen(function* () {
const installed = yield* npm.resolve(target)
const installed = yield* npm.resolve(target, { subpaths: ["tui"] })
const outdated = yield* npm.check(target).pipe(Effect.exit)
return {
runtime: "TUI" as const,
@@ -9,9 +9,8 @@ import { ServiceConfig } from "../../../services/service-config"
import { Config } from "../../../config"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { Host } from "@opencode-ai/plugin/host"
import { fileURLToPath } from "node:url"
import { discoverPluginTargets, localPluginDirectories, localSource } from "@opencode-ai/tui/plugin/discovery"
import { discoverTuiPlugins, localPluginDirectories, localSource } from "@opencode-ai/tui/plugin/discovery"
export default Runtime.handler(
Commands.commands.plugin.commands.list,
@@ -23,27 +22,27 @@ export default Runtime.handler(
const global = yield* Global.Service
const info = yield* config.get()
const discovered = yield* Effect.promise(() =>
localPluginDirectories(process.cwd(), global.config).then(discoverPluginTargets),
localPluginDirectories(process.cwd(), global.config).then(discoverTuiPlugins),
)
const npm = yield* Npm.Service
const configured = yield* Effect.forEach([...(info.plugins ?? []), ...discovered], (entry) =>
const configured = yield* Effect.forEach(info.plugins ?? [], (entry) =>
Effect.gen(function* () {
const target = typeof entry === "string" ? entry : entry.package
if (target.startsWith("-") || target === "*" || target.endsWith(".*") || target.startsWith("opencode."))
return []
const local = localSource(target, path.dirname(config.path))
if (local) {
const directory = fileURLToPath(local)
const entrypoints = Host.resolve({ directory })
return entrypoints.tui ? [{ target: directory, version: "local" }] : []
}
if (local) return [{ target: fileURLToPath(local), version: "local" }]
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(target)))) return []
const installed = yield* npm.resolve(target)
if (!Host.resolve(installed).tui) return []
const installed = yield* npm.resolve(target, { subpaths: ["tui"] })
if (!installed.entrypoint) return []
return [{ target, version: installed.version }]
}),
)
const output = format(response.data, configured.flat(), input.builtin)
const output = format(
response.data,
[...configured.flat(), ...discovered.map((target) => ({ target, version: "local" }))],
input.builtin,
)
if (!output) {
process.stdout.write("No plugins found" + EOL)
return
@@ -82,7 +81,9 @@ export function format(
? plugin.source.target === item.target
: plugin.source.type === "local" &&
(plugin.source.path === item.target ||
(plugin.features.tui && path.dirname(plugin.source.path) === item.target)),
(plugin.features.tui &&
path.dirname(plugin.source.path) ===
(item.version === "local" && path.extname(item.target) ? path.dirname(item.target) : item.target))),
),
)
.filter((plugin, index, all) => all.findIndex((candidate) => candidate.target === plugin.target) === index)
@@ -26,7 +26,7 @@ export default Runtime.handler(
(item) =>
(item.runtime === "Server"
? Effect.promise(() => result.client.plugin.update({ location: result.location, targets: [item.target] }))
: npm.update(item.target).pipe(Effect.asVoid)
: npm.update(item.target, { subpaths: ["tui"] }).pipe(Effect.asVoid)
).pipe(
Effect.exit,
Effect.map((result) => ({ item, result })),
+2 -2
View File
@@ -6,7 +6,7 @@ import { Commands } from "./commands/commands"
import { Runtime } from "./framework/runtime"
import { Observability } from "@opencode-ai/util/observability"
import { Updater } from "./services/updater"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "./version"
import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "./version"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
@@ -113,7 +113,7 @@ Effect.gen(function* () {
Observability.layer({
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
headers: process.env.OTEL_EXPORTER_OTLP_HEADERS,
client: process.env.OPENCODE_CLIENT ?? OPENCODE_ARTIFACT,
client: process.env.OPENCODE_CLIENT ?? "cli",
version: OPENCODE_VERSION,
channel: OPENCODE_CHANNEL,
}),
+2 -2
View File
@@ -4,7 +4,7 @@ import { NodeServices } from "@effect/platform-node"
import { Service, type DiscoverOptions } from "@opencode-ai/client/effect/service"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
import { AppProcess } from "@opencode-ai/util/process"
import { randomBytes, randomUUID } from "node:crypto"
import { spawn } from "node:child_process"
@@ -86,7 +86,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
const server = yield* start(
{
app: {
name: process.env.OPENCODE_CLIENT ?? OPENCODE_ARTIFACT,
name: process.env.OPENCODE_CLIENT ?? "cli",
version: OPENCODE_VERSION,
channel: OPENCODE_CHANNEL,
},
+20 -39
View File
@@ -2,16 +2,21 @@ import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { OpenCode } from "@opencode-ai/client"
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
import { Context, Duration, Effect, FileSystem, Layer, Ref, Schedule, Semaphore, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
import { action, parseReleaseVersion, type Action, type Policy } from "./updater-action"
declare const OPENCODE_CLI_NAME: string | undefined
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
export type Method = (typeof methods)[number]
const packageName =
typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node" ? "opencode-node" : "@opencode-ai/cli"
export interface Interface {
readonly check: () => Effect.Effect<void>
readonly monitor: (input: {
@@ -175,15 +180,6 @@ const make = Effect.gen(function* () {
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
const installedPackage = yield* Effect.gen(function* () {
const executable = yield* fs.realPath(process.execPath)
const directory = path.dirname(path.dirname(executable))
const manifest: { name: string; bin?: Record<string, string> } = yield* fs
.readFileString(path.join(directory, "package.json"))
.pipe(Effect.flatMap((text) => Effect.try(() => JSON.parse(text))))
if (Object.values(manifest.bin ?? {}).some((bin) => path.resolve(directory, bin) === executable))
return manifest.name
}).pipe(Effect.orElseSucceed(() => undefined))
const readPolicy = Effect.fnUntraced(function* () {
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
@@ -220,11 +216,10 @@ const make = Effect.gen(function* () {
process.platform === "win32" ? "opencode2.exe" : "opencode2",
)
if (path.resolve(process.execPath) === path.resolve(binary)) return "curl"
if (!installedPackage) return
const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
{ method: "npm", command: ["npm", "list", "-g", "--depth=0", installedPackage] },
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", installedPackage] },
{ method: "npm", command: ["npm", "list", "-g", "--depth=0", packageName] },
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", packageName] },
{ method: "bun", command: ["bun", "pm", "ls", "-g"] },
{ method: "yarn", command: ["yarn", "global", "list"] },
]
@@ -233,49 +228,35 @@ const make = Effect.gen(function* () {
(check) => run(check.command).pipe(Effect.map((result) => ({ check, result }))),
{ concurrency: "unbounded" },
)
return results.find((result) => result.result.stdout.includes(installedPackage))?.check.method
return results.find((result) => result.result.stdout.includes(packageName))?.check.method
})
const release = Effect.fnUntraced(function* () {
const latest = Effect.fnUntraced(function* () {
const response = yield* Effect.tryPromise({
try: () =>
fetch(
`https://update.opencode.ai/api/${encodeURIComponent(channel)}/${encodeURIComponent(OPENCODE_ARTIFACT)}/npm`,
{
headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` },
signal: AbortSignal.timeout(10_000),
},
),
fetch(`https://update.opencode.ai/api/${encodeURIComponent(channel)}/cli/npm`, {
headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` },
signal: AbortSignal.timeout(10_000),
}),
catch: (cause) => new Error("Failed to check for updates", { cause }),
})
if (!response.ok) return yield* Effect.fail(new Error(`Update check failed with status ${response.status}`))
const data: { version: string; metadata?: { package?: string } } = yield* Effect.tryPromise({
const data = yield* Effect.tryPromise({
try: () => response.json(),
catch: (cause) => new Error("Failed to read update information", { cause }),
})
if (!data.metadata?.package) return yield* Effect.fail(new Error("Update information did not include a package"))
return { package: data.metadata.package, version: data.version }
if (typeof data !== "object" || data === null || !("version" in data) || typeof data.version !== "string") {
return yield* Effect.fail(new Error("Update information did not include a version"))
}
return data.version
})
const latest = () => release().pipe(Effect.map((data) => data.version))
const upgrade = Effect.fnUntraced(function* (method: Method, input: string) {
if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`))
const version = input.trim().replace(/^v/, "")
const packageName = (yield* release()).package
const target = `${packageName}@${version}`
if (installedPackage && packageName !== installedPackage && (method === "pnpm" || method === "yarn")) {
return yield* Effect.fail(new Error(`Reinstall ${target} with ${method} to migrate from ${installedPackage}.`))
}
const commands: Record<Exclude<Method, "bun" | "curl">, string[]> = {
// Keep the old package: uninstalling it can unlink the replacement command.
npm: [
"npm",
"install",
"--global",
...(installedPackage && packageName !== installedPackage ? ["--force"] : []),
target,
],
npm: ["npm", "install", "--global", target],
pnpm: ["pnpm", "add", "--global", `--allow-build=${packageName}`, target],
yarn: ["yarn", "global", "add", target],
}
+1 -3
View File
@@ -1,10 +1,8 @@
declare const OPENCODE_VERSION: string
declare const OPENCODE_CHANNEL: string
declare const OPENCODE_ARTIFACT: string
const version = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
const channel = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
const artifact = typeof OPENCODE_ARTIFACT === "string" ? OPENCODE_ARTIFACT : "cli"
export { version as OPENCODE_VERSION, channel as OPENCODE_CHANNEL, artifact as OPENCODE_ARTIFACT }
export { version as OPENCODE_VERSION, channel as OPENCODE_CHANNEL }
export const OPENCODE_LOCAL = channel === "local"
+4 -29
View File
@@ -1,7 +1,7 @@
import { NodeServices } from "@effect/platform-node"
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { expect, spyOn, test } from "bun:test"
import { expect, test } from "bun:test"
import { Effect, FileSystem, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { existsSync } from "node:fs"
@@ -17,29 +17,11 @@ function fixture(
respond: (command: ChildProcess.StandardCommand) => Partial<AppProcess.RunResult> & {
error?: AppProcess.AppProcessError
} = () => ({}),
name = "@opencode-ai/cli",
) {
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const root = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-updater-" })
const executable = path.join(root, "package", "bin", "opencode")
yield* fs.makeDirectory(path.dirname(executable), { recursive: true })
yield* fs.writeFileString(
path.join(root, "package", "package.json"),
JSON.stringify({ name, bin: { opencode: "bin/opencode" } }),
)
// The updater uses global fetch; scope this replacement to each install test.
yield* Effect.acquireRelease(
Effect.sync(() =>
spyOn(globalThis, "fetch").mockImplementation(
Object.assign(async () => Response.json({ version: "2.3.4", metadata: { package: name } }), {
preconnect: fetch.preconnect,
}),
),
),
(request) => Effect.sync(() => request.mockRestore()),
)
const global = Global.make({
home: path.join(root, "home"),
data: path.join(root, "data"),
@@ -55,10 +37,6 @@ function fixture(
const updater = yield* Updater.Service.pipe(
Effect.provide(Updater.layer),
Effect.provideService(Global.Service, global),
Effect.provideService(FileSystem.FileSystem, {
...fs,
realPath: (input) => (input === process.execPath ? Effect.succeed(executable) : fs.realPath(input)),
}),
Effect.provideService(
AppProcess.Service,
AppProcess.Service.of({
@@ -236,12 +214,9 @@ test("Node distribution honors the compile-time CLI name", async () => {
if (typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node") {
it.live("Node distribution resolves the published npm package", () =>
Effect.gen(function* () {
const test = yield* fixture(
(command) => ({
stdout: Buffer.from(command.command === "npm" ? "opencode-node@2.3.4" : ""),
}),
"opencode-node",
)
const test = yield* fixture((command) => ({
stdout: Buffer.from(command.command === "npm" ? "opencode-node@2.3.4" : ""),
}))
expect(yield* test.updater.method()).toBe("npm")
yield* test.updater.upgrade("npm", "v2.3.4")
yield* test.updater.upgrade("pnpm", "v2.3.4")
-1
View File
@@ -268,7 +268,6 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
OPENCODE_VERSION: JSON.stringify(input.version),
OPENCODE_CLI_NAME: JSON.stringify("opencode2-node"),
OPENCODE_CHANNEL: JSON.stringify(input.channel),
OPENCODE_ARTIFACT: JSON.stringify("cli-node"),
OPENCODE_LIBC: input.target.platform === "linux" ? JSON.stringify("glibc") : "undefined",
FFF_LIBC: input.target.platform === "linux" ? JSON.stringify("gnu") : "undefined",
"process.env.WS_NO_BUFFER_UTIL": JSON.stringify("1"),
+1
View File
@@ -967,6 +967,7 @@ export type SessionLogOutput =
readonly reason: "auto" | "manual"
readonly text: string
readonly recent: string
readonly retained?: { readonly from: SessionMessage.ID; readonly through: SessionMessage.ID } | undefined
}
}
| {
+21 -1
View File
@@ -147,6 +147,7 @@ export type SessionMessageCompactionCompleted = {
reason: "auto" | "manual"
summary: string
recent: string
retained?: { from: string; through: string }
}
export type SessionActive = { type: "running" }
@@ -816,7 +817,13 @@ export type SessionCompactionEnded = {
type: "session.compaction.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; reason: "auto" | "manual"; text: string; recent: string }
data: {
sessionID: string
reason: "auto" | "manual"
text: string
recent: string
retained?: { from: string; through: string }
}
}
export type SessionCompactionFailed = {
@@ -985,6 +992,15 @@ export type ReferenceUpdated = {
data: {}
}
export type PluginAdded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "plugin.added"
location?: LocationRef
data: { id: string }
}
export type PluginUpdated = {
id: string
created: number
@@ -2290,6 +2306,7 @@ export type V2Event =
| ReferenceUpdated
| PermissionAsked
| PermissionReplied
| PluginAdded
| PluginUpdated
| ProjectUpdated
| WorktreeUpdated
@@ -3065,6 +3082,7 @@ export type SessionImportInput = {
readonly reason: "auto" | "manual"
readonly summary: string
readonly recent: string
readonly retained?: { readonly from: string; readonly through: string }
}
| {
readonly type: "compaction"
@@ -3342,6 +3360,7 @@ export type SessionImportInput = {
readonly reason: "auto" | "manual"
readonly summary: string
readonly recent: string
readonly retained?: { readonly from: string; readonly through: string }
}
| {
readonly type: "compaction"
@@ -3619,6 +3638,7 @@ export type SessionImportInput = {
readonly reason: "auto" | "manual"
readonly summary: string
readonly recent: string
readonly retained?: { readonly from: string; readonly through: string }
}
| {
readonly type: "compaction"
+431 -220
View File
@@ -83,8 +83,10 @@ type LocationData = {
agent?: AgentInfo[]
command?: CommandInfo[]
integration?: IntegrationInfo[]
mcpServer?: McpServer[]
mcpResource?: McpResource[]
mcp?: {
server?: McpServer[]
resource?: McpResource[]
}
model?: ModelInfo[]
provider?: ProviderInfo[]
reference?: ReferenceInfo[]
@@ -107,6 +109,7 @@ type Store = {
messageCursor: Record<string, string | undefined>
messageLoading: Record<string, boolean>
pending: Record<string, SessionInboxInfo[]>
input: Record<string, string[]>
permission: Record<string, PermissionRequest[]>
// Pending forms keyed by owner: a session ID or the temporary "global" elicitation sentinel.
form: Record<string, FormWithLocation[]>
@@ -122,8 +125,8 @@ export function locationKey(location: LocationRef) {
return JSON.stringify([location.directory, location.workspaceID])
}
function locationQuery(ref: LocationRef) {
return { directory: ref.directory, workspace: ref.workspaceID }
function locationQuery(ref?: LocationRef) {
return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined
}
function formRequestOptions(sessionID: string, ref?: LocationRef) {
@@ -197,6 +200,7 @@ export function createData(config: CreateDataInput) {
messageCursor: {},
messageLoading: {},
pending: {},
input: {},
permission: {},
form: {},
},
@@ -229,6 +233,13 @@ export function createData(config: CreateDataInput) {
sessionID,
(store.session.pending[sessionID] ?? []).filter((item) => item.id !== inboxID),
)
if (store.session.input[sessionID]?.includes(inboxID))
setStore(
"session",
"input",
sessionID,
(store.session.input[sessionID] ?? []).filter((id) => id !== inboxID),
)
}
function removePermission(sessionID: string, requestID: string) {
@@ -326,8 +337,8 @@ export function createData(config: CreateDataInput) {
return request
}
// Upsert an admitted inbox item into pending and (for user and synthetic
// items) the visible transcript. Used by the inbox.enqueued
// Upsert an admitted inbox item into pending, input, and (for user and
// synthetic items) the visible transcript. Used by the inbox.enqueued
// handler and by optimistic admission; the upsert is what reconciles
// the durable echo with an optimistic placeholder — the durable payload and
// times replace the client's guess.
@@ -342,6 +353,8 @@ export function createData(config: CreateDataInput) {
at < 0 ? [...pending, item] : pending.map((entry, index) => (index === at ? item : entry)),
)
if (item.type === "compaction") return
const input = store.session.input[item.sessionID] ?? []
if (!input.includes(item.id)) setStore("session", "input", item.sessionID, [...input, item.id])
materializeInboxMessage(item)
})
}
@@ -390,44 +403,15 @@ export function createData(config: CreateDataInput) {
index.set(item.id, messages.length)
messages.push(item)
},
insert(sessionID: string, item: SessionMessageInfo) {
message.update(sessionID, (draft, index) => message.append(draft, index, item))
},
// Streaming events target one assistant message and, within it, the latest part of a kind.
// A missing target means the row was never loaded or was evicted; the event is dropped.
editAssistant(sessionID: string, messageID: string, fn: (assistant: SessionMessageAssistant) => void) {
message.update(sessionID, (draft, index) => {
const position = index.get(messageID)
const item = position === undefined ? undefined : draft[position]
if (item?.type === "assistant") fn(item)
})
},
editTool(sessionID: string, messageID: string, toolID: string, fn: (tool: SessionMessageAssistantTool) => void) {
message.editAssistant(sessionID, messageID, (assistant) => {
const tool = assistant.content.findLast(
(item): item is SessionMessageAssistantTool => item.type === "tool" && item.id === toolID,
)
if (tool) fn(tool)
})
},
editText(sessionID: string, messageID: string, fn: (text: SessionMessageAssistantText) => void) {
message.editAssistant(sessionID, messageID, (assistant) => {
const text = assistant.content.findLast((item): item is SessionMessageAssistantText => item.type === "text")
if (text) fn(text)
})
},
editReasoning(sessionID: string, messageID: string, fn: (reasoning: SessionMessageAssistantReasoning) => void) {
message.editAssistant(sessionID, messageID, (assistant) => {
const reasoning = assistant.content.findLast(
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed,
)
if (reasoning) fn(reasoning)
})
},
activeAssistant(messages: SessionMessageInfo[]) {
const item = messages.findLast((item) => item.type === "assistant" && !item.time.completed)
return item?.type === "assistant" ? item : undefined
},
assistant(messages: SessionMessageInfo[], index: Map<string, number>, messageID: string) {
const position = index.get(messageID)
const item = position === undefined ? undefined : messages[position]
return item?.type === "assistant" ? item : undefined
},
shell(messages: SessionMessageInfo[], shellID: string) {
const item = messages.findLast((item) => item.type === "shell" && item.shellID === shellID)
return item?.type === "shell" ? item : undefined
@@ -436,6 +420,19 @@ export function createData(config: CreateDataInput) {
const item = messages.findLast((item) => item.type === "compaction" && item.status === "running")
return item?.type === "compaction" ? item : undefined
},
latestTool(assistant: SessionMessageAssistant | undefined, id?: string) {
return assistant?.content.findLast(
(item): item is SessionMessageAssistantTool => item.type === "tool" && (id === undefined || item.id === id),
)
},
latestText(assistant: SessionMessageAssistant | undefined) {
return assistant?.content.findLast((item): item is SessionMessageAssistantText => item.type === "text")
},
latestReasoning(assistant: SessionMessageAssistant | undefined) {
return assistant?.content.findLast(
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed,
)
},
reindex(messages: SessionMessageInfo[], index: Map<string, number>, start: number) {
for (let position = start; position < messages.length; position++) {
const item = messages[position]
@@ -513,8 +510,12 @@ export function createData(config: CreateDataInput) {
delete draft.messageCursor[sessionID]
delete draft.messageLoading[sessionID]
delete draft.pending[sessionID]
delete draft.input[sessionID]
if (messages.length) draft.message[sessionID] = messages
if (pending.length) draft.pending[sessionID] = pending
if (pending.length) {
draft.pending[sessionID] = pending
draft.input[sessionID] = pending.filter((item) => item.type !== "compaction").map((item) => item.id)
}
}),
)
}
@@ -538,6 +539,7 @@ export function createData(config: CreateDataInput) {
delete draft.messageCursor[sessionID]
delete draft.messageLoading[sessionID]
delete draft.pending[sessionID]
delete draft.input[sessionID]
delete draft.permission[sessionID]
delete draft.form[sessionID]
for (const [rootID, family] of Object.entries(draft.family)) {
@@ -609,12 +611,14 @@ export function createData(config: CreateDataInput) {
const previous = store.session.info[event.data.sessionID]?.agent
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "agent", event.data.agent)
message.insert(event.data.sessionID, {
id: messageIDFromEvent(event.id),
type: "agent-switched",
agent: event.data.agent,
previous,
time: { created: event.created },
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),
type: "agent-switched",
agent: event.data.agent,
previous,
time: { created: event.created },
})
})
return
}
@@ -622,11 +626,13 @@ export function createData(config: CreateDataInput) {
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "model", event.data.model)
if (!store.session.message[event.data.sessionID]) return
message.insert(event.data.sessionID, {
id: messageIDFromEvent(event.id),
type: "model-switched",
model: event.data.model,
time: { created: event.created },
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),
type: "model-switched",
model: event.data.model,
time: { created: event.created },
})
})
void api()
.session.message({ sessionID: event.data.sessionID, messageID: messageIDFromEvent(event.id) })
@@ -661,14 +667,16 @@ export function createData(config: CreateDataInput) {
setStore("session", "info", event.data.sessionID, "location", event.data.location)
if (event.data.projectID) setStore("session", "info", event.data.sessionID, "projectID", event.data.projectID)
setStore("session", "info", event.data.sessionID, "subpath", event.data.subpath)
message.insert(event.data.sessionID, {
id: messageIDFromEvent(event.id),
type: "location-switched",
location: event.data.location,
projectID: event.data.projectID,
subpath: event.data.subpath,
previous,
time: { created: event.created },
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),
type: "location-switched",
location: event.data.location,
projectID: event.data.projectID,
subpath: event.data.subpath,
previous,
time: { created: event.created },
})
})
}
return
@@ -698,7 +706,7 @@ export function createData(config: CreateDataInput) {
return
}
case "session.inbox.delivered": {
const admitted = result.session.input.has(event.data.sessionID, event.data.inboxID)
const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inboxID) ?? false
removePending(event.data.sessionID, event.data.inboxID)
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.inboxID)
@@ -742,36 +750,42 @@ export function createData(config: CreateDataInput) {
// and produce no transcript message.
const updateText = event.data.text
if (updateText === undefined) return
message.insert(event.data.sessionID, {
id: messageIDFromEvent(event.id),
type: "system",
text: updateText,
description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
metadata: event.metadata,
time: { created: event.created },
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),
type: "system",
text: updateText,
description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
metadata: event.metadata,
time: { created: event.created },
})
})
return
case "session.synthetic":
message.insert(event.data.sessionID, {
id: messageIDFromEvent(event.id),
type: "synthetic",
text: event.data.text,
description: event.data.description,
metadata: event.data.metadata,
time: { created: event.created },
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),
type: "synthetic",
text: event.data.text,
description: event.data.description,
metadata: event.data.metadata,
time: { created: event.created },
})
})
return
case "session.shell.started":
message.insert(event.data.sessionID, {
id: messageIDFromEvent(event.id),
type: "shell",
shellID: event.data.shell.id,
command: event.data.shell.command,
status: event.data.shell.status,
exit: event.data.shell.exit,
metadata:
event.data.shell.metadata.background === true ? { ...event.metadata, background: true } : event.metadata,
time: { created: event.created },
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),
type: "shell",
shellID: event.data.shell.id,
command: event.data.shell.command,
status: event.data.shell.status,
exit: event.data.shell.exit,
metadata:
event.data.shell.metadata.background === true ? { ...event.metadata, background: true } : event.metadata,
time: { created: event.created },
})
})
return
case "session.shell.ended":
@@ -786,8 +800,9 @@ export function createData(config: CreateDataInput) {
return
case "session.message.content.updated": {
if (store.session.message[event.data.sessionID])
message.editAssistant(event.data.sessionID, event.data.messageID, (assistant) => {
assistant.content = [...event.data.content]
message.update(event.data.sessionID, (draft, index) => {
const assistant = message.assistant(draft, index, event.data.messageID)
if (assistant) assistant.content = [...event.data.content]
})
if (!sync.pending(`session.message:${event.data.sessionID}`)) return
result.session.message.invalidate(event.data.sessionID)
@@ -829,54 +844,65 @@ export function createData(config: CreateDataInput) {
})
return
case "session.step.streamed":
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
assistant.time.streamed = event.created
message.update(event.data.sessionID, (draft, index) => {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
if (currentAssistant) currentAssistant.time.streamed = event.created
})
return
case "session.step.ended": {
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
assistant.time.completed = event.created
assistant.finish = event.data.finish
assistant.rawFinish = event.data.rawFinish
assistant.providerState = event.data.providerState
assistant.cost = event.data.cost
assistant.tokens = event.data.tokens
if (event.data.snapshot) assistant.snapshot = { ...assistant.snapshot, end: event.data.snapshot }
message.update(event.data.sessionID, (draft, index) => {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
if (!currentAssistant) return
currentAssistant.time.completed = event.created
currentAssistant.finish = event.data.finish
currentAssistant.rawFinish = event.data.rawFinish
currentAssistant.providerState = event.data.providerState
currentAssistant.cost = event.data.cost
currentAssistant.tokens = event.data.tokens
if (event.data.snapshot)
currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.data.snapshot }
})
return
}
case "session.step.failed":
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
assistant.time.completed = event.created
assistant.finish = event.data.finish ?? "error"
assistant.rawFinish = event.data.rawFinish
assistant.providerState = event.data.providerState
assistant.error = event.data.error
assistant.retry = undefined
message.update(event.data.sessionID, (draft, index) => {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
if (!currentAssistant) return
currentAssistant.time.completed = event.created
currentAssistant.finish = event.data.finish ?? "error"
currentAssistant.rawFinish = event.data.rawFinish
currentAssistant.providerState = event.data.providerState
currentAssistant.error = event.data.error
currentAssistant.retry = undefined
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
assistant.cost = event.data.cost
assistant.tokens = event.data.tokens
currentAssistant.cost = event.data.cost
currentAssistant.tokens = event.data.tokens
}
})
return
case "session.text.started":
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
assistant.content.push({ type: "text", text: "" })
message.update(event.data.sessionID, (draft, index) => {
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
type: "text",
text: "",
})
})
return
case "session.text.delta":
message.editText(event.data.sessionID, event.data.assistantMessageID, (text) => {
text.text += event.data.delta
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID))
if (match) match.text += event.data.delta
})
return
case "session.text.ended":
message.editText(event.data.sessionID, event.data.assistantMessageID, (text) => {
text.text = event.data.text
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID))
if (match) match.text = event.data.text
})
return
case "session.tool.input.started":
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
assistant.content.push({
message.update(event.data.sessionID, (draft, index) => {
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
type: "tool",
id: event.data.id,
name: event.data.name,
@@ -886,60 +912,86 @@ export function createData(config: CreateDataInput) {
})
return
case "session.tool.input.delta":
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
if (tool.state.status === "streaming") tool.state.input += event.data.delta
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.id,
)
if (match?.state.status === "streaming") match.state.input += event.data.delta
})
return
case "session.tool.input.ended":
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
if (tool.state.status === "streaming") tool.state.input = event.data.text
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.id,
)
if (match?.state.status === "streaming") match.state.input = event.data.text
})
return
case "session.tool.called":
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
tool.time.ran = event.created
tool.executed = event.data.executed
tool.providerState = event.data.state
tool.state = { status: "running", input: event.data.input, metadata: {} }
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.id,
)
if (!match) return
match.time.ran = event.created
match.executed = event.data.executed
match.providerState = event.data.state
match.state = { status: "running", input: event.data.input, metadata: {} }
})
return
case "session.tool.progress":
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
if (tool.state.status === "running") tool.state.metadata = event.data.metadata
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.id,
)
if (match?.state.status !== "running") return
match.state.metadata = event.data.metadata
})
return
case "session.tool.success":
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
if (tool.state.status !== "running") return
tool.state = {
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.id,
)
if (match?.state.status !== "running") return
match.state = {
status: "completed",
input: tool.state.input,
input: match.state.input,
metadata: event.data.metadata,
content: [...event.data.content],
}
tool.executed = event.data.executed || tool.executed === true
tool.providerResultState = event.data.resultState
tool.time.completed = event.created
match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState
match.time.completed = event.created
})
return
case "session.tool.failed":
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
if (tool.state.status !== "streaming" && tool.state.status !== "running") return
tool.state = {
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.id,
)
if (!match || (match.state.status !== "streaming" && match.state.status !== "running")) return
match.state = {
status: "error",
error: event.data.error,
input: typeof tool.state.input === "string" ? {} : tool.state.input,
input: typeof match.state.input === "string" ? {} : match.state.input,
metadata: event.data.metadata,
content: event.data.content,
}
tool.executed = event.data.executed || tool.executed === true
tool.providerResultState = event.data.resultState
tool.time.completed = event.created
match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState
match.time.completed = event.created
})
return
case "session.reasoning.started":
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
assistant.content.push({
message.update(event.data.sessionID, (draft, index) => {
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
type: "reasoning",
text: "",
state: event.data.state,
@@ -948,20 +1000,30 @@ export function createData(config: CreateDataInput) {
})
return
case "session.reasoning.delta":
message.editReasoning(event.data.sessionID, event.data.assistantMessageID, (reasoning) => {
reasoning.text += event.data.delta
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID))
if (match) match.text += event.data.delta
})
return
case "session.reasoning.ended":
message.editReasoning(event.data.sessionID, event.data.assistantMessageID, (reasoning) => {
reasoning.text = event.data.text
reasoning.time = { created: reasoning.time?.created ?? event.created, completed: event.created }
if (event.data.state !== undefined) reasoning.state = event.data.state
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID))
if (match) {
match.text = event.data.text
match.time = { created: match.time?.created ?? event.created, completed: event.created }
if (event.data.state !== undefined) match.state = event.data.state
}
})
return
case "session.retry.scheduled":
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
assistant.retry = { attempt: event.data.attempt, at: event.data.at, error: event.data.error }
message.update(event.data.sessionID, (draft, index) => {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
if (!currentAssistant) return
currentAssistant.retry = {
attempt: event.data.attempt,
at: event.data.at,
error: event.data.error,
}
})
return
case "session.execution.started":
@@ -969,14 +1031,16 @@ export function createData(config: CreateDataInput) {
return
case "session.compaction.started":
if (event.data.inputID) removePending(event.data.sessionID, event.data.inputID)
message.insert(event.data.sessionID, {
id: event.data.inputID ?? messageIDFromEvent(event.id),
type: "compaction",
status: "running",
reason: event.data.reason,
summary: "",
recent: event.data.recent ?? "",
time: { created: event.created },
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: event.data.inputID ?? messageIDFromEvent(event.id),
type: "compaction",
status: "running",
reason: event.data.reason,
summary: "",
recent: event.data.recent ?? "",
time: { created: event.created },
})
})
if (event.data.inputID) compacting.get(event.data.sessionID)?.observed.add(event.data.inputID)
return
@@ -1011,12 +1075,11 @@ export function createData(config: CreateDataInput) {
if (store.session.info[event.data.sessionID]) {
setStore("session", "info", event.data.sessionID, "revert", undefined)
}
// The projector also deletes inbox items enqueued at or after the boundary without a cancel event.
setStore(
"session",
"pending",
"input",
event.data.sessionID,
(store.session.pending[event.data.sessionID] ?? []).filter((item) => item.id < event.data.to),
(store.session.input[event.data.sessionID] ?? []).filter((id) => id < event.data.to),
)
message.update(event.data.sessionID, (draft, index) => {
const position = draft.findIndex((item) => item.id >= event.data.to)
@@ -1040,6 +1103,7 @@ export function createData(config: CreateDataInput) {
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
retained: event.data.retained,
})
return
}
@@ -1050,6 +1114,7 @@ export function createData(config: CreateDataInput) {
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
retained: event.data.retained,
time: { created: event.created },
})
})
@@ -1180,8 +1245,8 @@ export function createData(config: CreateDataInput) {
}))
break
case "reference.updated":
result.location.reference.invalidate(location)
void result.location.reference.sync(location)
result.location.reference.invalidate()
void result.location.reference.sync()
break
case "integration.updated":
result.location.integration.invalidate(location)
@@ -1210,41 +1275,6 @@ export function createData(config: CreateDataInput) {
}
}
// A cached per-location catalog. `sync` loads once per invalidation, keyed by the
// effective location, and publishes under the server's canonical location; `alias`
// also publishes under the requested key when the two differ.
function locationResource<Field extends keyof LocationData>(
field: Field,
load: (location: ReturnType<typeof locationQuery>) => Promise<{ location: LocationRef; data: LocationData[Field] }>,
options?: { alias?: boolean },
) {
const publish = (key: string, value: LocationData[Field]) => setStore("location", key, { [field]: value })
return {
list: (ref?: LocationRef) => store.location[locationKey(ref ?? defaultLocation())]?.[field],
sync: (ref?: LocationRef) => {
const location = ref ?? defaultLocation()
const id = locationKey(location)
return sync.run(`location.${field}:${id}`, async () => {
const response = await load(locationQuery(location))
const key = locationKey(response.location)
publish(key, response.data)
if (options?.alias && key !== id) publish(id, response.data)
})
},
invalidate: (ref?: LocationRef) => sync.invalidate(`location.${field}:${locationKey(ref ?? defaultLocation())}`),
}
}
const vcs = locationResource("vcs", (location) => api().vcs.get({ location }))
const shells = locationResource("shell", async (location) => {
const response = await api().shell.list({ location })
const ref = { directory: response.location.directory, workspaceID: response.location.workspaceID }
return {
location: response.location,
data: Object.fromEntries(response.data.map((info) => [info.id, { ...info, location: ref }])),
}
})
const result = {
on: config.event.on,
listen: config.event.listen,
@@ -1293,17 +1323,12 @@ export function createData(config: CreateDataInput) {
status(sessionID: string) {
return store.session.active[sessionID] ?? "idle"
},
// Inputs are the pending user and synthetic items; compactions are control items.
input: {
list(sessionID: string) {
return (store.session.pending[sessionID] ?? []).flatMap((item) =>
item.type === "compaction" ? [] : [item.id],
)
return store.session.input[sessionID] ?? []
},
has(sessionID: string, inboxID: string) {
return (
store.session.pending[sessionID]?.some((item) => item.id === inboxID && item.type !== "compaction") ?? false
)
return store.session.input[sessionID]?.includes(inboxID) ?? false
},
},
pending: {
@@ -1327,6 +1352,12 @@ export function createData(config: CreateDataInput) {
const merged = inflight.length === 0 ? pending : [...pending, ...inflight]
batch(() => {
setStore("session", "pending", sessionID, reconcile(merged))
setStore(
"session",
"input",
sessionID,
reconcile(merged.filter((item) => item.type !== "compaction").map((item) => item.id)),
)
merged.forEach(materializeInboxMessage)
})
})
@@ -1711,7 +1742,7 @@ export function createData(config: CreateDataInput) {
},
shell: {
list(location?: LocationRef) {
return Object.values(shells.list(location) ?? {})
return Object.values(store.location[locationKey(location ?? defaultLocation())]?.shell ?? {})
},
listBySession(sessionID: string) {
return Object.values(store.location)
@@ -1723,8 +1754,31 @@ export function createData(config: CreateDataInput) {
.map((data) => data.shell?.[id])
.find((shell) => shell !== undefined)
},
sync: shells.sync,
invalidate: shells.invalidate,
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.shell:${id}`, async () => {
const response = await api().shell.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, {
...store.location[key],
shell: Object.fromEntries(
response.data.map((info) => [
info.id,
{
...info,
location: {
directory: response.location.directory,
workspaceID: response.location.workspaceID,
},
},
]),
),
})
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.shell:${locationKey(ref ?? defaultLocation())}`)
},
},
location: {
info(ref?: LocationRef) {
@@ -1779,20 +1833,162 @@ export function createData(config: CreateDataInput) {
result.shell.invalidate(location)
result.session.form.invalidate("global", location)
},
vcs: { info: vcs.list, sync: vcs.sync, invalidate: vcs.invalidate },
agent: locationResource("agent", (location) => api().agent.list({ location })),
command: locationResource("command", (location) => api().command.list({ location })),
integration: locationResource("integration", (location) => api().integration.list({ location })),
vcs: {
info(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.vcs
},
sync(ref?: LocationRef) {
const location = ref ?? defaultLocation()
return sync.run(`location.vcs:${locationKey(location)}`, async () => {
const response = await api().vcs.get({ location: locationQuery(location) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], vcs: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.vcs:${locationKey(ref ?? defaultLocation())}`)
},
},
agent: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.agent
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.agent:${id}`, async () => {
const response = await api().agent.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], agent: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.agent:${locationKey(ref ?? defaultLocation())}`)
},
},
command: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.command
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.command:${id}`, async () => {
const response = await api().command.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], command: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.command:${locationKey(ref ?? defaultLocation())}`)
},
},
integration: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.integration
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.integration:${id}`, async () => {
const response = await api().integration.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], integration: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.integration:${locationKey(ref ?? defaultLocation())}`)
},
},
mcp: {
server: locationResource("mcpServer", (location) => api().mcp.list({ location })),
resource: locationResource("mcpResource", async (location) => {
const response = await api().mcp.resource.catalog({ location })
return { location: response.location, data: response.data.resources }
}),
server: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.mcp?.server
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.mcp.server:${id}`, async () => {
const response = await api().mcp.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, {
...store.location[key],
mcp: { ...store.location[key]?.mcp, server: response.data },
})
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.mcp.server:${locationKey(ref ?? defaultLocation())}`)
},
},
resource: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.mcp?.resource
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.mcp.resource:${id}`, async () => {
const response = await api().mcp.resource.catalog({
location: locationQuery(ref ?? defaultLocation()),
})
const key = locationKey(response.location)
setStore("location", key, {
...store.location[key],
mcp: { ...store.location[key]?.mcp, resource: response.data.resources },
})
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.mcp.resource:${locationKey(ref ?? defaultLocation())}`)
},
},
},
model: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.model
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.model:${id}`, async () => {
const response = await api().model.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], model: response.data })
if (key !== id) setStore("location", id, { ...store.location[id], model: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.model:${locationKey(ref ?? defaultLocation())}`)
},
},
provider: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.provider
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.provider:${id}`, async () => {
const response = await api().provider.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], provider: response.data })
if (key !== id) setStore("location", id, { ...store.location[id], provider: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.provider:${locationKey(ref ?? defaultLocation())}`)
},
},
reference: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.reference
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.reference:${id}`, async () => {
const response = await api().reference.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], reference: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.reference:${locationKey(ref ?? defaultLocation())}`)
},
},
model: locationResource("model", (location) => api().model.list({ location }), { alias: true }),
provider: locationResource("provider", (location) => api().provider.list({ location }), { alias: true }),
reference: locationResource("reference", (location) => api().reference.list({ location })),
websearch: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.websearch
@@ -1807,7 +2003,22 @@ export function createData(config: CreateDataInput) {
})
},
},
skill: locationResource("skill", (location) => api().skill.list({ location })),
skill: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.skill
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.skill:${id}`, async () => {
const response = await api().skill.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], skill: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.skill:${locationKey(ref ?? defaultLocation())}`)
},
},
},
}
+13 -2
View File
@@ -101,10 +101,21 @@ test.each(["started", "cancelled", "failed"])(
fixture.emit({
...event,
type: "session.compaction.ended",
data: { sessionID, reason: "manual", text: "Summary", recent: "Recent" },
data: {
sessionID,
reason: "manual",
text: "Summary",
recent: "",
retained: { from: "msg_retained_user", through: "msg_retained_assistant" },
},
})
expect(fixture.data.session.message.list(sessionID)).toMatchObject([
{ type: "compaction", status: "completed", summary: "Summary" },
{
type: "compaction",
status: "completed",
summary: "Summary",
retained: { from: "msg_retained_user", through: "msg_retained_assistant" },
},
])
}
},
-60
View File
@@ -409,66 +409,6 @@ test("refreshes global credential events across every loaded location and worksp
}
})
test("refreshes references for the location an update names", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const requests: URL[] = []
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
const url = new URL(request.url)
requests.push(url)
const directory = url.searchParams.get("location[directory]") ?? "/project"
return Response.json({
location: {
directory,
workspaceID: url.searchParams.get("location[workspace]") ?? undefined,
project: { id: "project", directory, canonical: directory },
},
data: [],
})
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/project",
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
connection: { status: () => "connected" },
}),
dispose,
}))
const other = { directory: "/other", workspaceID: "workspace-other" }
try {
await Promise.all([setup.data.location.reference.sync(), setup.data.location.reference.sync(other)])
requests.length = 0
const updated: OpenCodeEvent = {
id: "evt_reference.updated",
created: 1,
type: "reference.updated",
location: other,
data: {},
}
listeners.forEach((listener) => listener({ name: updated.type, details: updated }))
await wait(() => requests.length === 1)
expect([
requests[0]!.pathname,
requests[0]!.searchParams.get("location[directory]"),
requests[0]!.searchParams.get("location[workspace]"),
]).toEqual(["/api/reference", "/other", "workspace-other"])
} finally {
setup.dispose()
}
})
test("reports optimistic sessions as creating until the request settles", async () => {
const release = Promise.withResolvers<void>()
const api = OpenCode.make({
@@ -20378,6 +20378,44 @@
"required": ["id", "created", "type", "data"],
"additionalProperties": false
},
"plugin.added": {
"type": "object",
"properties": {
"id": {
"type": "string",
"allOf": [
{
"pattern": "^evt_"
}
]
},
"created": {
"type": "number"
},
"metadata": {
"type": "object"
},
"type": {
"type": "string",
"enum": ["plugin.added"]
},
"location": {
"$ref": "#/components/schemas/Location.Ref"
},
"data": {
"type": "object",
"properties": {
"id": {
"type": "string"
}
},
"required": ["id"],
"additionalProperties": false
}
},
"required": ["id", "created", "type", "data"],
"additionalProperties": false
},
"plugin.updated": {
"type": "object",
"properties": {
@@ -23085,6 +23123,9 @@
{
"$ref": "#/components/schemas/permission.v2.replied"
},
{
"$ref": "#/components/schemas/plugin.added"
},
{
"$ref": "#/components/schemas/plugin.updated"
},
-1
View File
@@ -20,7 +20,6 @@
"migration": "bun run script/migration.ts",
"fix-node-pty": "bun run script/fix-node-pty.ts",
"benchmark:location": "bun run script/benchmark-location.ts",
"benchmark:location-memory": "bun run script/benchmark-location-memory.ts",
"build": "bun run script/build.ts",
"update-models-snapshot": "bun run script/update-models-snapshot.ts",
"test": "bun run script/test.ts",
@@ -1,210 +0,0 @@
// Measures the heap retained by Location service graphs and by the models.dev
// catalog plugin. Everything runs against a temporary global directory with a
// temporary home, an in-memory database, no filesystem watchers, and no network,
// so it never touches a live server, database, or user configuration.
//
// bun run script/benchmark-location-memory.ts [--locations 6] [--plugins 8] [--json out.json]
//
// "retained" numbers are heapUsed after two forced GCs; "peak" numbers are the
// highest heapUsed sampled without forcing GC and are reported separately.
import fs from "fs/promises"
import os from "os"
import path from "path"
import { heapStats } from "bun:jsc"
import { Effect, Layer, Logger, Scope } from "effect"
import { Global } from "@opencode-ai/util/global"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { AppNodeBuilder } from "../src/effect/app-node-builder"
import { Bus } from "../src/bus"
import { Catalog } from "../src/catalog"
import { Database } from "../src/database/database"
import { Integration } from "../src/integration"
import { Location } from "../src/location"
import { LocationServiceMap } from "../src/location-service-map"
import { ModelsDev } from "../src/models-dev"
import { Plugin } from "../src/plugin"
import { ModelsDevPlugin } from "../src/plugin/models-dev"
import { AbsolutePath } from "../src/schema"
import { Watcher } from "../src/filesystem/watcher"
import { location } from "../test/fixture/location"
import { catalogHost, host, integrationHost } from "../test/plugin/host"
const args = process.argv.slice(2)
const flag = (name: string, fallback: number) => {
const index = args.indexOf(`--${name}`)
if (index === -1) return fallback
const value = Number(args[index + 1])
if (!Number.isInteger(value) || value < 1) {
console.error(`--${name} must be a positive integer`)
process.exit(1)
}
return value
}
const locationCount = flag("locations", 6)
const pluginCount = flag("plugins", 8)
const jsonIndex = args.indexOf("--json")
const jsonPath = jsonIndex === -1 ? undefined : args[jsonIndex + 1]
type Sample = { heapUsed: number; rss: number; objects: number }
const sample = (): Sample => {
Bun.gc(true)
Bun.gc(true)
const usage = process.memoryUsage()
return { heapUsed: usage.heapUsed, rss: usage.rss, objects: heapStats().objectCount }
}
const mib = (bytes: number) => (bytes / 1024 / 1024).toFixed(2).padStart(8)
const median = (values: ReadonlyArray<number>) => {
const sorted = values.toSorted((a, b) => a - b)
const middle = Math.floor(sorted.length / 2)
const upper = sorted[middle] ?? 0
return sorted.length % 2 === 0 ? ((sorted[middle - 1] ?? 0) + upper) / 2 : upper
}
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-location-memory-")))
const globalLayer = Global.layerWith({
home: path.join(root, "home"),
data: path.join(root, "data"),
cache: path.join(root, "cache"),
config: path.join(root, "config"),
state: path.join(root, "state"),
tmp: path.join(root, "tmp"),
bin: path.join(root, "cache", "bin"),
log: path.join(root, "data", "log"),
repos: path.join(root, "data", "repos"),
})
const replacements = [
Global.node.replace(globalLayer),
ModelsDev.node.replace(ModelsDev.configured({ fetch: false })),
Watcher.node.replace(Watcher.configured({ enabled: false })),
]
// One full Location graph per directory, retained for the rest of the run, the
// way a long-running server retains every directory a client has touched.
const locationsProgram = Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const scope = yield* Scope.Scope
const before = sample()
const deltas: number[] = []
const rss: number[] = []
let previous = before
for (let index = 0; index < locationCount; index++) {
const directory = path.join(root, "projects", `location-${index}`)
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
const context = yield* locations
.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(directory) }))
.pipe(Scope.provide(scope))
const plugins = yield* Plugin.Service.pipe(Effect.provideContext(context))
yield* plugins.awaitActivation
const current = sample()
deltas.push(current.heapUsed - previous.heapUsed)
rss.push(current.rss)
previous = current
}
const catalog = yield* Catalog.Service.pipe(
Effect.provideContext(
yield* locations
.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(path.join(root, "projects", `location-0`)) }))
.pipe(Scope.provide(scope)),
),
)
const models = yield* catalog.model.all()
const providers = yield* catalog.provider.all()
return {
before,
after: previous,
deltas,
rss,
catalog: { providers: providers.length, models: models.length },
}
}).pipe(Effect.scoped)
// The models.dev plugin alone, against a real Catalog and Integration state per
// instance, isolates the catalog-copy contribution from the rest of the graph.
const pluginProgram = Effect.gen(function* () {
const modelsDev = yield* ModelsDev.Service
const snapshot = yield* modelsDev.get()
const scope = yield* Scope.Scope
const before = sample()
const deltas: number[] = []
let previous = before
for (let index = 0; index < pluginCount; index++) {
const directory = AbsolutePath.make(path.join(root, "plugins", `instance-${index}`))
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location(Location.Ref.make({ directory }))),
)
const context = yield* Layer.build(
AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.node, Bus.node]), [
Location.node.replace(locationLayer),
...replacements,
]),
).pipe(Scope.provide(scope))
const catalog = yield* Catalog.Service.pipe(Effect.provideContext(context))
const integration = yield* Integration.Service.pipe(Effect.provideContext(context))
yield* ModelsDevPlugin.effect(
host({ catalog: catalogHost(catalog), integration: integrationHost(integration) }),
).pipe(Effect.provideService(ModelsDev.Service, modelsDev), Effect.provideContext(context), Scope.provide(scope))
yield* catalog.model.all()
yield* integration.list()
const current = sample()
deltas.push(current.heapUsed - previous.heapUsed)
previous = current
}
return {
before,
after: previous,
deltas,
snapshot: {
providers: snapshot.length,
models: snapshot.reduce((total, provider) => total + provider.models.length, 0),
},
}
}).pipe(Effect.scoped)
const program = Effect.gen(function* () {
const plugin = yield* pluginProgram.pipe(
Effect.provide(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, ModelsDev.node]), replacements)),
)
const locations = yield* locationsProgram.pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), replacements),
),
)
return { plugin, locations }
}).pipe(Effect.provide(Logger.layer([])))
const result = await Effect.runPromise(program)
await fs.rm(root, { recursive: true, force: true }).catch(() => undefined)
console.log(
`models.dev snapshot: ${result.plugin.snapshot.providers} providers, ${result.plugin.snapshot.models} models`,
)
console.log(`ModelsDevPlugin instances: ${pluginCount}`)
console.log(` retained heap per instance (MiB): ${result.plugin.deltas.map((delta) => mib(delta).trim()).join(", ")}`)
console.log(` median per instance: ${mib(median(result.plugin.deltas))} MiB`)
console.log(
`Location graphs: ${locationCount} (catalog ${result.locations.catalog.providers} providers, ${result.locations.catalog.models} models each)`,
)
console.log(
` retained heap per location (MiB): ${result.locations.deltas.map((delta) => mib(delta).trim()).join(", ")}`,
)
console.log(` median per location: ${mib(median(result.locations.deltas))} MiB`)
console.log(
` heapUsed before ${mib(result.locations.before.heapUsed)} MiB -> after ${mib(result.locations.after.heapUsed)} MiB`,
)
console.log(` rss before ${mib(result.locations.before.rss)} MiB -> after ${mib(result.locations.after.rss)} MiB`)
if (jsonPath) {
await fs.mkdir(path.dirname(jsonPath), { recursive: true })
await fs.writeFile(
jsonPath,
JSON.stringify(
{ revision: process.env.OPENCODE_BENCH_REVISION, bun: Bun.version, locationCount, pluginCount, ...result },
null,
2,
),
)
}
+11 -11
View File
@@ -34,7 +34,7 @@ type Data = {
default?: ID
}
export type Editor = {
export type Draft = {
list: () => readonly Info[]
get: (id: ID) => Info | undefined
default: (id: ID | undefined) => void
@@ -42,7 +42,7 @@ export type Editor = {
remove: (id: ID) => void
}
export interface Interface extends State.Transformable<Editor> {
export interface Interface extends State.Transformable<Draft> {
readonly get: (id: ID) => Effect.Effect<Info | undefined>
readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined>
readonly select: (id?: ID | string) => Effect.Effect<Selection>
@@ -62,32 +62,32 @@ const layer = Layer.effect(
{ action: "external_directory", resource: path.join(global.tmp, "*"), effect: "allow" },
{ action: "external_directory", resource: path.join(global.config, "*"), effect: "allow" },
]
const state = State.create<Data, Editor>({
const state = State.create<Data, Draft>({
name: "agent",
initial: () => ({ agents: new Map() }),
editor: (editor) => ({
list: () => Array.fromIterable(editor.agents.values()) as Info[],
get: (id) => editor.agents.get(id),
draft: (draft) => ({
list: () => Array.fromIterable(draft.agents.values()) as Info[],
get: (id) => draft.agents.get(id),
default: (id) => {
editor.default = id
draft.default = id
},
update: (id, fn) => {
const defaults = Info.default(id)
const current =
editor.agents.get(id) ??
draft.agents.get(id) ??
({
...defaults,
permissions: [...defaults.permissions, ...permissions],
} as Types.DeepMutable<Info>)
if (!editor.agents.has(id)) editor.agents.set(id, current)
if (!draft.agents.has(id)) draft.agents.set(id, current)
fn(current)
current.id = id
},
remove: (id) => {
editor.agents.delete(id)
draft.agents.delete(id)
},
}),
notify: () => bus.publish(Agent.Event.Updated, {}).pipe(Effect.asVoid),
notify: bus.publish(Agent.Event.Updated, {}).pipe(Effect.asVoid),
})
const selectable = (agent: Info | undefined) =>
agent && agent.mode !== "subagent" && !agent.hidden ? agent : undefined
+17 -18
View File
@@ -23,7 +23,7 @@ type Data = {
defaultModel?: DefaultModel
}
export type Editor = {
export type Draft = {
provider: {
list: () => readonly ProviderRecord[]
get: (providerID: Provider.ID) => ProviderRecord | undefined
@@ -41,7 +41,7 @@ export type Editor = {
}
}
export interface Interface extends State.Transformable<Editor> {
export interface Interface extends State.Transformable<Draft> {
readonly provider: {
readonly get: (providerID: Provider.ID) => Effect.Effect<Provider.Info | undefined>
readonly all: () => Effect.Effect<Provider.Info[]>
@@ -82,40 +82,39 @@ const layer = Layer.effect(
} satisfies Model.Info
}
const state = State.create<Data, Editor>({
const state = State.create<Data, Draft>({
name: "catalog",
initial: () => ({ providers: new Map() }),
editor: (editor) => {
const result: Editor = {
draft: (draft) => {
const result: Draft = {
provider: {
list: () => Array.fromIterable(editor.providers.values()) as ProviderRecord[],
get: (providerID) => editor.providers.get(providerID),
list: () => Array.fromIterable(draft.providers.values()) as ProviderRecord[],
get: (providerID) => draft.providers.get(providerID),
update: (providerID, fn) => {
let current = editor.providers.get(providerID)
let current = draft.providers.get(providerID)
if (!current) {
current = {
provider: Provider.Info.empty(providerID) as Provider.MutableInfo,
models: new Map<Model.ID, Model.MutableInfo>(),
}
editor.providers.set(providerID, current)
draft.providers.set(providerID, current)
}
fn(current.provider)
current.provider.id = providerID
},
remove: (providerID) => {
editor.providers.delete(providerID)
draft.providers.delete(providerID)
},
},
model: {
get: (providerID, modelID) => editor.providers.get(providerID)?.models.get(modelID),
get: (providerID, modelID) => draft.providers.get(providerID)?.models.get(modelID),
update: (providerID, modelID, fn) => {
let record = editor.providers.get(providerID)
let record = draft.providers.get(providerID)
if (!record) {
record = {
provider: Provider.Info.empty(providerID) as Provider.MutableInfo,
models: new Map<Model.ID, Model.MutableInfo>(),
}
editor.providers.set(providerID, record)
draft.providers.set(providerID, record)
}
const model = record.models.get(modelID) ?? (Model.Info.default(providerID, modelID) as Model.MutableInfo)
if (!record.models.has(modelID)) record.models.set(modelID, model)
@@ -124,19 +123,19 @@ const layer = Layer.effect(
model.providerID = providerID
},
remove: (providerID, modelID) => {
editor.providers.get(providerID)?.models.delete(modelID)
draft.providers.get(providerID)?.models.delete(modelID)
},
default: {
get: () => editor.defaultModel,
get: () => draft.defaultModel,
set: (providerID, modelID) => {
editor.defaultModel = { providerID, modelID }
draft.defaultModel = { providerID, modelID }
},
},
},
}
return result
},
notify: () => bus.publish(Catalog.Event.Updated, {}).pipe(Effect.asVoid, Effect.withSpan("Catalog.notify")),
notify: bus.publish(Catalog.Event.Updated, {}).pipe(Effect.asVoid, Effect.withSpan("Catalog.notify")),
})
const result: Interface = {
transform: state.transform,
+6 -6
View File
@@ -25,7 +25,7 @@ export interface Definition {
readonly execute: (input: Invocation) => Effect.Effect<void, unknown>
}
export type Editor = {
export type Draft = {
add: (definition: Definition) => void
}
@@ -39,7 +39,7 @@ export class ExecutionError extends Schema.TaggedError<ExecutionError>()("Comman
message: Schema.String,
}) {}
export interface Interface extends State.Transformable<Editor> {
export interface Interface extends State.Transformable<Draft> {
readonly get: (name: string) => Effect.Effect<Info | undefined>
readonly list: () => Effect.Effect<Info[]>
readonly execute: (input: {
@@ -54,13 +54,13 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const state = State.create<Map<string, Definition>, Editor>({
const state = State.create<Map<string, Definition>, Draft>({
name: "command",
initial: () => new Map(),
editor: (editor) => ({
add: (definition) => editor.set(definition.name, definition),
draft: (draft) => ({
add: (definition) => draft.set(definition.name, definition),
}),
notify: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
notify: bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
})
const info = (definition: Definition) =>
Info.make({
+12 -28
View File
@@ -188,15 +188,7 @@ export const layer = (options?: Options) =>
const globalDirectory = AbsolutePath.make(global.config)
const globalAgentsDirectory = AbsolutePath.make(path.join(global.home, ".agents"))
const globalClaudeDirectory = AbsolutePath.make(path.join(global.home, ".claude"))
// Global roots and the walk are compared by canonical path: the same
// directory reached under two spellings (a symlinked checkout, macOS
// /var vs /private/var, OPENCODE_CONFIG_DIR inside the project) must
// classify identically or it enters discovery twice.
const globalRoots = yield* Effect.forEach(
[globalDirectory, globalClaudeDirectory, globalAgentsDirectory],
(item) => fs.resolve(item),
)
const locationIsGlobal = (yield* fs.resolve(location.directory)) === globalRoots[0]
const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config)
const discovered =
locationIsGlobal || options?.project === false
? []
@@ -205,30 +197,22 @@ export const layer = (options?: Options) =>
targets: [".opencode", ".claude", ".agents", ...names.toReversed()],
start: location.directory,
})
.pipe(
Effect.flatMap((items) =>
Effect.forEach(items, (item) =>
fs.resolve(item).pipe(Effect.map((resolved) => ({ item, resolved }))),
),
),
Effect.orDie,
)
.pipe(Effect.orDie)
const globalEnabled = options?.global !== false
// A walked path that resolves into a global root is global config
// however the walk reached it (home above the project, or a location
// beneath the global config dir), so global: false excludes it
// uniformly — classified once here, not per consumer below. With
// global enabled, the roots themselves and the global config files are
// already loaded below, so the walk must not add them a second time.
const globalFiles = yield* Effect.forEach(names, (name) => fs.resolve(path.join(globalDirectory, name)))
const visible = discovered
.filter(({ resolved }) =>
globalEnabled
? !globalRoots.includes(resolved) && !globalFiles.includes(resolved)
: !globalRoots.some((root) => resolved === root || resolved.startsWith(root + path.sep)),
)
.map(({ item }) => item)
// uniformly — classified once here, not per consumer below.
const globalRoots = [globalDirectory, globalClaudeDirectory, globalAgentsDirectory].map((item) =>
path.resolve(item),
)
const visible = globalEnabled
? discovered
: discovered.filter((item) => {
const resolved = path.resolve(item)
return !globalRoots.some((root) => resolved === root || resolved.startsWith(root + path.sep))
})
// We load certain files from a few other folders in the ecosystem
const claude = [
...new Set([
+18 -27
View File
@@ -4,7 +4,7 @@ import { define } from "@opencode-ai/plugin/effect/plugin"
import { Document, Info, type Entry } from "@opencode-ai/schema/config"
import { ConfigAgent } from "@opencode-ai/schema/config/agent"
import path from "path"
import { Effect, Option, PubSub, Schema, Stream } from "effect"
import { Effect, Option, Schema, Stream } from "effect"
import { Agent } from "../../agent.js"
import { Config } from "../../config.js"
import { ConfigMarkdown } from "../markdown.js"
@@ -59,51 +59,42 @@ export const Plugin = define({
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
Effect.andThen(ctx.agent.reload()),
)
// One trigger feed serializes reloads and shares one debounce window;
// subscribing before the initial scan means updates racing the scan still
// trigger a rebuild. Each source is subscribed eagerly on its own fiber
// (Stream.merge and Stream.debounce both open upstream a fiber hop later)
// so no update slips through while the debounce starts its pull.
const changes = yield* PubSub.sliding<void>(1)
const notify = () => PubSub.publish(changes, undefined)
yield* config.changes().pipe(
Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isAgentSource(entries, update.path))),
Stream.runForEach(notify),
Effect.forkScoped({ startImmediately: true }),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(notify),
Effect.forkScoped({ startImmediately: true }),
)
const updates = yield* PubSub.subscribe(changes)
yield* Stream.fromSubscription(updates).pipe(
// One merged trigger stream serializes reloads and shares one debounce
// window; subscribing before the initial scan means updates racing the
// scan still trigger a rebuild.
const sourceChanges = config
.changes()
.pipe(
Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isAgentSource(entries, update.path))),
)
const configUpdates = ctx.event.subscribe().pipe(Stream.filter((event) => event.type === "config.updated"))
yield* Stream.merge(sourceChanges, configUpdates).pipe(
Stream.debounce("100 millis"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
loaded.documents = yield* load()
yield* ctx.agent.transform((editor) => {
yield* ctx.agent.transform((draft) => {
const permissions = expandPermissions(
loaded.documents.flatMap((document) => document.info.permissions ?? []),
global.home,
)
const configuredDefault = Config.latest(loaded.documents, "default_agent")
if (configuredDefault !== undefined) editor.default(Agent.ID.make(configuredDefault))
for (const current of editor.list()) {
editor.update(current.id, (agent) => agent.permissions.push(...permissions))
if (configuredDefault !== undefined) draft.default(Agent.ID.make(configuredDefault))
for (const current of draft.list()) {
draft.update(current.id, (agent) => agent.permissions.push(...permissions))
}
for (const document of loaded.documents) {
for (const [id, item] of Object.entries(document.info.agents ?? {})) {
const agentID = Agent.ID.make(id)
if (item.disabled) {
editor.remove(agentID)
draft.remove(agentID)
continue
}
const exists = editor.get(agentID) !== undefined
editor.update(agentID, (agent) => {
const exists = draft.get(agentID) !== undefined
draft.update(agentID, (agent) => {
if (!exists) agent.permissions.push(...permissions)
if (item.model !== undefined)
agent.model = {
+15 -22
View File
@@ -8,7 +8,7 @@ import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { AppProcess } from "@opencode-ai/util/process"
import path from "path"
import { Effect, Option, PubSub, Schema, Stream } from "effect"
import { Effect, Option, Schema, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { Config } from "../../config.js"
import { Location } from "../../location.js"
@@ -40,34 +40,27 @@ export const Plugin = define({
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
Effect.andThen(ctx.command.reload()),
)
// One trigger feed serializes reloads and shares one debounce window;
// subscribing before the initial scan means updates racing the scan still
// trigger a rebuild. Each source is subscribed eagerly on its own fiber
// (Stream.merge and Stream.debounce both open upstream a fiber hop later)
// so no update slips through while the debounce starts its pull.
const changes = yield* PubSub.sliding<void>(1)
const notify = () => PubSub.publish(changes, undefined)
yield* config.changes().pipe(
Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isCommandSource(entries, update.path))),
Stream.runForEach(notify),
Effect.forkScoped({ startImmediately: true }),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(notify),
Effect.forkScoped({ startImmediately: true }),
)
const updates = yield* PubSub.subscribe(changes)
yield* Stream.fromSubscription(updates).pipe(
// One merged trigger stream serializes reloads and shares one debounce
// window; subscribing before the initial scan means updates racing the
// scan still trigger a rebuild.
const sourceChanges = config
.changes()
.pipe(
Stream.filterEffect((update) =>
Effect.map(config.entries(), (entries) => isCommandSource(entries, update.path)),
),
)
const configUpdates = ctx.event.subscribe().pipe(Stream.filter((event) => event.type === "config.updated"))
yield* Stream.merge(sourceChanges, configUpdates).pipe(
Stream.debounce("100 millis"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
loaded.documents = yield* load()
yield* ctx.command.transform((editor) => {
yield* ctx.command.transform((draft) => {
for (const document of loaded.documents) {
for (const [name, command] of Object.entries(document.commands ?? {})) {
editor.add({
draft.add({
name,
description: command.description,
execute: (input) =>
@@ -12,10 +12,10 @@ export const Plugin = define({
const config = yield* Config.Service
const compaction = yield* SessionCompaction.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, compaction.reload())
yield* compaction.transform((editor) => {
yield* compaction.transform((draft) => {
for (const entry of loaded.entries) {
if (entry.type !== "document" || !entry.info.compaction) continue
editor.configure({
draft.configure({
...(entry.info.compaction.auto === undefined ? {} : { auto: entry.info.compaction.auto }),
...(entry.info.compaction.buffer === undefined ? {} : { buffer: entry.info.compaction.buffer }),
...(entry.info.compaction.keep?.tokens === undefined ? {} : { tokens: entry.info.compaction.keep.tokens }),
+4 -4
View File
@@ -24,7 +24,7 @@ export const Plugin = define({
const processes = yield* AppProcess.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, formatter.reload())
yield* formatter.transform((editor) => {
yield* formatter.transform((draft) => {
const configured = Config.latest(loaded.entries, "formatter")
if (!configured) return
const builtIns = make({
@@ -35,12 +35,12 @@ export const Plugin = define({
processes,
bin: global.bin,
})
builtIns.forEach(editor.set)
builtIns.forEach(draft.set)
if (configured === true) return
for (const [name, entry] of Object.entries(configured)) {
if (entry.disabled) {
editor.remove(name)
draft.remove(name)
continue
}
const builtIn = builtIns.find((formatter) => formatter.name === name)
@@ -51,7 +51,7 @@ export const Plugin = define({
enabled:
builtIn && !entry.command ? builtIn.enabled : Effect.succeed(entry.command ? [...entry.command] : false),
}
editor.set(current)
draft.set(current)
}
})
}),
+2 -2
View File
@@ -12,12 +12,12 @@ export const Plugin = define({
const config = yield* Config.Service
const image = yield* Image.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, image.reload())
yield* image.transform((editor) => {
yield* image.transform((draft) => {
for (const entry of loaded.entries) {
if (entry.type !== "document") continue
const configured = entry.info.media?.image
if (!configured) continue
editor.configure({
draft.configure({
...(configured.auto_resize === undefined ? {} : { autoResize: configured.auto_resize }),
...(configured.max_width === undefined ? {} : { maxWidth: configured.max_width }),
...(configured.max_height === undefined ? {} : { maxHeight: configured.max_height }),
@@ -103,26 +103,22 @@ export const Plugin = define({
(effect, ..._args: [file?: string]) => lock.withPermit(effect),
)
// Editor saves arrive as bursts of watcher events; settle before rescanning once. Subscribe
// before debouncing so no update slips through while the debounce starts its pull.
const updates = yield* PubSub.subscribe(changes)
yield* Stream.fromSubscription(updates).pipe(
Stream.debounce("100 millis"),
yield* Stream.fromPubSub(changes).pipe(
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(discovery.reload()))),
Effect.forkScoped({ startImmediately: true }),
)
yield* refresh()
yield* discovery.transform((editor) => {
yield* discovery.transform((draft) => {
if (loaded.current.type === "unavailable") {
editor.unavailable()
draft.unavailable()
return
}
for (const file of loaded.current.files) editor.add(file)
for (const file of loaded.current.files) draft.add(file)
})
}).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to activate instruction source", { cause }).pipe(
Effect.andThen(discovery.transform((editor) => editor.unavailable())),
Effect.andThen(discovery.transform((draft) => draft.unavailable())),
Effect.asVoid,
),
),
@@ -12,10 +12,10 @@ export const Plugin = define({
const config = yield* Config.Service
const policy = yield* LocationWatcherPolicy.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, policy.reload())
yield* policy.transform((editor) => {
yield* policy.transform((draft) => {
for (const entry of loaded.entries) {
if (entry.type !== "document" || !entry.info.watcher?.ignore) continue
editor.add(entry.info.watcher.ignore)
draft.add(entry.info.watcher.ignore)
}
})
}),
+3 -3
View File
@@ -36,7 +36,7 @@ export const register = Effect.fn("ConfigMCPPlugin.register")(function* (
// Subscribe before the initial load so updates racing it trigger a rebuild.
loaded.entries = yield* config.entries()
yield* mcp.transform((editor) => {
yield* mcp.transform((draft) => {
const documents = loaded.entries.filter((entry): entry is Document => entry.type === "document")
// Global timeout defaults merge in config order; each server can override them.
const timeout = Object.assign(
@@ -50,8 +50,8 @@ export const register = Effect.fn("ConfigMCPPlugin.register")(function* (
}
}
for (const [name, server] of servers) {
if (editor.get(name)) continue
editor.set(name, { ...server, timeout: { ...timeout, ...server.timeout } })
if (draft.get(name)) continue
draft.set(name, { ...server, timeout: { ...timeout, ...server.timeout } })
}
})
})
+2 -2
View File
@@ -19,14 +19,14 @@ export const Plugin = define({
const location = yield* Location.Service
const global = yield* Global.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.reference.reload())
yield* ctx.reference.transform((editor) => {
yield* ctx.reference.transform((draft) => {
for (const doc of loaded.entries.filter((entry): entry is Document => entry.type === "document")) {
const directory = doc.path ? path.dirname(doc.path) : location.directory
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
if (!validAlias(name)) continue
const description = typeof entry === "string" ? undefined : entry.description
const hidden = typeof entry === "string" ? undefined : entry.hidden
editor.add(
draft.add(
name,
local(entry)
? Reference.LocalSource.make({
+2 -2
View File
@@ -12,9 +12,9 @@ export const Plugin = define({
const config = yield* Config.Service
const shell = yield* ShellSelect.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, shell.reload())
yield* shell.transform((editor) => {
yield* shell.transform((draft) => {
const configured = Config.latest(loaded.entries, "shell")
if (configured) editor.configure(configured)
if (configured) draft.configure(configured)
})
}),
})
+3 -7
View File
@@ -171,17 +171,13 @@ export const Plugin = define({
(effect, ..._args: [file?: string]) => lock.withPermit(effect),
)
// Editor saves arrive as bursts of watcher events; settle before rescanning once. Subscribe
// before debouncing so no update slips through while the debounce starts its pull.
const updates = yield* PubSub.subscribe(changes)
yield* Stream.fromSubscription(updates).pipe(
Stream.debounce("100 millis"),
yield* Stream.fromPubSub(changes).pipe(
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(ctx.skill.reload()))),
Effect.forkScoped({ startImmediately: true }),
)
yield* refresh()
yield* ctx.skill.transform((editor) => {
for (const skill of loaded.skills) editor.add(skill)
yield* ctx.skill.transform((draft) => {
for (const skill of loaded.skills) draft.add(skill)
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
+2 -2
View File
@@ -12,10 +12,10 @@ export const Plugin = define({
const config = yield* Config.Service
const snapshot = yield* Snapshot.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, snapshot.reload())
yield* snapshot.transform((editor) => {
yield* snapshot.transform((draft) => {
const configured = Config.latest(loaded.entries, "snapshots")
if (configured === undefined) return
editor.configure(configured)
draft.configure(configured)
})
}),
})
+19 -39
View File
@@ -3,11 +3,10 @@ export * as ConfigPluginSource from "./source.js"
import { Directory, Document, type Entry } from "@opencode-ai/schema/config"
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Host } from "@opencode-ai/plugin/host"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Option, PubSub, Scope, Stream } from "effect"
import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
import { fileURLToPath } from "url"
import { Config } from "../../config.js"
import { Watcher } from "../../filesystem/watcher.js"
import { Location } from "../../location.js"
@@ -42,8 +41,8 @@ export const layer = Layer.effect(
const configuredChanges = yield* PubSub.unbounded<void>()
const watched = new Set<string>()
// Configured local plugins can live outside config roots, where the
// config change feed cannot see them; watch those targets directly.
// Configured local plugin entrypoints can live outside config roots, where the
// config change feed cannot see them; watch those entrypoints directly.
// Watches start on first sighting and are never torn down individually:
// a stale watch after a config edit costs one deduped fs handle and a
// no-op activation, and every watch dies with this layer's scope.
@@ -57,10 +56,7 @@ export const layer = Layer.effect(
// The config change feed already covers {plugin,plugins} directories.
if (isPluginSource(entries, operation.target)) continue
watched.add(operation.target)
const updates = yield* watcher.subscribe({
path: operation.target,
type: (yield* fs.isDir(operation.target)) ? "directory" : "file",
})
const updates = yield* watcher.subscribe({ path: operation.target, type: "file" })
yield* updates.pipe(
Stream.runForEach(() => PubSub.publish(configuredChanges, undefined)),
Effect.catchCause((cause) =>
@@ -152,40 +148,24 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* (
yield* Effect.logWarning("configured plugin path must be a directory", { target: operation.target })
return Option.none<Operation>()
}
return Option.some<Operation>(operation)
if (!(yield* fs.isDir(operation.target))) return Option.some<Operation>(operation)
const entrypoint = yield* PluginSourceDirectory.entrypoint(fs, operation.target)
if (Option.isSome(entrypoint)) return Option.some<Operation>({ ...operation, target: entrypoint.value })
yield* Effect.logWarning("configured plugin directory has no index entrypoint", { target: operation.target })
return Option.none<Operation>()
}),
).pipe(Effect.map((operations) => operations.flatMap(Option.toArray)))
// Explicit config is applied last so it can remove auto-discovered packages.
return yield* Effect.forEach([...discovered, ...resolved], (operation) =>
Effect.gen(function* () {
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return [operation]
if (!(yield* fs.existsSafe(operation.target))) return [operation]
const directory = yield* fs.isDir(operation.target)
const entrypoints: Host.Entrypoints = directory
? yield* Effect.sync(() => Host.resolve({ directory: operation.target }))
: { server: pathToFileURL(operation.target).href }
if (!entrypoints.server) return []
if (directory) {
const root = yield* fs.resolve(operation.target)
const server = yield* fs.resolve(fileURLToPath(entrypoints.server))
if (!FSUtil.contains(root, server)) return []
}
const times = yield* Effect.forEach(
[
...Object.values(entrypoints)
.filter((entry) => entry !== undefined)
.map((entry) => fileURLToPath(entry)),
path.join(directory ? operation.target : path.dirname(operation.target), "package.json"),
],
(entry) =>
fs.stat(entry).pipe(
Effect.map((info) => Option.getOrElse(info.mtime, () => new Date(0)).getTime()),
Effect.orElseSucceed(() => 0),
),
)
return [{ ...operation, mtime: Math.max(...times) }]
}),
).pipe(Effect.map((operations) => operations.flat()))
return yield* Effect.forEach([...discovered, ...resolved], (operation) => {
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
return fs.stat(operation.target).pipe(
Effect.map((info) => ({
...operation,
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
})),
Effect.orElseSucceed(() => operation),
)
})
})
function isPluginSource(entries: readonly Entry[], file: string) {
@@ -12,10 +12,10 @@ export const Plugin = define({
const config = yield* Config.Service
const output = yield* ToolOutput.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, output.reload())
yield* output.transform((editor) => {
yield* output.transform((draft) => {
const configured = Config.latest(loaded.entries, "tool_output")
if (!configured) return
editor.configure({
draft.configure({
...(configured.max_lines === undefined ? {} : { maxLines: configured.max_lines }),
...(configured.max_bytes === undefined ? {} : { maxBytes: configured.max_bytes }),
})
+21 -46
View File
@@ -2,7 +2,7 @@ export * as Database from "./database.js"
import { EffectDrizzleSqlite } from "./drizzle.js"
import { sqliteLayer, supportsForeignKeyToggle, supportsTuningPragmas } from "#sqlite"
import { Context, Effect, Layer, Schema, Semaphore } from "effect"
import { Context, Effect, Layer, Schema } from "effect"
import type { SqlClient } from "effect/unstable/sql"
import { Global } from "@opencode-ai/util/global"
import { isAbsolute, join } from "path"
@@ -23,52 +23,30 @@ export type Options = typeof Options.Type
export class Service extends Context.Service<Service, Interface>()("@opencode/storage/Database") {}
// The bootstrap lock is scoped to the database being built, never to this
// module: on workerd every Durable Object in an isolate shares module state, and
// releasing a shared semaphore resumes the waiting object's fiber inside the
// releasing object's I/O context, where its first storage call is rejected as
// cross-object I/O.
const databaseLayer = (lock: Effect.Effect<Semaphore.Semaphore>) =>
Layer.effect(
Service,
Effect.gen(function* () {
const db = yield* makeDatabase
const databaseLayer = Layer.effect(
Service,
Effect.gen(function* () {
const db = yield* makeDatabase
if (supportsTuningPragmas) {
yield* db.run("PRAGMA journal_mode = WAL")
yield* db.run("PRAGMA synchronous = NORMAL")
yield* db.run("PRAGMA busy_timeout = 5000")
yield* db.run("PRAGMA cache_size = -64000")
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
}
// Durable Object SQLite always enforces foreign keys and rejects the pragma.
if (supportsForeignKeyToggle) yield* db.run("PRAGMA foreign_keys = ON")
const semaphore = yield* lock
yield* semaphore.withPermit(DatabaseMigration.apply(db))
if (supportsTuningPragmas) {
yield* db.run("PRAGMA journal_mode = WAL")
yield* db.run("PRAGMA synchronous = NORMAL")
yield* db.run("PRAGMA busy_timeout = 5000")
yield* db.run("PRAGMA cache_size = -64000")
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
}
// Durable Object SQLite always enforces foreign keys and rejects the pragma.
if (supportsForeignKeyToggle) yield* db.run("PRAGMA foreign_keys = ON")
yield* DatabaseMigration.apply(db)
return { db }
}).pipe(Effect.orDie),
)
// Two instances over one file bootstrap the same schema, so file databases
// share a lock per path. Each in-memory database is its own connection.
const locks = new Map<string, Semaphore.Semaphore>()
function lockFor(filename: string) {
const existing = locks.get(filename)
if (existing) return existing
const lock = Semaphore.makeUnsafe(1)
locks.set(filename, lock)
return lock
}
return { db }
}).pipe(Effect.orDie),
)
export function layer(options: Options = { path: ":memory:" }) {
return Layer.unwrap(
Effect.gen(function* () {
const provide = (filename: string) =>
databaseLayer(filename === ":memory:" ? Semaphore.make(1) : Effect.succeed(lockFor(filename))).pipe(
Layer.provide(sqliteLayer({ filename })),
)
const provide = (filename: string) => layerFromClient.pipe(Layer.provide(sqliteLayer({ filename })))
const filename = options.path ?? ":memory:"
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
const global = yield* Global.Service
@@ -80,11 +58,8 @@ export function layer(options: Options = { path: ":memory:" }) {
// The database service over an injected SqlClient, for runtimes that receive
// database storage instead of opening a filesystem path. Any client provided
// here still goes through the pragma guards and migrations; Global is required
// because migrations may read it (the v1 import). The lock is created per build
// because every Durable Object builds this layer over its own storage.
export const layerFromClient: Layer.Layer<Service, never, SqlClient.SqlClient | Global.Service> = databaseLayer(
Semaphore.make(1),
)
// because migrations may read it (the v1 import).
export const layerFromClient: Layer.Layer<Service, never, SqlClient.SqlClient | Global.Service> = databaseLayer
export function configured(options?: Options) {
return makeGlobalNode({ service: Service, layer: layer(options), deps: [Global.node] })
+33 -32
View File
@@ -1,7 +1,7 @@
export * as DatabaseMigration from "./migration.js"
import { sql } from "drizzle-orm"
import { Effect } from "effect"
import { Effect, Semaphore } from "effect"
import { supportsForeignKeyToggle } from "#sqlite"
import type { EffectDrizzleSqlite } from "./drizzle.js"
import { migrations } from "./migration.gen.js"
@@ -10,6 +10,7 @@ import { Global } from "@opencode-ai/util/global"
type Database = EffectDrizzleSqlite.EffectSQLiteDatabase
type Transaction = Parameters<Parameters<Database["transaction"]>[0]>[0]
const lock = Semaphore.makeUnsafe(1)
export type Migration = {
id: string
@@ -17,38 +18,38 @@ export type Migration = {
up: (tx: Transaction) => Effect.Effect<void, unknown, Global.Service>
}
// Not serialized here: the Database layer holds a lock scoped to the database
// it is bootstrapping, since two instances over one file must not race.
export function apply(db: Database) {
return Effect.gen(function* () {
// OpenCode owns the unprefixed table namespace. Embedders sharing this
// database may own underscore-prefixed tables, which bootstrap ignores.
const tables = yield* db.all<{ name: string }>(
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND substr(name, 1, 1) <> '_'`,
)
if (tables.some((table) => table.name === "session" || table.name === "session_v2"))
return yield* applyOnly(db, migrations)
if (tables.length > 0) return yield* Effect.die(new Error("Database is not empty and has no session table"))
const started = Date.now()
yield* Effect.logInfo("database schema bootstrap started", { migrations: migrations.length })
yield* db.transaction((tx) =>
Effect.gen(function* () {
yield* schema.up(tx)
yield* tx.run(
sql`CREATE TABLE ${sql.identifier("migration")} (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`,
)
yield* Effect.forEach(migrations, (migration) =>
tx.run(
sql`INSERT INTO ${sql.identifier("migration")} (id, time_completed) VALUES (${migration.id}, ${Date.now()})`,
),
)
}),
)
yield* Effect.logInfo("database schema bootstrap completed", {
migrations: migrations.length,
durationMs: Date.now() - started,
})
})
return lock.withPermit(
Effect.gen(function* () {
// OpenCode owns the unprefixed table namespace. Embedders sharing this
// database may own underscore-prefixed tables, which bootstrap ignores.
const tables = yield* db.all<{ name: string }>(
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND substr(name, 1, 1) <> '_'`,
)
if (tables.some((table) => table.name === "session" || table.name === "session_v2"))
return yield* applyOnly(db, migrations)
if (tables.length > 0) return yield* Effect.die(new Error("Database is not empty and has no session table"))
const started = Date.now()
yield* Effect.logInfo("database schema bootstrap started", { migrations: migrations.length })
yield* db.transaction((tx) =>
Effect.gen(function* () {
yield* schema.up(tx)
yield* tx.run(
sql`CREATE TABLE ${sql.identifier("migration")} (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`,
)
yield* Effect.forEach(migrations, (migration) =>
tx.run(
sql`INSERT INTO ${sql.identifier("migration")} (id, time_completed) VALUES (${migration.id}, ${Date.now()})`,
),
)
}),
)
yield* Effect.logInfo("database schema bootstrap completed", {
migrations: migrations.length,
durationMs: Date.now() - started,
})
}),
)
}
export function applyOnly(db: Database, input: Migration[]) {
@@ -8,12 +8,12 @@ type Data = {
ignore: string[]
}
export type Editor = {
export type Draft = {
add: (ignore: readonly string[]) => void
list: () => readonly string[]
}
export interface Interface extends State.Transformable<Editor> {
export interface Interface extends State.Transformable<Draft> {
readonly current: () => readonly string[]
readonly observe: (
listener: (ignore: readonly string[]) => Effect.Effect<void>,
@@ -26,15 +26,14 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const listeners = new Set<(ignore: readonly string[]) => Effect.Effect<void>>()
const state = State.create<Data, Editor>({
const state = State.create<Data, Draft>({
name: "location-watcher-policy",
initial: () => ({ ignore: [] }),
editor: (editor) => ({
add: (ignore) => editor.ignore.push(...ignore),
list: () => editor.ignore,
draft: (draft) => ({
add: (ignore) => draft.ignore.push(...ignore),
list: () => draft.ignore,
}),
// Read per listener: a reentrant transform inside an earlier listener must reach later ones.
notify: () => Effect.forEach(listeners, (listener) => listener(current()), { discard: true }),
notify: Effect.forEach(listeners, (listener) => listener(current()), { discard: true }),
})
// Annotated to break the inference cycle through notify: notify reads current, current reads state.
const current = (): readonly string[] => state.get().ignore
@@ -24,6 +24,7 @@ const layer = Layer.effect(
const bus = yield* Bus.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const plugins = yield* Plugin.Service
const policy = yield* LocationWatcherPolicy.Service
const publish = (update: { type: "create" | "update" | "delete"; path: string }) =>
bus.publish(FileSystem.Event.Changed, {
@@ -50,14 +51,16 @@ const layer = Layer.effect(
),
)
const lock = Semaphore.makeUnsafe(1)
let requested = 0
let stopped = false
let active: { path: string; scope: Scope.Closeable } | undefined
const reconcile = () =>
lock.withPermit(
const reconcile = (ignore: readonly string[]) => {
const request = ++requested
return lock.withPermit(
Effect.gen(function* () {
if (stopped) return
if (stopped || request !== requested) return
const resolved = yield* target
const ignore = policy.current()
if (stopped || request !== requested) return
const next = resolved && !resolved.aliases.some((alias) => ignore.includes(alias)) ? resolved.path : undefined
if (active?.path === next) return
if (active) yield* Scope.close(active.scope, Exit.void)
@@ -77,10 +80,12 @@ const layer = Layer.effect(
)
}).pipe(Effect.withSpan("LocationWatcher.reconcile", { attributes: { directory: location.directory } })),
)
}
yield* Effect.addFinalizer(() =>
lock.withPermit(
Effect.gen(function* () {
stopped = true
requested++
if (active) yield* Scope.close(active.scope, Exit.void)
active = undefined
}),
@@ -88,8 +93,8 @@ const layer = Layer.effect(
)
yield* policy.observe(reconcile)
yield* Effect.gen(function* () {
yield* Plugin.awaitActivation
yield* reconcile()
yield* plugins.awaitActivation
yield* reconcile(policy.current())
}).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterrupts(cause),
+5 -24
View File
@@ -5,7 +5,6 @@ import { createWrapper } from "@parcel/watcher/wrapper"
import type ParcelWatcher from "@parcel/watcher"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Cause, Context, Effect, Layer, PubSub, RcMap, Schema, Stream } from "effect"
import { lazy } from "../util/lazy.js"
import { watch } from "node:fs"
@@ -71,7 +70,7 @@ export type Options = typeof Options.Type
export class Service extends Context.Service<Service, Interface>()("@opencode/Watcher") {}
export interface TestInterface extends Interface {
/** Delivers one update to every active watch whose target covers `update.path`. */
/** Broadcasts one update to every subscriber. */
readonly emit: (update: Update) => Effect.Effect<void>
/** Returns every subscribe call observed so far, in order. */
readonly subscriptions: () => Effect.Effect<readonly WatchInput[]>
@@ -151,14 +150,12 @@ export const layer = (options?: Options) =>
/**
* Watcher for tests: the real lifecycle over an in-memory Native that records
* acquired watches and routes emitted updates the way the OS watches would: a
* file watch receives updates for its own path, a directory watch receives
* updates for paths inside it that no ignore entry covers.
* acquired watches and broadcasts emitted updates to every active watch.
*/
export const testLayer = Layer.effectContext(
Effect.gen(function* () {
const subscriptions: WatchInput[] = []
const active = new Map<(update: Update) => void, (path: string) => boolean>()
const active = new Set<(update: Update) => void>()
const native = Native.of({
subscribe: (input) =>
Effect.sync(() => {
@@ -169,17 +166,7 @@ export const testLayer = Layer.effectContext(
? { path: input.target, type: "directory", ignore: input.ignore }
: { path: input.target, type: "directory" },
)
// Ignore entries resolve against the target like the parcel wrapper's
// literal paths. Glob entries resolve to paths nothing lives under, so
// they are inert here rather than compiled the way parcel compiles them.
const ignored = input.ignore.map((entry) => path.resolve(input.target, entry))
active.set(
input.publish,
input.type === "file"
? (target) => target === input.target
: (target) =>
FSUtil.contains(input.target, target) && !ignored.some((entry) => FSUtil.contains(entry, target)),
)
active.add(input.publish)
return {
unsubscribe: () => {
active.delete(input.publish)
@@ -191,13 +178,7 @@ export const testLayer = Layer.effectContext(
const context = yield* Layer.build(layer().pipe(Layer.provide(Layer.succeed(Native, native))))
const test = Test.of({
subscribe: Context.get(context, Service).subscribe,
emit: (update) =>
Effect.sync(() => {
const target = path.resolve(update.path)
active.forEach((matches, publish) => {
if (matches(target)) publish(update)
})
}),
emit: (update) => Effect.sync(() => active.forEach((publish) => publish(update))),
subscriptions: () => Effect.sync(() => [...subscriptions]),
})
return Context.empty().pipe(Context.add(Service, test), Context.add(Test, test))
+8 -8
View File
@@ -13,12 +13,12 @@ type Data = {
formatters: Info[]
}
export type Editor = {
export type Draft = {
set: (formatter: Info) => void
remove: (name: string) => void
}
export interface Interface extends State.Transformable<Editor> {
export interface Interface extends State.Transformable<Draft> {
readonly file: (filepath: string) => Effect.Effect<boolean>
}
@@ -30,17 +30,17 @@ const layer = Layer.effect(
const location = yield* Location.Service
const processes = yield* AppProcess.Service
const commands = new WeakMap<Info, string[] | false>()
const state = State.create<Data, Editor>({
const state = State.create<Data, Draft>({
name: "formatter",
initial: () => ({ formatters: [] }),
editor: (editor) => ({
draft: (draft) => ({
set: (formatter) => {
const index = editor.formatters.findIndex((item) => item.name === formatter.name)
if (index === -1) editor.formatters.push(formatter)
else editor.formatters[index] = formatter
const index = draft.formatters.findIndex((item) => item.name === formatter.name)
if (index === -1) draft.formatters.push(formatter)
else draft.formatters[index] = formatter
},
remove: (name) => {
editor.formatters = editor.formatters.filter((formatter) => formatter.name !== name)
draft.formatters = draft.formatters.filter((formatter) => formatter.name !== name)
},
}),
})
+8 -8
View File
@@ -39,11 +39,11 @@ export type Limits = {
maxBase64Bytes: number
}
export type Editor = {
export type Draft = {
configure: (limits: Partial<Limits>) => void
}
export interface Interface extends State.Transformable<Editor> {
export interface Interface extends State.Transformable<Draft> {
readonly normalize: (
resource: string,
content: FileSystem.Content & { readonly encoding: "base64" },
@@ -58,7 +58,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Im
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = State.create<Limits, Editor>({
const state = State.create<Limits, Draft>({
name: "image",
initial: () => ({
autoResize: true,
@@ -66,12 +66,12 @@ const layer = Layer.effect(
maxHeight: 2_000,
maxBase64Bytes: 5 * 1024 * 1024,
}),
editor: (editor) => ({
draft: (draft) => ({
configure: (limits) => {
if (limits.autoResize !== undefined) editor.autoResize = limits.autoResize
if (limits.maxWidth !== undefined) editor.maxWidth = limits.maxWidth
if (limits.maxHeight !== undefined) editor.maxHeight = limits.maxHeight
if (limits.maxBase64Bytes !== undefined) editor.maxBase64Bytes = limits.maxBase64Bytes
if (limits.autoResize !== undefined) draft.autoResize = limits.autoResize
if (limits.maxWidth !== undefined) draft.maxWidth = limits.maxWidth
if (limits.maxHeight !== undefined) draft.maxHeight = limits.maxHeight
if (limits.maxBase64Bytes !== undefined) draft.maxBase64Bytes = limits.maxBase64Bytes
},
}),
})
+10 -10
View File
@@ -25,7 +25,7 @@ export type Data = {
available: boolean
}
export type Editor = {
export type Draft = {
list: () => readonly Types.DeepMutable<File>[]
// Map insertion order is render order: config adds global then nearest-to-farthest project files;
// sibling contributors interleave by transform registration order.
@@ -35,7 +35,7 @@ export type Editor = {
unavailable: () => void
}
export interface Interface extends State.Transformable<Editor> {
export interface Interface extends State.Transformable<Draft> {
// Discovery policy lives here because internal plugins have no per-composition options channel.
// Move it into plugin config once plugins can consume their own options.
readonly project: boolean
@@ -57,24 +57,24 @@ export const layer = (options?: Options) =>
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const state = State.create<Data, Editor>({
const state = State.create<Data, Draft>({
name: "instruction-discovery",
initial: () => ({ files: new Map(), available: true }),
editor: (editor) => ({
list: () => Array.from(editor.files.values()),
add: (file) => editor.files.set(file.path, new File(file) as Types.DeepMutable<File>),
draft: (draft) => ({
list: () => Array.from(draft.files.values()),
add: (file) => draft.files.set(file.path, new File(file) as Types.DeepMutable<File>),
update: (path, update) => {
const current = editor.files.get(AbsolutePath.make(path))
const current = draft.files.get(AbsolutePath.make(path))
if (!current) return
update(current)
current.path = AbsolutePath.make(path)
},
remove: (path) => editor.files.delete(AbsolutePath.make(path)),
remove: (path) => draft.files.delete(AbsolutePath.make(path)),
unavailable: () => {
editor.available = false
draft.available = false
},
}),
notify: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
notify: bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
})
const source = (value: ReadonlyArray<File> | Instructions.Unavailable | Instructions.Removed) =>
+15 -15
View File
@@ -130,7 +130,7 @@ type Data = {
integrations: Map<ID, Entry>
}
export type Editor = {
export type Draft = {
list: () => readonly Ref[]
get: (id: ID) => Ref | undefined
update: (id: ID, update: (integration: Types.DeepMutable<Ref>) => void) => void
@@ -142,7 +142,7 @@ export type Editor = {
}
}
export interface Interface extends State.Transformable<Editor> {
export interface Interface extends State.Transformable<Draft> {
/** Registers a scoped transform over the integration registry. */
/** Returns one integration with its methods and current connections. */
readonly get: (id: ID) => Effect.Effect<Info | undefined>
@@ -266,27 +266,27 @@ const layer = Layer.effect(
const scope = yield* Scope.Scope
const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>())
const commandAttempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, CommandAttemptEntry>())
const state = State.create<Data, Editor>({
const state = State.create<Data, Draft>({
name: "integration",
initial: () => ({ integrations: new Map<ID, Entry>() }),
editor: (editor) => ({
list: () => Array.from(editor.integrations.values(), (entry) => entry.ref) as Ref[],
get: (id) => editor.integrations.get(id)?.ref as Ref | undefined,
draft: (draft) => ({
list: () => Array.from(draft.integrations.values(), (entry) => entry.ref) as Ref[],
get: (id) => draft.integrations.get(id)?.ref as Ref | undefined,
update: (id, update) => {
const current = editor.integrations.get(id) ?? {
const current = draft.integrations.get(id) ?? {
ref: { id, name: id },
methods: [],
implementations: new Map(),
}
if (!editor.integrations.has(id)) editor.integrations.set(id, current)
if (!draft.integrations.has(id)) draft.integrations.set(id, current)
update(current.ref)
current.ref.id = id
},
remove: (id) => editor.integrations.delete(id),
remove: (id) => draft.integrations.delete(id),
method: {
list: (integrationID) => (editor.integrations.get(integrationID)?.methods as Method[] | undefined) ?? [],
list: (integrationID) => (draft.integrations.get(integrationID)?.methods as Method[] | undefined) ?? [],
update: (implementation) => {
const current = editor.integrations.get(implementation.integrationID) ?? {
const current = draft.integrations.get(implementation.integrationID) ?? {
ref: {
id: implementation.integrationID,
name: implementation.integrationID,
@@ -294,8 +294,8 @@ const layer = Layer.effect(
methods: [],
implementations: new Map<MethodID, Types.DeepMutable<OAuthImplementation>>(),
}
if (!editor.integrations.has(implementation.integrationID)) {
editor.integrations.set(implementation.integrationID, current)
if (!draft.integrations.has(implementation.integrationID)) {
draft.integrations.set(implementation.integrationID, current)
}
const index = current.methods.findIndex((method) => {
if (method.type !== implementation.method.type) return false
@@ -315,7 +315,7 @@ const layer = Layer.effect(
}
},
remove: (integrationID, method) => {
const current = editor.integrations.get(integrationID)
const current = draft.integrations.get(integrationID)
if (!current) return
const index = current.methods.findIndex((candidate) => {
if (candidate.type !== method.type) return false
@@ -328,7 +328,7 @@ const layer = Layer.effect(
},
},
}),
notify: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
notify: bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
})
const createCredential = Effect.fnUntraced(function* (input: Parameters<Credential.Interface["create"]>[0]) {
+14 -14
View File
@@ -133,7 +133,7 @@ type Data = {
removed: Set<ServerName>
}
export type Editor = {
export type Draft = {
list: () => readonly [ServerName, Types.DeepMutable<Mcp.ServerConfig>][]
get: (server: ServerName | string) => Types.DeepMutable<Mcp.ServerConfig> | undefined
set: (server: ServerName | string, config: Mcp.ServerConfig) => void
@@ -143,7 +143,7 @@ export type Editor = {
const cloneConfig = (config: Mcp.ServerConfig) => structuredClone(config) as Types.DeepMutable<Mcp.ServerConfig>
export interface Interface extends State.Transformable<Editor> {
export interface Interface extends State.Transformable<Draft> {
readonly servers: () => Effect.Effect<ServerInfo[]>
readonly add: (server: ServerName | string, config: Mcp.ServerConfig) => Effect.Effect<void>
readonly connect: (server: ServerName | string) => Effect.Effect<void, NotFoundError>
@@ -227,12 +227,12 @@ export const layer = (options?: Options) =>
const scope = yield* Scope.fork(root)
entry.registration = { dispose: Scope.close(scope, Exit.void) }
yield* integration
.transform((editor) => {
editor.update(integrationID, (ref) => {
.transform((draft) => {
draft.update(integrationID, (ref) => {
ref.name = name
ref.metadata = { source: "mcp" }
})
editor.method.update({
draft.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: name },
authorize: () =>
@@ -678,7 +678,7 @@ export const layer = (options?: Options) =>
Stream.runForEach((event) => Effect.sync(() => fork(reconnect(event.data.integrationID)))),
),
)
const state: State.Interface<Data, Editor> = State.create<Data, Editor>({
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
name: "mcp",
initial: () => ({
servers: new Map(
@@ -688,22 +688,22 @@ export const layer = (options?: Options) =>
),
removed: new Set(Array.from(overrides).flatMap(([name, config]) => (config === false ? [name] : []))),
}),
editor: (editor) => ({
list: () => Array.from(editor.servers),
get: (server) => editor.servers.get(ServerName.make(server)),
draft: (draft) => ({
list: () => Array.from(draft.servers),
get: (server) => draft.servers.get(ServerName.make(server)),
set: (server, serverConfig) => {
const name = ServerName.make(server)
if (editor.removed.has(name)) return
editor.servers.set(name, cloneConfig(serverConfig))
if (draft.removed.has(name)) return
draft.servers.set(name, cloneConfig(serverConfig))
},
update: (server, update) => {
const current = editor.servers.get(ServerName.make(server))
const current = draft.servers.get(ServerName.make(server))
if (!current) return
update(current)
},
remove: (server) => editor.servers.delete(ServerName.make(server)),
remove: (server) => draft.servers.delete(ServerName.make(server)),
}),
notify: () => State.reconcile(root, fork, () => reconcileLock.withPermit(reconcile())),
notify: State.reconcile(root, fork, () => reconcileLock.withPermit(reconcile())),
})
// Suspend so each await sees current entries; a bare Map iterator is exhausted after one run.
+64 -56
View File
@@ -2,6 +2,7 @@ export * as Plugin from "./plugin.js"
export { Event, ID, Info, Source, State } from "@opencode-ai/schema/plugin"
import { Plugin } from "@opencode-ai/schema/plugin"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
import { Node } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import type { PersistentPty } from "./persistent-pty.js"
@@ -9,10 +10,27 @@ import { Cause, Context, Effect, Exit, Latch, Layer, Logger, References, Scope,
import { Bus } from "./bus.js"
import { KV } from "./kv.js"
import { PluginHost } from "./plugin/host.js"
import { type Failure, type Generation, Service } from "./plugin/service.js"
import { State } from "./state.js"
export { awaitActivation, type Generation, type Interface, Service } from "./plugin/service.js"
export interface Interface {
readonly activate: (plugins: readonly Generation[], failures?: readonly Failure[]) => Effect.Effect<void>
readonly list: () => Effect.Effect<Plugin.Info[]>
readonly close: (exit: Exit.Exit<unknown, unknown>) => Effect.Effect<void>
/** Wait for announced updates and activation to settle; failures remain in the inventory. */
readonly awaitActivation: Effect.Effect<void>
/** Keep readiness pending while preparing an update. Run the returned Effect to release it. */
readonly hold: () => Effect.Effect<Effect.Effect<void>>
}
type Failure = Plugin.Info & { readonly state: Extract<Plugin.State, { readonly status: "failed" }> }
export type Generation = PluginDefinition & {
readonly revision: string
readonly source?: Plugin.Source
readonly features?: Plugin.Features
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
const layer = Layer.effect(
Service,
@@ -20,9 +38,7 @@ const layer = Layer.effect(
const bus = yield* Bus.Service
const kv = yield* KV.Service
const scope = yield* Scope.make()
// One slot per requested definition in activation order, including ones whose setup failed, so
// the prefix diff below stays index-aligned and a failed revision is not retried until it changes.
const active = new Map<Plugin.ID, Slot>()
const active = new Map<Plugin.ID, { readonly plugin: Generation; readonly scope: Scope.Closeable }>()
const lock = Semaphore.makeUnsafe(1)
const ready = yield* Latch.make(true)
const pending = new Set<object>()
@@ -54,6 +70,7 @@ const layer = Layer.effect(
),
),
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": plugin.id } }),
Effect.andThen(bus.publish(Plugin.Event.Added, { id: Plugin.ID.make(plugin.id) })),
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
Effect.exit,
)
@@ -81,18 +98,18 @@ const layer = Layer.effect(
() =>
lock.withPermit(
Effect.gen(function* () {
const current = Array.from(active.values())
const changed = definitions.findIndex((definition, index) => {
const entry = current[index]
return entry?.plugin.id !== definition.id || entry.plugin.revision !== definition.revision
})
const prefix = changed === -1 ? definitions.length : changed
for (const definition of definitions.slice(0, prefix)) {
const entry = active.get(definition.id)
if (entry) active.set(definition.id, { ...entry, plugin: definition })
}
if (prefix === definitions.length && active.size === definitions.length) {
const nextInventory = [...Array.from(active.values()).map(slotInfo), ...failures]
if (
active.size === definitions.length &&
Array.from(active.values()).every((entry, index) => {
const definition = definitions[index]
return entry.plugin.id === definition?.id && entry.plugin.revision === definition.revision
})
) {
for (const definition of definitions) {
const entry = active.get(definition.id)
if (entry) active.set(definition.id, { ...entry, plugin: definition })
}
const nextInventory = [...definitions.map(activeInfo), ...failures]
if (JSON.stringify(inventory) === JSON.stringify(nextInventory)) return
inventory = nextInventory
yield* bus.publish(Plugin.Event.Updated, {})
@@ -101,37 +118,29 @@ const layer = Layer.effect(
yield* State.batch(
Effect.gen(function* () {
// Registrations are ordered by setup, so only the unchanged prefix can stay alive.
const previous = new Map(Array.from(active.entries()).slice(prefix))
yield* Effect.forEach(
Array.from(previous.entries()).toReversed(),
([id, slot]) =>
Effect.gen(function* () {
active.delete(id)
if (slot.loaded) yield* Scope.close(slot.loaded.scope, Exit.void)
}),
{ discard: true },
)
for (const definition of definitions.slice(prefix)) {
const nextInventory: Plugin.Info[] = []
for (const definition of definitions) {
const previous = active.get(definition.id)
active.delete(definition.id)
if (previous) yield* Scope.close(previous.scope, Exit.void)
const loaded = yield* load(definition)
if (loaded.scope !== undefined) {
active.set(definition.id, {
plugin: definition,
loaded: { plugin: definition, scope: loaded.scope },
})
active.set(definition.id, { plugin: definition, scope: loaded.scope })
nextInventory.push(activeInfo(definition))
continue
}
active.set(definition.id, { plugin: definition, error: loaded.error })
nextInventory.push({
id: definition.id,
source: definition.source ?? { type: "builtin" },
state: { status: "failed", error: loaded.error },
features: { server: true, ...definition.features },
})
const fallback = previous.get(definition.id)?.loaded
if (!fallback) continue
const restored = yield* load(fallback.plugin)
if (!previous) continue
const restored = yield* load(previous.plugin)
if (restored.scope !== undefined) {
active.set(definition.id, {
plugin: definition,
loaded: { plugin: fallback.plugin, scope: restored.scope },
error: loaded.error,
})
active.set(definition.id, { plugin: previous.plugin, scope: restored.scope })
continue
}
yield* Effect.logError("failed to restore plugin; deactivating", {
@@ -139,7 +148,14 @@ const layer = Layer.effect(
})
}
inventory = [...Array.from(active.values()).map(slotInfo), ...failures]
const removed = Array.from(active.entries())
.filter(([id]) => !ids.has(id))
.toReversed()
removed.forEach(([id]) => active.delete(id))
yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void), {
discard: true,
})
inventory = [...nextInventory, ...failures]
}),
)
yield* bus.publish(Plugin.Event.Updated, {})
@@ -168,20 +184,12 @@ const layer = Layer.effect(
}),
)
// `plugin` is the definition the slot was last asked to run; `loaded` is the generation actually
// running, which stays an older fallback while the requested revision keeps failing setup.
type Slot = {
readonly plugin: Generation
readonly loaded?: { readonly plugin: Generation; readonly scope: Scope.Closeable }
readonly error?: string
}
function slotInfo(slot: Slot): Plugin.Info {
function activeInfo(plugin: Generation): Plugin.Info {
return {
id: Plugin.ID.make(slot.plugin.id),
source: slot.plugin.source ?? { type: "builtin" },
state: slot.error === undefined ? { status: "active" } : { status: "failed", error: slot.error },
features: { server: true, ...slot.plugin.features },
id: Plugin.ID.make(plugin.id),
source: plugin.source ?? { type: "builtin" },
state: { status: "active" },
features: { server: true, ...plugin.features },
}
}
+7 -7
View File
@@ -83,15 +83,15 @@ Rules:
export const Plugin = define({
id: "opencode.agent",
effect: Effect.fn(function* (ctx) {
yield* ctx.agent.transform((editor) => {
editor.update(Agent.defaultID, (item) => {
yield* ctx.agent.transform((draft) => {
draft.update(Agent.defaultID, (item) => {
item.name = Agent.Name.make("Build")
item.description = "The default agent. Executes tools based on configured permissions."
item.mode = "primary"
item.permissions.push({ action: "question", resource: "*", effect: "allow" })
})
editor.update(Agent.ID.make("general"), (item) => {
draft.update(Agent.ID.make("general"), (item) => {
item.name = Agent.Name.make("General")
item.description =
"General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel."
@@ -102,7 +102,7 @@ export const Plugin = define({
)
})
editor.update(Agent.ID.make("explore"), (item) => {
draft.update(Agent.ID.make("explore"), (item) => {
const externalDirectories = item.permissions.filter(
(rule) => rule.action === "external_directory" && rule.effect === "allow",
)
@@ -130,13 +130,13 @@ export const Plugin = define({
)
})
editor.update(Agent.ID.make("compaction"), (item) => {
draft.update(Agent.ID.make("compaction"), (item) => {
item.name = Agent.Name.make("Compaction")
item.mode = "primary"
item.hidden = true
})
editor.update(Agent.ID.make("title"), (item) => {
draft.update(Agent.ID.make("title"), (item) => {
item.name = Agent.Name.make("Title")
item.mode = "primary"
item.hidden = true
@@ -144,7 +144,7 @@ export const Plugin = define({
item.permissions.push({ action: "*", resource: "*", effect: "deny" })
})
editor.update(Agent.ID.make("summary"), (item) => {
draft.update(Agent.ID.make("summary"), (item) => {
item.name = Agent.Name.make("Summary")
item.mode = "primary"
item.hidden = true
+4 -4
View File
@@ -25,8 +25,8 @@ export const Plugin = define({
Effect.forkScoped({ startImmediately: true }),
)
loaded.prompts = yield* mcp.prompts()
yield* ctx.command.transform((editor) => {
editor.add({
yield* ctx.command.transform((draft) => {
draft.add({
name: "init",
description: "guided AGENTS.md setup",
execute: (input) =>
@@ -39,7 +39,7 @@ export const Plugin = define({
})
.pipe(Effect.asVoid),
})
editor.add({
draft.add({
name: "review",
description: "review changes [commit|branch|pr], defaults to uncommitted",
execute: (input) =>
@@ -53,7 +53,7 @@ export const Plugin = define({
.pipe(Effect.asVoid),
})
for (const prompt of loaded.prompts) {
editor.add({
draft.add({
name: mcpCommandName(prompt.server, prompt.name),
description: prompt.description,
execute: (input) =>
+43 -46
View File
@@ -136,13 +136,13 @@ export const make = Effect.fn("PluginHost.make")(function* (
},
reload: agents.reload,
transform: (callback) =>
agents.transform((editor) => {
agents.transform((draft) => {
callback({
list: () => mutable(editor.list()),
get: (id) => mutable(editor.get(Agent.ID.make(id))),
default: (id) => editor.default(id === undefined ? undefined : Agent.ID.make(id)),
update: (id, update) => editor.update(Agent.ID.make(id), update),
remove: (id) => editor.remove(Agent.ID.make(id)),
list: () => mutable(draft.list()),
get: (id) => mutable(draft.get(Agent.ID.make(id))),
default: (id) => draft.default(id === undefined ? undefined : Agent.ID.make(id)),
update: (id, update) => draft.update(Agent.ID.make(id), update),
remove: (id) => draft.remove(Agent.ID.make(id)),
})
}),
},
@@ -196,25 +196,24 @@ export const make = Effect.fn("PluginHost.make")(function* (
},
reload: catalog.reload,
transform: (callback) =>
catalog.transform((editor) => {
catalog.transform((draft) => {
callback({
provider: {
list: () => mutable(editor.provider.list()),
get: (id) => mutable(editor.provider.get(Provider.ID.make(id))),
update: (id, update) => editor.provider.update(Provider.ID.make(id), update),
remove: (id) => editor.provider.remove(Provider.ID.make(id)),
list: () => mutable(draft.provider.list()),
get: (id) => mutable(draft.provider.get(Provider.ID.make(id))),
update: (id, update) => draft.provider.update(Provider.ID.make(id), update),
remove: (id) => draft.provider.remove(Provider.ID.make(id)),
},
model: {
get: (providerID, modelID) =>
mutable(editor.model.get(Provider.ID.make(providerID), Model.ID.make(modelID))),
mutable(draft.model.get(Provider.ID.make(providerID), Model.ID.make(modelID))),
update: (providerID, modelID, update) =>
editor.model.update(Provider.ID.make(providerID), Model.ID.make(modelID), update),
remove: (providerID, modelID) =>
editor.model.remove(Provider.ID.make(providerID), Model.ID.make(modelID)),
draft.model.update(Provider.ID.make(providerID), Model.ID.make(modelID), update),
remove: (providerID, modelID) => draft.model.remove(Provider.ID.make(providerID), Model.ID.make(modelID)),
default: {
get: editor.model.default.get,
get: draft.model.default.get,
set: (providerID, modelID) =>
editor.model.default.set(Provider.ID.make(providerID), Model.ID.make(modelID)),
draft.model.default.set(Provider.ID.make(providerID), Model.ID.make(modelID)),
},
},
})
@@ -316,17 +315,17 @@ export const make = Effect.fn("PluginHost.make")(function* (
),
},
transform: (callback) =>
integration.transform((editor) => {
integration.transform((draft) => {
callback({
list: () => mutable(editor.list()),
get: (id) => mutable(editor.get(Integration.ID.make(id))),
update: (id, update) => editor.update(Integration.ID.make(id), update),
remove: (id) => editor.remove(Integration.ID.make(id)),
list: () => mutable(draft.list()),
get: (id) => mutable(draft.get(Integration.ID.make(id))),
update: (id, update) => draft.update(Integration.ID.make(id), update),
remove: (id) => draft.remove(Integration.ID.make(id)),
method: {
list: (id) => editor.method.list(Integration.ID.make(id)),
update: (input) => editor.method.update(methodImplementation(input)),
list: (id) => draft.method.list(Integration.ID.make(id)),
update: (input) => draft.method.update(methodImplementation(input)),
remove: (id, method) =>
editor.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)),
draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)),
},
})
}),
@@ -351,13 +350,13 @@ export const make = Effect.fn("PluginHost.make")(function* (
},
reload: mcp.reload,
transform: (callback) =>
mcp.transform((editor) => {
mcp.transform((draft) => {
callback({
list: () => editor.list().map(([name, config]) => [name, mutable(config)]),
get: (name) => mutable(editor.get(name)),
set: (name, config) => editor.set(name, Schema.decodeUnknownSync(ServerConfig)(config)),
update: editor.update,
remove: editor.remove,
list: () => draft.list().map(([name, config]) => [name, mutable(config)]),
get: (name) => mutable(draft.get(name)),
set: (name, config) => draft.set(name, Schema.decodeUnknownSync(ServerConfig)(config)),
update: draft.update,
remove: draft.remove,
})
}),
},
@@ -392,12 +391,11 @@ export const make = Effect.fn("PluginHost.make")(function* (
list: () => response(reference.list()),
reload: reference.reload,
transform: (callback) =>
reference.transform((editor) => {
reference.transform((draft) => {
callback({
add: (name, source) => editor.add(name, Schema.decodeUnknownSync(Reference.Source)(source)),
remove: editor.remove,
list: editor.list,
get: editor.get,
add: (name, source) => draft.add(name, Schema.decodeUnknownSync(Reference.Source)(source)),
remove: draft.remove,
list: draft.list,
})
}),
},
@@ -405,13 +403,12 @@ export const make = Effect.fn("PluginHost.make")(function* (
list: () => response(skill.list()),
reload: skill.reload,
transform: (callback) =>
skill.transform((editor) => {
skill.transform((draft) => {
callback({
list: () => mutable(editor.list()),
get: editor.get,
add: (value) => editor.add(Schema.decodeUnknownSync(Skill.Info)(value)),
update: editor.update,
remove: editor.remove,
list: () => mutable(draft.list()),
add: (value) => draft.add(Schema.decodeUnknownSync(Skill.Info)(value)),
update: draft.update,
remove: draft.remove,
})
}),
},
@@ -444,18 +441,18 @@ export const make = Effect.fn("PluginHost.make")(function* (
),
reload: websearch.reload,
transform: (callback) =>
websearch.transform((editor) => {
websearch.transform((draft) => {
callback({
add: (definition) =>
editor.add({
draft.add({
id: WebSearch.ID.make(definition.id),
name: definition.name,
execute: definition.execute,
}),
default: {
get: editor.default.get,
get: draft.default.get,
set: (selection) =>
editor.default.set(
draft.default.set(
selection === false || selection === "random" ? selection : WebSearch.ID.make(selection),
),
},
-2
View File
@@ -63,7 +63,6 @@ import { PatchTool } from "../tool/plugin/patch.js"
import { EditTool } from "../tool/plugin/edit.js"
import { GlobTool } from "../tool/plugin/glob.js"
import { GrepTool } from "../tool/plugin/grep.js"
import { OpenCodeTools } from "../tool/plugin/opencode.js"
import { QuestionTool } from "../tool/plugin/question.js"
import { ReadToolFileSystem } from "../tool/read-filesystem.js"
import { ReadTool } from "../tool/plugin/read.js"
@@ -205,7 +204,6 @@ const pre = [
EditTool.Plugin,
GlobTool.Plugin,
GrepTool.Plugin,
OpenCodeTools.Plugin,
QuestionTool.Plugin,
ReadTool.Plugin,
ShellTool.Plugin,
@@ -9,8 +9,8 @@ const urls = [/^https:\/\/executor\.sh\/[^/]+\/mcp$/]
export const Plugin = define({
id: "opencode.mcp.codemode.exclusion",
effect: Effect.fn(function* (ctx) {
yield* ctx.mcp.transform((editor) => {
for (const [, server] of editor.list()) {
yield* ctx.mcp.transform((draft) => {
for (const [, server] of draft.list()) {
if (server.codemode !== undefined) continue
if (server.type === "local") {
if (server.command[0] === "executor" && server.command[1] === "mcp") server.codemode = false
+5 -8
View File
@@ -10,8 +10,6 @@ export const ModelsDevPlugin = define({
effect: Effect.fn(function* (ctx) {
const modelsDev = yield* ModelsDev.Service
const bus = yield* Bus.Service
// The normalized snapshot is shared by every Location and only read here; the catalog
// receives copies below, so retaining a second copy per Location is unnecessary.
const loaded = { data: snapshots(yield* modelsDev.get()) }
yield* ctx.integration.transform((integrations) => {
for (const provider of loaded.data) {
@@ -34,7 +32,7 @@ export const ModelsDevPlugin = define({
yield* ctx.catalog.transform((catalog) => {
for (const provider of loaded.data) {
catalog.provider.update(provider.info.id, (draft) => {
Object.assign(draft, copy(provider.info))
Object.assign(draft, provider.info)
draft.integrationID = Integration.ID.make(provider.info.id)
})
for (const model of provider.models) {
@@ -67,16 +65,15 @@ function environmentNames(provider: ModelsDev.Snapshot) {
}
function snapshots(data: readonly ModelsDev.Snapshot[]) {
return data.filter(
return copy(data).filter(
// These deprecated aliases are replaced by the canonical Azure and Google Vertex providers.
(provider) => provider.info.id !== "azure-cognitive-services" && provider.info.id !== "google-vertex-anthropic",
)
}
// The catalog owns and mutates its provider and model records in place, so every rebuild
// needs fresh copies of the thousands of shared snapshot records. Snapshot data is plain
// JSON, and a direct copy is an order of magnitude faster than structuredClone's general
// graph walk on the startup path.
// The catalog owns and mutates its model records, so every rebuild needs fresh copies of the
// thousands of snapshot models. Snapshot data is plain JSON, and a direct copy is an order of
// magnitude faster than structuredClone's general graph walk on the startup path.
function copy<T>(value: T): T {
if (Array.isArray(value)) return value.map(copy) as T
if (value !== null && typeof value === "object") {
+43 -24
View File
@@ -1,12 +1,12 @@
export * as PluginModule from "./module.js"
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import { Host } from "@opencode-ai/plugin/host"
import { Npm } from "@opencode-ai/util/npm"
import { importModule } from "@opencode-ai/util/runtime-import"
import { Effect, Schema } from "effect"
import { readdir } from "node:fs/promises"
import path from "path"
import { stat } from "node:fs/promises"
import { fileURLToPath, pathToFileURL } from "url"
import { pathToFileURL } from "url"
import type { ConfigPluginSource } from "../config/plugin/source.js"
import type { Generation } from "../plugin.js"
import { PluginPromise } from "./promise.js"
@@ -35,27 +35,21 @@ export const load = Effect.fn("PluginModule.load")(function* (
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
options?: { readonly install?: boolean },
) {
const local = path.isAbsolute(operation.target)
const npm = yield* Npm.Service
const installed = local
? undefined
const local = path.isAbsolute(operation.target)
const installed: Npm.EntryPoint = local
? { directory: path.dirname(operation.target), entrypoint: pathToFileURL(operation.target).href }
: options?.install === false
? yield* npm.resolve(operation.target)
: yield* npm.add(operation.target)
// Legacy auto-discovery still admits standalone server sources. Configured
// local plugins always arrive here as directories.
const entrypoints: Host.Entrypoints =
local && (yield* Effect.promise(() => stat(operation.target))).isFile()
? { server: pathToFileURL(operation.target).href }
: yield* Effect.sync(() => Host.resolve(installed ?? { directory: operation.target }))
const entrypoint = entrypoints.server
? yield* npm.resolve(operation.target, { subpaths: ["server", ""] })
: yield* npm.add(operation.target, { subpaths: ["server", ""] })
const entrypoint = installed.entrypoint
if (!local && options?.install === false && !entrypoint) return { pending: true as const }
if (!entrypoint) return yield* new LoadError({ message: `Plugin entrypoint not found: ${operation.target}` })
// Bun currently ignores query parameters when caching file:// imports.
const target = typeof Bun !== "undefined" ? fileURLToPath(entrypoint).replaceAll("\\", "/") : entrypoint
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
const source = operation.mtime === undefined ? entrypoint : `${target}?mtime=${operation.mtime}`
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
const mod = yield* Effect.promise(() => Host.load(source))
const mod = yield* Effect.promise(() => importModule(source))
const value = (yield* Schema.decodeUnknownEffect(Module)(mod).pipe(
Effect.mapError(
(cause) =>
@@ -66,20 +60,45 @@ export const load = Effect.fn("PluginModule.load")(function* (
),
)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
const features = local
? yield* localFeatures(operation.target)
: yield* Effect.all({
tui: npm.resolve(operation.target, { subpaths: ["tui"] }),
rpc: npm.resolve(operation.target, { subpaths: ["rpc"] }),
}).pipe(
Effect.map((resolved) => ({
...(resolved.tui.entrypoint ? { tui: true as const } : {}),
...(resolved.rpc.entrypoint ? { rpc: true as const } : {}),
})),
)
return {
id: plugin.id,
features: {
...(entrypoints.tui ? { tui: true as const } : {}),
...(entrypoints.rpc ? { rpc: true as const } : {}),
},
revision: JSON.stringify([operation, installed?.revision]),
features,
revision: JSON.stringify([operation, installed.revision]),
source: path.isAbsolute(operation.target)
? { type: "local" as const, path: fileURLToPath(entrypoint) }
? { type: "local" as const, path: operation.target }
: {
type: "package" as const,
target: operation.target,
...(installed?.version ? { version: installed.version } : {}),
...(installed.version ? { version: installed.version } : {}),
},
effect: (host) => plugin.effect({ ...host, options: operation.options }),
} satisfies Generation
})
function localFeatures(entrypoint: string) {
if (!path.basename(entrypoint).startsWith("index.")) return Effect.succeed({})
return Effect.promise(() => readdir(path.dirname(entrypoint), { withFileTypes: true })).pipe(
Effect.map((entries) => {
const names = new Set(
entries.filter((entry) => entry.isFile() || entry.isSymbolicLink()).map((entry) => entry.name),
)
const has = (name: string) =>
["ts", "tsx", "js", "jsx", "mts", "mjs", "cts", "cjs"].some((extension) => names.has(`${name}.${extension}`))
return {
...(has("tui") ? { tui: true as const } : {}),
...(has("rpc") ? { rpc: true as const } : {}),
}
}),
)
}

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